auth and projects
This commit is contained in:
1 parent
558bb005f4
commit
0ba2db0ec2
14 files changed
+1207
-94
No files matched your search
@@ -246,11 +246,13 @@
|
|||||||
DEVELOPMENT_TEAM = WTV7UT4GYT;
|
DEVELOPMENT_TEAM = WTV7UT4GYT;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Доступ к локальной сети нужен для подключения к серверу Ulitka.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -278,11 +280,13 @@
|
|||||||
DEVELOPMENT_TEAM = WTV7UT4GYT;
|
DEVELOPMENT_TEAM = WTV7UT4GYT;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Доступ к локальной сети нужен для подключения к серверу Ulitka.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.6;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -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<UserResponse> = 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
))))
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Response: Decodable>(
|
||||||
|
_ 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<Payload: Decodable>: Decodable {
|
||||||
|
let data: Payload
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-15
@@ -6,27 +6,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct SnailApp: App {
|
struct SnailApp: App {
|
||||||
var sharedModelContainer: ModelContainer = {
|
@State private var auth = AuthStore()
|
||||||
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)")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
RootView()
|
||||||
|
.environment(auth)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user