init
This commit is contained in:
1 parent
558bb005f4
commit
9ed2f17768
14 files changed
+1207
-94
No files matched your search
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// ProjectFormView.swift
|
||||
// Snail
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ProjectFormMode: Identifiable {
|
||||
case create
|
||||
case edit(Project)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .create: "create"
|
||||
case .edit(let project): project.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Форма создания и изменения проекта. Ключ задаётся только при создании.
|
||||
struct ProjectFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let mode: ProjectFormMode
|
||||
let onSubmit: (_ key: String, _ name: String) async throws -> Void
|
||||
|
||||
/// Ограничения совпадают с серверными (ProjectName.MAX_LENGTH) и форматом номеров задач KEY-1.
|
||||
private static let maxNameLength = 100
|
||||
private static let maxKeyLength = 10
|
||||
|
||||
private enum Field {
|
||||
case name
|
||||
case key
|
||||
}
|
||||
|
||||
@State private var name: String
|
||||
@State private var key: String
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
init(mode: ProjectFormMode, onSubmit: @escaping (_ key: String, _ name: String) async throws -> Void) {
|
||||
self.mode = mode
|
||||
self.onSubmit = onSubmit
|
||||
switch mode {
|
||||
case .create:
|
||||
_name = State(initialValue: "")
|
||||
_key = State(initialValue: "")
|
||||
case .edit(let project):
|
||||
_name = State(initialValue: project.name)
|
||||
_key = State(initialValue: project.key)
|
||||
}
|
||||
}
|
||||
|
||||
private var isCreating: Bool {
|
||||
if case .create = mode { true } else { false }
|
||||
}
|
||||
|
||||
private var trimmedName: String {
|
||||
name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var isKeyValid: Bool {
|
||||
key.wholeMatch(of: /[A-Z][A-Z0-9]{1,9}/) != nil
|
||||
}
|
||||
|
||||
private var isNameValid: Bool {
|
||||
!trimmedName.isEmpty && trimmedName.count <= Self.maxNameLength
|
||||
}
|
||||
|
||||
private var hasChanges: Bool {
|
||||
switch mode {
|
||||
case .create: !trimmedName.isEmpty || !key.isEmpty
|
||||
case .edit(let project): trimmedName != project.name
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
isNameValid && (isCreating ? isKeyValid : hasChanges) && !isSaving
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Название", text: $name, prompt: Text("Например, Мобильное приложение"))
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.submitLabel(isCreating ? .next : .done)
|
||||
.focused($focusedField, equals: .name)
|
||||
.onSubmit {
|
||||
if isCreating {
|
||||
focusedField = .key
|
||||
} else {
|
||||
submit()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Название")
|
||||
} footer: {
|
||||
if trimmedName.count > Self.maxNameLength {
|
||||
Text("Не больше \(Self.maxNameLength) символов.")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if isCreating {
|
||||
TextField("Ключ", text: $key, prompt: Text(verbatim: "APP"))
|
||||
.font(.body.monospaced())
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.asciiCapable)
|
||||
.submitLabel(.done)
|
||||
.focused($focusedField, equals: .key)
|
||||
.onSubmit(submit)
|
||||
.onChange(of: key) { _, newValue in
|
||||
let normalized = String(
|
||||
newValue.uppercased()
|
||||
.filter { ("A"..."Z").contains($0) || ("0"..."9").contains($0) }
|
||||
.prefix(Self.maxKeyLength)
|
||||
)
|
||||
if normalized != newValue { key = normalized }
|
||||
}
|
||||
} else {
|
||||
LabeledContent("Ключ") {
|
||||
Text(key).monospaced()
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Ключ")
|
||||
} footer: {
|
||||
if isCreating {
|
||||
Text("От 2 до \(Self.maxKeyLength) латинских букв и цифр, начинается с буквы. Используется в номерах задач — \(key.isEmpty ? "APP" : key)-1. Изменить ключ потом нельзя.")
|
||||
} else {
|
||||
Text("Ключ используется в номерах задач и не меняется.")
|
||||
}
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Label(errorMessage, systemImage: "exclamationmark.circle")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isCreating ? "Новый проект" : "Изменить проект")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.disabled(isSaving)
|
||||
.interactiveDismissDisabled(hasChanges || isSaving)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена", role: .cancel) { dismiss() }
|
||||
.disabled(isSaving)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button(isCreating ? "Создать" : "Сохранить", action: submit)
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { focusedField = .name }
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
guard canSubmit else { return }
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
Task {
|
||||
defer { isSaving = false }
|
||||
do {
|
||||
try await onSubmit(key, trimmedName)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
AccessibilityNotification.Announcement(error.localizedDescription).post()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Создание") {
|
||||
ProjectFormView(mode: .create) { _, _ in }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// ProjectListModel.swift
|
||||
// Snail
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Постраничная загрузка списка проектов: следующая страница подгружается при прокрутке.
|
||||
@Observable
|
||||
final class ProjectListModel {
|
||||
private static let pageSize = 20
|
||||
|
||||
private(set) var projects: [Project] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var hasLoaded = false
|
||||
private(set) var error: APIError?
|
||||
|
||||
private var loadedPage = 0
|
||||
private var totalPages = 1
|
||||
private let service: ProjectService
|
||||
|
||||
init(service: ProjectService = ProjectService()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
var hasMorePages: Bool {
|
||||
loadedPage < totalPages
|
||||
}
|
||||
|
||||
/// Загружает первую страницу заново (первый показ, pull-to-refresh, «Повторить»).
|
||||
func reload() async {
|
||||
await load(page: 1)
|
||||
}
|
||||
|
||||
/// Вызывается при появлении строки: если это последняя строка, грузим следующую страницу.
|
||||
func loadMoreIfNeeded(after project: Project) async {
|
||||
guard project.id == projects.last?.id, hasMorePages, !isLoading else { return }
|
||||
await load(page: loadedPage + 1)
|
||||
}
|
||||
|
||||
func create(key: String, name: String) async throws {
|
||||
let project = try await service.create(key: key, name: name)
|
||||
projects.insert(project, at: 0)
|
||||
}
|
||||
|
||||
func rename(_ project: Project, to name: String) async throws {
|
||||
let updated = try await service.update(id: project.id, name: name)
|
||||
if let index = projects.firstIndex(where: { $0.id == updated.id }) {
|
||||
projects[index] = updated
|
||||
}
|
||||
}
|
||||
|
||||
private func load(page: Int) async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let result = try await service.list(page: page, limit: Self.pageSize)
|
||||
if page == 1 {
|
||||
projects = result.list
|
||||
} else {
|
||||
// Список мог сдвинуться между запросами — отбрасываем дубликаты.
|
||||
let known = Set(projects.map(\.id))
|
||||
projects += result.list.filter { !known.contains($0.id) }
|
||||
}
|
||||
loadedPage = page
|
||||
totalPages = result.meta.totalPages
|
||||
error = nil
|
||||
} catch let apiError as APIError {
|
||||
error = apiError
|
||||
} catch {
|
||||
self.error = .invalidResponse
|
||||
}
|
||||
hasLoaded = true
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// ProjectService.swift
|
||||
// Snail
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated struct Project: Codable, Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
let key: String
|
||||
let name: String
|
||||
let ownerId: String
|
||||
let createdAt: Date
|
||||
let updatedAt: Date
|
||||
}
|
||||
|
||||
nonisolated struct PaginationMeta: Decodable, Sendable {
|
||||
let totalCount: Int
|
||||
let page: Int
|
||||
let totalPages: Int
|
||||
}
|
||||
|
||||
nonisolated struct ProjectPage: Decodable, Sendable {
|
||||
let list: [Project]
|
||||
let meta: PaginationMeta
|
||||
}
|
||||
|
||||
nonisolated struct ProjectService: Sendable {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient = APIClient()) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// Проекты, в которых участвует текущий пользователь. Страницы нумеруются с 1.
|
||||
func list(page: Int, limit: Int) async throws -> ProjectPage {
|
||||
let response: Envelope<ProjectPage> = try await client.send(
|
||||
"GET",
|
||||
"projects/list",
|
||||
query: [
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
]
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
func create(key: String, name: String) async throws -> Project {
|
||||
let response: Envelope<Project> = try await client.send(
|
||||
"POST",
|
||||
"projects",
|
||||
body: ["key": key, "name": name]
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/// Менять можно только название — ключ участвует в номерах задач.
|
||||
func update(id: String, name: String) async throws -> Project {
|
||||
let response: Envelope<Project> = try await client.send(
|
||||
"PATCH",
|
||||
"projects/\(id)",
|
||||
body: ["name": name]
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user