diff --git a/Balance/Balance/App/ContentView.swift b/Balance/Balance/App/ContentView.swift index dd17df6..0a793c2 100644 --- a/Balance/Balance/App/ContentView.swift +++ b/Balance/Balance/App/ContentView.swift @@ -38,3 +38,8 @@ struct ContentView: View { } } } + +#Preview("Главный экран") { + ContentView() + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Shared/BalanceModelContainer.swift b/Balance/Balance/Shared/BalanceModelContainer.swift index 603e540..ec78345 100644 --- a/Balance/Balance/Shared/BalanceModelContainer.swift +++ b/Balance/Balance/Shared/BalanceModelContainer.swift @@ -56,3 +56,101 @@ enum BalanceModelContainer { } } } + +// MARK: - Превью + +extension BalanceModelContainer { + /// Контейнер в памяти с демо-данными для #Preview. + @MainActor + static var previewContainer: ModelContainer { + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + do { + let container = try ModelContainer(for: schema, configurations: [configuration]) + PreviewSampleData.seed(into: container.mainContext) + return container + } catch { + fatalError("Не удалось создать контейнер для превью: \(error.localizedDescription)") + } + } +} + +/// Демо-данные, которыми наполняется контейнер превью. +@MainActor +private enum PreviewSampleData { + static func seed(into context: ModelContext) { + let calendar = Calendar.current + let monthStart = FinanceCalculations.monthInterval(containing: .now, calendar: calendar).start + + context.insert(CustomCategory( + name: "Кофе", + icon: "cup.and.saucer.fill", + emoji: "☕️", + colorName: "brown", + kind: .expense + )) + + let expenses: [(name: String, icon: String, emoji: String, colorName: String, amount: Double)] = [ + ("Продукты", "cart.fill", "", "auto", 3_400), + ("Транспорт", "car.fill", "", "auto", 1_200), + ("Развлечения", "gamecontroller.fill", "", "auto", 2_600), + ("Подписки", "repeat", "", "auto", 649), + ("Кофе", "cup.and.saucer.fill", "☕️", "brown", 380), + ("Покупки", "bag.fill", "", "auto", 4_800) + ] + + for monthOffset in 0..<6 { + guard let anchor = calendar.date(byAdding: .month, value: -monthOffset, to: monthStart) else { + continue + } + + if let salaryDate = calendar.date(byAdding: .day, value: 2, to: anchor) { + context.insert(FinanceTransaction( + amount: 150_000, + date: salaryDate, + note: "", + categoryName: "Зарплата", + categoryIcon: "banknote.fill", + kind: .income + )) + } + + for (index, template) in expenses.enumerated() { + guard let date = calendar.date(byAdding: .day, value: 4 + index * 3, to: anchor) else { + continue + } + let variation = 1 + Double((monthOffset + index) % 4) * 0.08 + context.insert(FinanceTransaction( + amount: (template.amount * variation).rounded(), + date: date, + note: "", + categoryName: template.name, + categoryIcon: template.icon, + categoryEmoji: template.emoji, + categoryColorName: template.colorName, + kind: .expense + )) + } + } + + let budgets: [(name: String, icon: String, emoji: String, limit: Double)] = [ + ("Продукты", "cart.fill", "", 30_000), + ("Транспорт", "car.fill", "", 8_000), + ("Развлечения", "gamecontroller.fill", "", 10_000), + ("Кофе", "cup.and.saucer.fill", "☕️", 5_000) + ] + + for budget in budgets { + context.insert(MonthlyBudget( + categoryName: budget.name, + categoryIcon: budget.icon, + categoryEmoji: budget.emoji, + limit: budget.limit, + monthStart: monthStart + )) + } + } +} diff --git a/Balance/Balance/Shared/ServerSync.swift b/Balance/Balance/Shared/ServerSync.swift index 75b54c9..bbb93fe 100644 --- a/Balance/Balance/Shared/ServerSync.swift +++ b/Balance/Balance/Shared/ServerSync.swift @@ -3,7 +3,10 @@ import Security import SwiftData import SwiftUI -private struct ServerUser: Codable { let id: String; let email: String } +private struct ServerUser: Codable { + let id: String + let email: String +} private struct ServerSession: Codable { let accessToken: String let refreshToken: String @@ -11,7 +14,10 @@ private struct ServerSession: Codable { let user: ServerUser } private struct ServerErrorEnvelope: Decodable { - struct Detail: Decodable { let code: String; let message: String } + struct Detail: Decodable { + let code: String + let message: String + } let error: Detail } private struct SyncChange: Codable { @@ -23,10 +29,25 @@ private struct SyncChange: Codable { let version: Int64? let sequence: Int64? - enum CodingKeys: String, CodingKey { case entity, id, deleted, updatedAt, payload, version, sequence } - init(entity: String, id: String, deleted: Bool, updatedAt: Date, payload: Data? = nil, version: Int64? = nil, sequence: Int64? = nil) { - self.entity = entity; self.id = id; self.deleted = deleted; self.updatedAt = updatedAt - self.payload = payload; self.version = version; self.sequence = sequence + enum CodingKeys: String, CodingKey { + case entity, id, deleted, updatedAt, payload, version, sequence + } + init( + entity: String, + id: String, + deleted: Bool, + updatedAt: Date, + payload: Data? = nil, + version: Int64? = nil, + sequence: Int64? = nil + ) { + self.entity = entity + self.id = id + self.deleted = deleted + self.updatedAt = updatedAt + self.payload = payload + self.version = version + self.sequence = sequence } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) @@ -39,26 +60,48 @@ private struct SyncChange: Codable { if values.contains(.payload) { let object = try values.decode(JSONValue.self, forKey: .payload) payload = try ServerCoding.encoder.encode(object) - } else { payload = nil } + } else { + payload = nil + } } func encode(to encoder: Encoder) throws { var values = encoder.container(keyedBy: CodingKeys.self) - try values.encode(entity, forKey: .entity); try values.encode(id, forKey: .id) - try values.encode(deleted, forKey: .deleted); try values.encode(updatedAt, forKey: .updatedAt) - try values.encodeIfPresent(version, forKey: .version); try values.encodeIfPresent(sequence, forKey: .sequence) - if let payload { try values.encode(ServerCoding.decoder.decode(JSONValue.self, from: payload), forKey: .payload) } + try values.encode(entity, forKey: .entity) + try values.encode(id, forKey: .id) + try values.encode(deleted, forKey: .deleted) + try values.encode(updatedAt, forKey: .updatedAt) + try values.encodeIfPresent(version, forKey: .version) + try values.encodeIfPresent(sequence, forKey: .sequence) + if let payload { + try values.encode( + ServerCoding.decoder.decode(JSONValue.self, from: payload), + forKey: .payload + ) + } } } private enum JSONValue: Codable { - case string(String), number(Double), bool(Bool), object([String: JSONValue]), array([JSONValue]), null + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer() - if value.decodeNil() { self = .null } - else if let item = try? value.decode(Bool.self) { self = .bool(item) } - else if let item = try? value.decode(Double.self) { self = .number(item) } - else if let item = try? value.decode(String.self) { self = .string(item) } - else if let item = try? value.decode([String: JSONValue].self) { self = .object(item) } - else { self = .array(try value.decode([JSONValue].self)) } + if value.decodeNil() { + self = .null + } else if let item = try? value.decode(Bool.self) { + self = .bool(item) + } else if let item = try? value.decode(Double.self) { + self = .number(item) + } else if let item = try? value.decode(String.self) { + self = .string(item) + } else if let item = try? value.decode([String: JSONValue].self) { + self = .object(item) + } else { + self = .array(try value.decode([JSONValue].self)) + } } func encode(to encoder: Encoder) throws { var value = encoder.singleValueContainer() @@ -86,14 +129,30 @@ private struct SyncResponse: Decodable { } private struct TransactionPayload: Codable { - let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String - let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String + let amount: Double + let date: Date + let note: String + let categoryName: String + let categoryIcon: String + let categoryEmoji: String + let categoryColorName: String + let isBalanceAdjustment: Bool + let kindRawValue: String } private struct CategoryPayload: Codable { - let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date + let name: String + let icon: String + let emoji: String + let colorName: String + let kindRawValue: String + let createdAt: Date } private struct BudgetPayload: Codable { - let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date + let categoryName: String + let categoryIcon: String + let categoryEmoji: String + let limit: Double + let monthStart: Date } private enum ServerCoding { @@ -101,9 +160,11 @@ private enum ServerCoding { let value = JSONEncoder() value.dateEncodingStrategy = .custom { date, encoder in let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.formatOptions = [ + .withInternetDateTime, .withFractionalSeconds, + ] var container = encoder.singleValueContainer() - try container.encode(formatter.string(from:date)) + try container.encode(formatter.string(from: date)) } return value }() @@ -112,9 +173,14 @@ private enum ServerCoding { value.dateDecodingStrategy = .custom { decoder in let text = try decoder.singleValueContainer().decode(String.self) let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + fractional.formatOptions = [ + .withInternetDateTime, .withFractionalSeconds, + ] let regular = ISO8601DateFormatter() - guard let date = fractional.date(from: text) ?? regular.date(from: text) else { + guard + let date = fractional.date(from: text) + ?? regular.date(from: text) + else { throw DecodingError.dataCorruptedError( in: try decoder.singleValueContainer(), debugDescription: "Invalid ISO-8601 date" @@ -127,11 +193,15 @@ private enum ServerCoding { } enum ServerSyncError: LocalizedError { - case invalidURL, insecureURL, response(String), notSignedIn + case invalidURL, insecureURL + case response(String) + case notSignedIn var errorDescription: String? { switch self { - case .invalidURL: "Укажите полный адрес сервера, например https://balance.example.com" - case .insecureURL: "Для удалённого сервера требуется HTTPS. HTTP разрешён только для localhost." + case .invalidURL: + "Укажите полный адрес сервера, например https://balance.example.com" + case .insecureURL: + "Для удалённого сервера требуется HTTPS. HTTP разрешён только для localhost." case .response(let message): message case .notSignedIn: "Сначала войдите в аккаунт." } @@ -156,131 +226,281 @@ final class ServerAccountStore: ObservableObject { var isSignedIn: Bool { session != nil } private init() { - if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) { - session = value; signedInEmail = value.user.email + if let data = KeychainStore.read(sessionKey), + let value = try? ServerCoding.decoder.decode( + ServerSession.self, + from: data + ) + { + session = value + signedInEmail = value.user.email } lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date } func saveServer(_ value: String) throws { guard !isBusy else { - throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.") + throw ServerSyncError.response( + "Дождитесь завершения текущей синхронизации." + ) } let normalized = try normalizedServer(value) - if normalized != serverURL { clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState() } + if normalized != serverURL { + clearSession() + defaults.set(normalized, forKey: serverKey) + clearSyncState() + } } - func register(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/register", email: email, password: password, context: context) } - func login(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/login", email: email, password: password, context: context) } + func register(email: String, password: String, context: ModelContext) async + { + await authenticate( + path: "v1/auth/register", + email: email, + password: password, + context: context + ) + } + func login(email: String, password: String, context: ModelContext) async { + await authenticate( + path: "v1/auth/login", + email: email, + password: password, + context: context + ) + } func logout() async { - if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse } - clearSession(); message = "Вы вышли из аккаунта." + if let refresh = session?.refreshToken { + _ = + try? await request( + path: "v1/auth/logout", + body: ["refreshToken": refresh], + authorized: false + ) as EmptyResponse + } + clearSession() + message = "Вы вышли из аккаунта." } func sync(context: ModelContext) async { guard session != nil, !isBusy else { return } - isBusy = true; message = nil + isBusy = true + message = nil defer { isBusy = false } do { try context.save() let cutoff = Date.now - let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast - let threshold = lastPush <= cutoff.addingTimeInterval(5) + let lastPush = + defaults.object(forKey: pushKey()) as? Date ?? .distantPast + let threshold = + lastPush <= cutoff.addingTimeInterval(5) ? lastPush.addingTimeInterval(-5) : .distantPast - let initialCursor = max(0, Int64(defaults.integer(forKey: cursorKey()))) + let initialCursor = max( + 0, + Int64(defaults.integer(forKey: cursorKey())) + ) - try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor) - try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor) - try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor) + try await pushTransactions( + context: context, + after: threshold, + through: cutoff, + cursor: initialCursor + ) + try await pushCategories( + context: context, + after: threshold, + through: cutoff, + cursor: initialCursor + ) + try await pushBudgets( + context: context, + after: threshold, + through: cutoff, + cursor: initialCursor + ) try await pushTombstones(through: cutoff, cursor: initialCursor) try await pullChanges(context: context, cursor: initialCursor) defaults.set(cutoff, forKey: pushKey()) - lastSyncAt = .now; defaults.set(lastSyncAt, forKey: "balance.server.lastSync") + lastSyncAt = .now + defaults.set(lastSyncAt, forKey: "balance.server.lastSync") message = "Синхронизация завершена." } catch { message = error.localizedDescription } } func resetSynchronization(context: ModelContext) async { guard !isBusy else { return } - defaults.removeObject(forKey:cursorKey()) - defaults.removeObject(forKey:pushKey()) + defaults.removeObject(forKey: cursorKey()) + defaults.removeObject(forKey: pushKey()) message = "Запущена полная пересинхронизация…" - await sync(context:context) + await sync(context: context) } - private func authenticate(path: String, email: String, password: String, context: ModelContext) async { - guard !isBusy else { return }; isBusy = true; message = nil; defer { isBusy = false } + private func authenticate( + path: String, + email: String, + password: String, + context: ModelContext + ) async { + guard !isBusy else { return } + isBusy = true + message = nil + defer { isBusy = false } do { - let response: ServerSession = try await request(path: path, body: ["email":email, "password":password], authorized: false) - session = response; signedInEmail = response.user.email - try KeychainStore.write(ServerCoding.encoder.encode(response), key: sessionKey) + let response: ServerSession = try await request( + path: path, + body: ["email": email, "password": password], + authorized: false + ) + session = response + signedInEmail = response.user.email + try KeychainStore.write( + ServerCoding.encoder.encode(response), + key: sessionKey + ) message = "Вход выполнен. Запускаю синхронизацию…" - isBusy = false; await sync(context: context) + isBusy = false + await sync(context: context) } catch { message = error.localizedDescription } } - private func request(path: String, body: Body, authorized: Bool, retry: Bool = true) async throws -> Response { + private func request( + path: String, + body: Body, + authorized: Bool, + retry: Bool = true + ) async throws -> Response { if authorized { try await refreshIfNeeded() } let base = try normalizedServer(serverURL) - guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL } - var request = URLRequest(url: url); request.httpMethod = "POST" + guard let url = URL(string: base + "/" + path) else { + throw ServerSyncError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + if authorized, let token = session?.accessToken { + request.setValue( + "Bearer \(token)", + forHTTPHeaderField: "Authorization" + ) + } request.httpBody = try ServerCoding.encoder.encode(body) let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") } - if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, body:body, authorized:true, retry:false) } + guard let http = response as? HTTPURLResponse else { + throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") + } + if http.statusCode == 401, authorized, retry { + try await refreshSession() + return try await self.request( + path: path, + body: body, + authorized: true, + retry: false + ) + } guard (200..<300).contains(http.statusCode) else { - let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data) + let envelope = try? ServerCoding.decoder.decode( + ServerErrorEnvelope.self, + from: data + ) if path == "v1/sync", envelope?.error.code == "invalid_json" { - throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.") + throw ServerSyncError.response( + "Обновите Balance Server до версии из текущего архива приложения." + ) } - throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)") + throw ServerSyncError.response( + envelope?.error.message + ?? "Ошибка сервера: HTTP \(http.statusCode)" + ) + } + if Response.self == EmptyResponse.self { + return EmptyResponse() as! Response } - if Response.self == EmptyResponse.self { return EmptyResponse() as! Response } return try ServerCoding.decoder.decode(Response.self, from: data) } - private func refreshIfNeeded() async throws { if let session, session.expiresAt.timeIntervalSinceNow < 60 { try await refreshSession() } } + private func refreshIfNeeded() async throws { + if let session, session.expiresAt.timeIntervalSinceNow < 60 { + try await refreshSession() + } + } private func refreshSession() async throws { - guard let refresh = session?.refreshToken else { throw ServerSyncError.notSignedIn } - let updated: ServerSession = try await request(path:"v1/auth/refresh", body:["refreshToken":refresh], authorized:false, retry:false) - session = updated; signedInEmail = updated.user.email - try KeychainStore.write(ServerCoding.encoder.encode(updated), key: sessionKey) + guard let refresh = session?.refreshToken else { + throw ServerSyncError.notSignedIn + } + let updated: ServerSession = try await request( + path: "v1/auth/refresh", + body: ["refreshToken": refresh], + authorized: false, + retry: false + ) + session = updated + signedInEmail = updated.user.email + try KeychainStore.write( + ServerCoding.encoder.encode(updated), + key: sessionKey + ) } private func normalizedServer(_ input: String) throws -> String { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), let host = url.host, scheme == "https" || scheme == "http" else { throw ServerSyncError.invalidURL } - if scheme == "http" && host != "localhost" && host != "127.0.0.1" && host != "::1" { throw ServerSyncError.insecureURL } + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), let host = url.host, + scheme == "https" || scheme == "http" + else { throw ServerSyncError.invalidURL } + if scheme == "http" && host != "localhost" && host != "127.0.0.1" + && host != "::1" + { + throw ServerSyncError.insecureURL + } return trimmed } - private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) } + private func clearSession() { + session = nil + signedInEmail = nil + KeychainStore.delete(sessionKey) + } private func clearSyncState() { for key in defaults.dictionaryRepresentation().keys - where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") { + where key.hasPrefix("balance.server.cursor.") + || key.hasPrefix("balance.server.lastPush.") + { defaults.removeObject(forKey: key) } } - private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" } - private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" } + private func cursorKey() -> String { + "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" + } + private func pushKey() -> String { + "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" + } private func deviceID() -> String { - if let value = defaults.string(forKey: "balance.server.deviceID") { return value } - let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value + if let value = defaults.string(forKey: "balance.server.deviceID") { + return value + } + let value = UUID().uuidString + defaults.set(value, forKey: "balance.server.deviceID") + return value } - private func pushTransactions(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws { + private func pushTransactions( + context: ModelContext, + after threshold: Date, + through cutoff: Date, + cursor: Int64 + ) async throws { var offset = 0 while true { let lowerBound = threshold let upperBound = cutoff var descriptor = FetchDescriptor( predicate: #Predicate { item in - item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound + item.syncUpdatedAt >= lowerBound + && item.syncUpdatedAt <= upperBound }, sortBy: [SortDescriptor(\FinanceTransaction.syncUpdatedAt)] ) @@ -289,8 +509,24 @@ final class ServerAccountStore: ObservableObject { let items = try context.fetch(descriptor) guard !items.isEmpty else { break } let changes = try items.map { item in - let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue) - return SyncChange(entity:"transaction", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) + let value = TransactionPayload( + amount: item.amount, + date: item.date, + note: item.note, + categoryName: item.categoryName, + categoryIcon: item.categoryIcon, + categoryEmoji: item.categoryEmoji, + categoryColorName: item.categoryColorName, + isBalanceAdjustment: item.isBalanceAdjustment, + kindRawValue: item.kindRawValue + ) + return SyncChange( + entity: "transaction", + id: item.id.uuidString, + deleted: false, + updatedAt: item.syncUpdatedAt, + payload: try ServerCoding.encoder.encode(value) + ) } try await sendPush(changes, cursor: cursor) offset += items.count @@ -299,14 +535,20 @@ final class ServerAccountStore: ObservableObject { } } - private func pushCategories(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws { + private func pushCategories( + context: ModelContext, + after threshold: Date, + through cutoff: Date, + cursor: Int64 + ) async throws { var offset = 0 while true { let lowerBound = threshold let upperBound = cutoff var descriptor = FetchDescriptor( predicate: #Predicate { item in - item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound + item.syncUpdatedAt >= lowerBound + && item.syncUpdatedAt <= upperBound }, sortBy: [SortDescriptor(\CustomCategory.syncUpdatedAt)] ) @@ -315,8 +557,21 @@ final class ServerAccountStore: ObservableObject { let items = try context.fetch(descriptor) guard !items.isEmpty else { break } let changes = try items.map { item in - let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt) - return SyncChange(entity:"category", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) + let value = CategoryPayload( + name: item.name, + icon: item.icon, + emoji: item.emoji, + colorName: item.colorName, + kindRawValue: item.kindRawValue, + createdAt: item.createdAt + ) + return SyncChange( + entity: "category", + id: item.id.uuidString, + deleted: false, + updatedAt: item.syncUpdatedAt, + payload: try ServerCoding.encoder.encode(value) + ) } try await sendPush(changes, cursor: cursor) offset += items.count @@ -325,14 +580,20 @@ final class ServerAccountStore: ObservableObject { } } - private func pushBudgets(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws { + private func pushBudgets( + context: ModelContext, + after threshold: Date, + through cutoff: Date, + cursor: Int64 + ) async throws { var offset = 0 while true { let lowerBound = threshold let upperBound = cutoff var descriptor = FetchDescriptor( predicate: #Predicate { item in - item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound + item.syncUpdatedAt >= lowerBound + && item.syncUpdatedAt <= upperBound }, sortBy: [SortDescriptor(\MonthlyBudget.syncUpdatedAt)] ) @@ -341,8 +602,20 @@ final class ServerAccountStore: ObservableObject { let items = try context.fetch(descriptor) guard !items.isEmpty else { break } let changes = try items.map { item in - let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart) - return SyncChange(entity:"budget", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) + let value = BudgetPayload( + categoryName: item.categoryName, + categoryIcon: item.categoryIcon, + categoryEmoji: item.categoryEmoji, + limit: item.limit, + monthStart: item.monthStart + ) + return SyncChange( + entity: "budget", + id: item.id.uuidString, + deleted: false, + updatedAt: item.syncUpdatedAt, + payload: try ServerCoding.encoder.encode(value) + ) } try await sendPush(changes, cursor: cursor) offset += items.count @@ -351,16 +624,25 @@ final class ServerAccountStore: ObservableObject { } } - private func pushTombstones(through cutoff: Date, cursor: Int64) async throws { + private func pushTombstones(through cutoff: Date, cursor: Int64) + async throws + { var offset = 0 while true { let items = SyncDeletionStore.page( - through:cutoff, - offset:offset, - limit:syncBatchSize + through: cutoff, + offset: offset, + limit: syncBatchSize ) guard !items.isEmpty else { break } - let changes = items.map { SyncChange(entity:$0.entity, id:$0.recordID, deleted:true, updatedAt:$0.updatedAt) } + let changes = items.map { + SyncChange( + entity: $0.entity, + id: $0.recordID, + deleted: true, + updatedAt: $0.updatedAt + ) + } try await sendPush(changes, cursor: cursor) offset += items.count if items.count < syncBatchSize { break } @@ -371,35 +653,63 @@ final class ServerAccountStore: ObservableObject { private func sendPush(_ changes: [SyncChange], cursor: Int64) async throws { guard !changes.isEmpty else { return } if changes.contains(where: { ($0.payload?.count ?? 0) > 256 * 1024 }) { - throw ServerSyncError.response("Одна из операций содержит слишком большую заметку для синхронизации.") + throw ServerSyncError.response( + "Одна из операций содержит слишком большую заметку для синхронизации." + ) } - let body = SyncRequest(cursor:cursor, deviceId:deviceID(), changes:changes, pull:false) + let body = SyncRequest( + cursor: cursor, + deviceId: deviceID(), + changes: changes, + pull: false + ) if try ServerCoding.encoder.encode(body).count > 1_500_000 { guard changes.count > 1 else { - throw ServerSyncError.response("Одна запись слишком велика для отправки на сервер.") + throw ServerSyncError.response( + "Одна запись слишком велика для отправки на сервер." + ) } let middle = changes.count / 2 - try await sendPush(Array(changes[..= cursor else { - throw ServerSyncError.response("Сервер вернул некорректный курсор синхронизации.") + throw ServerSyncError.response( + "Сервер вернул некорректный курсор синхронизации." + ) } - try apply(response.changes, context:context) + try apply(response.changes, context: context) try context.save() - defaults.set(response.cursor, forKey:cursorKey()) + defaults.set(response.cursor, forKey: cursorKey()) guard response.hasMore == true else { break } guard response.cursor > cursor else { - throw ServerSyncError.response("Сервер не смог продолжить постраничную синхронизацию.") + throw ServerSyncError.response( + "Сервер не смог продолжить постраничную синхронизацию." + ) } cursor = response.cursor await Task.yield() @@ -407,75 +717,243 @@ final class ServerAccountStore: ObservableObject { } private func apply(_ changes: [SyncChange], context: ModelContext) throws { - for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) { - if let id = UUID(uuidString:change.id) { + for change in changes.sorted(by: { + ($0.sequence ?? 0) < ($1.sequence ?? 0) + }) { + if let id = UUID(uuidString: change.id) { if change.deleted { switch change.entity { - case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) } - case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) } - case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) } + case "transaction": + if let item = try transaction(id: id, context: context), + item.syncUpdatedAt <= change.updatedAt + { + context.delete(item) + } + case "category": + if let item = try category(id: id, context: context), + item.syncUpdatedAt <= change.updatedAt + { + context.delete(item) + } + case "budget": + if let item = try budget(id: id, context: context), + item.syncUpdatedAt <= change.updatedAt + { + context.delete(item) + } default: break } } else if let data = change.payload { switch change.entity { case "transaction": - guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { continue } - if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } } - else { context.insert(FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, syncUpdatedAt:change.updatedAt)) } + guard + let value = try? ServerCoding.decoder.decode( + TransactionPayload.self, + from: data + ) + else { continue } + if let item = try transaction(id: id, context: context) + { + if item.syncUpdatedAt < change.updatedAt { + update(item, value, change.updatedAt) + } + } else { + context.insert( + FinanceTransaction( + id: id, + amount: value.amount, + date: value.date, + note: value.note, + categoryName: value.categoryName, + categoryIcon: value.categoryIcon, + categoryEmoji: value.categoryEmoji, + categoryColorName: value.categoryColorName, + isBalanceAdjustment: value + .isBalanceAdjustment, + kind: TransactionKind( + rawValue: value.kindRawValue + ) ?? .expense, + syncUpdatedAt: change.updatedAt + ) + ) + } case "category": - guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { continue } - if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } } - else { context.insert(CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)) } + guard + let value = try? ServerCoding.decoder.decode( + CategoryPayload.self, + from: data + ) + else { continue } + if let item = try category(id: id, context: context) { + if item.syncUpdatedAt < change.updatedAt { + update(item, value, change.updatedAt) + } + } else { + context.insert( + CustomCategory( + id: id, + name: value.name, + icon: value.icon, + emoji: value.emoji, + colorName: value.colorName, + kind: TransactionKind( + rawValue: value.kindRawValue + ) ?? .expense, + createdAt: value.createdAt, + syncUpdatedAt: change.updatedAt + ) + ) + } case "budget": - guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { continue } - if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } } - else { context.insert(MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)) } + guard + let value = try? ServerCoding.decoder.decode( + BudgetPayload.self, + from: data + ) + else { continue } + if let item = try budget(id: id, context: context) { + if item.syncUpdatedAt < change.updatedAt { + update(item, value, change.updatedAt) + } + } else { + context.insert( + MonthlyBudget( + id: id, + categoryName: value.categoryName, + categoryIcon: value.categoryIcon, + categoryEmoji: value.categoryEmoji, + limit: value.limit, + monthStart: value.monthStart, + syncUpdatedAt: change.updatedAt + ) + ) + } default: break } } } - SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt) + SyncDeletionStore.remove( + entity: change.entity, + recordID: change.id, + through: change.updatedAt + ) } } - private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? { + private func transaction(id: UUID, context: ModelContext) throws + -> FinanceTransaction? + { let recordID = id - var descriptor = FetchDescriptor(predicate:#Predicate { item in item.id == recordID }) + var descriptor = FetchDescriptor( + predicate: #Predicate { item in item.id == recordID } + ) descriptor.fetchLimit = 1 return try context.fetch(descriptor).first } - private func category(id: UUID, context: ModelContext) throws -> CustomCategory? { + private func category(id: UUID, context: ModelContext) throws + -> CustomCategory? + { let recordID = id - var descriptor = FetchDescriptor(predicate:#Predicate { item in item.id == recordID }) + var descriptor = FetchDescriptor( + predicate: #Predicate { item in item.id == recordID } + ) descriptor.fetchLimit = 1 return try context.fetch(descriptor).first } - private func budget(id: UUID, context: ModelContext) throws -> MonthlyBudget? { + private func budget(id: UUID, context: ModelContext) throws + -> MonthlyBudget? + { let recordID = id - var descriptor = FetchDescriptor(predicate:#Predicate { item in item.id == recordID }) + var descriptor = FetchDescriptor( + predicate: #Predicate { item in item.id == recordID } + ) descriptor.fetchLimit = 1 return try context.fetch(descriptor).first } - private func update(_ item: FinanceTransaction, _ value: TransactionPayload, _ date: Date) { item.amount=value.amount; item.date=value.date; item.note=value.note; item.categoryName=value.categoryName; item.categoryIcon=value.categoryIcon; item.categoryEmoji=value.categoryEmoji; item.categoryColorName=value.categoryColorName; item.isBalanceAdjustment=value.isBalanceAdjustment; item.kindRawValue=value.kindRawValue; item.syncUpdatedAt=date } - private func update(_ item: CustomCategory, _ value: CategoryPayload, _ date: Date) { item.name=value.name; item.icon=value.icon; item.emoji=value.emoji; item.colorName=value.colorName; item.kindRawValue=value.kindRawValue; item.createdAt=value.createdAt; item.syncUpdatedAt=date } - private func update(_ item: MonthlyBudget, _ value: BudgetPayload, _ date: Date) { item.categoryName=value.categoryName; item.categoryIcon=value.categoryIcon; item.categoryEmoji=value.categoryEmoji; item.limit=value.limit; item.monthStart=value.monthStart; item.syncUpdatedAt=date } + private func update( + _ item: FinanceTransaction, + _ value: TransactionPayload, + _ date: Date + ) { + item.amount = value.amount + item.date = value.date + item.note = value.note + item.categoryName = value.categoryName + item.categoryIcon = value.categoryIcon + item.categoryEmoji = value.categoryEmoji + item.categoryColorName = value.categoryColorName + item.isBalanceAdjustment = value.isBalanceAdjustment + item.kindRawValue = value.kindRawValue + item.syncUpdatedAt = date + } + private func update( + _ item: CustomCategory, + _ value: CategoryPayload, + _ date: Date + ) { + item.name = value.name + item.icon = value.icon + item.emoji = value.emoji + item.colorName = value.colorName + item.kindRawValue = value.kindRawValue + item.createdAt = value.createdAt + item.syncUpdatedAt = date + } + private func update( + _ item: MonthlyBudget, + _ value: BudgetPayload, + _ date: Date + ) { + item.categoryName = value.categoryName + item.categoryIcon = value.categoryIcon + item.categoryEmoji = value.categoryEmoji + item.limit = value.limit + item.monthStart = value.monthStart + item.syncUpdatedAt = date + } } private struct EmptyResponse: Codable {} private enum KeychainStore { static func read(_ key: String) -> Data? { - let query: [String:Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:"BalanceServer", kSecAttrAccount as String:key, kSecReturnData as String:true, kSecMatchLimit as String:kSecMatchLimitOne] - var result: AnyObject?; guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }; return result as? Data + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "BalanceServer", + kSecAttrAccount as String: key, kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + guard + SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess + else { return nil } + return result as? Data } static func write(_ data: Data, key: String) throws { delete(key) - let query: [String:Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:"BalanceServer", kSecAttrAccount as String:key, kSecValueData as String:data, kSecAttrAccessible as String:kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] - guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { throw ServerSyncError.response("Не удалось сохранить сессию в Keychain.") } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "BalanceServer", + kSecAttrAccount as String: key, kSecValueData as String: data, + kSecAttrAccessible as String: + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { + throw ServerSyncError.response( + "Не удалось сохранить сессию в Keychain." + ) + } + } + static func delete(_ key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "BalanceServer", + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) } - static func delete(_ key: String) { let query:[String:Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:"BalanceServer", kSecAttrAccount as String:key]; SecItemDelete(query as CFDictionary) } } @MainActor @@ -487,49 +965,119 @@ struct ServerAccountView: View { @State private var password = "" var body: some View { - Form { - Section("Сервер") { - TextField("https://balance.example.com", text:$server) - .serverTextEntryConfiguration() - Button("Сохранить адрес") { do { try account.saveServer(server); server = account.serverURL; account.message = "Адрес сохранён. Войдите заново." } catch { account.message = error.localizedDescription } } - .disabled(account.isBusy) + #if os(iOS) + Form { + ServerAccountViewContent } - Section(account.isSignedIn ? "Аккаунт" : "Вход или регистрация") { - if let value = account.signedInEmail { LabeledContent("Выполнен вход", value:value) } - else { - TextField("Email", text:$email) - .serverTextEntryConfiguration() - SecureField("Пароль (минимум 8 символов)", text:$password) - Button("Войти") { Task { await account.login(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.isEmpty || account.isBusy) - Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy) - } + .navigationTitle("Сервер") + #else + List { + ServerAccountViewContent } - if account.isSignedIn { - Section("Синхронизация") { - Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy) - Button("Полная пересинхронизация") { Task { await account.resetSynchronization(context:modelContext) } } - .disabled(account.isBusy) - if let date = account.lastSyncAt { LabeledContent("Последняя", value:date.formatted(date:.abbreviated, time:.shortened)) } - Button("Выйти", role:.destructive) { Task { await account.logout() } } - .disabled(account.isBusy) + .navigationTitle("Сервер") + #endif + } + + @ViewBuilder + var ServerAccountViewContent: some View { + Section("Сервер") { + TextField("https://balance.example.com", text: $server) + .serverTextEntryConfiguration() + Button("Сохранить адрес") { + do { + try account.saveServer(server) + server = account.serverURL + account.message = "Адрес сохранён. Войдите заново." + } catch { account.message = error.localizedDescription } + } + .disabled(account.isBusy) + } + Section(account.isSignedIn ? "Аккаунт" : "Вход или регистрация") { + if let value = account.signedInEmail { + LabeledContent("Выполнен вход", value: value) + } else { + TextField("Email", text: $email) + .serverTextEntryConfiguration() + SecureField("Пароль (минимум 8 символов)", text: $password) + Button("Войти") { + Task { + await account.login( + email: email, + password: password, + context: modelContext + ) + } + }.disabled( + server.isEmpty || email.isEmpty || password.isEmpty + || account.isBusy + ) + Button("Зарегистрироваться") { + Task { + await account.register( + email: email, + password: password, + context: modelContext + ) + } + }.disabled( + server.isEmpty || email.isEmpty || password.count < 8 + || account.isBusy + ) + } + } + if account.isSignedIn { + Section("Синхронизация") { + Button("Синхронизировать сейчас") { + Task { await account.sync(context: modelContext) } + }.disabled(account.isBusy) + Button("Полная пересинхронизация") { + Task { + await account.resetSynchronization( + context: modelContext + ) + } } + .disabled(account.isBusy) + if let date = account.lastSyncAt { + LabeledContent( + "Последняя", + value: date.formatted( + date: .abbreviated, + time: .shortened + ) + ) + } + Button("Выйти", role: .destructive) { + Task { await account.logout() } + } + .disabled(account.isBusy) + } + } + if account.isBusy { ProgressView() } + if let message = account.message { + Section { + Text(message).font(.footnote).foregroundStyle(.secondary) } - if account.isBusy { ProgressView() } - if let message = account.message { Section { Text(message).font(.footnote).foregroundStyle(.secondary) } } } - .navigationTitle("Сервер") } } -private extension View { +extension View { @ViewBuilder - func serverTextEntryConfiguration() -> some View { + fileprivate func serverTextEntryConfiguration() -> some View { #if os(macOS) - self + self #else - self - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + self + .textInputAutocapitalization(.never) + .autocorrectionDisabled() #endif } } + +#Preview("Сервер и синхронизация") { + NavigationStack { + ServerAccountView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Analytics/AnalyticsView.swift b/Balance/Balance/Views/Analytics/AnalyticsView.swift index d947871..ef96292 100644 --- a/Balance/Balance/Views/Analytics/AnalyticsView.swift +++ b/Balance/Balance/Views/Analytics/AnalyticsView.swift @@ -334,6 +334,11 @@ struct AnalyticsView: View { } } +#Preview("Аналитика") { + AnalyticsView() + .modelContainer(BalanceModelContainer.previewContainer) +} + private struct AnalyticsMetricCard: View { let title: String let value: String @@ -355,3 +360,21 @@ private struct AnalyticsMetricCard: View { .background(.background, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } } + +#Preview("Карточка метрики") { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + AnalyticsMetricCard( + title: "Доходы", + value: "150 000 ₽", + icon: "arrow.down.left", + tint: .green + ) + AnalyticsMetricCard( + title: "Расходы", + value: "87 300 ₽", + icon: "arrow.up.right", + tint: .red + ) + } + .padding() +} diff --git a/Balance/Balance/Views/Budgets/BudgetEditorView.swift b/Balance/Balance/Views/Budgets/BudgetEditorView.swift index ed0a254..8edf3d2 100644 --- a/Balance/Balance/Views/Budgets/BudgetEditorView.swift +++ b/Balance/Balance/Views/Budgets/BudgetEditorView.swift @@ -111,3 +111,24 @@ struct BudgetEditorView: View { dismiss() } } + +#Preview("Лимиты") { + BudgetEditorView( + budgets: [ + MonthlyBudget( + categoryName: "Продукты", + categoryIcon: "cart.fill", + limit: 30_000, + monthStart: .now + ), + MonthlyBudget( + categoryName: "Транспорт", + categoryIcon: "car.fill", + limit: 8_000, + monthStart: .now + ) + ], + monthStart: FinanceCalculations.monthInterval(containing: .now).start + ) + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Budgets/BudgetsView.swift b/Balance/Balance/Views/Budgets/BudgetsView.swift index 7a2f46d..912dbb8 100644 --- a/Balance/Balance/Views/Budgets/BudgetsView.swift +++ b/Balance/Balance/Views/Budgets/BudgetsView.swift @@ -116,3 +116,8 @@ struct BudgetsView: View { return .indigo } } + +#Preview("Бюджеты") { + BudgetsView() + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Components/CategoryIconView.swift b/Balance/Balance/Views/Components/CategoryIconView.swift index 522a85d..1f71295 100644 --- a/Balance/Balance/Views/Components/CategoryIconView.swift +++ b/Balance/Balance/Views/Components/CategoryIconView.swift @@ -33,3 +33,20 @@ struct CategoryIconView: View { } } } + +#Preview("Иконки") { + HStack(spacing: 16) { + CategoryIconView(systemName: "cart.fill", emoji: "", tint: .orange, containerSize: 40) + CategoryIconView(systemName: "car.fill", emoji: "", tint: .blue) + CategoryIconView(systemName: "banknote.fill", emoji: "", tint: .green, size: 24, containerSize: 52) + } + .padding() +} + +#Preview("Эмодзи") { + HStack(spacing: 16) { + CategoryIconView(systemName: "star.fill", emoji: "☕️", tint: .brown, size: 21, containerSize: 40) + CategoryIconView(systemName: "star.fill", emoji: "🐶", tint: .purple, size: 21, containerSize: 40) + } + .padding() +} diff --git a/Balance/Balance/Views/Components/EmptyStateCard.swift b/Balance/Balance/Views/Components/EmptyStateCard.swift index 521beea..8da3d42 100644 --- a/Balance/Balance/Views/Components/EmptyStateCard.swift +++ b/Balance/Balance/Views/Components/EmptyStateCard.swift @@ -22,3 +22,12 @@ struct EmptyStateCard: View { .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) } } + +#Preview("Пустое состояние") { + EmptyStateCard( + icon: "tray", + title: "История пуста", + message: "Доходы и расходы будут собраны здесь." + ) + .padding() +} diff --git a/Balance/Balance/Views/Components/SummaryCard.swift b/Balance/Balance/Views/Components/SummaryCard.swift index 2f0acf5..6a7d64e 100644 --- a/Balance/Balance/Views/Components/SummaryCard.swift +++ b/Balance/Balance/Views/Components/SummaryCard.swift @@ -31,3 +31,23 @@ struct SummaryCard: View { .shadow(color: .black.opacity(0.05), radius: 12, y: 5) } } + +#Preview("Карточки сводки") { + HStack(spacing: 12) { + SummaryCard( + title: "Доходы", + amount: 150_000, + currencyCode: "RUB", + icon: "arrow.down.left", + tint: .green + ) + SummaryCard( + title: "Расходы", + amount: 87_300, + currencyCode: "RUB", + icon: "arrow.up.right", + tint: .red + ) + } + .padding() +} diff --git a/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift b/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift index ed779ba..5f40ef6 100644 --- a/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift +++ b/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift @@ -105,3 +105,8 @@ struct BalanceAdjustmentView: View { dismiss() } } + +#Preview("Корректировка баланса") { + BalanceAdjustmentView() + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Dashboard/DashboardView.swift b/Balance/Balance/Views/Dashboard/DashboardView.swift index e4b5bad..4ac6171 100644 --- a/Balance/Balance/Views/Dashboard/DashboardView.swift +++ b/Balance/Balance/Views/Dashboard/DashboardView.swift @@ -211,3 +211,8 @@ struct DashboardView: View { return FinanceCategory.color(named: item.colorName) } } + +#Preview("Обзор") { + DashboardView() + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Settings/CategoriesView.swift b/Balance/Balance/Views/Settings/CategoriesView.swift index 2c4aa63..ec4e13a 100644 --- a/Balance/Balance/Views/Settings/CategoriesView.swift +++ b/Balance/Balance/Views/Settings/CategoriesView.swift @@ -338,3 +338,28 @@ private struct CategoryEditorView: View { dismiss() } } + +#Preview("Категории") { + NavigationStack { + CategoriesView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Новая категория") { + CategoryEditorView() + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Редактирование категории") { + CategoryEditorView( + category: CustomCategory( + name: "Кофе", + icon: "cup.and.saucer.fill", + emoji: "☕️", + colorName: "brown", + kind: .expense + ) + ) + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Settings/SettingsView.swift b/Balance/Balance/Views/Settings/SettingsView.swift index 8f15992..f9a5cca 100644 --- a/Balance/Balance/Views/Settings/SettingsView.swift +++ b/Balance/Balance/Views/Settings/SettingsView.swift @@ -84,3 +84,8 @@ struct SettingsView: View { return "Без входа данные остаются на устройстве. Собственный сервер можно подключить выше без iCloud и платной Apple Developer Team." } } + +#Preview("Настройки") { + SettingsView() + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Transactions/NewTransactionView.swift b/Balance/Balance/Views/Transactions/NewTransactionView.swift index 875cb24..21cb482 100644 --- a/Balance/Balance/Views/Transactions/NewTransactionView.swift +++ b/Balance/Balance/Views/Transactions/NewTransactionView.swift @@ -175,3 +175,22 @@ struct NewTransactionView: View { return kind == .expense ? "Новый расход" : "Новый доход" } } + +#Preview("Новая операция") { + NewTransactionView() + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Редактирование операции") { + NewTransactionView( + transaction: FinanceTransaction( + amount: 2_450, + date: .now, + note: "Пятёрочка у дома", + categoryName: "Продукты", + categoryIcon: "cart.fill", + kind: .expense + ) + ) + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/Balance/Views/Transactions/TransactionRow.swift b/Balance/Balance/Views/Transactions/TransactionRow.swift index 461dea5..5fd36f0 100644 --- a/Balance/Balance/Views/Transactions/TransactionRow.swift +++ b/Balance/Balance/Views/Transactions/TransactionRow.swift @@ -44,3 +44,37 @@ struct TransactionRow: View { return prefix + transaction.amount.formattedCurrency(code: currencyCode) } } + +#Preview("Расход") { + List { + TransactionRow( + transaction: FinanceTransaction( + amount: 2_450, + date: .now, + note: "Пятёрочка у дома", + categoryName: "Продукты", + categoryIcon: "cart.fill", + kind: .expense + ), + currencyCode: "RUB" + ) + } + .listStyle(.plain) +} + +#Preview("Доход") { + List { + TransactionRow( + transaction: FinanceTransaction( + amount: 150_000, + date: .now, + note: "", + categoryName: "Зарплата", + categoryIcon: "banknote.fill", + kind: .income + ), + currencyCode: "RUB" + ) + } + .listStyle(.plain) +} diff --git a/Balance/Balance/Views/Transactions/TransactionsView.swift b/Balance/Balance/Views/Transactions/TransactionsView.swift index 763a22e..b26d180 100644 --- a/Balance/Balance/Views/Transactions/TransactionsView.swift +++ b/Balance/Balance/Views/Transactions/TransactionsView.swift @@ -102,3 +102,8 @@ struct TransactionsView: View { } } } + +#Preview("Операции") { + TransactionsView() + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/BalanceMac/MacContentView.swift b/Balance/BalanceMac/MacContentView.swift index 489e406..ecb9c18 100644 --- a/Balance/BalanceMac/MacContentView.swift +++ b/Balance/BalanceMac/MacContentView.swift @@ -434,3 +434,57 @@ private struct MacCategoriesView: View { } } } + +#Preview("Главное окно") { + MacContentView() + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Обзор") { + MacOverviewView() + .modelContainer(BalanceModelContainer.previewContainer) + .frame(width: 900, height: 620) +} + +#Preview("Карточки метрик") { + HStack(spacing: 14) { + MacMetricCard( + title: "Общий баланс", + value: "62 700 ₽", + icon: "creditcard.fill", + tint: .indigo + ) + MacMetricCard( + title: "Доходы за месяц", + value: "150 000 ₽", + icon: "arrow.down.left", + tint: .green + ) + MacMetricCard( + title: "Расходы за месяц", + value: "87 300 ₽", + icon: "arrow.up.right", + tint: .red + ) + } + .padding() + .frame(width: 720) +} + +#Preview("Операции") { + MacTransactionsView() + .modelContainer(BalanceModelContainer.previewContainer) + .frame(width: 900, height: 620) +} + +#Preview("Бюджеты") { + MacBudgetsView() + .modelContainer(BalanceModelContainer.previewContainer) + .frame(width: 900, height: 620) +} + +#Preview("Категории") { + MacCategoriesView() + .modelContainer(BalanceModelContainer.previewContainer) + .frame(width: 900, height: 620) +} diff --git a/Balance/BalanceMac/MacEditors.swift b/Balance/BalanceMac/MacEditors.swift index c000816..74787ca 100644 --- a/Balance/BalanceMac/MacEditors.swift +++ b/Balance/BalanceMac/MacEditors.swift @@ -107,6 +107,25 @@ struct MacTransactionEditor: View { } } +#Preview("Новая операция") { + MacTransactionEditor() + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Редактирование операции") { + MacTransactionEditor( + transaction: FinanceTransaction( + amount: 2_450, + date: .now, + note: "Пятёрочка у дома", + categoryName: "Продукты", + categoryIcon: "cart.fill", + kind: .expense + ) + ) + .modelContainer(BalanceModelContainer.previewContainer) +} + struct MacCategoryEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -231,3 +250,21 @@ struct MacCategoryEditor: View { dismiss() } } + +#Preview("Новая категория") { + MacCategoryEditor() + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Редактирование категории") { + MacCategoryEditor( + category: CustomCategory( + name: "Кофе", + icon: "cup.and.saucer.fill", + emoji: "☕️", + colorName: "brown", + kind: .expense + ) + ) + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/BalanceMac/MacFeatures.swift b/Balance/BalanceMac/MacFeatures.swift index ff627d6..9fb586f 100644 --- a/Balance/BalanceMac/MacFeatures.swift +++ b/Balance/BalanceMac/MacFeatures.swift @@ -174,6 +174,12 @@ struct MacAnalyticsView: View { } } +#Preview("Аналитика") { + MacAnalyticsView() + .modelContainer(BalanceModelContainer.previewContainer) + .frame(width: 1000, height: 700) +} + struct MacBalanceAdjustmentView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -229,6 +235,11 @@ struct MacBalanceAdjustmentView: View { } } +#Preview("Корректировка баланса") { + MacBalanceAdjustmentView() + .modelContainer(BalanceModelContainer.previewContainer) +} + struct MacBudgetEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -308,6 +319,27 @@ struct MacBudgetEditor: View { } } +#Preview("Лимиты") { + MacBudgetEditor( + budgets: [ + MonthlyBudget( + categoryName: "Продукты", + categoryIcon: "cart.fill", + limit: 30_000, + monthStart: .now + ), + MonthlyBudget( + categoryName: "Транспорт", + categoryIcon: "car.fill", + limit: 8_000, + monthStart: .now + ) + ], + monthStart: FinanceCalculations.monthInterval(containing: .now).start + ) + .modelContainer(BalanceModelContainer.previewContainer) +} + struct MacSettingsView: View { @Query private var transactions: [FinanceTransaction] @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue @@ -359,3 +391,11 @@ struct MacSettingsView: View { .padding(24) } } + +#Preview("Настройки") { + NavigationStack { + MacSettingsView() + } + .modelContainer(BalanceModelContainer.previewContainer) + .frame(width: 640, height: 640) +} diff --git a/Balance/BalanceWatch/WatchAddTransactionView.swift b/Balance/BalanceWatch/WatchAddTransactionView.swift index 49bfceb..6a88a97 100644 --- a/Balance/BalanceWatch/WatchAddTransactionView.swift +++ b/Balance/BalanceWatch/WatchAddTransactionView.swift @@ -108,3 +108,22 @@ struct WatchAddTransactionView: View { dismiss() } } + +#Preview("Новая операция") { + WatchAddTransactionView() + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Редактирование операции") { + WatchAddTransactionView( + transaction: FinanceTransaction( + amount: 2_450, + date: .now, + note: "Пятёрочка у дома", + categoryName: "Продукты", + categoryIcon: "cart.fill", + kind: .expense + ) + ) + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/BalanceWatch/WatchFeatures.swift b/Balance/BalanceWatch/WatchFeatures.swift index ccd5f8a..ebf6378 100644 --- a/Balance/BalanceWatch/WatchFeatures.swift +++ b/Balance/BalanceWatch/WatchFeatures.swift @@ -99,6 +99,13 @@ struct WatchAnalyticsView: View { } } +#Preview("Аналитика") { + NavigationStack { + WatchAnalyticsView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + struct WatchTransactionsView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \FinanceTransaction.date, order: .reverse) @@ -169,6 +176,13 @@ struct WatchTransactionsView: View { } } +#Preview("Операции") { + NavigationStack { + WatchTransactionsView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + struct WatchBudgetsView: View { @Query private var budgets: [MonthlyBudget] @Query private var transactions: [FinanceTransaction] @@ -218,6 +232,13 @@ struct WatchBudgetsView: View { } } +#Preview("Бюджеты") { + NavigationStack { + WatchBudgetsView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + private struct WatchBudgetEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -242,7 +263,9 @@ private struct WatchBudgetEditor: View { var body: some View { Form { ForEach(categories) { category in - TextField(category.name, value: binding(category.name), format: .number) + Section(category.name) { + TextField(category.name, value: binding(category.name), format: .number) + } } Button("Сохранить", action: save) } @@ -278,6 +301,29 @@ private struct WatchBudgetEditor: View { } } +#Preview("Лимиты") { + NavigationStack { + WatchBudgetEditor( + budgets: [ + MonthlyBudget( + categoryName: "Продукты", + categoryIcon: "cart.fill", + limit: 30_000, + monthStart: .now + ), + MonthlyBudget( + categoryName: "Транспорт", + categoryIcon: "car.fill", + limit: 8_000, + monthStart: .now + ) + ], + monthStart: FinanceCalculations.monthInterval(containing: .now).start + ) + } + .modelContainer(BalanceModelContainer.previewContainer) +} + struct WatchCategoriesView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \CustomCategory.createdAt) private var categories: [CustomCategory] @@ -323,6 +369,13 @@ struct WatchCategoriesView: View { } } +#Preview("Категории") { + NavigationStack { + WatchCategoriesView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + private struct WatchCategoryEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -425,6 +478,28 @@ private struct WatchCategoryEditor: View { } } +#Preview("Новая категория") { + NavigationStack { + WatchCategoryEditor() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + +#Preview("Редактирование категории") { + NavigationStack { + WatchCategoryEditor( + category: CustomCategory( + name: "Кофе", + icon: "cup.and.saucer.fill", + emoji: "☕️", + colorName: "brown", + kind: .expense + ) + ) + } + .modelContainer(BalanceModelContainer.previewContainer) +} + struct WatchBalanceAdjustmentView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -465,6 +540,13 @@ struct WatchBalanceAdjustmentView: View { } } +#Preview("Корректировка баланса") { + NavigationStack { + WatchBalanceAdjustmentView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} + struct WatchSettingsView: View { @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue @@ -491,3 +573,10 @@ struct WatchSettingsView: View { .navigationTitle("Настройки") } } + +#Preview("Настройки") { + NavigationStack { + WatchSettingsView() + } + .modelContainer(BalanceModelContainer.previewContainer) +} diff --git a/Balance/BalanceWatch/WatchRootView.swift b/Balance/BalanceWatch/WatchRootView.swift index fdd89fb..6c148b5 100644 --- a/Balance/BalanceWatch/WatchRootView.swift +++ b/Balance/BalanceWatch/WatchRootView.swift @@ -57,8 +57,9 @@ struct WatchRootView: View { showingAddTransaction = true } label: { Label("Добавить операцию", systemImage: "plus.circle.fill") - .frame(maxWidth: .infinity) +// .frame(maxWidth: .infinity) } + .frame(maxWidth: .infinity) .buttonStyle(.borderedProminent) VStack(spacing: 4) { @@ -67,35 +68,41 @@ struct WatchRootView: View { } label: { Label("Аналитика", systemImage: "chart.xyaxis.line") } + .frame(maxWidth: .infinity) NavigationLink { WatchTransactionsView() } label: { Label("Все операции", systemImage: "list.bullet.rectangle") } + .frame(maxWidth: .infinity) NavigationLink { WatchBudgetsView() } label: { Label("Бюджеты", systemImage: "target") } + .frame(maxWidth: .infinity) NavigationLink { WatchCategoriesView() } label: { Label("Категории", systemImage: "square.grid.2x2") } + .frame(maxWidth: .infinity) NavigationLink { WatchBalanceAdjustmentView() } label: { Label("Корректировка", systemImage: "slider.horizontal.3") } + .frame(maxWidth: .infinity) NavigationLink { WatchSettingsView() } label: { Label("Настройки", systemImage: "gearshape") } + .frame(maxWidth: .infinity) } if !transactions.isEmpty { - Text("Последние") + Text("Последние операции") .font(.headline) ForEach(transactions.prefix(4)) { transaction in HStack(spacing: 8) { @@ -156,3 +163,8 @@ struct WatchRootView: View { return FinanceCategory.color(named: transaction.categoryColorName) } } + +#Preview("Обзор") { + WatchRootView() + .modelContainer(BalanceModelContainer.previewContainer) +}