diff --git a/Snail.xcodeproj/project.pbxproj b/Snail.xcodeproj/project.pbxproj index 2896696..b9cfcdc 100644 --- a/Snail.xcodeproj/project.pbxproj +++ b/Snail.xcodeproj/project.pbxproj @@ -246,11 +246,13 @@ DEVELOPMENT_TEAM = WTV7UT4GYT; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Доступ к локальной сети нужен для подключения к серверу Ulitka."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -278,11 +280,13 @@ DEVELOPMENT_TEAM = WTV7UT4GYT; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Доступ к локальной сети нужен для подключения к серверу Ulitka."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 26.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Snail/Auth/AuthService.swift b/Snail/Auth/AuthService.swift new file mode 100644 index 0000000..303cf7b --- /dev/null +++ b/Snail/Auth/AuthService.swift @@ -0,0 +1,78 @@ +// +// AuthService.swift +// Snail +// + +import Foundation + +nonisolated struct User: Codable, Identifiable, Hashable, Sendable { + let id: String + let name: String + let email: String + let emailVerified: Bool + let image: String? + let createdAt: Date + + /// При входе по коду better-auth создаёт пользователя с пустым именем. + var displayName: String { + name.trimmingCharacters(in: .whitespaces).isEmpty ? email : name + } + + var initials: String { + let source = name.trimmingCharacters(in: .whitespaces).isEmpty ? email : name + let letters = source + .split(whereSeparator: { $0 == " " || $0 == "@" || $0 == "." }) + .prefix(2) + .compactMap(\.first) + return String(letters).uppercased() + } +} + +/// Эндпоинты better-auth (плагин emailOTP) и профиля. +nonisolated struct AuthService: Sendable { + private let client: APIClient + + init(client: APIClient = APIClient()) { + self.client = client + } + + func sendSignInCode(to email: String) async throws { + let _: SuccessResponse = try await client.send( + "POST", + "auth/email-otp/send-verification-otp", + body: ["email": email, "type": "sign-in"] + ) + } + + func signIn(email: String, code: String) async throws -> User { + let response: UserResponse = try await client.send( + "POST", + "auth/sign-in/email-otp", + body: ["email": email, "otp": code] + ) + return response.user + } + + /// Текущий пользователь или nil, если сессии нет. + func currentUser() async throws -> User? { + do { + let response: Envelope = try await client.send("GET", "users/me") + return response.data.user + } catch APIError.unauthorized { + return nil + } + } + + func signOut() async { + let _: SuccessResponse? = try? await client.send("POST", "auth/sign-out", body: [:]) + client.clearCookies() + } +} + +private nonisolated struct SuccessResponse: Decodable { + let success: Bool +} + +private nonisolated struct UserResponse: Decodable { + let user: User +} diff --git a/Snail/Auth/AuthStore.swift b/Snail/Auth/AuthStore.swift new file mode 100644 index 0000000..ad694a8 --- /dev/null +++ b/Snail/Auth/AuthStore.swift @@ -0,0 +1,59 @@ +// +// AuthStore.swift +// Snail +// + +import Foundation +import Observation + +@Observable +final class AuthStore { + enum State: Equatable { + case loading + case signedOut + case signedIn(User) + } + + private(set) var state: State + private let service: AuthService + + init(service: AuthService = AuthService(), state: State = .loading) { + self.service = service + self.state = state + } + + var currentUser: User? { + if case .signedIn(let user) = state { user } else { nil } + } + + /// Проверяет сохранённую cookie сессии при запуске. + func restoreSession() async { + do { + state = try await service.currentUser().map(State.signedIn) ?? .signedOut + } catch { + state = .signedOut + } + } + + func sendCode(to email: String) async throws { + try await service.sendSignInCode(to: email) + } + + func signIn(email: String, code: String) async throws { + state = .signedIn(try await service.signIn(email: email, code: code)) + } + + /// Обновляет профиль; если сессия больше не действительна — разлогинивает. + func refreshUser() async { + do { + state = try await service.currentUser().map(State.signedIn) ?? .signedOut + } catch { + // Сетевая ошибка — оставляем текущие данные. + } + } + + func signOut() async { + await service.signOut() + state = .signedOut + } +} diff --git a/Snail/Auth/ProfileView.swift b/Snail/Auth/ProfileView.swift new file mode 100644 index 0000000..8c18a64 --- /dev/null +++ b/Snail/Auth/ProfileView.swift @@ -0,0 +1,103 @@ +// +// ProfileView.swift +// Snail +// + +import SwiftUI + +struct ProfileView: View { + @Environment(AuthStore.self) private var auth + @Environment(\.dismiss) private var dismiss + @State private var isConfirmingSignOut = false + @State private var isSigningOut = false + + var body: some View { + NavigationStack { + List { + if let user = auth.currentUser { + header(for: user) + + Section("Аккаунт") { + LabeledContent("Электронная почта", value: user.email) + LabeledContent("Почта подтверждена", value: user.emailVerified ? String(localized: "Да") : String(localized: "Нет")) + LabeledContent("Дата регистрации", value: user.createdAt.formatted(date: .long, time: .omitted)) + } + } + + Section { + Button(role: .destructive) { + isConfirmingSignOut = true + } label: { + HStack { + Text("Выйти") + Spacer() + if isSigningOut { + ProgressView() + } + } + } + .disabled(isSigningOut) + .confirmationDialog("Выйти из аккаунта?", isPresented: $isConfirmingSignOut, titleVisibility: .visible) { + Button("Выйти", role: .destructive, action: signOut) + } message: { + Text("Чтобы войти снова, понадобится код из письма.") + } + } + } + .navigationTitle("Профиль") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await auth.refreshUser() } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } + } + } + } + } + + private func header(for user: User) -> some View { + Section { + VStack(spacing: 12) { + Text(user.initials) + .font(.title.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 80, height: 80) + .background(Color.accentColor.gradient, in: .circle) + .accessibilityHidden(true) + + VStack(spacing: 4) { + Text(user.displayName) + .font(.title2.weight(.semibold)) + if user.displayName != user.email { + Text(user.email) + .foregroundStyle(.secondary) + } + } + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityElement(children: .combine) + } + } + + private func signOut() { + isSigningOut = true + Task { + await auth.signOut() + isSigningOut = false + } + } +} + +#Preview { + ProfileView() + .environment(AuthStore(state: .signedIn(User( + id: "1", + name: "Вадим", + email: "vadim@example.com", + emailVerified: true, + image: nil, + createdAt: .now + )))) +} diff --git a/Snail/Auth/SignInView.swift b/Snail/Auth/SignInView.swift new file mode 100644 index 0000000..0aad528 --- /dev/null +++ b/Snail/Auth/SignInView.swift @@ -0,0 +1,219 @@ +// +// SignInView.swift +// Snail +// + +import SwiftUI + +/// Вход в два шага: адрес почты → одноразовый код из письма. +struct SignInView: View { + @Environment(AuthStore.self) private var auth + + private enum Step { + case email + case code + } + + private enum Field { + case email + case code + } + + /// Длина кода и пауза перед повторной отправкой (совпадают с настройками better-auth по умолчанию). + private static let codeLength = 6 + private static let resendDelay: TimeInterval = 60 + + @AppStorage("lastSignInEmail") private var rememberedEmail = "" + @State private var step: Step = .email + @State private var email = "" + @State private var code = "" + @State private var isLoading = false + @State private var errorMessage: String? + @State private var resendAvailableAt = Date.distantPast + @FocusState private var focusedField: Field? + + private var trimmedEmail: String { + email.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isEmailValid: Bool { + trimmedEmail.wholeMatch(of: /[^@\s]+@[^@\s]+\.[^@\s]+/) != nil + } + + var body: some View { + NavigationStack { + Form { + switch step { + case .email: + emailSection + case .code: + codeSection + } + } + .navigationTitle(step == .email ? "Вход" : "Код из письма") + .scrollDismissesKeyboard(.interactively) + .disabled(isLoading) + .animation(.default, value: step) + .onAppear { + if email.isEmpty { email = rememberedEmail } + focusedField = .email + } + } + } + + // MARK: - Шаг 1: почта + + @ViewBuilder + private var emailSection: some View { + Section { + TextField("Электронная почта", text: $email, prompt: Text(verbatim: "name@example.com")) + .textContentType(.emailAddress) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.continue) + .focused($focusedField, equals: .email) + .onSubmit(sendCode) + .onChange(of: email) { errorMessage = nil } + } footer: { + footer(hint: "Мы отправим на этот адрес одноразовый код для входа.") + } + + Section { + Button(action: sendCode) { + ProgressButtonLabel(title: "Получить код", isLoading: isLoading) + } + .disabled(!isEmailValid) + } + } + + // MARK: - Шаг 2: код + + @ViewBuilder + private var codeSection: some View { + Section { + TextField("Код", text: $code, prompt: Text(verbatim: String(repeating: "0", count: Self.codeLength))) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .font(.title2.monospacedDigit()) + .focused($focusedField, equals: .code) + .onChange(of: code) { _, newValue in + // Оставляем только цифры и отправляем, как только код введён полностью + // (в том числе при автозаполнении из письма). + let digits = String(newValue.filter(\.isNumber).prefix(Self.codeLength)) + if digits != newValue { + code = digits + return + } + if !digits.isEmpty { errorMessage = nil } + if digits.count == Self.codeLength { verifyCode() } + } + } footer: { + footer(hint: "Код отправлен на \(trimmedEmail). Если письма нет, проверьте папку «Спам».") + } + + Section { + Button(action: verifyCode) { + ProgressButtonLabel(title: "Войти", isLoading: isLoading) + } + .disabled(code.count != Self.codeLength) + + TimelineView(.periodic(from: .now, by: 1)) { context in + let remaining = Int(resendAvailableAt.timeIntervalSince(context.date).rounded(.up)) + Button(action: sendCode) { + if remaining > 0 { + Text("Отправить повторно через \(remaining) с") + } else { + Text("Отправить код повторно") + } + } + .disabled(remaining > 0) + } + + Button("Изменить адрес почты") { + code = "" + errorMessage = nil + step = .email + focusedField = .email + } + } + } + + @ViewBuilder + private func footer(hint: LocalizedStringKey) -> some View { + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.circle") + .foregroundStyle(.red) + } else { + Text(hint) + } + } + + // MARK: - Действия + + private func sendCode() { + guard isEmailValid, !isLoading else { return } + Task { + await perform { + try await auth.sendCode(to: trimmedEmail) + rememberedEmail = trimmedEmail + resendAvailableAt = .now.addingTimeInterval(Self.resendDelay) + code = "" + step = .code + focusedField = .code + } + } + } + + private func verifyCode() { + guard code.count == Self.codeLength, !isLoading else { return } + Task { + await perform { + try await auth.signIn(email: trimmedEmail, code: code) + } + if errorMessage != nil { + code = "" + focusedField = .code + } + } + } + + private func perform(_ action: () async throws -> Void) async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + try await action() + } catch { + errorMessage = error.localizedDescription + if case APIError.server(_, "TOO_MANY_ATTEMPTS", _) = error { + // Код больше не примут — сразу разрешаем запросить новый. + resendAvailableAt = .distantPast + } + AccessibilityNotification.Announcement(error.localizedDescription).post() + } + } +} + +/// Подпись кнопки с индикатором загрузки, не меняющая ширину строки. +private struct ProgressButtonLabel: View { + let title: LocalizedStringKey + let isLoading: Bool + + var body: some View { + HStack { + Text(title) + Spacer() + if isLoading { + ProgressView() + } + } + .accessibilityElement(children: .combine) + .accessibilityValue(isLoading ? Text("Загрузка") : Text(verbatim: "")) + } +} + +#Preview { + SignInView() + .environment(AuthStore(state: .signedOut)) +} diff --git a/Snail/ContentView.swift b/Snail/ContentView.swift deleted file mode 100644 index c57a73b..0000000 --- a/Snail/ContentView.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ContentView.swift -// Snail -// -// Created by Vadim on 29.10.2026. -// - -import SwiftUI -import SwiftData - -struct ContentView: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - - var body: some View { - NavigationSplitView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") - } label: { - Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) - } - } - .onDelete(perform: deleteItems) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - } detail: { - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(timestamp: Date()) - modelContext.insert(newItem) - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - for index in offsets { - modelContext.delete(items[index]) - } - } - } -} - -#Preview { - ContentView() - .modelContainer(for: Item.self, inMemory: true) -} diff --git a/Snail/Item.swift b/Snail/Item.swift deleted file mode 100644 index 6237775..0000000 --- a/Snail/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// Snail -// -// Created by Vadim on 29.10.2026. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Snail/Networking/APIClient.swift b/Snail/Networking/APIClient.swift new file mode 100644 index 0000000..8f67a7f --- /dev/null +++ b/Snail/Networking/APIClient.swift @@ -0,0 +1,175 @@ +// +// APIClient.swift +// Snail +// + +import Foundation + +nonisolated enum APIConfiguration { + /// Адрес бэкенда ulitka. В симуляторе localhost указывает на Mac, где запущен сервер; + /// на устройстве нужен адрес Mac в локальной сети. + #if targetEnvironment(simulator) + private static let host = "http://localhost:3000" + #else + private static let host = "http://192.168.1.77:3000" + #endif + + static let baseURL: URL = { + guard let url = URL(string: "\(host)/api") else { + preconditionFailure("Некорректный адрес бэкенда") + } + return url + }() +} + +nonisolated enum APIError: LocalizedError, Equatable { + case unauthorized + case server(status: Int, code: String?, message: String?) + case network(URLError) + case invalidResponse + + var errorDescription: String? { + switch self { + case .unauthorized: + return String(localized: "Сессия истекла. Войдите снова.") + case .server(_, let code, let message): + return Self.localizedMessage(forCode: code) ?? message ?? String(localized: "Что-то пошло не так. Попробуйте ещё раз.") + case .network(let error): + switch error.code { + case .notConnectedToInternet, .networkConnectionLost: + return String(localized: "Нет подключения к интернету.") + case .timedOut: + return String(localized: "Сервер не отвечает. Попробуйте позже.") + default: + return String(localized: "Не удалось связаться с сервером.") + } + case .invalidResponse: + return String(localized: "Сервер вернул неожиданный ответ.") + } + } + + /// Коды ошибок better-auth, которые стоит показать пользователю понятным текстом. + private static func localizedMessage(forCode code: String?) -> String? { + switch code { + case "INVALID_EMAIL": + return String(localized: "Проверьте адрес электронной почты.") + case "INVALID_OTP": + return String(localized: "Неверный код. Проверьте и попробуйте ещё раз.") + case "OTP_EXPIRED": + return String(localized: "Срок действия кода истёк. Запросите новый.") + case "TOO_MANY_ATTEMPTS": + return String(localized: "Слишком много попыток. Запросите новый код.") + default: + return nil + } + } +} + +/// Тонкая обёртка над URLSession. Сессия better-auth хранится в cookie, +/// которые URLSession сохраняет в HTTPCookieStorage.shared автоматически. +nonisolated struct APIClient: Sendable { + let baseURL: URL + let session: URLSession + + init(baseURL: URL = APIConfiguration.baseURL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + func send( + _ method: String, + _ path: String, + query: [URLQueryItem] = [], + body: [String: String]? = nil, + as type: Response.Type = Response.self + ) async throws -> Response { + var url = baseURL.appending(path: path) + if !query.isEmpty { + url.append(queryItems: query) + } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch let error as URLError { + throw APIError.network(error) + } + + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + guard (200..<300).contains(http.statusCode) else { + if http.statusCode == 401 { + throw APIError.unauthorized + } + let body = try? JSONDecoder().decode(ErrorBody.self, from: data) + throw APIError.server(status: http.statusCode, code: body?.code, message: body?.message) + } + + do { + return try Self.decoder.decode(Response.self, from: data) + } catch { + throw APIError.invalidResponse + } + } + + /// Удаляет cookie сессии — на случай, если сервер недоступен в момент выхода. + func clearCookies() { + let storage = HTTPCookieStorage.shared + storage.cookies(for: baseURL)?.forEach(storage.deleteCookie) + } + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + // Сервер отдаёт даты в ISO 8601 с миллисекундами: 2026-10-29T09:34:27.000Z + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + if let date = try? Date(string, strategy: .iso8601.year().month().day().time(includingFractionalSeconds: true)) { + return date + } + if let date = try? Date(string, strategy: .iso8601) { + return date + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Неизвестный формат даты: \(string)") + } + return decoder + }() +} + +/// Тело ошибки. better-auth отдаёт `{ code: "INVALID_OTP", message }`, +/// а Nest — `{ data: null, code: 400, message }`, где message может быть массивом. +private nonisolated struct ErrorBody: Decodable { + let code: String? + let message: String? + + private enum CodingKeys: String, CodingKey { + case code, message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = try? container.decode(String.self, forKey: .code) + if let text = try? container.decode(String.self, forKey: .message) { + message = text + } else if let lines = try? container.decode([String].self, forKey: .message) { + message = lines.joined(separator: "\n") + } else { + message = nil + } + } +} + +/// Обёртка ответов Nest-контроллеров: `{ data, code }`. +nonisolated struct Envelope: Decodable { + let data: Payload +} diff --git a/Snail/Projects/ProjectFormView.swift b/Snail/Projects/ProjectFormView.swift new file mode 100644 index 0000000..1c73eda --- /dev/null +++ b/Snail/Projects/ProjectFormView.swift @@ -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 } +} diff --git a/Snail/Projects/ProjectListModel.swift b/Snail/Projects/ProjectListModel.swift new file mode 100644 index 0000000..f8c7969 --- /dev/null +++ b/Snail/Projects/ProjectListModel.swift @@ -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 + } +} diff --git a/Snail/Projects/ProjectListView.swift b/Snail/Projects/ProjectListView.swift new file mode 100644 index 0000000..03657f9 --- /dev/null +++ b/Snail/Projects/ProjectListView.swift @@ -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)) +} diff --git a/Snail/Projects/ProjectService.swift b/Snail/Projects/ProjectService.swift new file mode 100644 index 0000000..d06f648 --- /dev/null +++ b/Snail/Projects/ProjectService.swift @@ -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 = 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 = 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 = try await client.send( + "PATCH", + "projects/\(id)", + body: ["name": name] + ) + return response.data + } +} diff --git a/Snail/RootView.swift b/Snail/RootView.swift new file mode 100644 index 0000000..93871cc --- /dev/null +++ b/Snail/RootView.swift @@ -0,0 +1,26 @@ +// +// RootView.swift +// Snail +// + +import SwiftUI + +/// Показывает вход или основной интерфейс в зависимости от состояния сессии. +struct RootView: View { + @Environment(AuthStore.self) private var auth + + var body: some View { + Group { + switch auth.state { + case .loading: + ProgressView() + case .signedOut: + SignInView() + case .signedIn: + ProjectListView() + } + } + .animation(.default, value: auth.state) + .task { await auth.restoreSession() } + } +} diff --git a/Snail/SnailApp.swift b/Snail/SnailApp.swift index cdc7e37..24ec454 100644 --- a/Snail/SnailApp.swift +++ b/Snail/SnailApp.swift @@ -6,27 +6,15 @@ // import SwiftUI -import SwiftData @main struct SnailApp: App { - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - Item.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) - - do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) - } catch { - fatalError("Could not create ModelContainer: \(error)") - } - }() + @State private var auth = AuthStore() var body: some Scene { WindowGroup { - ContentView() + RootView() + .environment(auth) } - .modelContainer(sharedModelContainer) } }