diff --git a/Balance/Balance/App/ContentView.swift b/Balance/Balance/App/ContentView.swift index 0a793c2..dd17df6 100644 --- a/Balance/Balance/App/ContentView.swift +++ b/Balance/Balance/App/ContentView.swift @@ -38,8 +38,3 @@ struct ContentView: View { } } } - -#Preview("Главный экран") { - ContentView() - .modelContainer(BalanceModelContainer.previewContainer) -} diff --git a/Balance/Balance/Shared/BalanceModelContainer.swift b/Balance/Balance/Shared/BalanceModelContainer.swift index ec78345..96222c0 100644 --- a/Balance/Balance/Shared/BalanceModelContainer.swift +++ b/Balance/Balance/Shared/BalanceModelContainer.swift @@ -9,23 +9,7 @@ enum BalanceModelContainer { SyncTombstone.self ]) - static var isCloudSyncEnabled: Bool { - Bundle.main.object(forInfoDictionaryKey: "BalanceCloudKitEnabled") as? Bool == true - } - static func make() -> ModelContainer { - if isCloudSyncEnabled, - let cloudContainer = try? ModelContainer( - for: schema, - configurations: [ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: false, - cloudKitDatabase: .automatic - )] - ) { - return cloudContainer - } - let localConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false, @@ -56,101 +40,3 @@ 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 bbb93fe..75b54c9 100644 --- a/Balance/Balance/Shared/ServerSync.swift +++ b/Balance/Balance/Shared/ServerSync.swift @@ -3,10 +3,7 @@ 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 @@ -14,10 +11,7 @@ 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 { @@ -29,25 +23,10 @@ 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) @@ -60,48 +39,26 @@ 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) - case number(Double) - case bool(Bool) - case object([String: JSONValue]) - case array([JSONValue]) - case null + case string(String), number(Double), bool(Bool), object([String: JSONValue]), array([JSONValue]), 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() @@ -129,30 +86,14 @@ 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 { @@ -160,11 +101,9 @@ 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 }() @@ -173,14 +112,9 @@ 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" @@ -193,15 +127,11 @@ private enum ServerCoding { } enum ServerSyncError: LocalizedError { - case invalidURL, insecureURL - case response(String) - case notSignedIn + case invalidURL, insecureURL, response(String), 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: "Сначала войдите в аккаунт." } @@ -226,281 +156,131 @@ 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)" - ) - } - if Response.self == EmptyResponse.self { - return EmptyResponse() as! Response + throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)") } + 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)] ) @@ -509,24 +289,8 @@ 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 @@ -535,20 +299,14 @@ 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)] ) @@ -557,21 +315,8 @@ 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 @@ -580,20 +325,14 @@ 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)] ) @@ -602,20 +341,8 @@ 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 @@ -624,25 +351,16 @@ 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 } @@ -653,63 +371,35 @@ 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() @@ -717,243 +407,75 @@ 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." - ) - } - } - 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) + 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) } } @MainActor @@ -965,119 +487,49 @@ struct ServerAccountView: View { @State private var password = "" var body: some View { - #if os(iOS) - Form { - ServerAccountViewContent - } - .navigationTitle("Сервер") - #else - List { - ServerAccountViewContent - } - .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) + Form { + Section("Сервер") { + TextField("https://balance.example.com", text:$server) .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 - ) + Button("Сохранить адрес") { do { try account.saveServer(server); server = account.serverURL; account.message = "Адрес сохранён. Войдите заново." } catch { account.message = error.localizedDescription } } + .disabled(account.isBusy) } - } - if account.isSignedIn { - Section("Синхронизация") { - Button("Синхронизировать сейчас") { - Task { await account.sync(context: modelContext) } - }.disabled(account.isBusy) - Button("Полная пересинхронизация") { - Task { - await account.resetSynchronization( - context: modelContext - ) - } + 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) } - .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.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) } } } + .navigationTitle("Сервер") } } -extension View { +private extension View { @ViewBuilder - fileprivate func serverTextEntryConfiguration() -> some View { + 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 ef96292..d947871 100644 --- a/Balance/Balance/Views/Analytics/AnalyticsView.swift +++ b/Balance/Balance/Views/Analytics/AnalyticsView.swift @@ -334,11 +334,6 @@ struct AnalyticsView: View { } } -#Preview("Аналитика") { - AnalyticsView() - .modelContainer(BalanceModelContainer.previewContainer) -} - private struct AnalyticsMetricCard: View { let title: String let value: String @@ -360,21 +355,3 @@ 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 8edf3d2..ed0a254 100644 --- a/Balance/Balance/Views/Budgets/BudgetEditorView.swift +++ b/Balance/Balance/Views/Budgets/BudgetEditorView.swift @@ -111,24 +111,3 @@ 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 912dbb8..7a2f46d 100644 --- a/Balance/Balance/Views/Budgets/BudgetsView.swift +++ b/Balance/Balance/Views/Budgets/BudgetsView.swift @@ -116,8 +116,3 @@ 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 1f71295..522a85d 100644 --- a/Balance/Balance/Views/Components/CategoryIconView.swift +++ b/Balance/Balance/Views/Components/CategoryIconView.swift @@ -33,20 +33,3 @@ 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 8da3d42..521beea 100644 --- a/Balance/Balance/Views/Components/EmptyStateCard.swift +++ b/Balance/Balance/Views/Components/EmptyStateCard.swift @@ -22,12 +22,3 @@ 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 6a7d64e..2f0acf5 100644 --- a/Balance/Balance/Views/Components/SummaryCard.swift +++ b/Balance/Balance/Views/Components/SummaryCard.swift @@ -31,23 +31,3 @@ 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 5f40ef6..ed779ba 100644 --- a/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift +++ b/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift @@ -105,8 +105,3 @@ 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 4ac6171..e4b5bad 100644 --- a/Balance/Balance/Views/Dashboard/DashboardView.swift +++ b/Balance/Balance/Views/Dashboard/DashboardView.swift @@ -211,8 +211,3 @@ 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 ec4e13a..2c4aa63 100644 --- a/Balance/Balance/Views/Settings/CategoriesView.swift +++ b/Balance/Balance/Views/Settings/CategoriesView.swift @@ -338,28 +338,3 @@ 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 f9a5cca..6db01b1 100644 --- a/Balance/Balance/Views/Settings/SettingsView.swift +++ b/Balance/Balance/Views/Settings/SettingsView.swift @@ -78,14 +78,6 @@ struct SettingsView: View { } private var syncDescription: String { - if BalanceModelContainer.isCloudSyncEnabled { - return "Данные синхронизируются через приватную базу CloudKit вашего Apple ID." - } - return "Без входа данные остаются на устройстве. Собственный сервер можно подключить выше без iCloud и платной Apple Developer Team." + return "Без входа данные остаются на устройстве. Синхронизация операций, категорий и бюджетов доступна через собственный Go-сервер." } } - -#Preview("Настройки") { - SettingsView() - .modelContainer(BalanceModelContainer.previewContainer) -} diff --git a/Balance/Balance/Views/Transactions/NewTransactionView.swift b/Balance/Balance/Views/Transactions/NewTransactionView.swift index 21cb482..875cb24 100644 --- a/Balance/Balance/Views/Transactions/NewTransactionView.swift +++ b/Balance/Balance/Views/Transactions/NewTransactionView.swift @@ -175,22 +175,3 @@ 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 5fd36f0..461dea5 100644 --- a/Balance/Balance/Views/Transactions/TransactionRow.swift +++ b/Balance/Balance/Views/Transactions/TransactionRow.swift @@ -44,37 +44,3 @@ 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 b26d180..763a22e 100644 --- a/Balance/Balance/Views/Transactions/TransactionsView.swift +++ b/Balance/Balance/Views/Transactions/TransactionsView.swift @@ -102,8 +102,3 @@ struct TransactionsView: View { } } } - -#Preview("Операции") { - TransactionsView() - .modelContainer(BalanceModelContainer.previewContainer) -} diff --git a/Balance/BalanceMac/MacContentView.swift b/Balance/BalanceMac/MacContentView.swift index ecb9c18..ce4d52b 100644 --- a/Balance/BalanceMac/MacContentView.swift +++ b/Balance/BalanceMac/MacContentView.swift @@ -336,7 +336,7 @@ private struct MacBudgetsView: View { ContentUnavailableView( "Бюджеты не заданы", systemImage: "target", - description: Text("Настройте лимиты на iPhone — они появятся здесь через iCloud.") + description: Text("Задайте лимиты здесь или подключите тот же Go-сервер и аккаунт для синхронизации с другими устройствами.") ) } else { List(currentBudgets) { budget in @@ -434,57 +434,3 @@ 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 74787ca..c000816 100644 --- a/Balance/BalanceMac/MacEditors.swift +++ b/Balance/BalanceMac/MacEditors.swift @@ -107,25 +107,6 @@ 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 @@ -250,21 +231,3 @@ 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 9fb586f..9534c13 100644 --- a/Balance/BalanceMac/MacFeatures.swift +++ b/Balance/BalanceMac/MacFeatures.swift @@ -174,12 +174,6 @@ 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 @@ -235,11 +229,6 @@ struct MacBalanceAdjustmentView: View { } } -#Preview("Корректировка баланса") { - MacBalanceAdjustmentView() - .modelContainer(BalanceModelContainer.previewContainer) -} - struct MacBudgetEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -319,27 +308,6 @@ 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 @@ -376,10 +344,8 @@ struct MacSettingsView: View { } Section("Хранение") { Label( - BalanceModelContainer.isCloudSyncEnabled - ? "Синхронизация CloudKit включена" - : "Локальный режим для Personal Team", - systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill" + "Локальное хранилище; синхронизация через Go-сервер", + systemImage: "internaldrive.fill" ) } Section { @@ -391,11 +357,3 @@ 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 6a88a97..49bfceb 100644 --- a/Balance/BalanceWatch/WatchAddTransactionView.swift +++ b/Balance/BalanceWatch/WatchAddTransactionView.swift @@ -108,22 +108,3 @@ 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 ebf6378..162b6f7 100644 --- a/Balance/BalanceWatch/WatchFeatures.swift +++ b/Balance/BalanceWatch/WatchFeatures.swift @@ -99,13 +99,6 @@ struct WatchAnalyticsView: View { } } -#Preview("Аналитика") { - NavigationStack { - WatchAnalyticsView() - } - .modelContainer(BalanceModelContainer.previewContainer) -} - struct WatchTransactionsView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \FinanceTransaction.date, order: .reverse) @@ -176,13 +169,6 @@ struct WatchTransactionsView: View { } } -#Preview("Операции") { - NavigationStack { - WatchTransactionsView() - } - .modelContainer(BalanceModelContainer.previewContainer) -} - struct WatchBudgetsView: View { @Query private var budgets: [MonthlyBudget] @Query private var transactions: [FinanceTransaction] @@ -232,13 +218,6 @@ struct WatchBudgetsView: View { } } -#Preview("Бюджеты") { - NavigationStack { - WatchBudgetsView() - } - .modelContainer(BalanceModelContainer.previewContainer) -} - private struct WatchBudgetEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -263,9 +242,7 @@ private struct WatchBudgetEditor: View { var body: some View { Form { ForEach(categories) { category in - Section(category.name) { - TextField(category.name, value: binding(category.name), format: .number) - } + TextField(category.name, value: binding(category.name), format: .number) } Button("Сохранить", action: save) } @@ -301,29 +278,6 @@ 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] @@ -369,13 +323,6 @@ struct WatchCategoriesView: View { } } -#Preview("Категории") { - NavigationStack { - WatchCategoriesView() - } - .modelContainer(BalanceModelContainer.previewContainer) -} - private struct WatchCategoryEditor: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -478,28 +425,6 @@ 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 @@ -540,13 +465,6 @@ 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 @@ -565,18 +483,11 @@ struct WatchSettingsView: View { Label("Сервер", systemImage: "server.rack") } Label( - BalanceModelContainer.isCloudSyncEnabled ? "CloudKit" : "Локально", - systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill" + "Локально; синхронизация через сервер", + systemImage: "internaldrive.fill" ) LabeledContent("Версия", value: "3.1.1") } .navigationTitle("Настройки") } } - -#Preview("Настройки") { - NavigationStack { - WatchSettingsView() - } - .modelContainer(BalanceModelContainer.previewContainer) -} diff --git a/Balance/BalanceWatch/WatchRootView.swift b/Balance/BalanceWatch/WatchRootView.swift index 6c148b5..fdd89fb 100644 --- a/Balance/BalanceWatch/WatchRootView.swift +++ b/Balance/BalanceWatch/WatchRootView.swift @@ -57,9 +57,8 @@ 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) { @@ -68,41 +67,35 @@ 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) { @@ -163,8 +156,3 @@ struct WatchRootView: View { return FinanceCategory.color(named: transaction.categoryColorName) } } - -#Preview("Обзор") { - WatchRootView() - .modelContainer(BalanceModelContainer.previewContainer) -} diff --git a/Balance/Config/Balance.entitlements b/Balance/Config/Balance.entitlements index 77072f1..0c67376 100644 --- a/Balance/Config/Balance.entitlements +++ b/Balance/Config/Balance.entitlements @@ -1,16 +1,5 @@ - - com.apple.developer.icloud-container-identifiers - - iCloud.com.example.Balance - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.example.Balance - + diff --git a/Balance/Config/BalanceMac.entitlements b/Balance/Config/BalanceMac.entitlements index c04e099..ee95ab7 100644 --- a/Balance/Config/BalanceMac.entitlements +++ b/Balance/Config/BalanceMac.entitlements @@ -2,16 +2,6 @@ - com.apple.developer.icloud-container-identifiers - - iCloud.com.example.Balance - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.example.Balance com.apple.security.app-sandbox com.apple.security.network.client diff --git a/Balance/Config/BalanceWatch.entitlements b/Balance/Config/BalanceWatch.entitlements index 77072f1..0c67376 100644 --- a/Balance/Config/BalanceWatch.entitlements +++ b/Balance/Config/BalanceWatch.entitlements @@ -1,16 +1,5 @@ - - com.apple.developer.icloud-container-identifiers - - iCloud.com.example.Balance - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.example.Balance - + diff --git a/Balance/README.md b/Balance/README.md index 3ef01d0..b5ef5d6 100644 --- a/Balance/README.md +++ b/Balance/README.md @@ -20,7 +20,6 @@ - выбор валюты: RUB, EUR, USD или SEK; - системная, светлая и тёмная тема; - экспорт истории в CSV; -- опциональная синхронизация финансовых данных через приватную базу iCloud CloudKit; - синхронизация операций, категорий и бюджетов через собственный Go-сервер; - регистрация, вход, безопасное хранение сессии в Keychain, ручная и автоматическая синхронизация; - смена сервера с обязательной повторной авторизацией; @@ -41,11 +40,11 @@ CSV-экспорт доступен на iPhone и Mac. На Apple Watch дос Поддерживаемые системы: iOS 17+, macOS 14+ и watchOS 10+. -## Personal Team — режим по умолчанию +## Локальное хранение и синхронизация -Проект по умолчанию использует локальное SwiftData-хранилище и собирается с бесплатной Personal Team без iCloud и Push Notifications capabilities. Выберите свою Team в **Signing & Capabilities** и запускайте нужную схему. +Проект использует локальное SwiftData-хранилище и собирается с бесплатной Personal Team. Выберите свою Team в **Signing & Capabilities** и запускайте нужную схему. -В этом режиме можно подключить self-hosted сервер без iCloud-capabilities. Пока сервер не указан, каждое приложение хранит данные только локально. +Единственный способ синхронизации — собственный Go-сервер. Пока сервер не указан и вход не выполнен, каждое приложение хранит данные только локально. ## Собственный сервер @@ -60,30 +59,10 @@ CSV-экспорт доступен на iPhone и Mac. На Apple Watch дос При изменении адреса токены и курсор предыдущего сервера удаляются, после чего приложение просит войти заново. Пароли не сохраняются; access/refresh-токены находятся в системном Keychain каждого устройства. В Apple-клиентах синхронизация выполняется сразу после входа, при запуске приложения, затем каждые две минуты. Android использует WorkManager с минимальным системным интервалом 15 минут. Во всех приложениях синхронизацию также можно запустить вручную. Изменения передаются ограниченными пакетами, поэтому большая история операций не загружается в память целиком. Журнал удалений хранится отдельно от SwiftData, чтобы обновления схемы старого локального хранилища не могли аварийно завершить приложение. Клиент и `BalanceServer` из архива должны обновляться вместе. -## Настройка iCloud - -CloudKit является опциональным режимом. Для него нужен платный Apple Developer Program Team и собственный контейнер. - -1. В Apple Developer зарегистрируйте три уникальных Bundle ID для iOS, macOS и watchOS. -2. Создайте CloudKit-контейнер, например `iCloud.com.yourcompany.Balance`. -3. Замените `iCloud.com.example.Balance` в шаблонах entitlements папки `Config`. -4. Для каждого target задайте **Code Signing Entitlements**: `Config/Balance.entitlements`, `Config/BalanceMac.entitlements` или `Config/BalanceWatch.entitlements`. -5. В **Signing & Capabilities** укажите платную Team, добавьте iCloud capability, включите CloudKit и выберите один и тот же контейнер. -6. В Build Settings каждого target добавьте пользовательскую настройку `INFOPLIST_KEY_BalanceCloudKitEnabled = YES`. -7. Войдите в один Apple ID с включённым iCloud на всех тестовых устройствах. -8. Запустите приложение для создания development-схемы. Перед TestFlight или App Store разверните схему в production через CloudKit Console. - -Если флаг не задан или CloudKit не настроен, приложение использует локальное хранилище. - -Не включайте CloudKit и собственный сервер одновременно для одной базы: выберите один способ синхронизации. - -Документация Apple: [Enabling CloudKit in Your App](https://developer.apple.com/documentation/cloudkit/enabling-cloudkit-in-your-app), [Syncing model data across a person's devices](https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices), [Creating independent watchOS apps](https://developer.apple.com/documentation/watchos-apps/creating-independent-watchos-apps/). - ## Стек - SwiftUI - SwiftData -- CloudKit - Swift Charts - XCTest - собственный Go 1.25 + SQLite сервер @@ -94,15 +73,3 @@ CloudKit является опциональным режимом. Для нег ## Устранение проблем сборки После перехода с версии 2.0–2.0.2 выполните **Product → Clean Build Folder**. Если Xcode продолжает использовать старую конфигурацию watchOS target, закройте Xcode и удалите DerivedData для проекта `Balance`, затем снова откройте проект. - -## Документация проекта - -- [`AGENTS.md`](AGENTS.md) — правила для AI-агентов и разработчиков, структура проекта, invariants и checklist. -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — архитектура приложения и ответственность слоёв. -- [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) — SwiftData-модели и финансовые инварианты. -- [`docs/SYNC.md`](docs/SYNC.md) — протокол авторизации и синхронизации с собственным сервером. -- [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — локальная разработка, сборка, тесты и signing. -- [`docs/PRODUCT.md`](docs/PRODUCT.md) — функциональная документация. - -> Примечание: текущий архив содержит Apple-клиент Balance. Внешний Go-сервер, упомянутый в старой версии README, в этом архиве отсутствует; `docs/SYNC.md` документирует клиентский контракт по коду `ServerSync.swift`. - diff --git a/BalanceAndroid/README.md b/BalanceAndroid/README.md index 8d50c3a..564bbdb 100644 --- a/BalanceAndroid/README.md +++ b/BalanceAndroid/README.md @@ -94,13 +94,3 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" Проект задаёт для Gradle 2 ГБ heap, 1 ГБ metaspace и не более двух параллельных workers. Эти параметры находятся в `gradle.properties` и применяются также при сборке из Android Studio. Debug APK появится в `app/build/outputs/apk/debug/`. - -## Документация проекта - -- [`AGENTS.md`](AGENTS.md) — правила для AI-агентов и разработчиков. -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — архитектура Android-клиента. -- [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) — Room-модели и финансовые правила. -- [`docs/SYNC.md`](docs/SYNC.md) — протокол синхронизации и безопасность сессии. -- [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — Android Studio, Gradle, тесты и release. -- [`docs/PRODUCT.md`](docs/PRODUCT.md) — функциональная документация. - diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt index ae278ca..d66f790 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt @@ -10,7 +10,6 @@ import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec -import androidx.core.content.edit data class ServerSession( val accessToken: String, @@ -34,10 +33,10 @@ class SecureSessionStore(context: Context) { .toString().toByteArray() val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.ENCRYPT_MODE, secretKey()) - preferences.edit { - putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) - .putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP)) - } + preferences.edit() + .putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP)) + .apply() } fun load(): ServerSession? = runCatching { @@ -59,7 +58,7 @@ class SecureSessionStore(context: Context) { } fun clear() { - preferences.edit { clear() } + preferences.edit().clear().apply() } private fun secretKey(): SecretKey { diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt index 2e39a78..02fd6a6 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt @@ -66,7 +66,7 @@ class ServerApi( return try { parseSession(post("v1/auth/refresh", JSONObject().put("refreshToken", session.refreshToken), null)) .also { sessions.save(it) } - } catch (_: Throwable) { + } catch (error: Throwable) { sessions.clear() throw ServerException("Сессия истекла. Войдите снова.") } diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt index 8183fdb..9f058a0 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt @@ -4,27 +4,26 @@ import android.content.Context import java.net.URI import java.security.MessageDigest import java.util.UUID -import androidx.core.content.edit class ServerPreferences(context: Context) { private val values = context.getSharedPreferences("balance_settings", Context.MODE_PRIVATE) var serverUrl: String get() = values.getString("server_url", "") ?: "" - set(value) { values.edit { putString("server_url", value) } } + set(value) { values.edit().putString("server_url", value).apply() } var email: String get() = values.getString("server_email", "") ?: "" - set(value) { values.edit { putString("server_email", value) } } + set(value) { values.edit().putString("server_email", value).apply() } var currency: String get() = values.getString("currency", "RUB") ?: "RUB" - set(value) { values.edit { putString("currency", value) } } + set(value) { values.edit().putString("currency", value).apply() } var theme: String get() = values.getString("theme", "system") ?: "system" - set(value) { values.edit { putString("theme", value) } } + set(value) { values.edit().putString("theme", value).apply() } val deviceId: String get() = values.getString("device_id", null) ?: UUID.randomUUID().toString().also { - values.edit { putString("device_id", it) } + values.edit().putString("device_id", it).apply() } fun normalizeServer(input: String): String { @@ -46,21 +45,11 @@ class ServerPreferences(context: Context) { } fun cursor(scope: String): Long = values.getLong("cursor_${hash(scope)}", 0).coerceAtLeast(0) - fun setCursor(scope: String, cursor: Long) = values.edit { - putLong( - "cursor_${hash(scope)}", - cursor - ) - } + fun setCursor(scope: String, cursor: Long) = values.edit().putLong("cursor_${hash(scope)}", cursor).apply() fun lastPush(scope: String): Long = values.getLong("push_${hash(scope)}", 0) - fun setLastPush(scope: String, value: Long) = values.edit { - putLong( - "push_${hash(scope)}", - value - ) - } + fun setLastPush(scope: String, value: Long) = values.edit().putLong("push_${hash(scope)}", value).apply() fun resetSync(scope: String) { - values.edit { remove("cursor_${hash(scope)}").remove("push_${hash(scope)}") } + values.edit().remove("cursor_${hash(scope)}").remove("push_${hash(scope)}").apply() } private fun hash(value: String): String = MessageDigest.getInstance("SHA-256") diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt index 4520a65..d7f36e6 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt @@ -32,8 +32,8 @@ class SyncRepository( val status: StateFlow = mutableStatus val session: StateFlow = mutableSession -// fun serverUrl(): String = preferences.serverUrl -// fun savedEmail(): String = preferences.email + fun serverUrl(): String = preferences.serverUrl + fun savedEmail(): String = preferences.email suspend fun changeServer(value: String): String { return try { diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt index e497610..9721d7f 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt @@ -18,14 +18,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Category import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Savings import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync @@ -45,7 +45,6 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -60,83 +59,49 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import com.example.balanceandroid.FinanceData import com.example.balanceandroid.FinanceUiState import com.example.balanceandroid.MainViewModel -import com.example.balanceandroid.data.BudgetEntity import com.example.balanceandroid.data.CategoryDesign -import com.example.balanceandroid.data.CategoryEntity import com.example.balanceandroid.data.FinanceMath import com.example.balanceandroid.data.TransactionEntity import com.example.balanceandroid.ui.theme.BalanceGreen import com.example.balanceandroid.ui.theme.BalanceIndigo import com.example.balanceandroid.ui.theme.BalancePurple import com.example.balanceandroid.ui.theme.BalanceRed -import com.example.balanceandroid.ui.theme.BalanceTheme import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter - private data class Destination(val route: String, val title: String, val icon: ImageVector) - private val destinations = listOf( Destination("overview", "Обзор", Icons.Default.Home), Destination("analytics", "Аналитика", Icons.Default.Analytics), - Destination("transactions", "Операции", Icons.AutoMirrored.Filled.List), + Destination("transactions", "Операции", Icons.Default.List), Destination("budgets", "Бюджеты", Icons.Default.Savings), Destination("settings", "Настройки", Icons.Default.Settings), ) @Composable fun BalanceRoot(model: MainViewModel, state: FinanceUiState) { - BalanceRootContent(state = state, model = model) -} - -@Composable -fun BalanceRootContent( - state: FinanceUiState, - model: MainViewModel? = null, -) { val nav = rememberNavController() val route = nav.currentBackStackEntryAsState().value?.destination?.route ?: "overview" Scaffold( bottomBar = { - if (destinations.any { it.route == route }) { - NavigationBar { - destinations.forEach { item -> - val selected = route == item.route - NavigationBarItem( - selected = selected, - onClick = { - nav.navigate(item.route) { - popUpTo("overview"); launchSingleTop = true - } - }, - icon = { - Icon( - imageVector = item.icon, - contentDescription = item.title, - modifier = Modifier.size(22.dp), - ) - }, - alwaysShowLabel = true, - label = { - Text( - text = item.title, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - style = MaterialTheme.typography.labelSmall, - ) - }, - ) - } + if (destinations.any { it.route == route }) NavigationBar { + destinations.forEach { item -> + NavigationBarItem( + selected = route == item.route, + onClick = { nav.navigate(item.route) { popUpTo("overview"); launchSingleTop = true } }, + icon = { Icon(item.icon, item.title) }, + label = { Text(item.title) }, + alwaysShowLabel = false, + ) } } }, @@ -154,7 +119,7 @@ fun BalanceRootContent( } @Composable -fun OverviewScreen(model: MainViewModel? = null, state: FinanceUiState) { +private fun OverviewScreen(model: MainViewModel, state: FinanceUiState) { var adjustment by remember { mutableStateOf(false) } val (start, end) = periodBounds(1) val summary = FinanceMath.summary(state.data.transactions, start, end) @@ -167,75 +132,38 @@ fun OverviewScreen(model: MainViewModel? = null, state: FinanceUiState) { item { ScreenTitle("Обзор") } item { Column( - Modifier - .fillMaxWidth() - .background( - Brush.linearGradient(listOf(BalanceIndigo, BalancePurple)), - RoundedCornerShape(22.dp), - ) - .padding(22.dp), + Modifier.fillMaxWidth().background( + Brush.linearGradient(listOf(BalanceIndigo, BalancePurple)), RoundedCornerShape(22.dp), + ).padding(22.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text("Общий баланс", color = Color.White.copy(alpha = .82f)) - Text( - formatMoney(FinanceMath.totalBalance(state.data.transactions), state.currency), - color = Color.White, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) + Text(formatMoney(FinanceMath.totalBalance(state.data.transactions), state.currency), color = Color.White, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) OutlinedButton(onClick = { adjustment = true }) { - Icon( - Icons.Default.Tune, - null - ); Spacer(Modifier.size(7.dp)); Text("Скорректировать", color = Color.White) + Icon(Icons.Default.Tune, null); Spacer(Modifier.size(7.dp)); Text("Скорректировать", color = Color.White) } } } item { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MetricCard( - "Доходы", - formatMoney(summary.income, state.currency), - BalanceGreen, - Modifier.weight(1f) - ) - MetricCard( - "Расходы", - formatMoney(summary.expenses, state.currency), - BalanceRed, - Modifier.weight(1f) - ) + MetricCard("Доходы", formatMoney(summary.income, state.currency), BalanceGreen, Modifier.weight(1f)) + MetricCard("Расходы", formatMoney(summary.expenses, state.currency), BalanceRed, Modifier.weight(1f)) } } item { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - "Расходы по категориям", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) + Text("Расходы по категориям", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) SpendingBars(spending, Modifier.fillMaxWidth()) } } } if (state.data.transactions.isNotEmpty()) { - item { - Text( - "Последние операции", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - items(state.data.transactions.take(5), key = { it.id }) { - TransactionRow( - it, - state.currency - ) - } + item { Text("Последние операции", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) } + items(state.data.transactions.take(5), key = { it.id }) { TransactionRow(it, state.currency) } } } - if (adjustment && model != null) BalanceAdjustmentDialog( + if (adjustment) BalanceAdjustmentDialog( current = FinanceMath.totalBalance(state.data.transactions), currency = state.currency, onDismiss = { adjustment = false }, @@ -244,291 +172,139 @@ fun OverviewScreen(model: MainViewModel? = null, state: FinanceUiState) { } @Composable -fun AnalyticsScreen(state: FinanceUiState) { +private fun AnalyticsScreen(state: FinanceUiState) { var months by remember { mutableIntStateOf(1) } val bounds = periodBounds(months) val summary = FinanceMath.summary(state.data.transactions, bounds.first, bounds.second) val spending = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second) val savings = if (summary.income > 0) summary.balance / summary.income * 100 else 0.0 - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = PaddingValues(18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) - ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { item { ScreenTitle("Аналитика") } item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf( - 1 to "Месяц", - 3 to "3 месяца", - 12 to "12 месяцев" - ).forEach { (value, title) -> - FilterChip( - selected = months == value, - onClick = { months = value }, - label = { Text(title) }) + listOf(1 to "Месяц", 3 to "3 месяца", 12 to "12 месяцев").forEach { (value, title) -> + FilterChip(selected = months == value, onClick = { months = value }, label = { Text(title) }) } } } item { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MetricCard( - "Доходы", - formatMoney(summary.income, state.currency), - BalanceGreen, - Modifier.weight(1f) - ) - MetricCard( - "Расходы", - formatMoney(summary.expenses, state.currency), - BalanceRed, - Modifier.weight(1f) - ) + MetricCard("Доходы", formatMoney(summary.income, state.currency), BalanceGreen, Modifier.weight(1f)) + MetricCard("Расходы", formatMoney(summary.expenses, state.currency), BalanceRed, Modifier.weight(1f)) } } item { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - MetricCard( - "Результат", - formatMoney(summary.balance, state.currency), - BalanceIndigo, - Modifier.weight(1f) - ) - MetricCard( - "Сбережения", - "%.0f%%".format(savings), - BalanceGreen, - Modifier.weight(1f) - ) + MetricCard("Результат", formatMoney(summary.balance, state.currency), BalanceIndigo, Modifier.weight(1f)) + MetricCard("Сбережения", "%.0f%%".format(savings), BalanceGreen, Modifier.weight(1f)) } } item { - Card { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - "Структура расходов", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - SpendingBars(spending, Modifier.fillMaxWidth()) - } - } + Card { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("Структура расходов", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + SpendingBars(spending, Modifier.fillMaxWidth()) + } } } } } @Composable -fun TransactionsScreen(model: MainViewModel? = null, state: FinanceUiState) { +private fun TransactionsScreen(model: MainViewModel, state: FinanceUiState) { var search by remember { mutableStateOf("") } var kind by remember { mutableStateOf("all") } var editing by remember { mutableStateOf(null) } var showingEditor by remember { mutableStateOf(false) } val filtered = state.data.transactions.filter { (kind == "all" || it.kindRawValue == kind) && - (search.isBlank() || it.categoryName.contains(search, true) || it.note.contains( - search, - true - )) + (search.isBlank() || it.categoryName.contains(search, true) || it.note.contains(search, true)) } Scaffold( floatingActionButton = { - ExtendedFloatingActionButton( - onClick = { editing = null; showingEditor = true }, - icon = { Icon(Icons.Default.Add, null) }, - text = { Text("Добавить") }) + ExtendedFloatingActionButton(onClick = { editing = null; showingEditor = true }, icon = { Icon(Icons.Default.Add, null) }, text = { Text("Добавить") }) }, ) { padding -> - LazyColumn( - Modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(18.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { item { ScreenTitle("Операции") } - item { - OutlinedTextField( - search, - { search = it }, - Modifier.fillMaxWidth(), - label = { Text("Поиск") }, - singleLine = true - ) - } + item { OutlinedTextField(search, { search = it }, Modifier.fillMaxWidth(), label = { Text("Поиск") }, singleLine = true) } item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf( - "all" to "Все", - "expense" to "Расходы", - "income" to "Доходы" - ).forEach { item -> - FilterChip( - kind == item.first, - { kind = item.first }, - label = { Text(item.second) }) + listOf("all" to "Все", "expense" to "Расходы", "income" to "Доходы").forEach { item -> + FilterChip(kind == item.first, { kind = item.first }, label = { Text(item.second) }) } } } if (filtered.isEmpty()) item { EmptyMessage("Операций пока нет") } items(filtered, key = { it.id }) { transaction -> - Card( - Modifier - .fillMaxWidth() - .clickable { editing = transaction; showingEditor = true }) { + Card(Modifier.fillMaxWidth().clickable { editing = transaction; showingEditor = true }) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { TransactionRow(transaction, state.currency, Modifier.weight(1f)) - IconButton({ - editing = transaction; showingEditor = true - }) { Icon(Icons.Default.Edit, "Изменить") } - IconButton({ model?.deleteTransaction(transaction) }) { - Icon( - Icons.Default.Delete, - "Удалить", - tint = BalanceRed - ) - } + IconButton({ editing = transaction; showingEditor = true }) { Icon(Icons.Default.Edit, "Изменить") } + IconButton({ model.deleteTransaction(transaction) }) { Icon(Icons.Default.Delete, "Удалить", tint = BalanceRed) } } } } item { Spacer(Modifier.height(72.dp)) } } } - if (showingEditor && model != null) TransactionEditorDialog( - model, -// state, - editing, - onDismiss = { showingEditor = false }) + if (showingEditor) TransactionEditorDialog(model, state, editing, onDismiss = { showingEditor = false }) } @Composable -fun BudgetsScreen(model: MainViewModel? = null, state: FinanceUiState) { +private fun BudgetsScreen(model: MainViewModel, state: FinanceUiState) { var editing by remember { mutableStateOf(false) } val month = MainViewModel.currentMonthStart() val budgets = state.data.budgets.filter { it.monthStart == month } val bounds = periodBounds(1) - val spent = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second) - .associate { it.name to it.amount } - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = PaddingValues(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + val spent = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second).associate { it.name to it.amount } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { item { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { ScreenTitle("Бюджеты", Modifier.weight(1f)) - Button({ editing = true }) { - Icon( - Icons.Default.Edit, - null - ); Spacer(Modifier.size(6.dp)); Text("Лимиты") - } + Button({ editing = true }) { Icon(Icons.Default.Edit, null); Spacer(Modifier.size(6.dp)); Text("Лимиты") } } } if (budgets.isEmpty()) item { EmptyMessage("Лимиты не заданы") } items(budgets, key = { it.id }) { budget -> val value = spent[budget.categoryName] ?: 0.0 - val progress = - (value / budget.limitAmount.coerceAtLeast(1.0)).toFloat().coerceIn(0f, 1f) - Card { - Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - CategoryBadge( - CategoryDesign( - budget.categoryName, - budget.categoryIcon, - budget.categoryEmoji, - "indigo", - "expense" - ), size = 36 - ) - Spacer(Modifier.size(10.dp)); Text( - budget.categoryName, - Modifier.weight(1f), - fontWeight = FontWeight.Bold - ) - Text(formatMoney(value, state.currency)) - } - LinearProgressIndicator({ progress }, Modifier.fillMaxWidth()) - Text( - "Лимит: ${formatMoney(budget.limitAmount, state.currency)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + val progress = (value / budget.limitAmount.coerceAtLeast(1.0)).toFloat().coerceIn(0f, 1f) + Card { Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CategoryBadge(CategoryDesign(budget.categoryName, budget.categoryIcon, budget.categoryEmoji, "indigo", "expense"), size = 36) + Spacer(Modifier.size(10.dp)); Text(budget.categoryName, Modifier.weight(1f), fontWeight = FontWeight.Bold) + Text(formatMoney(value, state.currency)) } - } + LinearProgressIndicator({ progress }, Modifier.fillMaxWidth()) + Text("Лимит: ${formatMoney(budget.limitAmount, state.currency)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } } - if (editing && model != null) BudgetEditorDialog(model, state, onDismiss = { editing = false }) + if (editing) BudgetEditorDialog(model, state, onDismiss = { editing = false }) } @Composable -fun SettingsScreen( - model: MainViewModel? = null, - state: FinanceUiState, - nav: NavHostController = rememberNavController() -) { - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = PaddingValues(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { +private fun SettingsScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { item { ScreenTitle("Настройки") } - item { - SettingsCard("Отображение") { - ChoiceRow( - "Валюта", - state.currency, - listOf("RUB", "EUR", "USD", "SEK"), - onSelect = { model?.setCurrency(it) }) - ChoiceRow( - "Тема", - state.theme, - listOf("system", "light", "dark"), - onSelect = { model?.setTheme(it) }, - label = { - when (it) { - "light" -> "Светлая"; "dark" -> "Тёмная"; else -> "Системная" - } - }) - } - } - item { - SettingsCard("Данные") { - SettingsLink("Категории", Icons.Default.Category) { nav.navigate("categories") } - HorizontalDivider() - SettingsLink( - "Сервер и синхронизация", - Icons.Default.Sync - ) { nav.navigate("server") } - Text( - "Сохранено операций: ${state.data.transactions.size}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - item { - SettingsCard("Приватность") { - Text("Данные хранятся локально. Токены сервера шифруются ключом Android Keystore.") - } - } - item { - Text( - "Версия 1.0.0", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + item { SettingsCard("Отображение") { + ChoiceRow("Валюта", state.currency, listOf("RUB", "EUR", "USD", "SEK"), model::setCurrency) + ChoiceRow("Тема", state.theme, listOf("system", "light", "dark"), model::setTheme, label = { when(it) { "light" -> "Светлая"; "dark" -> "Тёмная"; else -> "Системная" } }) + } } + item { SettingsCard("Данные") { + SettingsLink("Категории", Icons.Default.Category) { nav.navigate("categories") } + HorizontalDivider() + SettingsLink("Сервер и синхронизация", Icons.Default.Sync) { nav.navigate("server") } + Text("Сохранено операций: ${state.data.transactions.size}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } + item { SettingsCard("Приватность") { + Text("Данные хранятся локально. Токены сервера шифруются ключом Android Keystore.") + } } + item { Text("Версия 1.0.0", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @Composable -fun CategoriesScreen( - model: MainViewModel? = null, - state: FinanceUiState, - nav: NavHostController = rememberNavController() -) { - var editing by remember { mutableStateOf(null) } +private fun CategoriesScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) { + var editing by remember { mutableStateOf(null) } var showEditor by remember { mutableStateOf(false) } Scaffold(floatingActionButton = { ExtendedFloatingActionButton( @@ -537,474 +313,87 @@ fun CategoriesScreen( text = { Text("Категория") } ) }) { padding -> - LazyColumn( - Modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(18.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { item { BackTitle("Категории") { nav.popBackStack() } } if (state.data.categories.isEmpty()) item { EmptyMessage("Своих категорий пока нет") } items(state.data.categories, key = { it.id }) { category -> - Card( - Modifier - .fillMaxWidth() - .clickable { editing = category; showEditor = true }) { + Card(Modifier.fillMaxWidth().clickable { editing = category; showEditor = true }) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - CategoryBadge( - CategoryDesign( - category.name, - category.icon, - category.emoji, - category.colorName, - category.kindRawValue - ) - ) - Spacer(Modifier.size(12.dp)); Column(Modifier.weight(1f)) { - Text( - category.name, - fontWeight = FontWeight.Bold - ); Text( - if (category.kindRawValue == "income") "Доход" else "Расход", - style = MaterialTheme.typography.bodySmall - ) - } - IconButton({ - editing = category; showEditor = true - }) { Icon(Icons.Default.Edit, null) } - IconButton({ model?.deleteCategory(category) }) { - Icon( - Icons.Default.Delete, - null, - tint = BalanceRed - ) - } + CategoryBadge(CategoryDesign(category.name, category.icon, category.emoji, category.colorName, category.kindRawValue)) + Spacer(Modifier.size(12.dp)); Column(Modifier.weight(1f)) { Text(category.name, fontWeight = FontWeight.Bold); Text(if(category.kindRawValue == "income") "Доход" else "Расход", style = MaterialTheme.typography.bodySmall) } + IconButton({ editing = category; showEditor = true }) { Icon(Icons.Default.Edit, null) } + IconButton({ model.deleteCategory(category) }) { Icon(Icons.Default.Delete, null, tint = BalanceRed) } } } } item { Spacer(Modifier.height(72.dp)) } } } - if (showEditor && model != null) CategoryEditorDialog( - model, - state, - editing, - onDismiss = { showEditor = false }) + if (showEditor) CategoryEditorDialog(model, state, editing, onDismiss = { showEditor = false }) } @Composable -fun ServerScreen( - model: MainViewModel? = null, - state: FinanceUiState, - nav: NavHostController = rememberNavController() -) { +private fun ServerScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) { var server by remember(state.serverUrl) { mutableStateOf(state.serverUrl) } - var email by remember( - state.session?.email, - state.serverEmail - ) { mutableStateOf(state.session?.email ?: state.serverEmail) } + var email by remember(state.session?.email, state.serverEmail) { mutableStateOf(state.session?.email ?: state.serverEmail) } var password by remember { mutableStateOf("") } - val savedServerSelected = - state.serverUrl.isNotBlank() && server.trim().trimEnd('/') == state.serverUrl - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = PaddingValues(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { + val savedServerSelected = state.serverUrl.isNotBlank() && server.trim().trimEnd('/') == state.serverUrl + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { item { BackTitle("Сервер") { nav.popBackStack() } } - item { - SettingsCard("Адрес") { - OutlinedTextField( - server, - { server = it }, - Modifier.fillMaxWidth(), - label = { Text("https://balance.example.com") }, - singleLine = true - ) - Button( - { model?.changeServer(server) }, - enabled = !state.sync.running - ) { Text("Сохранить адрес") } - Text( - "Для эмулятора локальный сервер доступен как http://10.0.2.2:8080", - style = MaterialTheme.typography.bodySmall - ) - } - } - item { - SettingsCard("Аккаунт") { - if (state.session == null) { - OutlinedTextField( - email, - { email = it }, - Modifier.fillMaxWidth(), - label = { Text("Email") }, - singleLine = true - ) - OutlinedTextField( - password, - { password = it }, - Modifier.fillMaxWidth(), - label = { Text("Пароль") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation() - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - { model?.login(email, password) }, - enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.isNotBlank() - ) { Text("Войти") } - OutlinedButton( - { model?.register(email, password) }, - enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.length >= 8 - ) { Text("Регистрация") } - } - if (!savedServerSelected) Text( - "Сначала сохраните адрес сервера", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } else { - Text("Выполнен вход: ${state.session.email}", fontWeight = FontWeight.Bold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - { model?.sync() }, - enabled = !state.sync.running - ) { - Icon( - Icons.Default.Sync, - null - ); Spacer(Modifier.size(6.dp)); Text("Синхронизировать") - } - OutlinedButton( - { model?.logout() }, - enabled = !state.sync.running - ) { Text("Выйти") } - } - OutlinedButton( - { model?.resetSync() }, - enabled = !state.sync.running - ) { Text("Полная пересинхронизация") } + item { SettingsCard("Адрес") { + OutlinedTextField(server, { server = it }, Modifier.fillMaxWidth(), label = { Text("https://balance.example.com") }, singleLine = true) + Button({ model.changeServer(server) }, enabled = !state.sync.running) { Text("Сохранить адрес") } + Text("Для эмулятора локальный сервер доступен как http://10.0.2.2:8080", style = MaterialTheme.typography.bodySmall) + } } + item { SettingsCard("Аккаунт") { + if (state.session == null) { + OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") }, singleLine = true) + OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Пароль") }, singleLine = true, visualTransformation = PasswordVisualTransformation()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button({ model.login(email, password) }, enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.isNotBlank()) { Text("Войти") } + OutlinedButton({ model.register(email, password) }, enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.length >= 8) { Text("Регистрация") } } - if (state.sync.running) LinearProgressIndicator(Modifier.fillMaxWidth()) - state.sync.message?.let { - Text( - it, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (!savedServerSelected) Text("Сначала сохраните адрес сервера", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) + } else { + Text("Выполнен вход: ${state.session.email}", fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button({ model.sync() }, enabled = !state.sync.running) { Icon(Icons.Default.Sync, null); Spacer(Modifier.size(6.dp)); Text("Синхронизировать") } + OutlinedButton({ model.logout() }, enabled = !state.sync.running) { Text("Выйти") } } + OutlinedButton({ model.resetSync() }, enabled = !state.sync.running) { Text("Полная пересинхронизация") } } - } + if (state.sync.running) LinearProgressIndicator(Modifier.fillMaxWidth()) + state.sync.message?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } } } } -@Composable -private fun ScreenTitle(text: String, modifier: Modifier = Modifier) = Text( - text, - modifier, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold -) +@Composable private fun ScreenTitle(text: String, modifier: Modifier = Modifier) = Text(text, modifier, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) +@Composable private fun BackTitle(text: String, back: () -> Unit) = Row(verticalAlignment = Alignment.CenterVertically) { IconButton(back) { Icon(Icons.Default.ArrowBack, "Назад") }; ScreenTitle(text) } +@Composable private fun EmptyMessage(text: String) = Box(Modifier.fillMaxWidth().padding(30.dp), contentAlignment = Alignment.Center) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) } @Composable -private fun BackTitle(text: String, back: () -> Unit) = - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(back) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, "Назад" - ) - }; ScreenTitle(text) - } - -@Composable -private fun EmptyMessage(text: String) = Box( - Modifier - .fillMaxWidth() - .padding(30.dp), contentAlignment = Alignment.Center -) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) } - -@Composable -private fun TransactionRow( - value: TransactionEntity, - currency: String, - modifier: Modifier = Modifier -) { +private fun TransactionRow(value: TransactionEntity, currency: String, modifier: Modifier = Modifier) { Row(modifier, verticalAlignment = Alignment.CenterVertically) { - CategoryBadge( - CategoryDesign( - value.categoryName, - value.categoryIcon, - value.categoryEmoji, - value.categoryColorName, - value.kindRawValue - ), size = 38 - ) + CategoryBadge(CategoryDesign(value.categoryName, value.categoryIcon, value.categoryEmoji, value.categoryColorName, value.kindRawValue), size = 38) Spacer(Modifier.size(10.dp)); Column(Modifier.weight(1f)) { - Text(value.categoryName, fontWeight = FontWeight.SemiBold) - Text( - value.note.ifBlank { - Instant.ofEpochMilli(value.date).atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofPattern("d MMM")) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) - } - Text( - (if (value.kindRawValue == "income") "+" else "−") + formatMoney( - value.amount, - currency - ), - color = if (value.kindRawValue == "income") BalanceGreen else MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold - ) + Text(value.categoryName, fontWeight = FontWeight.SemiBold) + Text(value.note.ifBlank { Instant.ofEpochMilli(value.date).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("d MMM")) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + Text((if(value.kindRawValue == "income") "+" else "−") + formatMoney(value.amount, currency), color = if(value.kindRawValue == "income") BalanceGreen else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold) } } -@Composable -private fun SettingsCard(title: String, content: @Composable ColumnScope.() -> Unit) = - Card(Modifier.fillMaxWidth()) { - Column( - Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { Text(title, fontWeight = FontWeight.Bold); content() } - } +@Composable private fun SettingsCard(title: String, content: @Composable ColumnScope.() -> Unit) = Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(title, fontWeight = FontWeight.Bold); content() } } +@Composable private fun SettingsLink(title: String, icon: ImageVector, action: () -> Unit) = Row(Modifier.fillMaxWidth().clickable(onClick = action).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null); Spacer(Modifier.size(12.dp)); Text(title, Modifier.weight(1f)); Text("›", style = MaterialTheme.typography.titleLarge) } @Composable -private fun SettingsLink(title: String, icon: ImageVector, action: () -> Unit) = Row( - Modifier - .fillMaxWidth() - .clickable(onClick = action) - .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically -) { - Icon(icon, null); Spacer(Modifier.size(12.dp)); Text(title, Modifier.weight(1f)); Text( - "›", - style = MaterialTheme.typography.titleLarge -) -} - -@Composable -private fun ChoiceRow( - title: String, - current: String, - values: List, - onSelect: (String) -> Unit, - label: (String) -> String = { it } -) { +private fun ChoiceRow(title: String, current: String, values: List, onSelect: (String) -> Unit, label: (String) -> String = { it }) { var expanded by remember { mutableStateOf(false) } Box { - Row( - Modifier - .fillMaxWidth() - .clickable { expanded = true } - .padding(vertical = 7.dp)) { - Text(title, Modifier.weight(1f)); Text( - label(current), - color = MaterialTheme.colorScheme.primary - ) - } + Row(Modifier.fillMaxWidth().clickable { expanded = true }.padding(vertical = 7.dp)) { Text(title, Modifier.weight(1f)); Text(label(current), color = MaterialTheme.colorScheme.primary) } androidx.compose.material3.DropdownMenu(expanded, { expanded = false }) { - values.forEach { value -> - androidx.compose.material3.DropdownMenuItem( - { Text(label(value)) }, - { onSelect(value); expanded = false }) - } + values.forEach { value -> androidx.compose.material3.DropdownMenuItem({ Text(label(value)) }, { onSelect(value); expanded = false }) } } } } - -val SampleFinanceData = FinanceData( - transactions = listOf( - TransactionEntity( - id = "1", - date = System.currentTimeMillis(), - amount = 4500.0, - kindRawValue = "expense", - categoryName = "Продукты", - categoryEmoji = "🛒", - categoryColorName = "orange", - note = "Супермаркет" - ), - TransactionEntity( - id = "2", - date = System.currentTimeMillis() - 86400000, - amount = 120000.0, - kindRawValue = "income", - categoryName = "Зарплата", - categoryEmoji = "💰", - categoryColorName = "green", - note = "Аванс за месяц" - ), - TransactionEntity( - id = "3", - date = System.currentTimeMillis() - 172800000, - amount = 1200.0, - kindRawValue = "expense", - categoryName = "Кафе", - categoryEmoji = "☕", - categoryColorName = "indigo", - note = "Обед" - ), - TransactionEntity( - id = "4", - date = System.currentTimeMillis() - 259200000, - amount = 3500.0, - kindRawValue = "expense", - categoryName = "Транспорт", - categoryEmoji = "🚗", - categoryColorName = "purple", - note = "Бензин" - ), - ), - categories = listOf( - CategoryEntity( - id = "c1", - name = "Продукты", - kindRawValue = "expense", - icon = "cart.fill", - emoji = "🛒", - colorName = "orange" - ), - CategoryEntity( - id = "c2", - name = "Кафе", - kindRawValue = "expense", - icon = "fork.knife", - emoji = "☕", - colorName = "indigo" - ), - CategoryEntity( - id = "c3", - name = "Транспорт", - kindRawValue = "expense", - icon = "car.fill", - emoji = "🚗", - colorName = "purple" - ), - CategoryEntity( - id = "c4", - name = "Зарплата", - kindRawValue = "income", - icon = "banknote.fill", - emoji = "💰", - colorName = "green" - ), - ), - budgets = listOf( - BudgetEntity( - id = "b1", - categoryName = "Продукты", - limitAmount = 25000.0, - monthStart = MainViewModel.currentMonthStart(), - categoryEmoji = "🛒" - ), - BudgetEntity( - id = "b2", - categoryName = "Кафе", - limitAmount = 10000.0, - monthStart = MainViewModel.currentMonthStart(), - categoryEmoji = "☕" - ), - ), -) - -val SampleFinanceUiState = FinanceUiState( - data = SampleFinanceData, - currency = "RUB", - theme = "dark", - serverUrl = "https://balance.example.com", - serverEmail = "user@example.com", -) - -@Preview(name = "App UI (Dark)", group = "Screens", showBackground = true) -@Composable -fun BalanceRootDarkPreview() { - BalanceTheme(theme = "dark") { - Surface { - BalanceRootContent(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "App UI (Light)", group = "Screens", showBackground = true) -@Composable -fun BalanceRootLightPreview() { - BalanceTheme(theme = "light") { - Surface { - BalanceRootContent(state = SampleFinanceUiState.copy(theme = "light")) - } - } -} - -@Preview(name = "Overview Screen", group = "Screens", showBackground = true) -@Composable -fun OverviewScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - OverviewScreen(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "Analytics Screen", group = "Screens", showBackground = true) -@Composable -fun AnalyticsScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - AnalyticsScreen(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "Transactions Screen", group = "Screens", showBackground = true) -@Composable -fun TransactionsScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - TransactionsScreen(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "Budgets Screen", group = "Screens", showBackground = true) -@Composable -fun BudgetsScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - BudgetsScreen(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "Settings Screen", group = "Screens", showBackground = true) -@Composable -fun SettingsScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - SettingsScreen(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "Categories Screen", group = "Screens", showBackground = true) -@Composable -fun CategoriesScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - CategoriesScreen(state = SampleFinanceUiState) - } - } -} - -@Preview(name = "Server Screen", group = "Screens", showBackground = true) -@Composable -fun ServerScreenPreview() { - BalanceTheme(theme = "dark") { - Surface { - ServerScreen(state = SampleFinanceUiState) - } - } -} - diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt index 60cfa90..0fc22bb 100644 --- a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt @@ -12,9 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.DirectionsRun -import androidx.compose.material.icons.automirrored.filled.DirectionsWalk -import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.AccountBalanceWallet import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AutoAwesome @@ -29,9 +26,11 @@ import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Construction import androidx.compose.material.icons.filled.CreditCard import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.DirectionsRun +import androidx.compose.material.icons.filled.DirectionsWalk import androidx.compose.material.icons.filled.Eco -import androidx.compose.material.icons.filled.Fastfood import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Fastfood import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.Flight import androidx.compose.material.icons.filled.Home @@ -43,6 +42,7 @@ import androidx.compose.material.icons.filled.LocalGasStation import androidx.compose.material.icons.filled.LocalGroceryStore import androidx.compose.material.icons.filled.MedicalServices import androidx.compose.material.icons.filled.Medication +import androidx.compose.material.icons.filled.MenuBook import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.MusicNote @@ -75,13 +75,10 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.balanceandroid.data.CategoryDesign import com.example.balanceandroid.data.CategoryTotal -import com.example.balanceandroid.ui.theme.BalanceGreen -import com.example.balanceandroid.ui.theme.BalanceTheme import java.text.NumberFormat import java.time.Instant import java.time.ZoneId @@ -151,12 +148,12 @@ fun iconFor(name: String): ImageVector = when (name) { "printer.fill" -> Icons.Default.Print "pawprint.fill" -> Icons.Default.Pets "leaf.fill" -> Icons.Default.Eco - "figure.walk" -> Icons.AutoMirrored.Filled.DirectionsWalk - "figure.run" -> Icons.AutoMirrored.Filled.DirectionsRun + "figure.walk" -> Icons.Default.DirectionsWalk + "figure.run" -> Icons.Default.DirectionsRun "dumbbell.fill" -> Icons.Default.FitnessCenter "cross.case.fill", "stethoscope" -> Icons.Default.MedicalServices "pills.fill" -> Icons.Default.Medication - "book.fill" -> Icons.AutoMirrored.Filled.MenuBook + "book.fill" -> Icons.Default.MenuBook "school", "graduationcap.fill" -> Icons.Default.School "laptopcomputer" -> Icons.Default.Laptop "briefcase.fill" -> Icons.Default.Work @@ -230,34 +227,3 @@ fun periodBounds(months: Int, now: Long = System.currentTimeMillis()): Pair Unit, ) { var amount by remember(existing?.id) { mutableStateOf(existing?.amount?.toString().orEmpty()) } var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") } var note by remember(existing?.id) { mutableStateOf(existing?.note.orEmpty()) } - var date by remember(existing?.id) { mutableLongStateOf(existing?.date ?: System.currentTimeMillis()) } + var date by remember(existing?.id) { mutableStateOf(existing?.date ?: System.currentTimeMillis()) } val categories = model.categories(kind) var categoryName by remember(existing?.id) { mutableStateOf(existing?.categoryName ?: categories.first().name) } val selected = categories.firstOrNull { it.name == categoryName } ?: categories.first() diff --git a/BalanceAndroid/build.gradle.kts b/BalanceAndroid/build.gradle.kts index d5099dd..db09d62 100644 --- a/BalanceAndroid/build.gradle.kts +++ b/BalanceAndroid/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "9.2.1" apply false + id("com.android.application") version "8.13.0" apply false id("org.jetbrains.kotlin.android") version "2.3.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false diff --git a/BalanceAndroid/gradle.properties b/BalanceAndroid/gradle.properties index d57acb0..a907044 100644 --- a/BalanceAndroid/gradle.properties +++ b/BalanceAndroid/gradle.properties @@ -6,13 +6,3 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 kotlin.daemon.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m org.gradle.workers.max=2 org.gradle.parallel=false -android.defaults.buildfeatures.resvalues=true -android.sdk.defaultTargetSdkToCompileSdkIfUnset=false -android.enableAppCompileTimeRClass=false -android.usesSdkInManifest.disallowed=false -android.uniquePackageNames=false -android.dependency.useConstraints=true -android.r8.strictFullModeForKeepRules=false -android.r8.optimizedResourceShrinking=false -android.builtInKotlin=false -android.newDsl=false diff --git a/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties b/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties index c61a118..37f853b 100644 --- a/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties +++ b/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/BalanceFlutter/.gitignore b/BalanceFlutter/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/BalanceFlutter/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/BalanceFlutter/.metadata b/BalanceFlutter/.metadata new file mode 100644 index 0000000..c17e8fd --- /dev/null +++ b/BalanceFlutter/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "737ba8f885dc774b53b442759999a312c9461e4e" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + - platform: android + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + - platform: ios + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + - platform: linux + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + - platform: macos + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + - platform: web + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + - platform: windows + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/BalanceFlutter/FEATURE_PARITY.md b/BalanceFlutter/FEATURE_PARITY.md new file mode 100644 index 0000000..94dc90c --- /dev/null +++ b/BalanceFlutter/FEATURE_PARITY.md @@ -0,0 +1,137 @@ +# Native → Flutter feature audit + +Source audit of the Swift iOS/macOS/watchOS and Kotlin Android applications. Statuses distinguish source implementation, executed automated tests and remaining platform checks; none implies complete device verification. Paths are relative to the repository root. The Apple CloudKit removal was explicitly requested; finance features and the native Watch target are retained. + +## Functional matrix + +| Native feature | Source | Required behavior / native differences | Flutter status | +| --- | --- | --- | --- | +| Navigation | `Balance/Balance/App/ContentView.swift`; `Balance/BalanceMac/MacContentView.swift`; `BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt` | Overview, analytics, transactions, budgets, settings; categories and server settings; Mac sidebar, phone bottom tabs | Implemented in `lib/ui/app.dart`; responsive rail/bottom tabs; widget verification in progress | +| Transaction model | `Balance/Balance/Models/FinanceTransaction.swift`; Android `data/Entities.kt` | UUID, positive Double amount, date, note, category name/icon/emoji/color snapshot, kind, adjustment flag, updatedAt | Implemented in `lib/domain/finance.dart`; serialization tests pass; uses integer hundredths (see deviations) | +| Total balance | `Balance/Balance/Utilities/FinanceCalculations.swift`; Android `data/FinanceMath.kt` | All income minus all expenses, including adjustments and future dates; no calculation rounding | Implemented; finance tests pass | +| Period summary | Same | Income, expenses, result; exclude adjustments | Implemented with half-open periods; finance tests pass | +| Category spending | Same | Expense only, exclude adjustments; group by category name; descending sum; first record supplies design | Implemented; grouping/exclusion tests pass | +| Calendar periods | `Balance/Balance/Views/Analytics/AnalyticsView.swift`; Android `ui/Components.kt` | Current calendar month; 3 months start two months before current month, 12 start eleven before; end is next month's start; local timezone | Implemented in source; boundary math tests pass; DST/device checks pending | +| Overview | `Balance/Balance/Views/Dashboard/DashboardView.swift`; Android `ui/BalanceRoot.kt` | Overall balance, current-month income/expenses, category bars, recent operations, add/adjust actions. iOS top 5 categories / 4 recent; Android top 7 / 5 recent; Mac top 8 / no recent; Watch 4 recent | Implemented; top 5 categories / 5 recent; widget verification in progress | +| Analytics metrics | `Balance/Balance/Views/Analytics/AnalyticsView.swift`; `Balance/BalanceMac/MacFeatures.swift`; Android `ui/BalanceRoot.kt` | 1/3/12-month selector, income/expense/result, savings `(income-expenses)/income*100` or 0 when income ≤ 0 | Implemented in source, including result and largest category; visual verification pending | +| Cash-flow graph | iOS analytics; `Balance/BalanceMac/MacFeatures.swift` | Separate income/expense sums, buckets day/weekOfYear/month for 1/3/12-month periods; no inserted zero buckets; line + points. Absent on Android/Watch | Implemented as paired bars with explicit zero buckets; calendar weeks Monday-based; different visual representation from Apple lines | +| Spending structure | iOS analytics; Mac features; Android components; Watch features | iOS donut uses top 6, legend top 5; Mac top 8 donut; Android top 7 bars; Watch top 6 bars normalized against max(first amount, 1) | Implemented as category bars and an analytical donut; all category totals retained | +| Insights | iOS analytics | Largest expense category; average daily spending = all selected-period expenses / elapsed calendar days inclusive, minimum one | Implemented in source; average daily + largest category; DST daily denominator checks pending | +| Transaction list | `Balance/Balance/Views/Transactions/TransactionsView.swift`; Android root | Date descending, all/income/expense filter, case-insensitive category/note search; include adjustments | Implemented in source; filters/search/day grouping; widget verification in progress | +| Transaction CRUD | `Balance/Balance/Views/Transactions/NewTransactionView.swift`; Android `ui/Editors.kt`, `MainViewModel.kt`; `Balance/BalanceMac/MacEditors.swift`; `Balance/BalanceWatch/WatchAddTransactionView.swift` | Add/edit amount/type/category/date/note; delete with deletion journal; kind change selects first built-in category; update timestamp on edit | Implemented in store/UI; store tests pass; widget verification in progress | +| Adjustment editing | Same | Apple locks type/category and preserves archived snapshot; Android currently permits changing type/category while flag remains true | Implemented Apple behavior in source: locked kind/category, archived snapshot; dedicated widget test pending | +| Balance adjustment | `Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift`; Mac/Watch features; Android view model | Target-current; create abs(diff), income if positive else expense; minimum abs(diff) .005; flag=true; category `Корректировка`; default note `Ручная корректировка баланса` | Implemented in source with integer-cent difference; dedicated behavioral/UI test pending | +| Built-in categories | `Balance/Balance/Models/FinanceCategory.swift`; Android entities | 9 expense / 5 income categories, locked. `Другое` appears for both kinds with distinct icons | Implemented; 9/5 catalog test passes | +| Custom category CRUD | `Balance/Balance/Views/Settings/CategoriesView.swift`; Mac editors; Watch features; Android editors/view model/DAO | Name, immutable-after-create kind, icon or emoji, color; retain transaction snapshots after deletion; rename propagates snapshots; delete associated budgets | Implemented; rename/delete cascade store tests pass; widget verification in progress | +| Category design | `Balance/Balance/Shared/CategoryDesignOptions.swift`; Android editors/components | 50 icon names, 30 suggested emojis, 24 named colors; custom emoji input; unknown color→indigo; Apple `auto` resolves built-in color by name | Implemented; 50/30/24 catalog tests pass; auto-color fallback differs | +| Monthly budgets | `Balance/Balance/Views/Budgets/*.swift`; Mac/Watch features; Android root/editors/view model | Current month only; expense built-ins + custom categories; >0 create/update, empty/invalid/≤0 delete; expense totals exclude adjustments | Implemented; explicit delete and month navigation added; UTC-to-local month regression passes under Europe/Moscow | +| Budget feedback | iOS budgets | Progress capped at 1; ≥1 red, ≥.8 orange, otherwise indigo; spent/limit, remaining, overage. Android/Mac/Watch show simpler progress + limit | Implemented in source; 80/100% colors, remaining/overage; visual checks pending | +| Currency | `Balance/Balance/Utilities/CurrencyFormatting.swift`; Android settings/components | RUB default; EUR/USD/SEK; display-only setting, no conversion or per-record currency; locale-aware formatting | Implemented; fixed ru_RU locale and two decimals differ from native device-locale 0–2 digits | +| Theme | `Balance/Balance/Shared/AppTheme.swift`; Android `ui/theme/Theme.kt` | system default, light, dark; persisted locally | Implemented in source; secure device settings round-trip not tested | +| Settings details | iOS settings; Mac/Watch features; Android root | Category/server links, operation count, storage/privacy description, version; local preferences excluded from finance sync | Implemented links/categories/about/storage messaging; saved-operation count absent at review | +| CSV export | `Balance/Balance/Views/Settings/CSVDocument.swift` | Apple iOS/Mac only; all transactions descending; `balance-transactions.csv`, UTF-8, LF, ISO8601 UTC; raw positive amount; header `Дата,Тип,Категория,Сумма,Заметка`; every value quoted and embedded quotes doubled; adjustment type `Корректировка`; disabled for empty data | CSV encoding tests pass; mobile share and desktop/web save paths implemented; OS share/save dialogs unverified | +| Local persistence | `Balance/Balance/Shared/BalanceModelContainer.swift`; Android `data/AppDatabase.kt` | Durable finance entities; Apple SwiftData, Android Room v1; Apple recovery falls back to in-memory if local creation fails | Implemented using transactional Sembast / IndexedDB snapshots; restart tests use memory adapter, not real disk | +| Server account | `Balance/Balance/Shared/ServerSync.swift`; Android `sync/*` | URL save, login/register (registration password ≥8), logout, manual sync, full resync, busy/message/last-sync state; retain local finance records on logout/server change | Implemented; scoped account/server isolation tests pass; device secure-storage and registration integration pending | +| Session security | Same; Android `sync/SecureSessionStore.kt` | Apple Keychain AfterFirstUnlockThisDeviceOnly; Android AES-GCM encrypted session with Android Keystore; tokens never finance payload | Implemented through flutter_secure_storage with session-only fallback warning; physical-device secure storage unverified | +| Server protocol | Same | POST auth/register/login/refresh/logout and sync; bearer token; ISO8601 dates; stable device UUID; transaction/category/budget payloads; refresh within 60s of expiry and one retry on 401 | Implemented in `lib/services/sync_api.dart`; mock server store tests pass; see separate protocol integration evidence | +| Incremental sync | Same | 100-record pages; cutoff fixed for run; 5s overlap on lastPush; push entities + tombstones, then pull; per-server/user cursor; cursor monotonic; hasMore must advance cursor | Implemented durable dirty outbox, 100-record batching, push/pull cursor; differs from native 5-second watermark scanning | +| Conflict/deletion handling | Same; `Balance/Balance/Models/SyncTombstone.swift`; Android DAO | Remote upsert only when strictly newer, remote delete when local timestamp ≤ remote; order changes by sequence; deletion journal dedup by entity/id, clear after corresponding pull | Implemented; pending-delete conflict and failed-page rollback tests pass; delete-wins policy differs from native LWW | +| Automatic sync | Apple content/root views; Android `BalanceApplication.kt`, `sync/SyncWorker.kt` | Apple immediately and each 120 seconds while root task lives; Android ViewModel initialization and connected-network WorkManager every 15min, retry on failure | Foreground startup/resume and 2-minute timer plus Android 15-minute connected-network WorkManager implemented; native lock JVM tests pass; real suspended-app execution pending | +| CloudKit | Apple local store and entitlements | Intentionally removed by user request; synchronization uses the Go server only | **Removed by request; not a parity gap** | +| Apple Watch | `Balance/BalanceWatch/*.swift` | Native Watch overview, analytics, transaction/category CRUD, budgets, adjustment, theme/currency/server settings; independent server login, no CSV, no WatchConnectivity bridge or complications | Retained native Watch target via `scripts/build.sh watchos`; no Flutter watchOS runner; Apple SDK build unverified | + +## Edge cases and deliberate parity decisions + +1. **Period end:** Swift `DateInterval.contains` includes its end, Android uses `[start,end)`. A transaction exactly at next month's start can be counted twice by Apple month views. Choose and document one Flutter rule; do not accidentally use different rules for charts/budgets/summary. +2. **Future records:** overall balance includes every date. Period analytics includes future records within the period, while iOS daily-average denominator only runs through today. +3. **Money parsing:** iOS removes ASCII spaces, replaces comma with dot, requires >0. Android only replaces comma. Mac/Watch use native number input. Explicit finite-number validation is absent from native editors; NaN/Infinity must not corrupt persisted/exported finance data. +4. **Archived category:** Apple adds missing transaction category snapshots to editor choices; Android falls back to first built-in, losing archived design on save. +5. **Duplicate category names:** Apple blocks names matching any built-in/custom category across both kinds, case/diacritic insensitive. Android checks custom categories of the same kind only; built-in names can collide. +6. **Rename cascade:** iOS matches name+kind for transactions and only updates budgets for expense categories. Android/Mac/Watch match name only and can affect an opposite-kind collision. +7. **Delete cascade:** iOS deletes category budgets only for expense kind; Android/Mac/Watch delete all matching-name budgets. Transactions remain on every platform. +8. **Duplicate budgets:** no category/month uniqueness constraint. Apple editor's `Dictionary(uniqueKeysWithValues:)` traps on duplicate names. Android updates the first matching budget. Flutter should merge or reject duplicates explicitly. +9. **Month timezone:** Apple includes budget monthStart within current interval; Android requires exact equality with locally calculated month start. A timezone change can hide Android imported budgets. +10. **Limit precision:** iOS pre-fills budget limits with `%.0f`, so saving unchanged rounds fractional limits. Mac/Watch/Android retain fractions. +11. **Limit equality:** iOS displays "Лимит превышен на 0" at exact limit. Android progress divides by max(limit,1), which differs for sub-unit limits. +12. **Emoji:** iOS limits input to one grapheme; Mac/Watch take first Swift Character on save. Android accepts an arbitrary string. +13. **Notes:** iOS/Android transaction editors trim notes, Mac/Watch do not. Adjustment defaults: iOS trims; Android uses ifBlank without trimming a nonblank note; Mac/Watch use isEmpty. +14. **Unknown kind:** Apple getter defaults to expense. Android totalBalance subtracts unknown kind but summary/spending exclude it because they test literal `expense`. +15. **CSV is lossy:** no UUID, kind for adjustment, flag, timestamps, category design, custom-category definitions or budgets; an expense adjustment exports positive amount with no recoverable sign. It is not a complete migration format. Formula-like notes are quoted but not neutralized by native export. +16. **Sync sizes:** Apple rejects individual payload >256 KiB and splits full bodies >1.5 MB. Android splits changes arrays >1.5 MB, without the Apple single-payload guard. +17. **Server URL:** Apple HTTP allows localhost/127.0.0.1/::1; Android localhost/127.0.0.1/10.0.2.2. Android rejects userinfo/query/fragment; Apple has no explicit equivalent rejection. +18. **Deletion journal:** Apple UserDefaults journal quarantines corrupt data and validates UUID/entity; Android Room journal is transactional with local deletion. A successful push alone must not discard tombstones. +19. **Preferences:** theme, currency, server session remain device-local, including Watch; they are not in synced finance payloads. +20. **Rounding:** Apple currency display allows 0–2 fractional digits and narrow symbol; Android NumberFormat uses 0 fractional digits for integral values, otherwise up to 2. Display formatting must not alter stored amounts. + +## Storage and migration + +CloudKit was intentionally removed by user request. The Apple model container now always uses a persistent local SwiftData store (`cloudKitDatabase: .none`), with the existing in-memory recovery fallback. Cloud capabilities, configuration flags and setup instructions have been removed. Finance synchronization uses only the Go server; there is no CloudKit bridge or CloudKit parity requirement. + +No explicit old-store importer exists in native Swift/Kotlin. A new Flutter JSON/SQLite store does not automatically discover SwiftData or Room databases, and a new app identity does not grant access to the old sandbox. Preserve original installations until transfer is verified. + +The practical complete-model transfer is to sync the original app with its Go server, sign into the same server/account from Flutter, then pull transactions, custom categories, budgets and deletion records. Preserve UUIDs, timestamps, category snapshots and adjustment flags. Validate counts, per-month summaries, total balance and representative adjustments before retiring the original store. CSV is an export report, not a complete migration format (see edge case 15). + +Local-only data without a server still requires either this server route or a tested native upgrade/export plus complete Flutter importer. Data that was never downloaded from an earlier cloud-backed deployment is not migrated automatically by this change; keep that original deployment available until its records have been transferred. Do not claim automatic existing-data migration. + +## Native test baseline + +`Balance/BalanceTests/FinanceCalculationsTests.swift`: summary income/expenses, outside-period filtering, spending grouping with emoji/color, all-time balance including adjustments, adjustment exclusion, deletion journal dedup/removal. + +`BalanceAndroid/app/src/test/java/com/example/balanceandroid/FinanceMathTest.kt`: income/expense/adjustment summary and category grouping. + +Native tests do not cover period end, timezone changes, duplicate budgets, CSV or migration. Flutter verification should exercise those behaviors in addition to the baseline math tests. + +## Flutter review evidence and remaining checks + +The audit executed 12 `finance_test.dart` tests successfully before the additional exact-local-storage regression was added with `TZ=Europe/Moscow`, including an imported native budget at local January 1 serialized as December 31 UTC. `budgetUsed` now recovers the local calendar month. The store owner also normalizes budget duplicate comparisons with `.toLocal()`. + +Six `app_store_test.dart` tests passed in the audit run: restart snapshot, rename cascade, delete conflict, category deletion, failed pull rollback, account/server isolation. Storage uses the memory adapter in those tests; durable on-disk and OS keychain checks are separate requirements. + +Narrow-editor action overflows found in the initial run were corrected. The final 22 UI/screenshot tests pass on 320/390/1280 widths, including 1.5 text scale, dark mode, CRUD, negative adjustment and mocked native CSV selector/share. Screen-reader, real software keyboard, native system dialogs, suspended-app and production secure-storage behavior remain device checks. + +Intentional/current Flutter differences from the native source: + +- Money is stored as integer hundredths. Incoming native sub-cent amounts are rounded on import, even though native models accept arbitrary Double precision. New inputs permit at most two decimals; this is a data-model policy change, not bit-for-bit Double parity. +- Half-open month intervals avoid Apple's inclusive-end overlap. Unknown kinds default to expense consistently. +- UI uses a responsive Material 3 layout, paired cash-flow bars, category bars and an analytical donut. The cash-flow graph retains period totals with paired bars instead of the Apple line style. Zero periods are explicit. +- Locale is fixed to Russian and currency always uses two decimals. Native follows device locale and allows zero decimals for whole amounts. +- Budgets can navigate other months and are edited individually. Empty/zero does not delete through the editor; an explicit delete action is available. Categories are sorted by name rather than native creation order. +- Account/server ledgers are isolated. Native keeps one shared ledger across account/server changes. First guest import is conditional and once per server; preserve guest data and explain this condition in the UI. +- The Flutter outbox retains deletion knowledge and prefers an unsent local deletion over a newer server edit by issuing a newer delete. Native uses simple last-write-wins timestamps. + +Additional safety/data checks from source review: + +- Main ledger records do not contain auth tokens; session data goes through secure storage. Failed secure-store access produces a visible session-only fallback rather than writing plaintext credentials. +- Server URLs are validated by the same transport rules before account changes; insecure HTTP is limited to loopback and the Android emulator address. Android network-security and Apple local-network ATS exceptions are configured; real-device connectivity remains a device check. +- Transaction editor choices deduplicate imported category names to avoid duplicate dropdown-value assertions while retaining archived snapshot categories. +- A native preexisting SwiftData/Room database is not automatically imported by the new app identity. Use the Go transfer route above; the native Watch ledger also requires the same server/account. +- All scoped snapshots and their dirty outboxes are fully loaded into memory and rewritten as a complete snapshot on each edit/page. Large-ledger performance has not been measured. +- Corrupt local records can currently fail initialization; the startup error view offers retry without deleting data. A quarantined backup/recovery workflow is not implemented. +- Remote payload validation checks envelope shape but model decoding can still reject malformed fields and block a pull page. This preserves the cursor but needs diagnostics/recovery testing. +- Nonpositive imported budget limits do not produce division by zero; local UI requires a positive replacement limit. Invalid server data can be repaired through the editor. +- CSV retains the native lossy adjustment-sign behavior and does not neutralize formula-like text; treat it as an export report, not a restore format. + +## Android background synchronization verification + +`lib/services/background.dart` registers unique periodic WorkManager task `balance-periodic-sync` with a 15-minute frequency, connected-network constraint and update policy. Its preserved entry-point dispatcher initializes Flutter plugins, creates `AppStore(background: true)`, restores the current account and performs the same full push/pull/refresh flow as the foreground. Errors return false for WorkManager retry; credentials are not logged or copied to a plaintext mirror. No scheduler calls are made on web or non-Android platforms. `main.dart` surfaces scheduler initialization failure while keeping foreground use available. + +`packages/balance_runtime` is a real Android FlutterPlugin registered in foreground and headless engines. Its `balance/runtime` channel acquires a process-wide fair Semaphore and an OS FileChannel lock on one app-private lock file. Acquisition runs off the Android main thread. An engine-specific token is required to release the lock; detach cancels waiters and releases held locks. Overlapping acquisition from the same engine fails rather than deadlocking a single acquisition executor. + +AppStore holds this lock across each complete serialized operation, reloads preferences and protected credentials/ledger after acquisition, and releases after completion. Sembast connections open and close for each read/write so a headless isolate does not read a stale open-connection cache. Consequently OAuth refresh rotation, account changes, pushes, pulled-page commits and foreground edits share the same lock, rather than racing an independently refreshed token mirror. Background stores have no timer or lifecycle observer, and the worker awaits close before it exits. + +Executed checks: + +- Android release Java compilation passed for the local plugin. +- Three JVM tests passed: concurrent thread ownership + idempotent release; interrupted waiter recovery; a separate JVM process blocked on the real OS lock until the first owner released it. +- Three Dart channel tests passed: engine token supplied on release, absent token fails closed, non-Android calls are no-ops. +- Targeted Dart analysis of background/runtime modules passed. + +These checks do **not** prove actual Android OS wake-up, force-stop/reboot behavior, OEM battery restrictions, keychain access in a suspended process, or engine teardown during a real network request. Run a signed-device test with UI closed, an expired access token, queued edits/deletions, remote changes and simultaneous foreground resume. Fifteen minutes is the requested minimum interval; Android controls the actual execution time. The same native file lock guards engines/processes of this app; other platforms retain their foreground-only behavior. + +## Web multi-tab coordination + +`RuntimeLock` conditionally loads `runtime_lock_web.dart`. Each real AppStore operation acquires the exclusive origin-wide Web Lock `balance-ledger-v1`, then reloads shared preferences, credentials and the IndexedDB snapshot before mutating or refreshing tokens. The lock callback returns an unresolved JS Promise held until `release()` completes it, so another tab cannot overwrite a stale whole-ledger snapshot or rotate the same refresh token concurrently. Browser teardown releases ownership. Acquisition failures reject the waiting Dart caller; the app fails closed with a clear HTTPS/localhost or unsupported-Web-Locks message instead of silently using an unsafe no-op lock. + +The web helper uses SDK `dart:js_interop` only. The audit compiled `scripts/probe_web_lock.dart` to JavaScript and ran it against Node 24's native `navigator.locks`: two separate owner instances serialized, ownership released/reacquired, duplicate acquire/release rejected, insecure origin rejected, unsupported API rejected. The existing three Android channel tests still pass, and targeted analyzer checks pass. This exercises native Web Locks Promise semantics, not a real browser two-tab IndexedDB/keychain scenario; that integration remains an explicit runtime check. The platform owner rebuilds the full release web bundle after this change. + +Desktop multiple independent app processes are not coordinated by this Web/Android helper. Standard desktop runners have one foreground AppStore; simultaneous desktop instances require a separately tested OS file-lock implementation before they can be claimed safe. diff --git a/BalanceFlutter/README.md b/BalanceFlutter/README.md new file mode 100644 index 0000000..1b724a1 --- /dev/null +++ b/BalanceFlutter/README.md @@ -0,0 +1,128 @@ +# Баланс — Flutter / Material 3 + +Личный бюджет с локальным хранилищем и синхронизацией с существующим `BalanceServer`. Сервер и его протокол не переписаны. CloudKit удалён по запросу: данные синхронизируются только через Go-сервер. + +## Возможности + +- Доходы и расходы: создание, редактирование, удаление, дата/время, заметки, поиск, фильтр типа. +- Общий баланс, ручная корректировка отдельной операцией без влияния на аналитику. +- Аналитика за 1, 3 и 12 календарных месяцев, доходы/расходы, результат, сбережения, средний расход, крупнейшая категория, динамика и структура расходов. +- Месячные бюджеты по категориям, остаток и превышение; выбор месяца. +- Встроенные и собственные категории, 50 значков Material с совместимыми серверными именами, 30 эмодзи и собственный эмодзи, 24 цвета. Переименование обновляет связанные записи; удаление сохраняет историю. +- RUB, EUR, USD, SEK; системная, светлая и тёмная тема; CSV-экспорт. +- Локальная работа без аккаунта; сервер, регистрация, вход, выход, refresh, ручная и автоматическая синхронизация, полная пересинхронизация. +- Транзакционное сохранение данных и курсора, очередь изменений и удалений, повтор после сетевого сбоя; отдельные хранилища аккаунтов. +- Адаптивная навигация: нижняя панель на телефоне, боковая на широком экране. + +![Интерфейс на компьютере](docs/desktop.png) + +## Платформы + +| Платформа | Реализация / сборка | +| --- | --- | +| Android | Flutter, Android SDK и Java; APK/AAB | +| iPhone, iPad | Один iOS Flutter target; сборка на macOS с Xcode | +| macOS | Flutter runner; сборка на macOS с Xcode | +| Windows | Flutter runner; сборка на Windows с Visual Studio C++ Desktop | +| Linux | Flutter runner; GTK3, libsecret, jsoncpp, Clang, CMake, Ninja | +| Web | Flutter JavaScript release; отдельная статическая раздача | +| Apple Watch | Сохранён полноценный **нативный SwiftUI** target `BalanceWatch` в `../Balance/Balance.xcodeproj` | + +Flutter не предоставляет официального watchOS runner. Часы используют исходный независимый клиент и напрямую тот же Go-сервер: обзор, аналитика, операции, категории, бюджеты, корректировка и настройки сохранены. Интерфейс Material 3 реализован во Flutter; часы используют SwiftUI. Не добавлен фиктивный `flutter build watchos`. + +Проверенный SDK: Flutter **3.47.8**, Dart **3.13.7**. Зависимости закреплены в `pubspec.lock`. Точные выполненные проверки и ограничения среды перечислены в [VALIDATION.md](VALIDATION.md). + +## Запуск + +```sh +cd BalanceFlutter +flutter pub get +flutter run -d chrome # или устройство из flutter devices +flutter analyze +flutter test +``` + +Linux зависимости (Ubuntu): + +```sh +sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev +``` + +Сборка из корня Flutter-проекта: + +```sh +bash scripts/build.sh web +bash scripts/build.sh android +bash scripts/build.sh ios # macOS; без подписи, для проверки сборки +bash scripts/build.sh macos # macOS +bash scripts/build.sh windows # Windows, Git Bash +bash scripts/build.sh linux +bash scripts/build.sh watchos # macOS; исходный native Watch target, simulator +flutter build appbundle # Android публикация +``` + +iPad использует тот же iOS target. В Xcode откройте `ios/Runner.xcworkspace`, выберите Team, свой Bundle ID и устройство. Для macOS — `macos/Runner.xcworkspace`. Для часов — `../Balance/Balance.xcodeproj`, схема `BalanceWatch`, Team и уникальный Bundle ID. Сборка на симулятор без подписи не заменяет проверку на устройстве и настройку distribution signing. + +Перед публикацией настройте собственные идентификаторы пакетов и Android release keystore. Пример настроек подписи находится в `android/key.properties.example`; реальные ключи и пароли не коммитьте. Подпись и публикация в магазины не выполнялись. + +CI `.github/workflows/flutter.yml` содержит анализатор, тесты, отдельные сборки шести Flutter targets и нативного Watch. Это конфигурация для запуска в GitHub Actions; на Gitea её потребуется запускать совместимым runner либо перенести шаги в Gitea Actions. + +## Подключение существующего сервера и перенос данных + +1. Синхронизируйте старое приложение со своим работающим Go-сервером. +2. В новом приложении откройте **Настройки → Адрес сервера**, сохраните HTTPS-адрес. +3. Войдите с тем же аккаунтом и выполните синхронизацию. +4. Сравните общий баланс, операции, категории, бюджеты и корректировки, затем переходите на новый клиент. + +Сохраняются UUID, даты обновления, типы, снимки оформления категорий и флаг корректировки. Flutter не читает напрямую старые SwiftData/Room sandbox. CSV — отчёт, а не полный резервный формат, через него нельзя без потерь перенести бюджеты и корректировки. + +При первом подключении нового аккаунта текущие локальные записи переносятся в него. Последующие аккаунты и серверы имеют отдельные базы; выход не удаляет их данные. Валюта и тема остаются настройками устройства. Валюта меняет формат, обменных курсов и пересчёта нет. + +Внутренние суммы Flutter хранятся целыми сотыми долями валюты, серверный формат `amount`/`limit` остаётся числовым. Для старых записей с более чем двумя десятичными знаками используется округление до сотых. Интервалы везде `[начало, конец)`, чтобы полночь следующего месяца не учитывалась дважды. + +## Web и обратный прокси + +`flutter build web` создаёт `build/web`. Раздавайте его отдельной статикой, например Nginx, с fallback на `index.html`. Go-сервер продолжает отдавать свой встроенный старый Web-интерфейс; новая Flutter-сборка не заменяет его автоматически. + +Укажите origin Flutter-сайта в существующей настройке сервера `BALANCE_CORS_ORIGINS`. Например: `https://balance.example.org`. API можно проксировать под `/api/` с удалением префикса; тогда адрес сервера в приложении — `https://balance.example.org/api`. + +```nginx +server { + listen 443 ssl; + server_name balance.example.org; + # ssl_certificate /...; ssl_certificate_key /...; + root /var/www/balance; + location / { try_files $uri $uri/ /index.html; } + location /api/ { + proxy_pass http://127.0.0.1:8080/; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +Не переносите CSP заголовок встроенного Go Web-клиента на Flutter-статику: это другой frontend. На Web используйте HTTPS; browser storage доступен тому же origin и не обладает изоляцией системного Keychain/Keystore. При недоступном защищённом хранилище сессия сохраняется только в памяти, приложение показывает предупреждение. Финансовые записи остаются локально в IndexedDB. + +## Автоматическая синхронизация + +На всех платформах синхронизация запускается при входе/открытии, возвращении в приложение и раз в две минуты, пока оно активно. Android дополнительно регистрирует WorkManager-задачу раз в 15 минут при наличии сети. ОС может откладывать её по энергосбережению или после принудительной остановки приложения. + +Фоновая задача выполняет полный push/pull и обновление токенов. Общая нативная блокировка защищает сессию и хранилище между открытым приложением и фоновым Flutter engine. Проверены компиляция и тесты блокировки; выполнение задачи после закрытия приложения ещё нужно проверить на Android-устройстве. + +Web использует Web Locks API для защиты общей базы и refresh-токенов между вкладками. Требуется современный браузер и HTTPS (или localhost). На desktop рекомендуется один запущенный экземпляр приложения: межпроцессная координация desktop-клиентов отдельно не реализована. + +Roboto и CanvasKit раздаются вместе с Web-сборкой: сборочный скрипт использует `--no-web-resources-cdn`. Стандартный сгенерированный Flutter service worker кэширует оболочку; после первого открытия Web может работать с локальными данными без API, а синхронизация требует связи с сервером. Реальная установка PWA и восстановление без сети в браузере в этой среде не проверялись. + +## Устройство проекта + +- `lib/domain/finance.dart`: модели, расчёты, категории, CSV. +- `lib/services/app_store.dart`: CRUD, очередь, сессии, атомарное применение страниц. +- `lib/services/sync_api.dart`: существующий wire-протокол сервера. +- `lib/services/storage*.dart`: Sembast / IndexedDB. +- `lib/ui/`: интерфейс и формы Material 3. +- `test/`: расчёты, хранилище, HTTP-протокол, формы и адаптивные экраны. +- `tool/integration_sync.dart`: проверка с настоящим Go HTTP-сервером. +- [SYNC_PROTOCOL.md](SYNC_PROTOCOL.md), [FEATURE_PARITY.md](FEATURE_PARITY.md): протокол и аудит переноса. + +Для Linux в пользовательской сессии нужен работающий Secret Service, например GNOME Keyring или KWallet с поддержкой Secret Service, и библиотека `libsecret-1-0`: через него `flutter_secure_storage` сохраняет токены. Если служба недоступна или связка ключей заблокирована, приложение сохраняет сессию только в памяти и показывает предупреждение; локальные финансовые записи сохраняются отдельно в Sembast. Для сборки нужны `clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev`. diff --git a/BalanceFlutter/SYNC_PROTOCOL.md b/BalanceFlutter/SYNC_PROTOCOL.md new file mode 100644 index 0000000..03284f0 --- /dev/null +++ b/BalanceFlutter/SYNC_PROTOCOL.md @@ -0,0 +1,94 @@ +# Balance sync wire protocol + +`lib/services/sync_api.dart` implements the existing Go `/v1` API using +`package:http`. It uses JSON maps independently of local model classes. Add +`http` to the app's dependencies. No server modifications are required. + +## Authentication + +- `register(email, password)` and `login(email, password)` POST to + `/v1/auth/register` and `/v1/auth/login` with `{email,password}`. +- They return `{accessToken,refreshToken,expiresAt,user:{id,email}}`. +- `refresh(refreshToken)` POSTs `{refreshToken}` to `/v1/auth/refresh` and + **rotates** the refresh token. Serialize refresh operations and save the full + returned session atomically in the separate secure session store. +- `me(accessToken)` GETs `/v1/me` using `Authorization: Bearer …`. +- `logout(refreshToken)` POSTs to `/v1/auth/logout` (204). Clear the local store + in `finally`; offline logout cannot revoke the server session. + +The client is stateless and never persists tokens. Its caller should refresh +60 seconds before `expiresAt`, retain sessions on transient network/5xx errors, +clear them on invalid refresh credentials, and retry an authorized request at +most once following a successful refresh on 401. Concurrent refreshes must share +one in-flight operation because refresh tokens are single-use. `synchronize` +accepts an async `accessToken` provider for this external session manager; it +does not automatically replay failed HTTP requests. HTTP errors expose status, +server error code and message through `SyncApiException`; network, timeout and +format errors remain distinct exceptions. Never log session maps or passwords. + +## Sync and payloads + +POST `/v1/sync` with: + +```json +{"cursor":0,"deviceId":"stable-device-id","changes":[{"entity":"category","id":"UUID","deleted":false,"updatedAt":"2026-10-28T12:00:00Z","payload":{"name":"Food","icon":"cart","emoji":"","colorName":"indigo","kindRawValue":"expense","createdAt":"2026-10-28T12:00:00Z"}}],"pull":false} +``` + +The server returns `{cursor,changes,serverTime,hasMore}`. Changes include server +`version` and global `sequence`; neither is an optimistic concurrency precondition. +Timestamps use RFC3339 UTC (Dart: `date.toUtc().toIso8601String()`). Preserve +record IDs exactly, including UUID case; Swift uses UUID IDs. The current server +does not use `deviceId` for conflict resolution. + +| Entity | Active payload fields | +| --- | --- | +| `transaction` | `amount` number, `date` timestamp, `note`, `categoryName`, `categoryIcon`, `categoryEmoji`, `categoryColorName`, `isBalanceAdjustment` boolean, `kindRawValue` (`income` / `expense`) | +| `category` | `name`, `icon`, `emoji`, `colorName`, `kindRawValue`, `createdAt` timestamp | +| `budget` | `categoryName`, `categoryIcon`, `categoryEmoji`, `limit` number, `monthStart` timestamp | + +Deletion uses the same envelope with `deleted:true`, an updated timestamp and +no payload. Keep tombstones until confirmed by a pulled change. The server limits +requests to 2 MiB, 5000 changes and each payload to 256 KiB; it rejects timestamps +over 24 hours into its future. `synchronize` uploads immutable snapshots in batches +of 100, splitting batches beyond 1.5 MB, then downloads 500-record pages. Push-only +requests do not advance the cursor. `sync` exposes a single page for custom flows. + +## Durable merge contract + +Scope cursor and push watermark by normalized server URL **and user ID**. Never +reuse another account's cursor. Serialize full sync runs. Capture a cutoff and a +stable snapshot of local edits up to that cutoff before starting. Native clients +query edits after `lastPush - 5 seconds` (or from zero after a clock rollback), +plus pending tombstones; overlapping uploads are safe. + +The server keeps a change only when `incoming.updatedAt > existing.updatedAt`: +last timestamp wins, and equal timestamps leave the existing server value. Pull +in sequence order. Apply an active record only when it is newer than the local +record; apply a deletion when its timestamp is at least the local timestamp. +A pending local tombstone must also participate in this comparison, preventing +an older remote active record from resurrecting a newer local deletion. Remove +a pending tombstone only when a confirmed remote change is at least as new. + +`synchronize(..., applyPage: (changes, cursor) async { ... })` requires the caller +to merge a complete page and store its cursor in one durable transaction. Throw +on malformed/unapplied records; advancing the cursor would otherwise lose data. +It validates page order and cursor progress before invoking this callback. Record +the push cutoff only after the entire method succeeds. A failed run can replay +its snapshot safely; do not remove edits made concurrently after the cutoff. + +## Browser considerations + +Use HTTPS. Local HTTP development requires explicit `allowInsecureHttp:true`; +browsers still block HTTPS-to-HTTP mixed content. Cross-origin Flutter Web needs +the exact frontend origin in the existing server CORS configuration. This client +uses bearer headers and refresh tokens in JSON, not credentialed browser cookies; +the server currently has no `Access-Control-Allow-Credentials`, and its cookies +are `SameSite=Strict`. A cross-site cookie session cannot be assumed to work. +Browser token persistence is a separate security decision: do not use +localStorage/SharedPreferences for tokens; memory-only sessions require login +after reload. JavaScript execution can access any tokens returned to the app, +so XSS remains a risk even with encrypted browser persistence. Native platforms +should use their OS secure storage. Cursors above 2^53−1 are rejected to avoid +silent precision loss in Flutter Web. The transport disables redirect following +where supported; browsers may manage redirects themselves, so configure the +final HTTPS API URL directly. diff --git a/BalanceFlutter/VALIDATION.md b/BalanceFlutter/VALIDATION.md new file mode 100644 index 0000000..05b6406 --- /dev/null +++ b/BalanceFlutter/VALIDATION.md @@ -0,0 +1,31 @@ +# Проверки переноса + +Дата: 28 октября 2026. SDK Flutter 3.47.8 / Dart 3.13.7, Linux x64. + +## Подтверждено + +- Финансовые модели и расчёты: 13 unit-тестов, включая исключение корректировок, границы месяца, CSV, точное локальное хранение целых сотых и локальный месяц бюджета после серверного UTC round trip. Проверка часового пояса также запускалась с `TZ=Europe/Moscow`. +- Хранилище: 10 тестов — восстановление, dirty/tombstone, каскады, rollback курсора, scoped accounts, headless store, два экземпляра store, logout и ротация токенов между engines. +- HTTP-протокол: 7 MockClient-тестов — auth, пакетная отправка, страницы, ошибки и валидация. +- RuntimeLock: 3 Dart channel-теста. Web origin-wide Web Locks проверены компиляцией JavaScript и Node 24 native navigator.locks (владение, ожидание, освобождение, повторное получение и отказ без secure context/API). Android Java release compilation и 3 JVM-теста: сериализация потоков, повторное освобождение, прерывание ожидания, реальное межпроцессное состязание за file lock. +- Интерфейс: 22 widget/screenshot-теста. Все пять экранов с пустой и заполненной базой на 320×568, 390×844 и 1280×900, формы и ошибки ввода, CRUD операций/категорий/бюджетов, текст 1.5×, тёмная тема, отрицательная корректировка, CSV через save selector и share sheet с проверкой байтов. +- Настоящий Go HTTP-сервер: регистрация, вход, `/me`, 1102 записи, страницы `[500,500,102]`, изменения, удаление, replay старой записи, изоляция двух пользователей, refresh rotation и отзыв refresh после logout. Запуск: `bash tool/run_integration.sh` (нужны Go 1.25+ и Dart). +- Общий финальный запуск: **55 Flutter-тестов прошли**, `flutter analyze` — **No issues found**. +- Web JavaScript release с локальным CanvasKit и Linux x64 release собраны. Android universal release APK также собран и снабжён отдельной тестовой подписью; проверяются apksigner и 16 KiB zipalign. WorkManager Kotlin/AGP compatibility настроена в runner Gradle. +- Go-сервер не изменялся; cloud-capabilities удалены из Apple templates, SwiftData явно локальный. + +В исходном checkout отсутствует `BalanceServer/cmd/balance-server`, хотя README и Dockerfile на него ссылаются (каталог исключается исходным ignore pattern). Поэтому integration runner создаёт временный `main` **только во временной копии**, вызывая исходные config/database/httpapi; исходный сервер остаётся без изменений. Это ограничение исходного репозитория, а не изменение протокола или сервера в переносе. Для обычной серверной Docker-сборки нужен оригинальный entry point; уже работающий Go-сервер можно использовать напрямую. + +## Что ещё требует целевой среды + +- iOS/iPadOS, macOS, Windows и watchOS не собирались в этой Linux-среде. Runners и CI созданы; реальные результаты CI не получены. +- watchOS — сохранённый нативный SwiftUI target, не Flutter. Material 3 относится к Flutter-клиенту. +- Полная проверка WorkManager после закрытия/остановки приложения, system Keychain/Keystore, iPad share popover, файловых диалогов, доступности и реальной клавиатуры требует устройств. +- Linux release слинкован, но запуск GTK под Xvfb/DBus был заблокирован ограничением сокетов среды. Локальные финансы и запуск store проверены unit/widget-тестами; реальный Secret Service пользователя здесь не проверен. +- Web runtime в настоящем браузере, CORS с пользовательским сервером, PWA install/offline reload и обновление кэша здесь не проверялись. +- Подпись Apple, публикация и магазины не выполнялись. Android тестовая подпись, если приложена, предназначена для проверки установки. +- Перенос старой SwiftData/Room базы выполняется через существующий сервер; прямого чтения чужого sandbox нет. Старое приложение следует сохранить до сверки данных. + +## Принятые различия + +Material 3 и адаптивная навигация; денежные значения в целых сотых (старые доли менее сотой округляются); границы периодов `[start,end)`; отдельные данные аккаунтов; график динамики — пары столбцов. CloudKit удалён по прямому запросу. Финансовые payload, UUID, даты обновления, корректировки, категории, бюджеты и удаления совместимы с исходным сервером. diff --git a/BalanceFlutter/analysis_options.yaml b/BalanceFlutter/analysis_options.yaml new file mode 100644 index 0000000..52bd7ea --- /dev/null +++ b/BalanceFlutter/analysis_options.yaml @@ -0,0 +1,13 @@ +include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +linter: + rules: + prefer_single_quotes: true diff --git a/BalanceFlutter/android/.gitignore b/BalanceFlutter/android/.gitignore new file mode 100644 index 0000000..3a1e07d --- /dev/null +++ b/BalanceFlutter/android/.gitignore @@ -0,0 +1,16 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks + +.kotlin/ diff --git a/BalanceFlutter/android/app/build.gradle.kts b/BalanceFlutter/android/app/build.gradle.kts new file mode 100644 index 0000000..202beec --- /dev/null +++ b/BalanceFlutter/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +import java.util.Properties + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val releaseSigningFile = rootProject.file("key.properties") +val releaseSigning = Properties().apply { + if (releaseSigningFile.exists()) { + releaseSigningFile.inputStream().use { load(it) } + } +} + +android { + namespace = "com.balance.balance" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "com.balance.balance" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + if (releaseSigningFile.exists()) { + create("release") { + keyAlias = releaseSigning.getProperty("keyAlias") + keyPassword = releaseSigning.getProperty("keyPassword") + storeFile = file(releaseSigning.getProperty("storeFile")) + storePassword = releaseSigning.getProperty("storePassword") + } + } + } + + buildTypes { + release { + // CI builds remain unsigned until a private key.properties is supplied. + signingConfig = if (releaseSigningFile.exists()) { + signingConfigs.getByName("release") + } else { + null + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/BalanceFlutter/android/app/src/debug/AndroidManifest.xml b/BalanceFlutter/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/BalanceFlutter/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/BalanceFlutter/android/app/src/main/AndroidManifest.xml b/BalanceFlutter/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..68ffe87 --- /dev/null +++ b/BalanceFlutter/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceFlutter/android/app/src/main/kotlin/com/balance/balance/MainActivity.kt b/BalanceFlutter/android/app/src/main/kotlin/com/balance/balance/MainActivity.kt new file mode 100644 index 0000000..d704ff5 --- /dev/null +++ b/BalanceFlutter/android/app/src/main/kotlin/com/balance/balance/MainActivity.kt @@ -0,0 +1,5 @@ +package com.balance.balance + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/BalanceFlutter/android/app/src/main/res/drawable-v21/launch_background.xml b/BalanceFlutter/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/BalanceFlutter/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/BalanceFlutter/android/app/src/main/res/drawable/launch_background.xml b/BalanceFlutter/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/BalanceFlutter/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/BalanceFlutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/BalanceFlutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..d2b8984 Binary files /dev/null and b/BalanceFlutter/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/BalanceFlutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/BalanceFlutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..d234613 Binary files /dev/null and b/BalanceFlutter/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/BalanceFlutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/BalanceFlutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2dcb1fb Binary files /dev/null and b/BalanceFlutter/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/BalanceFlutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/BalanceFlutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..043473f Binary files /dev/null and b/BalanceFlutter/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/BalanceFlutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/BalanceFlutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..164eb48 Binary files /dev/null and b/BalanceFlutter/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/BalanceFlutter/android/app/src/main/res/values-night/styles.xml b/BalanceFlutter/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/BalanceFlutter/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/BalanceFlutter/android/app/src/main/res/values/styles.xml b/BalanceFlutter/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/BalanceFlutter/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/BalanceFlutter/android/app/src/main/res/xml/network_security_config.xml b/BalanceFlutter/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..2b82002 --- /dev/null +++ b/BalanceFlutter/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + localhost + 127.0.0.1 + ::1 + 10.0.2.2 + + diff --git a/BalanceFlutter/android/app/src/profile/AndroidManifest.xml b/BalanceFlutter/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/BalanceFlutter/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/BalanceFlutter/android/build.gradle.kts b/BalanceFlutter/android/build.gradle.kts new file mode 100644 index 0000000..9015008 --- /dev/null +++ b/BalanceFlutter/android/build.gradle.kts @@ -0,0 +1,37 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +// Workmanager 0.9.3 assumes AGP 9 always compiles Kotlin itself. Flutter's +// current compatibility mode disables built-in Kotlin, so enable KGP for it. +subprojects { + if (name == "workmanager_android" && + providers.gradleProperty("android.builtInKotlin").orNull == "false") { + pluginManager.withPlugin("com.android.library") { + pluginManager.apply("org.jetbrains.kotlin.android") + tasks.withType().configureEach { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8) + } + } + } +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/BalanceFlutter/android/gradle.properties b/BalanceFlutter/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/BalanceFlutter/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/BalanceFlutter/android/gradle/wrapper/gradle-wrapper.properties b/BalanceFlutter/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/BalanceFlutter/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/BalanceFlutter/android/key.properties.example b/BalanceFlutter/android/key.properties.example new file mode 100644 index 0000000..f75dc03 --- /dev/null +++ b/BalanceFlutter/android/key.properties.example @@ -0,0 +1,4 @@ +storePassword=YOUR_STORE_PASSWORD +keyPassword=YOUR_KEY_PASSWORD +keyAlias=upload +storeFile=../upload-keystore.jks diff --git a/BalanceFlutter/android/settings.gradle.kts b/BalanceFlutter/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/BalanceFlutter/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/BalanceFlutter/assets/app_icon.png b/BalanceFlutter/assets/app_icon.png new file mode 100644 index 0000000..f12b5f8 Binary files /dev/null and b/BalanceFlutter/assets/app_icon.png differ diff --git a/BalanceFlutter/assets/fonts/LICENSE.txt b/BalanceFlutter/assets/fonts/LICENSE.txt new file mode 100644 index 0000000..364bb75 --- /dev/null +++ b/BalanceFlutter/assets/fonts/LICENSE.txt @@ -0,0 +1,203 @@ +Copyright 2011 Google Inc. All Rights Reserved. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/BalanceFlutter/assets/fonts/Roboto-Bold.ttf b/BalanceFlutter/assets/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..d3f01ad Binary files /dev/null and b/BalanceFlutter/assets/fonts/Roboto-Bold.ttf differ diff --git a/BalanceFlutter/assets/fonts/Roboto-Medium.ttf b/BalanceFlutter/assets/fonts/Roboto-Medium.ttf new file mode 100644 index 0000000..1a7f3b0 Binary files /dev/null and b/BalanceFlutter/assets/fonts/Roboto-Medium.ttf differ diff --git a/BalanceFlutter/assets/fonts/Roboto-Regular.ttf b/BalanceFlutter/assets/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..2c97eea Binary files /dev/null and b/BalanceFlutter/assets/fonts/Roboto-Regular.ttf differ diff --git a/BalanceFlutter/docs/desktop.png b/BalanceFlutter/docs/desktop.png new file mode 100644 index 0000000..e674c2f Binary files /dev/null and b/BalanceFlutter/docs/desktop.png differ diff --git a/BalanceFlutter/docs/mobile.png b/BalanceFlutter/docs/mobile.png new file mode 100644 index 0000000..d8b0e9b Binary files /dev/null and b/BalanceFlutter/docs/mobile.png differ diff --git a/BalanceFlutter/ios/.gitignore b/BalanceFlutter/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/BalanceFlutter/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/BalanceFlutter/ios/Flutter/AppFrameworkInfo.plist b/BalanceFlutter/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/BalanceFlutter/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/BalanceFlutter/ios/Flutter/Debug.xcconfig b/BalanceFlutter/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/BalanceFlutter/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/BalanceFlutter/ios/Flutter/Release.xcconfig b/BalanceFlutter/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/BalanceFlutter/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/BalanceFlutter/ios/Runner.xcodeproj/project.pbxproj b/BalanceFlutter/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e91b929 --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,650 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/BalanceFlutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/BalanceFlutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceFlutter/ios/Runner.xcworkspace/contents.xcworkspacedata b/BalanceFlutter/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/BalanceFlutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/BalanceFlutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/BalanceFlutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/BalanceFlutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/BalanceFlutter/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/BalanceFlutter/ios/Runner/AppDelegate.swift b/BalanceFlutter/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/BalanceFlutter/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..66400cf Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..6ca0748 Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..0c527bf Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..873ef3d Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..8cf9865 Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..bdfe6cc Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..6a46ebf Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..0c527bf Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..05da886 Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..7cc030c Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..7cc030c Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..3ca67f2 Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..064788f Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..68cd9df Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..a700b58 Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/BalanceFlutter/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/BalanceFlutter/ios/Runner/Base.lproj/LaunchScreen.storyboard b/BalanceFlutter/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/BalanceFlutter/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceFlutter/ios/Runner/Base.lproj/Main.storyboard b/BalanceFlutter/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/BalanceFlutter/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceFlutter/ios/Runner/Info.plist b/BalanceFlutter/ios/Runner/Info.plist new file mode 100644 index 0000000..c03fdff --- /dev/null +++ b/BalanceFlutter/ios/Runner/Info.plist @@ -0,0 +1,75 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Balance + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + balance + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/BalanceFlutter/ios/Runner/Runner-Bridging-Header.h b/BalanceFlutter/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/BalanceFlutter/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/BalanceFlutter/ios/Runner/Runner.entitlements b/BalanceFlutter/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..30cc621 --- /dev/null +++ b/BalanceFlutter/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + + + diff --git a/BalanceFlutter/ios/Runner/SceneDelegate.swift b/BalanceFlutter/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/BalanceFlutter/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/BalanceFlutter/ios/RunnerTests/RunnerTests.swift b/BalanceFlutter/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/BalanceFlutter/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/BalanceFlutter/lib/domain/finance.dart b/BalanceFlutter/lib/domain/finance.dart new file mode 100644 index 0000000..5acc890 --- /dev/null +++ b/BalanceFlutter/lib/domain/finance.dart @@ -0,0 +1,685 @@ +/// Finance values use hundredths of a currency unit internally. JSON retains +/// the existing iOS / Android / Go synchronization protocol. +enum TransactionKind { + expense, + income; + + String get title => this == income ? 'Доход' : 'Расход'; + static TransactionKind parse(String value) => + value == 'income' ? income : expense; +} + +int moneyToMinor(num value) { + if (!value.isFinite) throw const FormatException('Invalid money amount'); + return (value * 100).round(); +} + +/// Parse user-entered amounts without passing through binary floating point. +/// An empty value, exponent, sign or more than two decimal places is rejected. +int parseMoney(String value) { + final normalized = value.trim().replaceAll(',', '.'); + if (!RegExp(r'^\d+(?:\.\d{1,2})?$').hasMatch(normalized)) { + throw const FormatException( + 'Введите положительную сумму, максимум 2 знака после запятой', + ); + } + final parts = normalized.split('.'); + return int.parse(parts[0]) * 100 + + (parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0'))); +} + +String formatMinor(int value) { + final absolute = value.abs(); + return '${value < 0 ? '-' : ''}${absolute ~/ 100}.${(absolute % 100).toString().padLeft(2, '0')}'; +} + +DateTime _date(dynamic value) => DateTime.parse(value as String); +String _timestamp(DateTime value) => value.toUtc().toIso8601String(); + +class TransactionRecord { + final String id; + final int amountMinor; + final DateTime date; + final String note, + categoryName, + categoryIcon, + categoryEmoji, + categoryColorName; + final bool isBalanceAdjustment; + final String kindRawValue; + final DateTime updatedAt; + + const TransactionRecord({ + required this.id, + required this.amountMinor, + required this.date, + required this.categoryName, + required this.updatedAt, + this.note = '', + this.categoryIcon = 'circle.fill', + this.categoryEmoji = '', + this.categoryColorName = 'auto', + this.isBalanceAdjustment = false, + this.kindRawValue = 'expense', + }); + + double get amount => amountMinor / 100; + TransactionKind get kind => TransactionKind.parse(kindRawValue); + Map payload() => { + 'amount': amount, + 'date': _timestamp(date), + 'note': note, + 'categoryName': categoryName, + 'categoryIcon': categoryIcon, + 'categoryEmoji': categoryEmoji, + 'categoryColorName': categoryColorName, + 'isBalanceAdjustment': isBalanceAdjustment, + 'kindRawValue': kindRawValue, + }; + Map toJson() => { + 'id': id, + 'updatedAt': _timestamp(updatedAt), + ...payload(), + 'amountMinor': amountMinor, + }; + factory TransactionRecord.fromJson(Map json) => + TransactionRecord.fromPayload( + id: json['id'] as String, + updatedAt: _date(json['updatedAt']), + payload: json, + ).copyWith(amountMinor: json['amountMinor'] as int?); + factory TransactionRecord.fromPayload({ + required String id, + required DateTime updatedAt, + required Map payload, + }) => TransactionRecord( + id: id, + updatedAt: updatedAt, + amountMinor: moneyToMinor(payload['amount'] as num), + date: _date(payload['date']), + categoryName: payload['categoryName'] as String, + note: payload['note'] as String? ?? '', + categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill', + categoryEmoji: payload['categoryEmoji'] as String? ?? '', + categoryColorName: payload['categoryColorName'] as String? ?? 'auto', + isBalanceAdjustment: payload['isBalanceAdjustment'] as bool? ?? false, + kindRawValue: payload['kindRawValue'] as String? ?? 'expense', + ); + TransactionRecord copyWith({ + String? id, + int? amountMinor, + DateTime? date, + String? note, + String? categoryName, + String? categoryIcon, + String? categoryEmoji, + String? categoryColorName, + bool? isBalanceAdjustment, + String? kindRawValue, + DateTime? updatedAt, + }) => TransactionRecord( + id: id ?? this.id, + amountMinor: amountMinor ?? this.amountMinor, + date: date ?? this.date, + updatedAt: updatedAt ?? this.updatedAt, + note: note ?? this.note, + categoryName: categoryName ?? this.categoryName, + categoryIcon: categoryIcon ?? this.categoryIcon, + categoryEmoji: categoryEmoji ?? this.categoryEmoji, + categoryColorName: categoryColorName ?? this.categoryColorName, + isBalanceAdjustment: isBalanceAdjustment ?? this.isBalanceAdjustment, + kindRawValue: kindRawValue ?? this.kindRawValue, + ); +} + +class CategoryRecord { + final String id, name, icon, emoji, colorName, kindRawValue; + final DateTime createdAt, updatedAt; + const CategoryRecord({ + required this.id, + required this.name, + required this.icon, + required this.colorName, + required this.createdAt, + required this.updatedAt, + this.emoji = '', + this.kindRawValue = 'expense', + }); + TransactionKind get kind => TransactionKind.parse(kindRawValue); + Map payload() => { + 'name': name, + 'icon': icon, + 'emoji': emoji, + 'colorName': colorName, + 'kindRawValue': kindRawValue, + 'createdAt': _timestamp(createdAt), + }; + Map toJson() => { + 'id': id, + 'updatedAt': _timestamp(updatedAt), + ...payload(), + }; + factory CategoryRecord.fromJson(Map json) => + CategoryRecord.fromPayload( + id: json['id'] as String, + updatedAt: _date(json['updatedAt']), + payload: json, + ); + factory CategoryRecord.fromPayload({ + required String id, + required DateTime updatedAt, + required Map payload, + }) => CategoryRecord( + id: id, + updatedAt: updatedAt, + name: payload['name'] as String, + icon: payload['icon'] as String? ?? 'star.fill', + emoji: payload['emoji'] as String? ?? '', + colorName: payload['colorName'] as String? ?? 'indigo', + kindRawValue: payload['kindRawValue'] as String? ?? 'expense', + createdAt: payload['createdAt'] == null + ? updatedAt + : _date(payload['createdAt']), + ); + CategoryRecord copyWith({ + String? id, + String? name, + String? icon, + String? emoji, + String? colorName, + String? kindRawValue, + DateTime? createdAt, + DateTime? updatedAt, + }) => CategoryRecord( + id: id ?? this.id, + name: name ?? this.name, + icon: icon ?? this.icon, + emoji: emoji ?? this.emoji, + colorName: colorName ?? this.colorName, + kindRawValue: kindRawValue ?? this.kindRawValue, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); +} + +class BudgetRecord { + final String id, categoryName, categoryIcon, categoryEmoji; + final int limitMinor; + final DateTime monthStart, updatedAt; + const BudgetRecord({ + required this.id, + required this.categoryName, + required this.limitMinor, + required this.monthStart, + required this.updatedAt, + this.categoryIcon = 'circle.fill', + this.categoryEmoji = '', + }); + double get limit => limitMinor / 100; + Map payload() => { + 'categoryName': categoryName, + 'categoryIcon': categoryIcon, + 'categoryEmoji': categoryEmoji, + 'limit': limit, + 'monthStart': _timestamp(monthStart), + }; + Map toJson() => { + 'id': id, + 'updatedAt': _timestamp(updatedAt), + ...payload(), + 'limitMinor': limitMinor, + }; + factory BudgetRecord.fromJson(Map json) => + BudgetRecord.fromPayload( + id: json['id'] as String, + updatedAt: _date(json['updatedAt']), + payload: json, + ).copyWith(limitMinor: json['limitMinor'] as int?); + factory BudgetRecord.fromPayload({ + required String id, + required DateTime updatedAt, + required Map payload, + }) => BudgetRecord( + id: id, + updatedAt: updatedAt, + categoryName: payload['categoryName'] as String, + categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill', + categoryEmoji: payload['categoryEmoji'] as String? ?? '', + limitMinor: moneyToMinor(payload['limit'] as num), + monthStart: _date(payload['monthStart']), + ); + BudgetRecord copyWith({ + String? id, + String? categoryName, + String? categoryIcon, + String? categoryEmoji, + int? limitMinor, + DateTime? monthStart, + DateTime? updatedAt, + }) => BudgetRecord( + id: id ?? this.id, + categoryName: categoryName ?? this.categoryName, + categoryIcon: categoryIcon ?? this.categoryIcon, + categoryEmoji: categoryEmoji ?? this.categoryEmoji, + limitMinor: limitMinor ?? this.limitMinor, + monthStart: monthStart ?? this.monthStart, + updatedAt: updatedAt ?? this.updatedAt, + ); +} + +class Tombstone { + final String entity, id; + final DateTime updatedAt; + const Tombstone({ + required this.entity, + required this.id, + required this.updatedAt, + }); + String get recordID => id; + Map toJson() => { + 'entity': entity, + 'id': id, + 'deleted': true, + 'updatedAt': _timestamp(updatedAt), + }; + factory Tombstone.fromJson(Map json) => Tombstone( + entity: json['entity'] as String, + id: json['id'] as String, + updatedAt: _date(json['updatedAt']), + ); +} + +class FinanceSummary { + final int incomeMinor, expensesMinor; + const FinanceSummary({ + required this.incomeMinor, + required this.expensesMinor, + }); + int get balanceMinor => incomeMinor - expensesMinor; + double get income => incomeMinor / 100; + double get expenses => expensesMinor / 100; + double get balance => balanceMinor / 100; +} + +class CategorySpending { + final String name, icon, emoji, colorName; + final int amountMinor; + const CategorySpending({ + required this.name, + required this.icon, + required this.emoji, + required this.colorName, + required this.amountMinor, + }); + double get amount => amountMinor / 100; +} + +class MonthlyTotal { + final DateTime month; + final FinanceSummary summary; + const MonthlyTotal(this.month, this.summary); +} + +class FinanceMath { + static int totalBalance(Iterable items) => items.fold( + 0, + (value, item) => + value + + (item.kind == TransactionKind.income + ? item.amountMinor + : -item.amountMinor), + ); + static DateTime monthStart(DateTime date) => date.isUtc + ? DateTime.utc(date.year, date.month) + : DateTime(date.year, date.month); + static DateTime nextMonth(DateTime date) => date.isUtc + ? DateTime.utc(date.year, date.month + 1) + : DateTime(date.year, date.month + 1); + static bool _inPeriod(TransactionRecord item, DateTime start, DateTime end) => + !item.isBalanceAdjustment && + !item.date.isBefore(start) && + item.date.isBefore(end); + static FinanceSummary summary( + Iterable items, + DateTime start, + DateTime endExclusive, + ) { + var income = 0; + var expenses = 0; + for (final item in items) { + if (!_inPeriod(item, start, endExclusive)) continue; + if (item.kind == TransactionKind.income) { + income += item.amountMinor; + } else { + expenses += item.amountMinor; + } + } + return FinanceSummary(incomeMinor: income, expensesMinor: expenses); + } + + static List spending( + Iterable items, + DateTime start, + DateTime endExclusive, + ) { + final grouped = {}; + for (final item in items) { + if (item.kind != TransactionKind.expense || + !_inPeriod(item, start, endExclusive)) { + continue; + } + final existing = grouped[item.categoryName]; + grouped[item.categoryName] = CategorySpending( + name: item.categoryName, + icon: existing?.icon ?? item.categoryIcon, + emoji: existing?.emoji ?? item.categoryEmoji, + colorName: existing?.colorName ?? item.categoryColorName, + amountMinor: (existing?.amountMinor ?? 0) + item.amountMinor, + ); + } + return grouped.values.toList()..sort((a, b) { + final byAmount = b.amountMinor.compareTo(a.amountMinor); + return byAmount == 0 ? a.name.compareTo(b.name) : byAmount; + }); + } + + /// Includes empty months, ordered from oldest to newest. + static List monthlyTrend( + Iterable items, + DateTime through, { + int months = 6, + }) { + if (months < 0) throw ArgumentError.value(months, 'months'); + final snapshot = items.toList(); + return List.generate(months, (index) { + final month = through.isUtc + ? DateTime.utc(through.year, through.month - months + 1 + index) + : DateTime(through.year, through.month - months + 1 + index); + return MonthlyTotal(month, summary(snapshot, month, nextMonth(month))); + }); + } + + static int budgetUsed( + BudgetRecord budget, + Iterable items, + ) { + // Native clients serialize local month starts as UTC instants. In positive + // offsets January 1 becomes December 31 on the wire; recover the local + // calendar month before building its spending interval. + final start = monthStart(budget.monthStart.toLocal()); + return items + .where( + (item) => + item.categoryName == budget.categoryName && + item.kind == TransactionKind.expense && + _inPeriod(item, start, nextMonth(start)), + ) + .fold(0, (value, item) => value + item.amountMinor); + } + + static String escapeCsv(String value) => '"${value.replaceAll('"', '""')}"'; + static String exportCsv(Iterable items) { + final ordered = items.toList()..sort((a, b) => b.date.compareTo(a.date)); + return [ + 'Дата,Тип,Категория,Сумма,Заметка', + ...ordered.map( + (item) => [ + _timestamp(item.date), + item.isBalanceAdjustment ? 'Корректировка' : item.kind.title, + item.categoryName, + formatMinor(item.amountMinor), + item.note, + ].map(escapeCsv).join(','), + ), + ].join('\n'); + } +} + +class CategoryDesignOptions { + static const icons = [ + 'star.fill', + 'heart.fill', + 'sparkles', + 'bolt.fill', + 'flame.fill', + 'drop.fill', + 'cart.fill', + 'bag.fill', + 'creditcard.fill', + 'banknote.fill', + 'gift.fill', + 'tag.fill', + 'cup.and.saucer.fill', + 'fork.knife', + 'takeoutbag.and.cup.and.straw.fill', + 'birthday.cake.fill', + 'car.fill', + 'bus.fill', + 'tram.fill', + 'bicycle', + 'airplane', + 'fuelpump.fill', + 'house.fill', + 'bed.double.fill', + 'lightbulb.fill', + 'wifi', + 'phone.fill', + 'printer.fill', + 'pawprint.fill', + 'leaf.fill', + 'figure.walk', + 'figure.run', + 'dumbbell.fill', + 'cross.case.fill', + 'pills.fill', + 'stethoscope', + 'book.fill', + 'graduationcap.fill', + 'laptopcomputer', + 'briefcase.fill', + 'gamecontroller.fill', + 'music.note', + 'film.fill', + 'camera.fill', + 'paintpalette.fill', + 'tshirt.fill', + 'hammer.fill', + 'wrench.and.screwdriver.fill', + 'shippingbox.fill', + 'globe.europe.africa.fill', + ]; + static const emojis = [ + '🍔', + '☕️', + '🛒', + '🚕', + '✈️', + '🏠', + '💡', + '📱', + '💻', + '🎮', + '🎬', + '🎵', + '📚', + '🎓', + '💊', + '🏋️', + '🐶', + '🐱', + '👕', + '🎁', + '💰', + '💳', + '📈', + '🔧', + '🌿', + '❤️', + '⭐️', + '🎯', + '🧾', + '🏖️', + ]; + static const colors = [ + 'red', + 'orange', + 'yellow', + 'green', + 'mint', + 'teal', + 'cyan', + 'blue', + 'indigo', + 'purple', + 'pink', + 'gray', + 'coral', + 'peach', + 'gold', + 'lime', + 'forest', + 'turquoise', + 'sky', + 'navy', + 'lavender', + 'magenta', + 'brown', + 'slate', + ]; +} + +class BuiltInCategories { + static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + static final expense = List.unmodifiable([ + CategoryRecord( + id: 'food', + name: 'Продукты', + icon: 'cart.fill', + colorName: 'orange', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'transport', + name: 'Транспорт', + icon: 'car.fill', + colorName: 'blue', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'home', + name: 'Дом', + icon: 'house.fill', + colorName: 'indigo', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'entertainment', + name: 'Развлечения', + icon: 'gamecontroller.fill', + colorName: 'pink', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'health', + name: 'Здоровье', + icon: 'cross.case.fill', + colorName: 'red', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'shopping', + name: 'Покупки', + icon: 'bag.fill', + colorName: 'purple', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'subscriptions', + name: 'Подписки', + icon: 'repeat', + colorName: 'cyan', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'education', + name: 'Образование', + icon: 'book.fill', + colorName: 'mint', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'other-expense', + name: 'Другое', + icon: 'ellipsis.circle.fill', + colorName: 'gray', + kindRawValue: 'expense', + createdAt: _epoch, + updatedAt: _epoch, + ), + ]); + static final income = List.unmodifiable([ + CategoryRecord( + id: 'salary', + name: 'Зарплата', + icon: 'banknote.fill', + colorName: 'green', + kindRawValue: 'income', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'freelance', + name: 'Подработка', + icon: 'laptopcomputer', + colorName: 'teal', + kindRawValue: 'income', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'investments', + name: 'Инвестиции', + icon: 'chart.line.uptrend.xyaxis', + colorName: 'blue', + kindRawValue: 'income', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'gift', + name: 'Подарок', + icon: 'gift.fill', + colorName: 'pink', + kindRawValue: 'income', + createdAt: _epoch, + updatedAt: _epoch, + ), + CategoryRecord( + id: 'other-income', + name: 'Другое', + icon: 'plus.circle.fill', + colorName: 'gray', + kindRawValue: 'income', + createdAt: _epoch, + updatedAt: _epoch, + ), + ]); + static List forKind(String kind) => + kind == 'income' ? income : expense; +} diff --git a/BalanceFlutter/lib/main.dart b/BalanceFlutter/lib/main.dart new file mode 100644 index 0000000..b4ebd37 --- /dev/null +++ b/BalanceFlutter/lib/main.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' + show LicenseRegistry, LicenseEntryWithLineBreaks; +import 'package:flutter/services.dart' show rootBundle; +import 'services/app_store.dart'; +import 'services/background.dart'; +import 'ui/app.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + LicenseRegistry.addLicense(() async* { + yield LicenseEntryWithLineBreaks([ + 'Roboto', + ], await rootBundle.loadString('assets/fonts/LICENSE.txt')); + }); + final store = AppStore(); + try { + await store.initialize(); + try { + await configureBackgroundSync(); + } catch (_) { + store.error = + 'Не удалось включить фоновую синхронизацию. Синхронизация при открытии приложения доступна.'; + } + runApp(BalanceApp(store: store)); + } catch (error) { + runApp( + MaterialApp( + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.storage_outlined, size: 48), + const SizedBox(height: 16), + const Text( + 'Не удалось открыть хранилище', + style: TextStyle(fontSize: 24), + ), + const SizedBox(height: 12), + SelectableText('$error'), + const SizedBox(height: 20), + FilledButton( + onPressed: main, + child: const Text('Повторить'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/BalanceFlutter/lib/services/app_store.dart b/BalanceFlutter/lib/services/app_store.dart new file mode 100644 index 0000000..353fdba --- /dev/null +++ b/BalanceFlutter/lib/services/app_store.dart @@ -0,0 +1,796 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../domain/finance.dart'; +import 'storage.dart'; +import 'runtime_lock.dart'; +import 'sync_api.dart'; + +class AuthSession { + AuthSession(this.data) { + if (userId.isEmpty || + email.isEmpty || + accessToken.isEmpty || + refreshToken.isEmpty || + expiresAt.millisecondsSinceEpoch <= 0) { + throw const FormatException('Invalid saved session'); + } + } + final Map data; + String get userId => (data['user'] as Map)['id'] as String; + String get email => (data['user'] as Map)['email'] as String; + String get accessToken => data['accessToken'] as String; + String get refreshToken => data['refreshToken'] as String; + DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String); +} + +/// All durable mutations and account transitions share one queue. A pull page +/// writes records, tombstones, dirty changes and cursor in one database commit. +class AppStore extends ChangeNotifier with WidgetsBindingObserver { + AppStore({ + SnapshotStorage? storage, + FlutterSecureStorage? secureStorage, + SyncApi Function(String serverUrl)? apiFactory, + this.background = false, + }) : _storage = storage, + _injectedStorage = storage != null, + _apiFactory = apiFactory, + _secureStorage = secureStorage ?? const FlutterSecureStorage(); + SnapshotStorage? _storage; + final bool background; + final bool _injectedStorage; + final RuntimeLock _runtimeLock = RuntimeLock(); + AuthSession? _volatileSession; + String? _volatileRevision; + String? _volatileServer; + final SyncApi Function(String serverUrl)? _apiFactory; + final FlutterSecureStorage _secureStorage; + SharedPreferences? _preferences; + Future _tail = Future.value(); + Timer? _timer; + bool _disposed = false; + bool ready = false; + bool syncing = false; + String? error; + String? syncMessage; + DateTime? lastSyncAt; + String serverUrl = ''; + String currency = 'RUB'; + String theme = 'system'; + AuthSession? session; + String? sessionPersistenceWarning; + String get email => session?.email ?? ''; + String _deviceId = ''; + String _scope = ''; + int _cursor = 0; + final Map _transactions = {}; + final Map _categories = {}; + final Map _budgets = {}; + final Map> _tombstones = {}; + final Map> _dirty = {}; + + List get transactions => + _transactions.values.toList()..sort((a, b) => b.date.compareTo(a.date)); + List get categories => + _categories.values.toList()..sort((a, b) => a.name.compareTo(b.name)); + List get budgets => + _budgets.values.toList() + ..sort((a, b) => b.monthStart.compareTo(a.monthStart)); + int get balanceMinor => _transactions.values.fold( + 0, + (sum, t) => + sum + (t.kindRawValue == 'income' ? t.amountMinor : -t.amountMinor), + ); + int get pendingChangeCount => _dirty.length; + int get cursor => _cursor; + static String newId() => const Uuid().v4(); + + Future _exclusive(Future Function() body) { + final result = _tail.then((_) async { + if (_disposed) throw StateError('AppStore is closed'); + if (!_injectedStorage) await _runtimeLock.acquire(); + try { + if (ready) await _reloadSharedState(); + return await body(); + } finally { + if (!_injectedStorage) await _runtimeLock.release(); + } + }); + _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + void clearError() { + error = null; + _notify(); + } + + Future initialize() => _exclusive(() async { + if (ready) return; + _preferences = await SharedPreferences.getInstance(); + await _preferences!.reload(); + serverUrl = _preferences!.getString('serverUrl') ?? serverUrl; + currency = _preferences!.getString('currency') ?? currency; + theme = _preferences!.getString('theme') ?? theme; + _deviceId = _preferences!.getString('deviceId') ?? newId(); + await _preferences!.setString('deviceId', _deviceId); + _storage ??= await LedgerStorage.open(); + await _restoreSession(); + await _loadScope(); + if (!background) { + WidgetsBinding.instance.addObserver(this); + _timer = Timer.periodic(const Duration(minutes: 2), (_) { + _backgroundSync(); + }); + } + ready = true; + _notify(); + if (!background) _backgroundSync(); + }); + + Future _reloadSharedState() async { + await _preferences!.reload(); + final nextServer = _preferences!.getString('serverUrl') ?? ''; + if (nextServer != serverUrl) { + _apiInstance?.close(); + _apiInstance = null; + serverUrl = nextServer; + } + currency = _preferences!.getString('currency') ?? 'RUB'; + theme = _preferences!.getString('theme') ?? 'system'; + _deviceId = _preferences!.getString('deviceId') ?? _deviceId; + await _restoreSession(); + await _loadScope(); + _notify(); + } + + Future _restoreSession() async { + session = null; + final revision = _preferences!.getString('sessionRevision.$serverUrl'); + if (_volatileServer == serverUrl && + _volatileRevision == revision && + _volatileSession != null) { + session = _volatileSession; + return; + } + _volatileSession = null; + _volatileRevision = null; + _volatileServer = null; + String? saved; + try { + if (!(_preferences!.getBool('sessionDisabled.$serverUrl') ?? false)) { + saved = await _secureStorage.read(key: _credentialKey); + } + } catch (_) { + _sessionStorageUnavailable(); + } + if (saved != null) { + try { + session = AuthSession( + Map.from(jsonDecode(saved) as Map), + ); + } catch (_) { + await _deleteCredentials(); + } + } + } + + void _sessionStorageUnavailable() { + sessionPersistenceWarning = + 'Защищённое хранилище недоступно. Вход действует до закрытия приложения.'; + syncMessage = sessionPersistenceWarning; + } + + Future _saveCredentials(Map value) async { + try { + await _secureStorage.write(key: _credentialKey, value: jsonEncode(value)); + await _preferences!.setBool('sessionDisabled.$serverUrl', false); + await _preferences!.setString('sessionRevision.$serverUrl', newId()); + _volatileSession = null; + _volatileRevision = null; + _volatileServer = null; + } catch (_) { + await _preferences!.setBool('sessionDisabled.$serverUrl', true); + _volatileRevision = newId(); + await _preferences!.setString( + 'sessionRevision.$serverUrl', + _volatileRevision!, + ); + _volatileSession = AuthSession(value); + _volatileServer = serverUrl; + _sessionStorageUnavailable(); + } + } + + Future _deleteCredentials() async { + // A failed secure delete must not restore a signed-out account next launch. + await _preferences!.setBool('sessionDisabled.$serverUrl', true); + await _preferences!.setString('sessionRevision.$serverUrl', newId()); + _volatileSession = null; + _volatileRevision = null; + _volatileServer = null; + try { + await _secureStorage.delete(key: _credentialKey); + } catch (_) { + _sessionStorageUnavailable(); + } + } + + String get _credentialKey => + 'balance.session.${Uri.encodeComponent(serverUrl)}'; + String _scopeFor(String? userId) => '$serverUrl|${userId ?? 'guest'}'; + SyncApi? _apiInstance; + SyncApi get _api => _apiInstance ??= + _apiFactory?.call(serverUrl) ?? + SyncApi( + serverUrl: serverUrl, + allowInsecureHttp: [ + 'localhost', + '127.0.0.1', + '::1', + '10.0.2.2', + ].contains(Uri.parse(serverUrl).host), + ); + + List categoriesForKind(Object kind) { + final raw = kind is TransactionKind ? kind.name : kind.toString(); + final combined = { + for (final category in BuiltInCategories.forKind(raw)) + category.name: category, + for (final category in categories.where((c) => c.kindRawValue == raw)) + category.name: category, + }; + return combined.values.toList(); + } + + Future _loadScope() async { + _scope = _scopeFor(session?.userId); + final raw = await _storage!.read(_scope); + _transactions.clear(); + _categories.clear(); + _budgets.clear(); + _dirty.clear(); + _tombstones.clear(); + _cursor = 0; + lastSyncAt = null; + if (raw == null) return; + for (final item in (raw['transactions'] as List? ?? [])) { + final record = TransactionRecord.fromJson( + Map.from(item as Map), + ); + _transactions[record.id] = record; + } + for (final item in (raw['categories'] as List? ?? [])) { + final record = CategoryRecord.fromJson( + Map.from(item as Map), + ); + _categories[record.id] = record; + } + for (final item in (raw['budgets'] as List? ?? [])) { + final record = BudgetRecord.fromJson( + Map.from(item as Map), + ); + _budgets[record.id] = record; + } + for (final item in (raw['dirty'] as List? ?? [])) { + final change = Map.from(item as Map); + _dirty[_key(change)] = change; + } + for (final item in (raw['tombstones'] as List? ?? [])) { + final change = Map.from(item as Map); + _tombstones[_key(change)] = change; + } + _cursor = (raw['cursor'] as num?)?.toInt() ?? 0; + lastSyncAt = DateTime.tryParse(raw['lastSyncAt'] as String? ?? ''); + } + + Map _snapshot() => { + 'transactions': _transactions.values.map((e) => e.toJson()).toList(), + 'categories': _categories.values.map((e) => e.toJson()).toList(), + 'budgets': _budgets.values.map((e) => e.toJson()).toList(), + 'dirty': _dirty.values.toList(), + 'tombstones': _tombstones.values.toList(), + 'cursor': _cursor, + 'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(), + }; + Future _persist() => _storage!.write(_scope, _snapshot()); + String _key(Map change) => + '${change['entity']}:${change['id']}'; + Map _change( + String entity, + String id, + DateTime time, + Map? payload, + ) => { + 'entity': entity, + 'id': id, + 'updatedAt': time.toUtc().toIso8601String(), + 'deleted': payload == null, + 'payload': ?payload, + }; + DateTime _now() { + // Strictly advance local timestamps even for successive edits in one tick. + var now = DateTime.now().toUtc(); + for (final change in _dirty.values) { + final previous = DateTime.parse(change['updatedAt'] as String); + if (!now.isAfter(previous)) { + now = previous.add(const Duration(microseconds: 1)); + } + } + return now; + } + + void _queueChange(Map change) { + final key = _key(change); + _dirty[key] = change; + if (change['deleted'] == true) { + _tombstones[key] = change; + } else { + _tombstones.remove(key); + } + } + + Future _edited() async { + try { + await _persist(); + } catch (_) { + await _loadScope(); + rethrow; + } + _notify(); + } + + Future saveTransaction(TransactionRecord record) => + _exclusive(() async { + if (record.amountMinor <= 0) { + throw ArgumentError('Сумма должна быть больше нуля'); + } + final saved = record.copyWith(updatedAt: _now()); + _transactions[saved.id] = saved; + _queueChange( + _change('transaction', saved.id, saved.updatedAt, saved.payload()), + ); + await _edited(); + }); + Future deleteTransaction(String id) => _delete('transaction', id); + Future deleteBudget(String id) => _delete('budget', id); + Future deleteCategory(String id) => _delete('category', id); + Future _delete(String entity, String id) => _exclusive(() async { + if (entity == 'transaction') _transactions.remove(id); + if (entity == 'category') { + final category = _categories.remove(id); + if (category != null && category.kindRawValue == 'expense') { + for (final budget + in _budgets.values + .where((b) => b.categoryName == category.name) + .toList()) { + _budgets.remove(budget.id); + _queueChange(_change('budget', budget.id, _now(), null)); + } + } + } + if (entity == 'budget') _budgets.remove(id); + _queueChange(_change(entity, id, _now(), null)); + await _edited(); + }); + + Future saveCategory(CategoryRecord record) => _exclusive(() async { + if (record.name.trim().isEmpty) { + throw ArgumentError('Введите название категории'); + } + final saved = record.copyWith(name: record.name.trim(), updatedAt: _now()); + if (_categories.values.any( + (c) => + c.id != saved.id && + c.name == saved.name && + c.kindRawValue == saved.kindRawValue, + )) { + throw ArgumentError('Такая категория уже существует'); + } + final previous = _categories[saved.id]; + _categories[saved.id] = saved; + _queueChange( + _change('category', saved.id, saved.updatedAt, saved.payload()), + ); + if (previous != null) { + for (final tx in _transactions.values.toList()) { + if (tx.categoryName != previous.name || + tx.kindRawValue != previous.kindRawValue) { + continue; + } + final changed = tx.copyWith( + categoryName: saved.name, + categoryIcon: saved.icon, + categoryEmoji: saved.emoji, + categoryColorName: saved.colorName, + updatedAt: saved.updatedAt, + ); + _transactions[tx.id] = changed; + _queueChange( + _change('transaction', tx.id, changed.updatedAt, changed.payload()), + ); + } + for (final budget in _budgets.values.toList()) { + if (budget.categoryName != previous.name) continue; + final changed = budget.copyWith( + categoryName: saved.name, + categoryIcon: saved.icon, + categoryEmoji: saved.emoji, + updatedAt: saved.updatedAt, + ); + _budgets[budget.id] = changed; + _queueChange( + _change('budget', budget.id, changed.updatedAt, changed.payload()), + ); + } + } + await _edited(); + }); + Future saveBudget(BudgetRecord record) => _exclusive(() async { + if (record.limitMinor <= 0) { + throw ArgumentError('Лимит должен быть больше нуля'); + } + final localMonth = record.monthStart.toLocal(); + final month = DateTime(localMonth.year, localMonth.month); + if (_budgets.values.any( + (b) => + b.id != record.id && + b.categoryName == record.categoryName && + b.monthStart.toLocal().year == month.year && + b.monthStart.toLocal().month == month.month, + )) { + throw ArgumentError('Бюджет для этой категории уже существует'); + } + final saved = record.copyWith(monthStart: month, updatedAt: _now()); + _budgets[saved.id] = saved; + _queueChange(_change('budget', saved.id, saved.updatedAt, saved.payload())); + await _edited(); + }); + Future adjustBalance(int targetMinor, {String note = ''}) => + _exclusive(() async { + final delta = targetMinor - balanceMinor; + if (delta == 0) return; + final now = _now(); + final record = TransactionRecord( + id: newId(), + amountMinor: delta.abs(), + date: now, + categoryName: 'Корректировка', + updatedAt: now, + note: note.isEmpty ? 'Ручная корректировка баланса' : note, + isBalanceAdjustment: true, + kindRawValue: delta > 0 ? 'income' : 'expense', + ); + _transactions[record.id] = record; + _queueChange(_change('transaction', record.id, now, record.payload())); + await _edited(); + }); + + Future setCurrency(String value) => _exclusive(() async { + currency = value; + await _preferences!.setString('currency', value); + _notify(); + }); + Future setTheme(String value) => _exclusive(() async { + theme = value; + await _preferences!.setString('theme', value); + _notify(); + }); + Future setServerUrl(String value) => _exclusive(() async { + final normalized = value.trim().replaceAll(RegExp(r'/+$'), ''); + final uri = Uri.tryParse(normalized); + if (uri == null || + !uri.hasAuthority || + !['http', 'https'].contains(uri.scheme)) { + throw ArgumentError('Укажите полный адрес сервера'); + } + // Validate the transport before clearing an existing account or changing its scope. + final validation = SyncApi( + serverUrl: normalized, + allowInsecureHttp: const [ + 'localhost', + '127.0.0.1', + '::1', + '10.0.2.2', + ].contains(uri.host), + ); + validation.close(); + if (normalized == serverUrl) return; + final unconfiguredGuest = serverUrl.isEmpty && session == null + ? _snapshot() + : null; + await _deleteCredentials(); + session = null; + _apiInstance?.close(); + _apiInstance = null; + serverUrl = normalized; + await _preferences!.setString('serverUrl', normalized); + // Credentials are deliberately not restored when switching servers. + await _deleteCredentials(); + if (unconfiguredGuest != null && + await _storage!.read(_scopeFor(null)) == null) { + await _storage!.write(_scopeFor(null), unconfiguredGuest); + } + await _loadScope(); + error = null; + syncMessage = null; + _notify(); + }); + + Future signIn(String email, String password) => + _authenticate(email, password, false); + Future register(String email, String password) => + _authenticate(email, password, true); + Future _authenticate( + String email, + String password, + bool register, + ) => _exclusive(() async { + final response = register + ? await _api.register(email, password) + : await _api.login(email, password); + final nextSession = AuthSession(response); + final nextScope = _scopeFor(nextSession.userId); + final exists = await _storage!.read(nextScope); + final marker = 'importedGuest.$serverUrl'; + final guest = + session == null && + exists == null && + !(_preferences!.getBool(marker) ?? false) + ? _snapshot() + : null; + session = nextSession; + await _saveCredentials(response); + await _loadScope(); + if (guest != null) { + await _storage!.write(_scope, guest); + await _loadScope(); + _cursor = 0; + // Imported rows may have been synced under another prior guest snapshot. + for (final t in _transactions.values) { + _queueChange(_change('transaction', t.id, t.updatedAt, t.payload())); + } + for (final c in _categories.values) { + _queueChange(_change('category', c.id, c.updatedAt, c.payload())); + } + for (final b in _budgets.values) { + _queueChange(_change('budget', b.id, b.updatedAt, b.payload())); + } + await _persist(); + await _preferences!.setBool(marker, true); + } + error = null; + _notify(); + await _syncLocked(); + }); + Future signOut() => _exclusive(() async { + final old = session; + // Local logout succeeds even if the network is unavailable. + session = null; + await _deleteCredentials(); + await _loadScope(); + error = null; + syncMessage = null; + _notify(); + if (old != null) { + try { + await _api.logout(old.refreshToken); + } catch (_) {} + } + }); + + Future _accessToken({bool force = false}) async { + final current = session; + if (current == null) throw StateError('Войдите в аккаунт'); + if (!force && + current.expiresAt.isAfter( + DateTime.now().add(const Duration(minutes: 1)), + )) { + return current.accessToken; + } + final refreshed = await _api.refresh(current.refreshToken); + session = AuthSession(refreshed); + await _saveCredentials(refreshed); + return session!.accessToken; + } + + Future sync() => _exclusive(_syncLocked); + Future resetAndSync() => _exclusive(() async { + _cursor = 0; + for (final t in _transactions.values) { + _queueChange(_change('transaction', t.id, t.updatedAt, t.payload())); + } + for (final c in _categories.values) { + _queueChange(_change('category', c.id, c.updatedAt, c.payload())); + } + for (final b in _budgets.values) { + _queueChange(_change('budget', b.id, b.updatedAt, b.payload())); + } + for (final tombstone in _tombstones.values.toList()) { + _queueChange(tombstone); + } + await _persist(); + await _syncLocked(); + }); + Future _syncLocked() async { + if (session == null || syncing) return; + syncing = true; + error = null; + syncMessage = 'Синхронизация…'; + _notify(); + final sent = Map>.from(_dirty); + try { + Future run() => _api.synchronize( + accessToken: _accessToken, + cursor: _cursor, + deviceId: _deviceId, + changes: sent.values.toList(), + applyPage: _applyPage, + ); + try { + await run(); + } on SyncApiException catch (e) { + if (e.statusCode != 401) rethrow; + await _accessToken(force: true); + await run(); + } + // Only acknowledge precisely the pushed revision, never a newer local edit. + for (final entry in sent.entries) { + if (_dirty[entry.key]?['updatedAt'] == entry.value['updatedAt']) { + _dirty.remove(entry.key); + } + } + lastSyncAt = DateTime.now(); + await _persist(); + syncMessage = + sessionPersistenceWarning ?? + (_dirty.isEmpty + ? 'Синхронизировано' + : 'Есть изменения для следующей синхронизации'); + } catch (e) { + error = e is SyncApiException + ? e.message + : 'Не удалось синхронизировать: $e'; + syncMessage = 'Изменения сохранены на устройстве'; + rethrow; + } finally { + syncing = false; + _notify(); + } + } + + Future _applyPage( + List> changes, + int cursor, + ) async { + try { + for (final change in changes) { + _merge(change); + } + _cursor = cursor; + await _persist(); + } catch (_) { + // Disk failure must not advance the in-memory cursor past an uncommitted page. + await _loadScope(); + rethrow; + } + } + + void _merge(Map change) { + final key = _key(change); + final entity = change['entity'] as String; + final id = change['id'] as String; + final incoming = DateTime.parse(change['updatedAt'] as String); + final pending = _dirty[key]; + // Preserve the user's pending deletion. A newer server edit means its LWW + // delete needs a newer revision, which remains dirty for the next push. + if (pending?['deleted'] == true && change['deleted'] != true) { + if (!DateTime.parse(pending!['updatedAt'] as String).isAfter(incoming)) { + _queueChange( + _change( + entity, + id, + incoming.add(const Duration(microseconds: 1)), + null, + ), + ); + } + return; + } + if (pending != null && + !incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) { + return; + } + final tombstone = _tombstones[key]; + if (tombstone != null && + !incoming.isAfter(DateTime.parse(tombstone['updatedAt'] as String))) { + return; + } + DateTime? local; + if (entity == 'transaction') local = _transactions[id]?.updatedAt; + if (entity == 'category') local = _categories[id]?.updatedAt; + if (entity == 'budget') local = _budgets[id]?.updatedAt; + if (local != null && incoming.isBefore(local)) return; + if (change['deleted'] == true) { + if (entity == 'transaction') _transactions.remove(id); + if (entity == 'category') _categories.remove(id); + if (entity == 'budget') _budgets.remove(id); + _tombstones[key] = Map.from(change); + } else { + final payload = Map.from(change['payload'] as Map); + if (entity == 'transaction') { + _transactions[id] = TransactionRecord.fromPayload( + id: id, + updatedAt: incoming, + payload: payload, + ); + } + if (entity == 'category') { + _categories[id] = CategoryRecord.fromPayload( + id: id, + updatedAt: incoming, + payload: payload, + ); + } + if (entity == 'budget') { + _budgets[id] = BudgetRecord.fromPayload( + id: id, + updatedAt: incoming, + payload: payload, + ); + } + _tombstones.remove(key); + } + if (pending != null && + incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) { + _dirty.remove(key); + } + } + + void _backgroundSync() { + if (ready && session != null) unawaited(sync().catchError((Object _) {})); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _backgroundSync(); + } + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.hidden) { + _timer?.cancel(); + _timer = null; + } else if (state == AppLifecycleState.resumed) { + _timer ??= Timer.periodic(const Duration(minutes: 2), (_) { + _backgroundSync(); + }); + } + } + + /// Wait for all serialized work before a headless engine exits. + Future close() async { + await _tail; + _apiInstance?.close(); + _apiInstance = null; + await _storage?.close(); + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + if (!background) WidgetsBinding.instance.removeObserver(this); + unawaited(close()); + super.dispose(); + } +} diff --git a/BalanceFlutter/lib/services/background.dart b/BalanceFlutter/lib/services/background.dart new file mode 100644 index 0000000..399c5be --- /dev/null +++ b/BalanceFlutter/lib/services/background.dart @@ -0,0 +1,51 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:workmanager/workmanager.dart'; + +import 'app_store.dart'; + +const _periodicSyncName = 'balance-periodic-sync'; +const _syncTask = 'balance.finance.sync'; + +@pragma('vm:entry-point') +void balanceBackgroundDispatcher() { + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + Workmanager().executeTask((task, inputData) async { + if (task != _syncTask) return true; + final store = AppStore(background: true); + var success = false; + try { + await store.initialize(); + await store.sync(); + success = true; + } catch (_) { + // WorkManager retries with backoff; details/tokens are never logged. + success = false; + } finally { + try { + await store.close(); + } catch (_) { + success = false; + } + store.dispose(); + } + return success; + }); +} + +/// Android OS scheduling survives UI engine shutdown. Other platforms retain +/// their existing foreground startup/resume/two-minute synchronization. +Future configureBackgroundSync() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + await Workmanager().initialize(balanceBackgroundDispatcher); + await Workmanager().registerPeriodicTask( + _periodicSyncName, + _syncTask, + frequency: const Duration(minutes: 15), + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingPeriodicWorkPolicy.update, + ); +} diff --git a/BalanceFlutter/lib/services/runtime_lock.dart b/BalanceFlutter/lib/services/runtime_lock.dart new file mode 100644 index 0000000..11eb3d0 --- /dev/null +++ b/BalanceFlutter/lib/services/runtime_lock.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'runtime_lock_stub.dart' + if (dart.library.js_interop) 'runtime_lock_web.dart' + as browser; + +/// Native Android lock shared by the UI engine and WorkManager headless engines. +/// Callers must hold it over reloading preferences/session, ledger IO and sync. +class RuntimeLock { + static const _channel = MethodChannel('balance/runtime'); + final _browserLock = browser.BrowserRuntimeLock(); + String? _token; + bool get _android => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + Future acquire() async { + if (kIsWeb) return _browserLock.acquire(); + if (!_android) return; + if (_token != null) throw StateError('Ledger lock is already held'); + final token = await _channel.invokeMethod('acquireLock'); + if (token == null || token.isEmpty) { + throw StateError('Native ledger lock returned no ownership token'); + } + _token = token; + } + + Future release() async { + if (kIsWeb) return _browserLock.release(); + if (!_android) return; + final token = _token; + if (token == null) throw StateError('Ledger lock is not held'); + await _channel.invokeMethod('releaseLock', {'token': token}); + _token = null; + } +} diff --git a/BalanceFlutter/lib/services/runtime_lock_stub.dart b/BalanceFlutter/lib/services/runtime_lock_stub.dart new file mode 100644 index 0000000..f4dc33a --- /dev/null +++ b/BalanceFlutter/lib/services/runtime_lock_stub.dart @@ -0,0 +1,5 @@ +/// Non-web storage does not use browser-origin coordination. +class BrowserRuntimeLock { + Future acquire() async {} + Future release() async {} +} diff --git a/BalanceFlutter/lib/services/runtime_lock_web.dart b/BalanceFlutter/lib/services/runtime_lock_web.dart new file mode 100644 index 0000000..0e30dec --- /dev/null +++ b/BalanceFlutter/lib/services/runtime_lock_web.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:js_interop'; + +@JS('isSecureContext') +external bool get _secureContext; + +@JS('navigator.locks') +external JSObject? get _navigatorLocks; + +extension type _LockManager(JSObject _) implements JSObject { + external JSPromise request(JSString name, JSFunction callback); +} + +/// Holds the origin-wide Web Lock until release, including IndexedDB commits +/// and rotated refresh-token persistence. Browser teardown releases the lock. +class BrowserRuntimeLock { + bool _waiting = false; + Completer? _release; + Future? _request; + + Future acquire() async { + if (_waiting || _release != null) { + throw StateError('Browser ledger lock is already held or pending'); + } + if (!_secureContext) { + throw StateError( + 'Для безопасного хранения данных в нескольких вкладках требуется HTTPS ' + '(или localhost). Откройте приложение по защищённому адресу.', + ); + } + final manager = _navigatorLocks; + if (manager == null) { + throw StateError( + 'Браузер не поддерживает Web Locks API. Обновите браузер: ' + 'без блокировки вкладок безопасная синхронизация недоступна.', + ); + } + final ready = Completer(); + final release = Completer(); + _waiting = true; + try { + final request = _LockManager(manager) + .request( + 'balance-ledger-v1'.toJS, + ((JSAny? lock) { + _waiting = false; + _release = release; + ready.complete(); + return release.future.toJS; + }).toJS, + ) + .toDart; + _request = request; + // A rejected request must wake an acquiring caller, not leave it waiting + // forever. Keep a rejection handler attached even before release awaits it. + unawaited( + request.then( + (_) { + if (!ready.isCompleted) { + ready.completeError( + StateError('Browser ledger lock was not granted'), + ); + } + }, + onError: (Object error, StackTrace stack) { + if (!ready.isCompleted) { + ready.completeError( + StateError('Не удалось получить блокировку данных браузера'), + stack, + ); + } + }, + ), + ); + await ready.future; + } catch (_) { + _waiting = false; + _release = null; + _request = null; + rethrow; + } + } + + Future release() async { + final release = _release; + if (release == null) throw StateError('Browser ledger lock is not held'); + release.complete(null); + try { + // Resolves only after navigator.locks has actually released ownership. + await _request; + } finally { + _release = null; + _request = null; + } + } +} diff --git a/BalanceFlutter/lib/services/storage.dart b/BalanceFlutter/lib/services/storage.dart new file mode 100644 index 0000000..e52ff88 --- /dev/null +++ b/BalanceFlutter/lib/services/storage.dart @@ -0,0 +1,58 @@ +import 'package:sembast/sembast.dart'; + +import 'storage_native.dart' + if (dart.library.js_interop) 'storage_web.dart' + as platform; + +/// A single transaction replaces the complete ledger, pending changes and cursor. +abstract class SnapshotStorage { + Future?> read(String key); + Future write(String key, Map value); + Future close(); +} + +/// Connections are opened lazily and closed after every operation, so another +/// Flutter engine never reads through an old Sembast in-memory cache. Android +/// callers serialize complete operations with the shared native store lock. +class LedgerStorage implements SnapshotStorage { + LedgerStorage._(); + final _store = stringMapStoreFactory.store('snapshots'); + static Future open() async => LedgerStorage._(); + @override + Future?> read(String key) async { + final db = await platform.openLedgerDatabase(); + try { + return await _store.record(key).get(db); + } finally { + await db.close(); + } + } + + @override + Future write(String key, Map value) async { + final db = await platform.openLedgerDatabase(); + try { + await db.transaction((txn) async { + await _store.record(key).put(txn, value); + }); + } finally { + await db.close(); + } + } + + @override + Future close() async {} +} + +class MemorySnapshotStorage implements SnapshotStorage { + final Map> values = {}; + @override + Future?> read(String key) async => values[key]; + @override + Future write(String key, Map value) async { + values[key] = value; + } + + @override + Future close() async {} +} diff --git a/BalanceFlutter/lib/services/storage_native.dart b/BalanceFlutter/lib/services/storage_native.dart new file mode 100644 index 0000000..e0089c2 --- /dev/null +++ b/BalanceFlutter/lib/services/storage_native.dart @@ -0,0 +1,8 @@ +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast_io.dart'; + +Future openLedgerDatabase() async { + final directory = await getApplicationSupportDirectory(); + await directory.create(recursive: true); + return databaseFactoryIo.openDatabase('${directory.path}/balance.sembast'); +} diff --git a/BalanceFlutter/lib/services/storage_web.dart b/BalanceFlutter/lib/services/storage_web.dart new file mode 100644 index 0000000..2a32312 --- /dev/null +++ b/BalanceFlutter/lib/services/storage_web.dart @@ -0,0 +1,4 @@ +import 'package:sembast_web/sembast_web.dart'; + +Future openLedgerDatabase() => + databaseFactoryWeb.openDatabase('balance'); diff --git a/BalanceFlutter/lib/services/sync_api.dart b/BalanceFlutter/lib/services/sync_api.dart new file mode 100644 index 0000000..a8c6cdf --- /dev/null +++ b/BalanceFlutter/lib/services/sync_api.dart @@ -0,0 +1,287 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// JSON wire objects, deliberately independent of local database models. +typedef WireMap = Map; +typedef AccessTokenProvider = Future Function(); + +class SyncApiException implements Exception { + const SyncApiException(this.code, this.message, {this.statusCode}); + + final String code; + final String message; + final int? statusCode; + + // Do not include response bodies, request headers or tokens in diagnostics. + @override + String toString() => 'SyncApiException($code, HTTP $statusCode): $message'; +} + +/// Stateless HTTP transport. Session persistence belongs to the caller's secure +/// store. Never persist returned session maps in preferences or browser storage. +class SyncApi { + SyncApi({ + required String serverUrl, + http.Client? client, + this.timeout = const Duration(seconds: 30), + bool allowInsecureHttp = false, + }) : _client = client ?? http.Client(), + _ownsClient = client == null, + baseUri = _normalizeServer(serverUrl, allowInsecureHttp); + + final Uri baseUri; + final Duration timeout; + final http.Client _client; + final bool _ownsClient; + + /// Register/login/refresh return {accessToken, refreshToken, expiresAt, + /// user: {id, email}}. Refresh rotates both tokens; save the result atomically. + Future register(String email, String password) => + _authenticate('register', email, password); + + Future login(String email, String password) => + _authenticate('login', email, password); + + Future _authenticate( + String action, + String email, + String password, + ) async => _session(await _request('POST', 'v1/auth/$action', body: { + 'email': email.trim(), + 'password': password, + })); + + Future refresh(String refreshToken) async => + _session(await _request('POST', 'v1/auth/refresh', body: { + 'refreshToken': refreshToken, + })); + + /// Clear the external local session in a finally block, even when offline. + /// An offline logout cannot revoke the server refresh token. + Future logout(String refreshToken) async { + await _request('POST', 'v1/auth/logout', body: { + 'refreshToken': refreshToken, + }); + } + + Future me(String accessToken) async { + final result = await _request('GET', 'v1/me', accessToken: accessToken); + _string(result, 'id'); + _string(result, 'email'); + return result; + } + + /// One wire request. Push-only calls leave cursor unchanged. The caller must + /// merge a pulled page durably before committing its returned cursor. + Future sync({ + required String accessToken, + required int cursor, + required String deviceId, + required List changes, + bool pull = true, + }) async { + _cursor(cursor); + if (changes.length > 5000) { + throw ArgumentError('A sync request supports at most 5000 changes'); + } + for (final change in changes) { + _validateChange(change, outgoing: true); + } + final page = await _request('POST', 'v1/sync', accessToken: accessToken, + body: _syncBody(cursor, deviceId, changes, pull)); + final next = _integer(page, 'cursor'); + _cursor(next); + if (next < cursor || page['hasMore'] is! bool || page['changes'] is! List) { + throw const FormatException('Invalid sync page'); + } + if (page['hasMore'] == true && next <= cursor) { + throw const FormatException('Sync cursor did not advance'); + } + DateTime.parse(_string(page, 'serverTime')); + final records = []; + var previousSequence = cursor; + for (final raw in page['changes'] as List) { + final change = _object(raw); + _validateChange(change); + final sequence = _integer(change, 'sequence'); + if (sequence <= previousSequence || sequence > next) { + throw const FormatException('Invalid change sequence'); + } + previousSequence = sequence; + records.add(change); + } + if (records.isNotEmpty && previousSequence != next) { + throw const FormatException('Cursor does not match the last change'); + } + if (records.isEmpty && next != cursor) { + throw const FormatException('Empty page advanced the cursor'); + } + return {...page, 'changes': records}; + } + + /// Pushes a stable snapshot in bounded batches, then pulls all pages. The + /// provider may refresh expired tokens through an external session manager. + /// applyPage must atomically merge changes and persist cursor, and must throw + /// on failure. Keep the push watermark unchanged until this method succeeds. + Future synchronize({ + required AccessTokenProvider accessToken, + required int cursor, + required String deviceId, + required List changes, + required Future Function(List changes, int cursor) applyPage, + }) async { + // Copy the complete JSON snapshot before the first await so concurrent edits + // cannot mutate a batch while it is being uploaded. + final snapshot = (jsonDecode(jsonEncode(changes)) as List) + .map(_object).toList(growable: false); + _cursor(cursor); + for (final change in snapshot) { + _validateChange(change, outgoing: true); + } + for (var start = 0; start < snapshot.length; start += 100) { + final end = start + 100 < snapshot.length ? start + 100 : snapshot.length; + await _pushBatch(accessToken, cursor, deviceId, snapshot.sublist(start, end)); + } + var next = cursor; + while (true) { + final page = await sync(accessToken: await accessToken(), cursor: next, + deviceId: deviceId, changes: const []); + next = page['cursor'] as int; + await applyPage((page['changes'] as List).cast(), next); + if (page['hasMore'] == false) return next; + } + } + + Future _pushBatch(AccessTokenProvider token, int cursor, String deviceId, + List changes) async { + final size = utf8.encode(jsonEncode(_syncBody(cursor, deviceId, changes, false))).length; + if (size > 1500000 && changes.length > 1) { + final middle = changes.length ~/ 2; + await _pushBatch(token, cursor, deviceId, changes.sublist(0, middle)); + await _pushBatch(token, cursor, deviceId, changes.sublist(middle)); + return; + } + await sync(accessToken: await token(), cursor: cursor, deviceId: deviceId, + changes: changes, pull: false); + } + + Future _request(String method, String path, { + WireMap? body, + String? accessToken, + }) async { + final request = http.Request(method, baseUri.resolve(path)); + request.followRedirects = false; + request.headers['Accept'] = 'application/json'; + if (accessToken != null) { + request.headers['Authorization'] = 'Bearer $accessToken'; + } + if (body != null) { + request.headers['Content-Type'] = 'application/json; charset=utf-8'; + request.bodyBytes = utf8.encode(jsonEncode(body)); + if (request.bodyBytes.length > 2 * 1024 * 1024) { + throw ArgumentError('Request exceeds the server 2 MiB limit'); + } + } + final response = await (() async { + final stream = await _client.send(request); + return http.Response.fromStream(stream); + })().timeout(timeout); + if (response.statusCode < 200 || response.statusCode >= 300) { + var code = 'http_error'; + var message = 'Server request failed'; + try { + final detail = _object(_object(jsonDecode(response.body))['error']); + code = _string(detail, 'code'); + message = _string(detail, 'message'); + } on FormatException { + // Proxies may return HTML. Never surface raw bodies containing secrets. + } + throw SyncApiException(code, message, statusCode: response.statusCode); + } + if (response.statusCode == 204) return {}; + return _object(jsonDecode(utf8.decode(response.bodyBytes))); + } + + void close() { + if (_ownsClient) _client.close(); + } + + static WireMap _syncBody(int cursor, String deviceId, + List changes, bool pull) => { + 'cursor': cursor, 'deviceId': deviceId, 'changes': changes, 'pull': pull, + }; + + static WireMap _session(WireMap value) { + _string(value, 'accessToken'); + _string(value, 'refreshToken'); + DateTime.parse(_string(value, 'expiresAt')); + final user = _object(value['user']); + _string(user, 'id'); + _string(user, 'email'); + return value; + } + + static void _validateChange(WireMap change, {bool outgoing = false}) { + if (!const {'transaction', 'category', 'budget'}.contains(change['entity'])) { + throw const FormatException('Unknown sync entity'); + } + final id = _string(change, 'id'); + if (utf8.encode(id).length > 128 || change['deleted'] is! bool) { + throw const FormatException('Invalid record ID or deletion flag'); + } + DateTime.parse(_string(change, 'updatedAt')); + if (change['deleted'] == false) { + _object(change['payload']); + } + if (outgoing) { + const keys = {'entity', 'id', 'deleted', 'updatedAt', 'payload', 'version', 'sequence'}; + if (change.keys.any((key) => !keys.contains(key))) { + throw const FormatException('Unknown change property'); + } + if (change.containsKey('payload') && + utf8.encode(jsonEncode(change['payload'])).length > 256 * 1024) { + throw const FormatException('Payload exceeds 256 KiB'); + } + } + } + + static WireMap _object(dynamic value) { + if (value is! Map) { + throw const FormatException('Expected a JSON object'); + } + return value; + } + + static String _string(WireMap value, String key) { + final result = value[key]; + if (result is! String || result.isEmpty) { + throw FormatException('Missing or invalid $key'); + } + return result; + } + + static int _integer(WireMap value, String key) { + final result = value[key]; + if (result is! int) throw FormatException('Missing or invalid $key'); + return result; + } + + static void _cursor(int value) { + // JS numbers cannot faithfully round-trip the full server int64 range. + if (value < 0 || value > 9007199254740991) { + throw ArgumentError('Cursor is outside the portable integer range'); + } + } + + static Uri _normalizeServer(String value, bool allowInsecureHttp) { + final uri = Uri.parse(value.trim()); + if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty || + uri.hasQuery || uri.hasFragment || + !(uri.scheme == 'https' || (allowInsecureHttp && uri.scheme == 'http'))) { + throw ArgumentError('Use an HTTPS server URL without credentials, query or fragment'); + } + return uri.replace(path: '${uri.path.replaceAll(RegExp(r'/+$'), '')}/'); + } +} diff --git a/BalanceFlutter/lib/ui/app.dart b/BalanceFlutter/lib/ui/app.dart new file mode 100644 index 0000000..c5a4ee2 --- /dev/null +++ b/BalanceFlutter/lib/ui/app.dart @@ -0,0 +1,1491 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:share_plus/share_plus.dart'; +import '../domain/finance.dart'; +import '../services/app_store.dart'; +import 'design.dart'; +import 'editors.dart'; + +class BalanceApp extends StatelessWidget { + const BalanceApp({super.key, required this.store}); + final AppStore store; + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: store, + builder: (context, _) => MaterialApp( + title: 'Баланс', + debugShowCheckedModeBanner: false, + locale: const Locale('ru'), + supportedLocales: const [Locale('ru'), Locale('en')], + localizationsDelegates: GlobalMaterialLocalizations.delegates, + theme: makeTheme(Brightness.light), + darkTheme: makeTheme(Brightness.dark), + themeMode: store.theme == 'dark' + ? ThemeMode.dark + : store.theme == 'light' + ? ThemeMode.light + : ThemeMode.system, + home: store.ready + ? BalanceShell(store: store) + : const Scaffold(body: Center(child: CircularProgressIndicator())), + ), + ); + ThemeData makeTheme(Brightness brightness) { + final scheme = ColorScheme.fromSeed( + seedColor: const Color(0xff356859), + brightness: brightness, + ); + return ThemeData( + useMaterial3: true, + fontFamily: 'BalanceRoboto', + colorScheme: scheme, + scaffoldBackgroundColor: scheme.surface, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + cardTheme: CardThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + centerTitle: false, + ), + ); + } +} + +class BalanceShell extends StatefulWidget { + const BalanceShell({super.key, required this.store}); + final AppStore store; + @override + State createState() => _BalanceShellState(); +} + +class _BalanceShellState extends State { + AppStore get store => widget.store; + int page = 0; + int months = 1; + String query = '', filter = 'all'; + DateTime budgetMonth = DateTime(DateTime.now().year, DateTime.now().month); + static const titles = [ + 'Обзор', + 'Операции', + 'Аналитика', + 'Бюджеты', + 'Настройки', + ]; + static const icons = [ + Icons.space_dashboard_outlined, + Icons.receipt_long_outlined, + Icons.insights_outlined, + Icons.savings_outlined, + Icons.settings_outlined, + ]; + Future run(Future Function() action, {String? success}) async { + try { + await action(); + if (success != null && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(success))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.toString().replaceFirst('Invalid argument(s): ', ''), + ), + duration: const Duration(seconds: 6), + ), + ); + } + } + } + + Future editTransaction([TransactionRecord? value]) async { + final result = await editDialog( + context, + TransactionEditor(existing: value, categories: store.categories), + ); + if (result != null) await run(() => store.saveTransaction(result)); + } + + Future editCategory([CategoryRecord? value]) async { + final result = await editDialog( + context, + CategoryEditor(existing: value, categories: store.categories), + ); + if (result != null) await run(() => store.saveCategory(result)); + } + + Future editBudget([BudgetRecord? value]) async { + final choices = store.categoriesForKind('expense'); + if (value != null && !choices.any((c) => c.name == value.categoryName)) { + choices.add( + CategoryRecord( + id: 'archive', + colorName: 'indigo', + name: value.categoryName, + icon: value.categoryIcon, + emoji: value.categoryEmoji, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + } + final result = await editDialog( + context, + BudgetEditor(existing: value, categories: choices, month: budgetMonth), + ); + if (result != null) await run(() => store.saveBudget(result)); + } + + Future adjust() async { + final amount = TextEditingController( + text: amountInput(FinanceMath.totalBalance(store.transactions)), + ); + final note = TextEditingController(); + final form = GlobalKey(); + final target = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: const Text('Скорректировать баланс'), + content: SizedBox( + width: 400, + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Укажите фактический остаток. Разница сохранится отдельной операцией и не повлияет на аналитику.', + ), + const SizedBox(height: 20), + TextFormField( + controller: amount, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + signed: true, + ), + decoration: InputDecoration( + labelText: 'Фактический баланс, ${store.currency}', + ), + validator: (s) => parseAmount(s ?? '', signed: true) == null + ? 'Введите сумму' + : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: note, + decoration: const InputDecoration(labelText: 'Заметка'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (form.currentState!.validate()) { + Navigator.pop(context, parseAmount(amount.text, signed: true)); + } + }, + child: const Text('Сохранить'), + ), + ], + ), + ); + if (target != null) { + await run(() => store.adjustBalance(target, note: note.text.trim())); + } + // Dialog route may still be animating; controllers are no longer attached after frame. + await Future.delayed(const Duration(milliseconds: 300)); + amount.dispose(); + note.dispose(); + } + + @override + Widget build(BuildContext context) { + final wide = MediaQuery.sizeOf(context).width >= 900; + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + Icons.account_balance_wallet_outlined, + color: Theme.of(context).colorScheme.onPrimary, + size: 21, + ), + ), + const SizedBox(width: 12), + const Text('Баланс', style: TextStyle(fontWeight: FontWeight.w700)), + ], + ), + actions: [ + if (store.syncing) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + tooltip: store.session == null + ? 'Настроить синхронизацию' + : 'Синхронизировать', + onPressed: store.session == null + ? () => setState(() => page = 4) + : () => run(store.sync), + icon: Icon( + store.session == null ? Icons.cloud_off_outlined : Icons.sync, + ), + ), + const SizedBox(width: 12), + ], + ), + body: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (wide) + NavigationRail( + selectedIndex: page, + onDestinationSelected: (i) => setState(() => page = i), + labelType: NavigationRailLabelType.all, + minWidth: 100, + leading: Padding( + padding: const EdgeInsets.only(bottom: 28, top: 16), + child: FloatingActionButton( + tooltip: 'Добавить операцию', + onPressed: editTransaction, + child: const Icon(Icons.add), + ), + ), + destinations: List.generate( + 5, + (i) => NavigationRailDestination( + icon: Icon(icons[i]), + label: Text(titles[i]), + ), + ), + ), + if (wide) const VerticalDivider(width: 1), + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1280), + child: ListView( + padding: EdgeInsets.fromLTRB( + wide ? 36 : 16, + 16, + wide ? 36 : 16, + 100, + ), + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + titles[page], + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 5), + Text( + page == 0 + ? DateFormat( + 'LLLL y', + 'ru', + ).format(DateTime.now()) + : [ + '', + 'Вся история ваших финансов', + 'Понимайте, куда уходят деньги', + 'Планируйте расходы по категориям', + 'Ваши предпочтения и данные', + ][page], + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (wide && page == 0) + FilledButton.icon( + onPressed: editTransaction, + icon: const Icon(Icons.add), + label: const Text('Новая операция'), + ), + ], + ), + const SizedBox(height: 28), + if (store.error != null) ...[ + MaterialBanner( + content: Text(store.error!), + leading: const Icon(Icons.cloud_off_outlined), + actions: [ + TextButton( + onPressed: store.clearError, + child: const Text('Закрыть'), + ), + ], + ), + const SizedBox(height: 20), + ], + ...switch (page) { + 0 => overview(), + 1 => transactions(), + 2 => analytics(), + 3 => budgets(), + _ => settings(), + }, + ], + ), + ), + ), + ), + ], + ), + bottomNavigationBar: wide + ? null + : NavigationBar( + selectedIndex: page, + onDestinationSelected: (i) => setState(() => page = i), + destinations: List.generate( + 5, + (i) => NavigationDestination( + icon: Icon(icons[i]), + label: titles[i], + ), + ), + ), + floatingActionButton: !wide && (page == 0 || page == 1) + ? FloatingActionButton.extended( + onPressed: editTransaction, + icon: const Icon(Icons.add), + label: const Text('Операция'), + ) + : null, + ); + } + + Widget sectionTitle(String title, {Widget? action}) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ?action, + ], + ), + ); + Widget metricRow(List metrics) => LayoutBuilder( + builder: (context, c) => c.maxWidth < 550 + ? Column( + children: [ + for (final m in metrics) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox(width: double.infinity, child: m), + ), + ], + ) + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int i = 0; i < metrics.length; i++) ...[ + if (i > 0) const SizedBox(width: 16), + Expanded(child: metrics[i]), + ], + ], + ), + ); + List overview() { + final now = DateTime.now(); + final start = DateTime(now.year, now.month); + final end = DateTime(now.year, now.month + 1); + final summary = FinanceMath.summary(store.transactions, start, end); + return [ + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(28), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded(child: Text('Общий баланс')), + TextButton.icon( + onPressed: adjust, + icon: const Icon(Icons.tune, size: 18), + label: const Text('Изменить'), + ), + ], + ), + const SizedBox(height: 8), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + money( + FinanceMath.totalBalance(store.transactions), + store.currency, + ), + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w700, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + Text( + store.session == null + ? 'Данные хранятся на этом устройстве' + : '${store.email} · ${store.syncMessage ?? 'Готово к синхронизации'}', + ), + ], + ), + ), + const SizedBox(height: 20), + metricRow([ + Metric( + label: 'Доходы за месяц', + value: money(summary.incomeMinor, store.currency), + icon: Icons.south_west, + color: Colors.teal, + ), + Metric( + label: 'Расходы за месяц', + value: money(summary.expensesMinor, store.currency), + icon: Icons.north_east, + color: Colors.deepOrange, + ), + ]), + const SizedBox(height: 28), + sectionTitle('Расходы по категориям'), + spending(start, end, limit: 5), + const SizedBox(height: 28), + sectionTitle( + 'Последние операции', + action: TextButton( + onPressed: () => setState(() => page = 1), + child: const Text('Все операции'), + ), + ), + if (store.transactions.isEmpty) + EmptyView( + title: 'Начните с первой операции', + subtitle: 'Запишите доход или расход, чтобы увидеть свой баланс.', + action: FilledButton.icon( + onPressed: editTransaction, + icon: const Icon(Icons.add), + label: const Text('Добавить'), + ), + ) + else + Panel( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: store.transactions.take(5).map(transactionTile).toList(), + ), + ), + ]; + } + + Widget transactionTile(TransactionRecord t) => ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), + leading: CategoryAvatar( + icon: t.isBalanceAdjustment ? 'tune' : t.categoryIcon, + emoji: t.categoryEmoji, + color: t.categoryColorName, + ), + title: Text( + t.isBalanceAdjustment ? 'Корректировка баланса' : t.categoryName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${DateFormat('d MMM, HH:mm', 'ru').format(t.date.toLocal())}${t.note.isEmpty ? '' : ' · ${t.note}'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${t.kindRawValue == 'income' ? '+' : '−'}${money(t.amountMinor, store.currency)}', + style: TextStyle( + fontWeight: FontWeight.w600, + color: t.kindRawValue == 'income' ? Colors.teal : null, + ), + ), + PopupMenuButton( + tooltip: 'Действия с операцией', + onSelected: (v) async { + if (v == 'edit') { + await editTransaction(t); + } else if (await confirmDelete(context, 'Удалить операцию?')) { + await run(() => store.deleteTransaction(t.id)); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('Изменить')), + PopupMenuItem(value: 'delete', child: Text('Удалить')), + ], + ), + ], + ), + onTap: () => editTransaction(t), + ); + List transactions() { + final items = store.transactions + .where( + (t) => + (filter == 'all' || t.kindRawValue == filter) && + (query.isEmpty || + '${t.categoryName} ${t.note}'.toLowerCase().contains( + query.toLowerCase(), + )), + ) + .toList(); + return [ + TextField( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Поиск по категории или заметке', + ), + onChanged: (s) => setState(() => query = s), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + for (final e in { + 'all': 'Все', + 'expense': 'Расходы', + 'income': 'Доходы', + }.entries) + ChoiceChip( + label: Text(e.value), + selected: filter == e.key, + onSelected: (_) => setState(() => filter = e.key), + ), + ], + ), + const SizedBox(height: 20), + if (items.isEmpty) + const EmptyView( + title: 'Операций пока нет', + subtitle: 'Добавьте операцию или измените условия поиска.', + ) + else + Panel( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: [ + for (int i = 0; i < items.length; i++) ...[ + if (i == 0 || + !DateUtils.isSameDay( + items[i - 1].date.toLocal(), + items[i].date.toLocal(), + )) + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + DateFormat( + 'd MMMM y', + 'ru', + ).format(items[i].date.toLocal()), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + transactionTile(items[i]), + ], + ], + ), + ), + ]; + } + + Widget spending(DateTime start, DateTime end, {int? limit}) { + var groups = FinanceMath.spending(store.transactions, start, end); + if (limit != null) groups = groups.take(limit).toList(); + final total = FinanceMath.summary( + store.transactions, + start, + end, + ).expensesMinor; + if (groups.isEmpty) { + return const EmptyView( + title: 'Расходов за период нет', + subtitle: 'Здесь появится структура ваших расходов.', + icon: Icons.donut_large, + ); + } + return Panel( + child: Column( + children: [ + for (final g in groups) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + CategoryAvatar( + icon: g.icon, + emoji: g.emoji, + color: g.colorName, + size: 36, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + g.name, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + money(g.amountMinor, store.currency), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: total == 0 ? 0 : g.amountMinor / total, + borderRadius: BorderRadius.circular(8), + minHeight: 5, + color: categoryColors[g.colorName], + ), + const SizedBox(height: 4), + Text( + '${total == 0 ? 0 : (g.amountMinor / total * 100).round()}% расходов', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + List analytics() { + final now = DateTime.now(); + final start = DateTime(now.year, now.month - months + 1); + final end = DateTime(now.year, now.month + 1); + final sum = FinanceMath.summary(store.transactions, start, end); + final grouped = FinanceMath.spending(store.transactions, start, end); + final saving = sum.incomeMinor > 0 + ? sum.balanceMinor / sum.incomeMinor * 100 + : 0.0; + final days = + (DateTime.utc(now.year, now.month, now.day) + .difference( + DateTime.utc(start.year, start.month, start.day), + ) + .inDays + + 1) + .clamp(1, 10000); + return [ + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('Месяц')), + ButtonSegment(value: 3, label: Text('3 месяца')), + ButtonSegment(value: 12, label: Text('12 месяцев')), + ], + selected: {months}, + onSelectionChanged: (s) => setState(() => months = s.first), + ), + const SizedBox(height: 20), + metricRow([ + Metric( + label: 'Доходы', + value: money(sum.incomeMinor, store.currency), + icon: Icons.south_west, + color: Colors.teal, + ), + Metric( + label: 'Расходы', + value: money(sum.expensesMinor, store.currency), + icon: Icons.north_east, + color: Colors.deepOrange, + ), + ]), + const SizedBox(height: 16), + metricRow([ + Metric( + label: 'Сбережения', + value: '${saving.toStringAsFixed(1)}%', + icon: Icons.savings_outlined, + ), + Metric( + label: 'Средний расход в день', + value: money((sum.expensesMinor / days).round(), store.currency), + icon: Icons.calendar_today_outlined, + ), + ]), + const SizedBox(height: 16), + metricRow([ + Metric( + label: 'Результат периода', + value: money(sum.balanceMinor, store.currency), + icon: Icons.account_balance_wallet_outlined, + ), + Metric( + label: 'Крупнейшая категория', + value: grouped.isEmpty ? '—' : grouped.first.name, + icon: Icons.category_outlined, + ), + ]), + const SizedBox(height: 28), + sectionTitle('Динамика доходов и расходов'), + trend(start, end), + const SizedBox(height: 28), + sectionTitle('Структура расходов'), + if (grouped.isNotEmpty) ...[ + Panel( + child: SpendingRing( + segments: grouped + .map( + (g) => ( + g.amountMinor, + categoryColors[g.colorName] ?? + Theme.of(context).colorScheme.primary, + ), + ) + .toList(), + total: money(sum.expensesMinor, store.currency), + ), + ), + const SizedBox(height: 16), + ], + spending(start, end), + ]; + } + + Widget trend(DateTime start, DateTime end) { + final buckets = <(DateTime, DateTime, String)>[]; + if (months == 1) { + for ( + var d = start; + d.isBefore(end); + d = DateTime(d.year, d.month, d.day + 1) + ) { + buckets.add((d, DateTime(d.year, d.month, d.day + 1), '${d.day}')); + } + } else if (months == 3) { + for ( + var d = DateTime( + start.year, + start.month, + start.day - start.weekday + 1, + ); + d.isBefore(end); + d = DateTime(d.year, d.month, d.day + 7) + ) { + final e = DateTime(d.year, d.month, d.day + 7); + buckets.add(( + d.isBefore(start) ? start : d, + e.isAfter(end) ? end : e, + DateFormat('d.MM').format(d), + )); + } + } else { + for (var d = start; d.isBefore(end); d = DateTime(d.year, d.month + 1)) { + buckets.add(( + d, + DateTime(d.year, d.month + 1), + DateFormat('MMM', 'ru').format(d), + )); + } + } + final summaries = buckets + .map((b) => FinanceMath.summary(store.transactions, b.$1, b.$2)) + .toList(); + var max = 1; + for (final s in summaries) { + if (s.incomeMinor > max) max = s.incomeMinor; + if (s.expensesMinor > max) max = s.expensesMinor; + } + return Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Wrap( + spacing: 20, + children: [ + Text('● Доходы', style: TextStyle(color: Colors.teal)), + Text('● Расходы', style: TextStyle(color: Colors.deepOrange)), + ], + ), + const SizedBox(height: 24), + SizedBox( + height: 210, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int i = 0; i < buckets.length; i++) + Expanded( + child: Tooltip( + message: + '${DateFormat('d MMM', 'ru').format(buckets[i].$1)}\nДоходы: ${money(summaries[i].incomeMinor, store.currency)}\nРасходы: ${money(summaries[i].expensesMinor, store.currency)}', + child: Semantics( + label: + '${buckets[i].$3}, доходы ${money(summaries[i].incomeMinor, store.currency)}, расходы ${money(summaries[i].expensesMinor, store.currency)}', + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Spacer(), + Expanded( + flex: 3, + child: Container( + height: + (summaries[i].incomeMinor / max * 174) + .clamp(2, 174) + .toDouble(), + decoration: BoxDecoration( + color: Colors.teal, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const SizedBox(width: 2), + Expanded( + flex: 3, + child: Container( + height: + (summaries[i].expensesMinor / + max * + 174) + .clamp(2, 174) + .toDouble(), + decoration: BoxDecoration( + color: Colors.deepOrange, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + const Spacer(), + ], + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 24, + child: i % ((buckets.length / 8).ceil()) == 0 + ? Text( + buckets[i].$3, + style: const TextStyle(fontSize: 10), + maxLines: 1, + ) + : null, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + List budgets() { + final entries = store.budgets + .where( + (b) => + b.monthStart.toLocal().year == budgetMonth.year && + b.monthStart.toLocal().month == budgetMonth.month, + ) + .toList(); + return [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 16, + runSpacing: 12, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Предыдущий месяц', + onPressed: () => setState( + () => budgetMonth = DateTime( + budgetMonth.year, + budgetMonth.month - 1, + ), + ), + icon: const Icon(Icons.chevron_left), + ), + Text(DateFormat('LLLL y', 'ru').format(budgetMonth)), + IconButton( + tooltip: 'Следующий месяц', + onPressed: () => setState( + () => budgetMonth = DateTime( + budgetMonth.year, + budgetMonth.month + 1, + ), + ), + icon: const Icon(Icons.chevron_right), + ), + ], + ), + FilledButton.icon( + onPressed: editBudget, + icon: const Icon(Icons.add), + label: const Text('Добавить лимит'), + ), + ], + ), + const SizedBox(height: 24), + if (entries.isEmpty) + const EmptyView( + title: 'Бюджет ещё не запланирован', + subtitle: 'Задайте месячные лимиты для категорий расходов.', + icon: Icons.savings_outlined, + ), + for (final b in entries) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: budgetCard(b), + ), + ]; + } + + Widget budgetCard(BudgetRecord b) { + final used = FinanceMath.budgetUsed(b, store.transactions); + final ratio = b.limitMinor > 0 ? used / b.limitMinor : 0.0; + final color = b.limitMinor <= 0 || ratio >= 1 + ? Theme.of(context).colorScheme.error + : ratio >= .8 + ? Colors.orange + : Theme.of(context).colorScheme.primary; + return Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CategoryAvatar(icon: b.categoryIcon, emoji: b.categoryEmoji), + const SizedBox(width: 12), + Expanded( + child: Text( + b.categoryName, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + IconButton( + tooltip: 'Изменить лимит', + onPressed: () => editBudget(b), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Удалить лимит', + onPressed: () async { + if (await confirmDelete(context, 'Удалить лимит?')) { + await run(() => store.deleteBudget(b.id)); + } + }, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + const SizedBox(height: 18), + Wrap( + spacing: 6, + children: [ + Text( + money(used, store.currency), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + Text('из ${money(b.limitMinor, store.currency)}'), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: ratio.clamp(0, 1), + color: color, + minHeight: 8, + borderRadius: BorderRadius.circular(8), + ), + const SizedBox(height: 12), + Text( + b.limitMinor <= 0 + ? 'Некорректный лимит. Измените бюджет.' + : used > b.limitMinor + ? 'Превышение: ${money(used - b.limitMinor, store.currency)}' + : 'Осталось: ${money(b.limitMinor - used, store.currency)}', + style: TextStyle(color: color), + ), + ], + ), + ); + } + + List settings() => [ + Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionTitle('Оформление'), + DropdownButtonFormField( + initialValue: store.currency, + decoration: const InputDecoration(labelText: 'Валюта'), + items: const [ + 'RUB', + 'EUR', + 'USD', + 'SEK', + ].map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(), + onChanged: (v) { + if (v != null) run(() => store.setCurrency(v)); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: store.theme, + decoration: const InputDecoration(labelText: 'Тема'), + items: const [ + DropdownMenuItem(value: 'system', child: Text('Системная')), + DropdownMenuItem(value: 'light', child: Text('Светлая')), + DropdownMenuItem(value: 'dark', child: Text('Тёмная')), + ], + onChanged: (v) { + if (v != null) run(() => store.setTheme(v)); + }, + ), + const SizedBox(height: 12), + Text( + 'Валюта задаёт формат сумм. Конвертация валют не выполняется.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + const SizedBox(height: 20), + Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionTitle('Сервер и синхронизация'), + Text( + store.serverUrl.isEmpty ? 'Сервер не подключён' : store.serverUrl, + ), + const SizedBox(height: 12), + Text( + store.session == null + ? 'Можно работать локально или подключить свой BalanceServer.' + : store.email, + ), + if (store.lastSyncAt != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Последняя синхронизация: ${DateFormat('d.MM.y HH:mm').format(store.lastSyncAt!)}', + ), + ), + if (store.syncMessage != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(store.syncMessage!), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: serverDialog, + icon: const Icon(Icons.dns_outlined), + label: const Text('Адрес сервера'), + ), + if (store.session == null) ...[ + FilledButton( + onPressed: store.serverUrl.isEmpty + ? null + : () => authDialog(false), + child: const Text('Войти'), + ), + TextButton( + onPressed: store.serverUrl.isEmpty + ? null + : () => authDialog(true), + child: const Text('Регистрация'), + ), + ] else ...[ + FilledButton.icon( + onPressed: store.syncing ? null : () => run(store.sync), + icon: const Icon(Icons.sync), + label: const Text('Синхронизировать'), + ), + OutlinedButton( + onPressed: store.syncing + ? null + : () => run(store.resetAndSync), + child: const Text('Полная синхронизация'), + ), + TextButton( + onPressed: store.syncing ? null : () => run(store.signOut), + child: const Text('Выйти'), + ), + ], + ], + ), + if (kIsWeb) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text( + 'Web: используйте HTTPS и разрешите адрес приложения в CORS-настройках сервера.', + ), + ), + ], + ), + ), + const SizedBox(height: 20), + Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionTitle( + 'Категории', + action: FilledButton.tonalIcon( + onPressed: editCategory, + icon: const Icon(Icons.add), + label: const Text('Добавить'), + ), + ), + if (store.categories.isEmpty) + const Text('Создайте свои категории или используйте встроенные.'), + for (final c in store.categories) + ListTile( + contentPadding: EdgeInsets.zero, + leading: CategoryAvatar( + icon: c.icon, + emoji: c.emoji, + color: c.colorName, + ), + title: Text(c.name), + subtitle: Text(c.kindRawValue == 'income' ? 'Доход' : 'Расход'), + onTap: () => editCategory(c), + trailing: IconButton( + tooltip: 'Удалить категорию', + onPressed: () async { + if (await confirmDelete( + context, + 'Удалить «${c.name}»?', + message: + 'Операции сохранят категорию. Связанные бюджеты будут удалены.', + )) { + await run(() => store.deleteCategory(c.id)); + } + }, + icon: const Icon(Icons.delete_outline), + ), + ), + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Встроенные категории'), + children: [ + for (final c in [ + ...BuiltInCategories.expense, + ...BuiltInCategories.income, + ]) + ListTile( + leading: CategoryAvatar( + icon: c.icon, + emoji: c.emoji, + color: c.colorName, + size: 32, + ), + title: Text(c.name), + subtitle: Text( + c.kindRawValue == 'income' ? 'Доход' : 'Расход', + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + sectionTitle('Данные'), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton.icon( + onPressed: export, + icon: const Icon(Icons.download_outlined), + label: const Text('Экспорт истории CSV'), + ), + TextButton( + onPressed: () => showAboutDialog( + context: context, + applicationName: 'Баланс', + applicationVersion: '4.0.0', + applicationIcon: const Icon( + Icons.account_balance_wallet_outlined, + size: 40, + ), + children: const [ + Text( + 'Личный бюджет · Flutter · Material 3\nСинхронизация с BalanceServer', + ), + ], + ), + child: const Text('О приложении'), + ), + ], + ), + ], + ), + ), + ]; + Future serverDialog() async { + final controller = TextEditingController(text: store.serverUrl); + final form = GlobalKey(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: const Text('Адрес сервера'), + content: SizedBox( + width: 460, + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'При смене сервера потребуется новый вход. Данные аккаунтов хранятся раздельно.', + ), + const SizedBox(height: 20), + TextFormField( + controller: controller, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + labelText: 'https://balance.example.com', + ), + validator: (s) { + final uri = Uri.tryParse((s ?? '').trim()); + if (uri == null || + !uri.hasAuthority || + !['https', 'http'].contains(uri.scheme)) { + return 'Введите полный адрес сервера'; + } + return null; + }, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (form.currentState!.validate()) { + Navigator.pop(context, controller.text); + } + }, + child: const Text('Сохранить'), + ), + ], + ), + ); + if (result != null) { + await run( + () => store.setServerUrl(result), + success: 'Адрес сервера сохранён', + ); + } + await Future.delayed(const Duration(milliseconds: 300)); + controller.dispose(); + } + + Future authDialog(bool register) async { + final email = TextEditingController(); + final password = TextEditingController(); + final form = GlobalKey(); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + scrollable: true, + title: Text(register ? 'Создать аккаунт' : 'Войти в аккаунт'), + content: SizedBox( + width: 420, + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: email, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + decoration: const InputDecoration( + labelText: 'Электронная почта', + ), + validator: (s) => + (s ?? '').contains('@') ? null : 'Введите email', + ), + const SizedBox(height: 16), + TextFormField( + controller: password, + obscureText: true, + autofillHints: [ + register + ? AutofillHints.newPassword + : AutofillHints.password, + ], + decoration: const InputDecoration(labelText: 'Пароль'), + validator: (s) => (s ?? '').isEmpty + ? 'Введите пароль' + : register && s!.length < 8 + ? 'Не менее 8 символов' + : null, + ), + const SizedBox(height: 12), + const Text( + 'При первом подключении нового аккаунта локальные записи переносятся в него. Аккаунты хранятся раздельно.', + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (form.currentState!.validate()) Navigator.pop(context, true); + }, + child: Text(register ? 'Зарегистрироваться' : 'Войти'), + ), + ], + ), + ); + if (result == true) { + await run( + () => register + ? store.register(email.text.trim(), password.text) + : store.signIn(email.text.trim(), password.text), + ); + } + await Future.delayed(const Duration(milliseconds: 300)); + email.dispose(); + password.dispose(); + } + + Future export() async { + await run(() async { + final csv = FinanceMath.exportCsv(store.transactions); + final bytes = Uint8List.fromList(utf8.encode(csv)); + if (!kIsWeb && + (defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS)) { + final box = context.findRenderObject() as RenderBox?; + await SharePlus.instance.share( + ShareParams( + files: [ + XFile.fromData( + bytes, + mimeType: 'text/csv', + name: 'balance-transactions.csv', + ), + ], + fileNameOverrides: ['balance-transactions.csv'], + sharePositionOrigin: box == null + ? null + : box.localToGlobal(Offset.zero) & box.size, + ), + ); + } else { + final location = await getSaveLocation( + suggestedName: 'balance-transactions.csv', + acceptedTypeGroups: const [ + XTypeGroup(label: 'CSV', extensions: ['csv']), + ], + ); + if (location != null) { + await XFile.fromData( + bytes, + mimeType: 'text/csv', + name: 'balance-transactions.csv', + ).saveTo(location.path); + } + } + }); + } +} diff --git a/BalanceFlutter/lib/ui/design.dart b/BalanceFlutter/lib/ui/design.dart new file mode 100644 index 0000000..64178ae --- /dev/null +++ b/BalanceFlutter/lib/ui/design.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +String money(int minor, String currency) => NumberFormat.currency( + locale: 'ru_RU', + name: currency, + symbol: + {'RUB': '₽', 'USD': r'$', 'EUR': '€', 'SEK': 'kr'}[currency] ?? currency, +).format(minor / 100); +int? parseAmount(String value, {bool signed = false}) { + final clean = value + .replaceAll(RegExp(r'[\s\u00a0\u202f]'), '') + .replaceAll(',', '.'); + if (!RegExp( + signed ? r'^-?\d{1,12}(\.\d{1,2})?$' : r'^\d{1,12}(\.\d{1,2})?$', + ).hasMatch(clean)) { + return null; + } + final parts = clean.replaceFirst('-', '').split('.'); + final result = + int.parse(parts[0]) * 100 + + (parts.length > 1 ? int.parse(parts[1].padRight(2, '0')) : 0); + return clean.startsWith('-') ? -result : result; +} + +String amountInput(int minor) => (minor / 100).toStringAsFixed(2); + +const categoryColors = { + 'red': Colors.red, + 'orange': Colors.orange, + 'yellow': Colors.amber, + 'green': Colors.green, + 'mint': Color(0xff39b997), + 'teal': Colors.teal, + 'cyan': Colors.cyan, + 'blue': Colors.blue, + 'indigo': Colors.indigo, + 'purple': Colors.purple, + 'pink': Colors.pink, + 'gray': Colors.blueGrey, + 'coral': Color(0xffee7063), + 'peach': Color(0xffe9a077), + 'gold': Color(0xffbf930c), + 'lime': Color(0xff91ad21), + 'forest': Color(0xff28734a), + 'turquoise': Color(0xff27a9a0), + 'sky': Color(0xff459bc8), + 'navy': Color(0xff364d94), + 'lavender': Color(0xff9b82ce), + 'magenta': Color(0xffbd439d), + 'brown': Colors.brown, + 'slate': Color(0xff627389), +}; +const categoryIcons = { + 'star.fill': Icons.star_rounded, + 'heart.fill': Icons.favorite_rounded, + 'sparkles': Icons.auto_awesome, + 'bolt.fill': Icons.bolt, + 'flame.fill': Icons.local_fire_department, + 'drop.fill': Icons.water_drop, + 'cart.fill': Icons.shopping_cart_outlined, + 'bag.fill': Icons.shopping_bag_outlined, + 'creditcard.fill': Icons.credit_card, + 'banknote.fill': Icons.payments_outlined, + 'gift.fill': Icons.card_giftcard, + 'tag.fill': Icons.sell_outlined, + 'cup.and.saucer.fill': Icons.coffee, + 'fork.knife': Icons.restaurant, + 'takeoutbag.and.cup.and.straw.fill': Icons.takeout_dining, + 'birthday.cake.fill': Icons.cake_outlined, + 'car.fill': Icons.directions_car_outlined, + 'bus.fill': Icons.directions_bus, + 'tram.fill': Icons.tram, + 'bicycle': Icons.pedal_bike, + 'airplane': Icons.flight, + 'fuelpump.fill': Icons.local_gas_station, + 'house.fill': Icons.home_outlined, + 'bed.double.fill': Icons.bed_outlined, + 'lightbulb.fill': Icons.lightbulb_outline, + 'wifi': Icons.wifi, + 'phone.fill': Icons.phone_outlined, + 'printer.fill': Icons.print_outlined, + 'pawprint.fill': Icons.pets, + 'leaf.fill': Icons.eco_outlined, + 'figure.walk': Icons.directions_walk, + 'figure.run': Icons.directions_run, + 'dumbbell.fill': Icons.fitness_center, + 'cross.case.fill': Icons.medical_services_outlined, + 'pills.fill': Icons.medication_outlined, + 'stethoscope': Icons.monitor_heart_outlined, + 'book.fill': Icons.menu_book, + 'graduationcap.fill': Icons.school_outlined, + 'laptopcomputer': Icons.laptop, + 'briefcase.fill': Icons.work_outline, + 'gamecontroller.fill': Icons.sports_esports_outlined, + 'music.note': Icons.music_note, + 'film.fill': Icons.movie_outlined, + 'camera.fill': Icons.photo_camera_outlined, + 'paintpalette.fill': Icons.palette_outlined, + 'tshirt.fill': Icons.checkroom, + 'hammer.fill': Icons.gavel, + 'wrench.and.screwdriver.fill': Icons.handyman_outlined, + 'shippingbox.fill': Icons.inventory_2_outlined, + 'globe.europe.africa.fill': Icons.public, + 'repeat': Icons.repeat, + 'chart.line.uptrend.xyaxis': Icons.trending_up, + 'ellipsis.circle.fill': Icons.more_horiz, + 'plus.circle.fill': Icons.add_circle_outline, + 'slider.horizontal.3': Icons.tune, + 'tune': Icons.tune, +}; + +class CategoryAvatar extends StatelessWidget { + const CategoryAvatar({ + super.key, + required this.icon, + this.emoji = '', + this.color = 'indigo', + this.size = 44, + }); + final String icon, emoji, color; + final double size; + @override + Widget build(BuildContext context) { + final c = categoryColors[color] ?? Theme.of(context).colorScheme.primary; + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: c.withValues(alpha: .13), + borderRadius: BorderRadius.circular(size * .32), + ), + child: emoji.isNotEmpty + ? Text(emoji, style: TextStyle(fontSize: size * .48)) + : Icon( + categoryIcons[icon] ?? Icons.category_outlined, + color: c, + size: size * .52, + ), + ); + } +} + +class Panel extends StatelessWidget { + const Panel({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(24), + }); + final Widget child; + final EdgeInsetsGeometry padding; + @override + Widget build(BuildContext context) => Card( + margin: EdgeInsets.zero, + elevation: 0, + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Padding(padding: padding, child: child), + ); +} + +class EmptyView extends StatelessWidget { + const EmptyView({ + super.key, + required this.title, + required this.subtitle, + this.action, + this.icon = Icons.receipt_long_outlined, + }); + final String title, subtitle; + final Widget? action; + final IconData icon; + @override + Widget build(BuildContext context) => Panel( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text(subtitle, textAlign: TextAlign.center), + if (action != null) ...[const SizedBox(height: 20), action!], + ], + ), + ), + ), + ); +} + +class Metric extends StatelessWidget { + const Metric({ + super.key, + required this.label, + required this.value, + required this.icon, + this.color, + }); + final String label, value; + final IconData icon; + final Color? color; + @override + Widget build(BuildContext context) => Panel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + icon, + color: color ?? Theme.of(context).colorScheme.primary, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.labelLarge), + ), + ], + ), + const SizedBox(height: 14), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ], + ), + ); +} + +class SpendingRing extends StatelessWidget { + const SpendingRing({super.key, required this.segments, required this.total}); + final List<(int, Color)> segments; + final String total; + @override + Widget build(BuildContext context) => Semantics( + label: 'Всего расходов: $total. Структура по категориям приведена ниже.', + child: SizedBox( + height: 200, + child: Center( + child: SizedBox.square( + dimension: 190, + child: CustomPaint( + painter: _RingPainter(segments), + child: Center( + child: Padding( + padding: const EdgeInsets.all(30), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Расходы', + style: Theme.of(context).textTheme.labelMedium, + ), + const SizedBox(height: 6), + FittedBox( + child: Text( + total, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); +} + +class _RingPainter extends CustomPainter { + _RingPainter(this.segments); + final List<(int, Color)> segments; + @override + void paint(Canvas canvas, Size size) { + final total = segments.fold(0, (sum, s) => sum + s.$1); + if (total <= 0) return; + final rect = Offset.zero & size; + var angle = -1.5707963267948966; + for (final segment in segments) { + final sweep = segment.$1 / total * 6.283185307179586; + canvas.drawArc( + rect.deflate(13), + angle, + sweep, + false, + Paint() + ..color = segment.$2 + ..style = PaintingStyle.stroke + ..strokeWidth = 20, + ); + angle += sweep; + } + } + + @override + bool shouldRepaint(covariant _RingPainter oldDelegate) => + oldDelegate.segments != segments; +} diff --git a/BalanceFlutter/lib/ui/editors.dart b/BalanceFlutter/lib/ui/editors.dart new file mode 100644 index 0000000..c5f5aa9 --- /dev/null +++ b/BalanceFlutter/lib/ui/editors.dart @@ -0,0 +1,585 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; +import '../domain/finance.dart'; +import 'design.dart'; + +const _uuid = Uuid(); +Future editDialog(BuildContext context, Widget child) => showDialog( + context: context, + builder: (_) => Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: child, + ), + ), +); + +class TransactionEditor extends StatefulWidget { + const TransactionEditor({super.key, this.existing, required this.categories}); + final TransactionRecord? existing; + final List categories; + @override + State createState() => _TransactionEditorState(); +} + +class _TransactionEditorState extends State { + final form = GlobalKey(); + late final TextEditingController amount, note; + late DateTime date; + late String kind; + late CategoryRecord selected; + List get choices { + final list = [ + ...BuiltInCategories.forKind(kind), + ...widget.categories.where((c) => c.kindRawValue == kind), + ]; + if (!list.any((c) => c.name == selected.name)) list.add(selected); + return {for (final c in list) c.name: c}.values.toList(); + } + + @override + void initState() { + super.initState(); + final t = widget.existing; + amount = TextEditingController( + text: t == null ? '' : amountInput(t.amountMinor), + ); + note = TextEditingController(text: t?.note ?? ''); + date = t?.date.toLocal() ?? DateTime.now(); + kind = t?.kindRawValue ?? 'expense'; + selected = t == null + ? BuiltInCategories.expense.first + : CategoryRecord( + id: 'archived', + name: t.categoryName, + icon: t.categoryIcon, + emoji: t.categoryEmoji, + colorName: t.categoryColorName, + kindRawValue: kind, + createdAt: date, + updatedAt: date, + ); + } + + @override + void dispose() { + amount.dispose(); + note.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.existing == null ? 'Новая операция' : 'Изменить операцию', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + SegmentedButton( + segments: const [ + ButtonSegment( + value: 'expense', + label: Text('Расход'), + icon: Icon(Icons.north_east), + ), + ButtonSegment( + value: 'income', + label: Text('Доход'), + icon: Icon(Icons.south_west), + ), + ], + selected: {kind}, + onSelectionChanged: widget.existing?.isBalanceAdjustment == true + ? null + : (s) => setState(() { + kind = s.first; + selected = BuiltInCategories.forKind(kind).first; + }), + ), + const SizedBox(height: 20), + TextFormField( + controller: amount, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Сумма', + border: OutlineInputBorder(), + ), + validator: (v) { + final m = parseAmount(v ?? ''); + return m == null || m <= 0 + ? 'Введите положительную сумму, до 2 знаков после запятой' + : null; + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: selected.name, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Категория', + border: OutlineInputBorder(), + ), + items: choices + .map( + (c) => DropdownMenuItem( + value: c.name, + child: Row( + children: [ + CategoryAvatar( + icon: c.icon, + emoji: c.emoji, + color: c.colorName, + size: 28, + ), + const SizedBox(width: 12), + Flexible( + child: Text(c.name, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ) + .toList(), + onChanged: widget.existing?.isBalanceAdjustment == true + ? null + : (v) => setState( + () => selected = choices.firstWhere((c) => c.name == v), + ), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.calendar_today_outlined), + title: Text(DateFormat('d MMMM y, HH:mm', 'ru').format(date)), + trailing: const Icon(Icons.edit_outlined), + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: date, + firstDate: DateTime(1900), + lastDate: DateTime(2200), + ); + if (d == null || !context.mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(date), + ); + if (time != null && mounted) { + setState( + () => date = DateTime( + d.year, + d.month, + d.day, + time.hour, + time.minute, + ), + ); + } + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: note, + maxLines: 3, + maxLength: 4000, + decoration: const InputDecoration( + labelText: 'Заметка', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + if (!form.currentState!.validate()) return; + final now = DateTime.now(); + Navigator.pop( + context, + TransactionRecord( + id: widget.existing?.id ?? _uuid.v4(), + amountMinor: parseAmount(amount.text)!, + date: date, + note: note.text.trim(), + categoryName: selected.name, + categoryIcon: selected.icon, + categoryEmoji: selected.emoji, + categoryColorName: selected.colorName, + kindRawValue: kind, + isBalanceAdjustment: + widget.existing?.isBalanceAdjustment ?? false, + updatedAt: now, + ), + ); + }, + child: const Text('Сохранить'), + ), + ], + ), + ], + ), + ), + ); +} + +class CategoryEditor extends StatefulWidget { + const CategoryEditor({super.key, this.existing, required this.categories}); + final CategoryRecord? existing; + final List categories; + @override + State createState() => _CategoryEditorState(); +} + +class _CategoryEditorState extends State { + final form = GlobalKey(); + late final TextEditingController name, emoji; + late String kind, icon, color; + @override + void initState() { + super.initState(); + final c = widget.existing; + name = TextEditingController(text: c?.name ?? ''); + emoji = TextEditingController(text: c?.emoji ?? ''); + kind = c?.kindRawValue ?? 'expense'; + icon = c?.icon ?? 'star.fill'; + color = c?.colorName ?? 'indigo'; + } + + @override + void dispose() { + name.dispose(); + emoji.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.existing == null ? 'Новая категория' : 'Изменить категорию', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 20), + TextFormField( + controller: name, + maxLength: 80, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + ), + validator: (s) { + final n = (s ?? '').trim().toLowerCase(); + if (n.isEmpty) return 'Введите название'; + if ([ + ...BuiltInCategories.expense, + ...BuiltInCategories.income, + ...widget.categories, + ].any( + (c) => c.id != widget.existing?.id && c.name.toLowerCase() == n, + )) { + return 'Категория с таким названием уже есть'; + } + return null; + }, + ), + const SizedBox(height: 16), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'expense', label: Text('Расход')), + ButtonSegment(value: 'income', label: Text('Доход')), + ], + selected: {kind}, + onSelectionChanged: widget.existing != null + ? null + : (s) => setState(() => kind = s.first), + ), + const SizedBox(height: 20), + Row( + children: [ + CategoryAvatar( + icon: icon, + emoji: emoji.text, + color: color, + size: 60, + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: emoji, + decoration: InputDecoration( + labelText: 'Свой эмодзи', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + tooltip: 'Убрать эмодзи', + icon: const Icon(Icons.close), + onPressed: () => setState(() => emoji.clear()), + ), + ), + onChanged: (_) => setState(() {}), + maxLength: 8, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 4, + runSpacing: 4, + children: CategoryDesignOptions.emojis + .map( + (e) => IconButton( + onPressed: () => setState(() => emoji.text = e), + tooltip: e, + icon: Text(e, style: const TextStyle(fontSize: 23)), + ), + ) + .toList(), + ), + const SizedBox(height: 16), + Text('Значок', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: CategoryDesignOptions.icons + .map( + (i) => IconButton.filledTonal( + isSelected: i == icon && emoji.text.isEmpty, + onPressed: () => setState(() { + icon = i; + emoji.clear(); + }), + tooltip: i, + icon: Icon(categoryIcons[i] ?? Icons.category_outlined), + ), + ) + .toList(), + ), + const SizedBox(height: 20), + Text('Цвет', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: CategoryDesignOptions.colors + .map( + (c) => IconButton( + style: IconButton.styleFrom( + backgroundColor: categoryColors[c], + ), + tooltip: c, + onPressed: () => setState(() => color = c), + icon: Icon( + c == color ? Icons.check : Icons.circle, + color: c == color ? Colors.white : Colors.transparent, + ), + ), + ) + .toList(), + ), + const SizedBox(height: 24), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + if (!form.currentState!.validate()) return; + final now = DateTime.now(); + Navigator.pop( + context, + CategoryRecord( + id: widget.existing?.id ?? _uuid.v4(), + name: name.text.trim(), + icon: icon, + emoji: emoji.text.characters.take(1).toString(), + colorName: color, + kindRawValue: kind, + createdAt: widget.existing?.createdAt ?? now, + updatedAt: now, + ), + ); + }, + child: const Text('Сохранить'), + ), + ], + ), + ], + ), + ), + ); +} + +class BudgetEditor extends StatefulWidget { + const BudgetEditor({ + super.key, + this.existing, + required this.categories, + required this.month, + }); + final BudgetRecord? existing; + final List categories; + final DateTime month; + @override + State createState() => _BudgetEditorState(); +} + +class _BudgetEditorState extends State { + final form = GlobalKey(); + late final TextEditingController amount; + late CategoryRecord selected; + @override + void initState() { + super.initState(); + amount = TextEditingController( + text: widget.existing == null + ? '' + : amountInput(widget.existing!.limitMinor), + ); + selected = + widget.categories + .where((c) => c.name == widget.existing?.categoryName) + .firstOrNull ?? + widget.categories.first; + } + + @override + void dispose() { + amount.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: form, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Лимит на ${DateFormat('LLLL y', 'ru').format(widget.month)}', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + DropdownButtonFormField( + initialValue: selected.name, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Категория', + border: OutlineInputBorder(), + ), + items: widget.categories + .map( + (c) => DropdownMenuItem(value: c.name, child: Text(c.name)), + ) + .toList(), + onChanged: (n) => setState( + () => selected = widget.categories.firstWhere((c) => c.name == n), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: amount, + autofocus: true, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Месячный лимит', + border: OutlineInputBorder(), + ), + validator: (v) => + parseAmount(v ?? '') == null || parseAmount(v ?? '')! <= 0 + ? 'Введите положительный лимит' + : null, + ), + const SizedBox(height: 24), + Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () { + if (!form.currentState!.validate()) return; + Navigator.pop( + context, + BudgetRecord( + id: widget.existing?.id ?? _uuid.v4(), + categoryName: selected.name, + categoryIcon: selected.icon, + categoryEmoji: selected.emoji, + limitMinor: parseAmount(amount.text)!, + monthStart: widget.month, + updatedAt: DateTime.now(), + ), + ); + }, + child: const Text('Сохранить'), + ), + ], + ), + ], + ), + ), + ); +} + +Future confirmDelete( + BuildContext context, + String title, { + String message = + 'Это действие будет синхронизировано с другими устройствами.', +}) async => + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Удалить'), + ), + ], + ), + ) ?? + false; diff --git a/BalanceFlutter/linux/.gitignore b/BalanceFlutter/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/BalanceFlutter/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/BalanceFlutter/linux/CMakeLists.txt b/BalanceFlutter/linux/CMakeLists.txt new file mode 100644 index 0000000..977a2f5 --- /dev/null +++ b/BalanceFlutter/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "balance") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.balance.balance") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/BalanceFlutter/linux/flutter/CMakeLists.txt b/BalanceFlutter/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/BalanceFlutter/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/BalanceFlutter/linux/flutter/generated_plugin_registrant.cc b/BalanceFlutter/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..3ccd551 --- /dev/null +++ b/BalanceFlutter/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/BalanceFlutter/linux/flutter/generated_plugin_registrant.h b/BalanceFlutter/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/BalanceFlutter/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/BalanceFlutter/linux/flutter/generated_plugins.cmake b/BalanceFlutter/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..fbedf4a --- /dev/null +++ b/BalanceFlutter/linux/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_secure_storage_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/BalanceFlutter/linux/runner/CMakeLists.txt b/BalanceFlutter/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/BalanceFlutter/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/BalanceFlutter/linux/runner/main.cc b/BalanceFlutter/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/BalanceFlutter/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/BalanceFlutter/linux/runner/my_application.cc b/BalanceFlutter/linux/runner/my_application.cc new file mode 100644 index 0000000..fd047ba --- /dev/null +++ b/BalanceFlutter/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "balance"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "balance"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/BalanceFlutter/linux/runner/my_application.h b/BalanceFlutter/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/BalanceFlutter/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/BalanceFlutter/macos/.gitignore b/BalanceFlutter/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/BalanceFlutter/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/BalanceFlutter/macos/Flutter/Flutter-Debug.xcconfig b/BalanceFlutter/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/BalanceFlutter/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/BalanceFlutter/macos/Flutter/Flutter-Release.xcconfig b/BalanceFlutter/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/BalanceFlutter/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/BalanceFlutter/macos/Flutter/GeneratedPluginRegistrant.swift b/BalanceFlutter/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..4fe7662 --- /dev/null +++ b/BalanceFlutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_selector_macos +import flutter_secure_storage_darwin +import share_plus +import shared_preferences_foundation +import workmanager_apple + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + WorkmanagerPlugin.register(with: registry.registrar(forPlugin: "WorkmanagerPlugin")) +} diff --git a/BalanceFlutter/macos/Runner.xcodeproj/project.pbxproj b/BalanceFlutter/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6511b05 --- /dev/null +++ b/BalanceFlutter/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* balance.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "balance.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* balance.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* balance.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/balance.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/balance"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/balance.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/balance"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/balance.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/balance"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/BalanceFlutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/BalanceFlutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/BalanceFlutter/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/BalanceFlutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/BalanceFlutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..9afe3f0 --- /dev/null +++ b/BalanceFlutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceFlutter/macos/Runner.xcworkspace/contents.xcworkspacedata b/BalanceFlutter/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/BalanceFlutter/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/BalanceFlutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/BalanceFlutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/BalanceFlutter/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/BalanceFlutter/macos/Runner/AppDelegate.swift b/BalanceFlutter/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/BalanceFlutter/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..66400cf Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..c7d12f3 Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..71dca5a Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..ad46c05 Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..86a3f53 Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..2636d62 Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..d443100 Binary files /dev/null and b/BalanceFlutter/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/BalanceFlutter/macos/Runner/Base.lproj/MainMenu.xib b/BalanceFlutter/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/BalanceFlutter/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceFlutter/macos/Runner/Configs/AppInfo.xcconfig b/BalanceFlutter/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..ff046d0 --- /dev/null +++ b/BalanceFlutter/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = balance + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.balance. All rights reserved. diff --git a/BalanceFlutter/macos/Runner/Configs/Debug.xcconfig b/BalanceFlutter/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/BalanceFlutter/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/BalanceFlutter/macos/Runner/Configs/Release.xcconfig b/BalanceFlutter/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/BalanceFlutter/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/BalanceFlutter/macos/Runner/Configs/Warnings.xcconfig b/BalanceFlutter/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/BalanceFlutter/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/BalanceFlutter/macos/Runner/DebugProfile.entitlements b/BalanceFlutter/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..8e0aa26 --- /dev/null +++ b/BalanceFlutter/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + keychain-access-groups + + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + + + diff --git a/BalanceFlutter/macos/Runner/Info.plist b/BalanceFlutter/macos/Runner/Info.plist new file mode 100644 index 0000000..878a615 --- /dev/null +++ b/BalanceFlutter/macos/Runner/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/BalanceFlutter/macos/Runner/MainFlutterWindow.swift b/BalanceFlutter/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/BalanceFlutter/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/BalanceFlutter/macos/Runner/Release.entitlements b/BalanceFlutter/macos/Runner/Release.entitlements new file mode 100644 index 0000000..79edac3 --- /dev/null +++ b/BalanceFlutter/macos/Runner/Release.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + keychain-access-groups + + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + + + diff --git a/BalanceFlutter/macos/RunnerTests/RunnerTests.swift b/BalanceFlutter/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/BalanceFlutter/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/BalanceFlutter/packages/balance_runtime/android/build.gradle b/BalanceFlutter/packages/balance_runtime/android/build.gradle new file mode 100644 index 0000000..793493d --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/android/build.gradle @@ -0,0 +1,20 @@ +group 'com.balance.runtime' +version '1.0' + +apply plugin: 'com.android.library' + +android { + namespace 'com.balance.runtime' + compileSdk 36 + defaultConfig { + minSdk 23 + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + testImplementation 'junit:junit:4.13.2' +} diff --git a/BalanceFlutter/packages/balance_runtime/android/src/main/AndroidManifest.xml b/BalanceFlutter/packages/balance_runtime/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/BalanceFlutter/packages/balance_runtime/android/src/main/java/com/balance/runtime/BalanceRuntimePlugin.java b/BalanceFlutter/packages/balance_runtime/android/src/main/java/com/balance/runtime/BalanceRuntimePlugin.java new file mode 100644 index 0000000..8a1d3da --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/android/src/main/java/com/balance/runtime/BalanceRuntimePlugin.java @@ -0,0 +1,105 @@ +package com.balance.runtime; + +import android.os.Handler; +import android.os.Looper; +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; + +/** Serializes complete ledger/session operations across Flutter engines/processes. */ +public final class BalanceRuntimePlugin implements FlutterPlugin, MethodChannel.MethodCallHandler { + private final Handler main = new Handler(Looper.getMainLooper()); + private final Object state = new Object(); + private MethodChannel channel; + private ExecutorService executor; + private File lockFile; + private boolean detached = true; + private boolean acquiring; + private LedgerFileLock held; + + @Override + public void onAttachedToEngine(FlutterPluginBinding binding) { + synchronized (state) { + detached = false; + acquiring = false; + lockFile = new File(binding.getApplicationContext().getFilesDir(), "balance-runtime.lock"); + executor = Executors.newSingleThreadExecutor(); + } + channel = new MethodChannel(binding.getBinaryMessenger(), "balance/runtime"); + channel.setMethodCallHandler(this); + } + + @Override + public void onMethodCall(MethodCall call, MethodChannel.Result result) { + if ("acquireLock".equals(call.method)) { + synchronized (state) { + if (detached || acquiring || held != null) { + result.error("lock_busy", "This engine already owns or is waiting for the ledger lock", null); + return; + } + acquiring = true; + executor.execute(() -> acquire(result)); + } + } else if ("releaseLock".equals(call.method)) { + LedgerFileLock release; + synchronized (state) { + String token = call.argument("token"); + if (held == null || !held.token.equals(token)) { + result.error("lock_owner", "Ledger lock token does not match this engine", null); + return; + } + release = held; + held = null; + } + // Never queue release behind another engine's blocking acquisition. + release.close(); + result.success(null); + } else { + result.notImplemented(); + } + } + + private void acquire(MethodChannel.Result result) { + try { + LedgerFileLock acquired = LedgerFileLock.acquire(lockFile); + synchronized (state) { + acquiring = false; + if (detached) { + acquired.close(); + return; + } + held = acquired; + } + main.post(() -> { + synchronized (state) { + if (detached || held != acquired) return; + result.success(acquired.token); + } + }); + } catch (Exception error) { + synchronized (state) { acquiring = false; } + main.post(() -> { + synchronized (state) { + if (!detached) result.error("lock_failed", "Unable to acquire the local ledger lock", null); + } + }); + } + } + + @Override + public void onDetachedFromEngine(FlutterPluginBinding binding) { + LedgerFileLock release; + synchronized (state) { + detached = true; + release = held; + held = null; + if (executor != null) executor.shutdownNow(); + } + if (channel != null) channel.setMethodCallHandler(null); + if (release != null) release.close(); + } + +} diff --git a/BalanceFlutter/packages/balance_runtime/android/src/main/java/com/balance/runtime/LedgerFileLock.java b/BalanceFlutter/packages/balance_runtime/android/src/main/java/com/balance/runtime/LedgerFileLock.java new file mode 100644 index 0000000..574b9a1 --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/android/src/main/java/com/balance/runtime/LedgerFileLock.java @@ -0,0 +1,55 @@ +package com.balance.runtime; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.util.UUID; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +/** OS lock plus JVM serialization, independent of any Flutter engine lifetime. */ +final class LedgerFileLock implements AutoCloseable { + // FileChannel alone throws OverlappingFileLockException in one JVM. + private static final Semaphore PROCESS_LOCK = new Semaphore(1, true); + final String token = UUID.randomUUID().toString(); + private final RandomAccessFile file; + private final FileChannel channel; + private final FileLock lock; + private final AtomicBoolean closed = new AtomicBoolean(); + + private LedgerFileLock(RandomAccessFile file, FileChannel channel, FileLock lock) { + this.file = file; + this.channel = channel; + this.lock = lock; + } + + static LedgerFileLock acquire(File path) throws IOException, InterruptedException { + PROCESS_LOCK.acquire(); + RandomAccessFile file = null; + FileChannel channel = null; + FileLock lock = null; + try { + file = new RandomAccessFile(path, "rw"); + channel = file.getChannel(); + lock = channel.lock(); + return new LedgerFileLock(file, channel, lock); + } catch (IOException | RuntimeException error) { + try { if (lock != null) lock.release(); } catch (IOException ignored) {} + try { if (channel != null) channel.close(); } catch (IOException ignored) {} + try { if (file != null) file.close(); } catch (IOException ignored) {} + PROCESS_LOCK.release(); + throw error; + } + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + try { lock.release(); } catch (IOException ignored) {} + try { channel.close(); } catch (IOException ignored) {} + try { file.close(); } catch (IOException ignored) {} + PROCESS_LOCK.release(); + } +} diff --git a/BalanceFlutter/packages/balance_runtime/android/src/test/java/com/balance/runtime/LedgerFileLockTest.java b/BalanceFlutter/packages/balance_runtime/android/src/test/java/com/balance/runtime/LedgerFileLockTest.java new file mode 100644 index 0000000..7d9c04e --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/android/src/test/java/com/balance/runtime/LedgerFileLockTest.java @@ -0,0 +1,86 @@ +package com.balance.runtime; + +import static org.junit.Assert.*; +import java.io.File; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; + +public class LedgerFileLockTest { + @Test + public void separateProcessWaitsForOsFileLock() throws Exception { + File path = Files.createTempFile("balance-process-lock", ".lock").toFile(); + Process child = null; + try (LedgerFileLock first = LedgerFileLock.acquire(path)) { + String classpath = new File(LedgerLockProbe.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).getPath() + + File.pathSeparator + new File(LedgerFileLock.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).getPath(); + child = new ProcessBuilder(new File(System.getProperty("java.home"), "bin/java").getPath(), + "-cp", classpath, LedgerLockProbe.class.getName(), path.getPath()) + .redirectErrorStream(true).start(); + BufferedReader output = new BufferedReader(new InputStreamReader(child.getInputStream())); + assertEquals("waiting", output.readLine()); + assertFalse(child.waitFor(100, TimeUnit.MILLISECONDS)); + first.close(); + assertTrue(child.waitFor(3, TimeUnit.SECONDS)); + assertEquals("acquired", output.readLine()); + assertEquals(0, child.exitValue()); + } finally { + if (child != null && child.isAlive()) child.destroyForcibly(); + path.delete(); + } + } + + @Test + public void concurrentOwnersWaitAndReleaseIsIdempotent() throws Exception { + File path = Files.createTempFile("balance-lock", ".lock").toFile(); + LedgerFileLock first = LedgerFileLock.acquire(path); + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); + Thread second = new Thread(() -> { + waiting.countDown(); + try (LedgerFileLock ignored = LedgerFileLock.acquire(path)) { + entered.countDown(); + } catch (Exception error) { throw new AssertionError(error); } + }); + second.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + assertFalse(entered.await(100, TimeUnit.MILLISECONDS)); + first.close(); + first.close(); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + second.join(2000); + assertFalse(second.isAlive()); + path.delete(); + } + + @Test + public void interruptedWaiterDoesNotLeakOrReleaseAnotherOwner() throws Exception { + File path = Files.createTempFile("balance-lock", ".lock").toFile(); + LedgerFileLock first = LedgerFileLock.acquire(path); + CountDownLatch waiting = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + Thread waiter = new Thread(() -> { + waiting.countDown(); + try (LedgerFileLock ignored = LedgerFileLock.acquire(path)) { + fail("Interrupted waiter must not acquire"); + } catch (InterruptedException expected) { interrupted.set(true); } + catch (Exception error) { throw new AssertionError(error); } + }); + waiter.start(); + assertTrue(waiting.await(2, TimeUnit.SECONDS)); + waiter.interrupt(); + waiter.join(2000); + assertTrue(interrupted.get()); + first.close(); + try (LedgerFileLock after = LedgerFileLock.acquire(path)) { + assertNotNull(after.token); + } + path.delete(); + } +} diff --git a/BalanceFlutter/packages/balance_runtime/android/src/test/java/com/balance/runtime/LedgerLockProbe.java b/BalanceFlutter/packages/balance_runtime/android/src/test/java/com/balance/runtime/LedgerLockProbe.java new file mode 100644 index 0000000..7e2b7a0 --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/android/src/test/java/com/balance/runtime/LedgerLockProbe.java @@ -0,0 +1,14 @@ +package com.balance.runtime; + +import java.io.File; + +/** Separate JVM helper used to exercise the actual OS-level file lock. */ +public final class LedgerLockProbe { + public static void main(String[] args) throws Exception { + System.out.println("waiting"); + System.out.flush(); + try (LedgerFileLock ignored = LedgerFileLock.acquire(new File(args[0]))) { + System.out.println("acquired"); + } + } +} diff --git a/BalanceFlutter/packages/balance_runtime/lib/balance_runtime.dart b/BalanceFlutter/packages/balance_runtime/lib/balance_runtime.dart new file mode 100644 index 0000000..c9b140c --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/lib/balance_runtime.dart @@ -0,0 +1,2 @@ +/// The Android plugin registers the balance/runtime channel in every engine. +library; diff --git a/BalanceFlutter/packages/balance_runtime/pubspec.yaml b/BalanceFlutter/packages/balance_runtime/pubspec.yaml new file mode 100644 index 0000000..be9b6aa --- /dev/null +++ b/BalanceFlutter/packages/balance_runtime/pubspec.yaml @@ -0,0 +1,16 @@ +name: balance_runtime +description: Shared Android ledger lock for foreground and background Flutter engines. +version: 1.0.0 +publish_to: none +environment: + sdk: '>=3.8.0 <4.0.0' + flutter: '>=3.22.0' +dependencies: + flutter: + sdk: flutter +flutter: + plugin: + platforms: + android: + package: com.balance.runtime + pluginClass: BalanceRuntimePlugin diff --git a/BalanceFlutter/pubspec.lock b/BalanceFlutter/pubspec.lock new file mode 100644 index 0000000..1943666 --- /dev/null +++ b/BalanceFlutter/pubspec.lock @@ -0,0 +1,806 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + balance_runtime: + dependency: "direct main" + description: + path: "packages/balance_runtime" + relative: true + source: path + version: "1.0.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "828110d598123b5ea96c00c9f3c72105bf79f8ee36c20a39b26209ade421ec57" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 + url: "https://pub.dev" + source: hosted + version: "3.0.8" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: b7cc2390aac296c083bea762859413eb1cf5b630c70e0c7e4d831018f762a8b2 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c + url: "https://pub.dev" + source: hosted + version: "0.5.2+11" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" + url: "https://pub.dev" + source: hosted + version: "0.5.3+7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + url: "https://pub.dev" + source: hosted + version: "0.9.5+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 + url: "https://pub.dev" + source: hosted + version: "11.2.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 + url: "https://pub.dev" + source: hosted + version: "0.4.3" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea + url: "https://pub.dev" + source: hosted + version: "3.0.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + idb_shim: + dependency: transitive + description: + name: idb_shim + sha256: "923c92ef88569de4ec1b257b6a6d10360dc3407179f61d0c73329701a7d7b12e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f + url: "https://pub.dev" + source: hosted + version: "1.1.1" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b" + url: "https://pub.dev" + source: hosted + version: "9.6.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + sembast: + dependency: "direct main" + description: + name: sembast + sha256: "05da81104e6d4a1ecbeb9aa92062231620d360c8bd9303b82495842c4d92632b" + url: "https://pub.dev" + source: hosted + version: "3.9.0" + sembast_web: + dependency: "direct main" + description: + name: sembast_web + sha256: "99dad5c3610c7b9559add7f394ab272ccb76a595a28dc94bc40d05c45481c7bf" + url: "https://pub.dev" + source: hosted + version: "2.4.6+1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224 + url: "https://pub.dev" + source: hosted + version: "13.3.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e396ac6f8751ab7d2d6e27f22f32c8350b07006028d534f5221503382f1fa69" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: fa49e1674cb84acb900141714e6c6e1f9ae099827c24d141430ff6819db4e1bc + url: "https://pub.dev" + source: hosted + version: "2.4.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" + url: "https://pub.dev" + source: hosted + version: "15.4.0" + web: + dependency: transitive + description: + name: web + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "96b09a4a852ec487d29371f27031dab80ce2e260911e2dbe0f904951e22a3956" + url: "https://pub.dev" + source: hosted + version: "0.10.10" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: ffe8aa061885370c9363f7ae2d2196d31855217c58e7b15899d915d36c768c4a + url: "https://pub.dev" + source: hosted + version: "0.10.9" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "9e0ca0b857df986b37ca964ffa3b21990e1bc0f7935356a9706b47e5b71a7524" + url: "https://pub.dev" + source: hosted + version: "0.9.11" + workmanager_linux: + dependency: transitive + description: + name: workmanager_linux + sha256: "7d8520e1103b910a43d8bafe37ec2415f4c7c148afcb1eee0ec73ae67b4cdd22" + url: "https://pub.dev" + source: hosted + version: "0.1.1+1" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: a1022dca1284d64ba8bbe046edc9089cc5158b3d0e2cf9f68c7cde2fbf89746c + url: "https://pub.dev" + source: hosted + version: "0.10.5" + workmanager_web: + dependency: transitive + description: + name: workmanager_web + sha256: "2ac481ade599bdd957ee2891be09a0c34be87923a2c3ca4dfe36add6fc735ffd" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0" diff --git a/BalanceFlutter/pubspec.yaml b/BalanceFlutter/pubspec.yaml new file mode 100644 index 0000000..b3353da --- /dev/null +++ b/BalanceFlutter/pubspec.yaml @@ -0,0 +1,42 @@ +name: balance_flutter +description: Local-first personal finance with BalanceServer synchronization. +publish_to: none +version: 4.0.0+1 +environment: + sdk: '>=3.8.0 <4.0.0' +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + cupertino_icons: ^2.0.0 + http: ^1.5.0 + intl: any + uuid: ^4.5.1 + sembast: ^3.8.5 + sembast_web: ^2.4.2 + path_provider: ^2.1.5 + flutter_secure_storage: ^11.2.0 + file_selector: ^1.0.3 + share_plus: ^13.3.1 + cross_file: ^0.3.4+2 + shared_preferences: ^2.5.3 + workmanager: ^0.10.10 + balance_runtime: + path: packages/balance_runtime +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 +flutter: + uses-material-design: true + assets: + - assets/fonts/LICENSE.txt + fonts: + - family: BalanceRoboto + fonts: + - asset: assets/fonts/Roboto-Regular.ttf + - asset: assets/fonts/Roboto-Medium.ttf + weight: 500 + - asset: assets/fonts/Roboto-Bold.ttf + weight: 700 diff --git a/BalanceFlutter/scripts/build.sh b/BalanceFlutter/scripts/build.sh new file mode 100755 index 0000000..111183a --- /dev/null +++ b/BalanceFlutter/scripts/build.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +platform="${1:-web}" +if [[ $# -gt 0 ]]; then shift; fi +flutter_bin="${FLUTTER_BIN:-flutter}" +case "$platform" in + web|android|ios|macos|windows|linux) ;; + watchos) + # watchOS uses the existing companion app; Flutter has no watchOS runner. + exec xcodebuild -project ../Balance/Balance.xcodeproj -scheme BalanceWatch \ + -configuration Release -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' \ + -derivedDataPath build/watchos CODE_SIGNING_ALLOWED=NO build "$@" + ;; + *) echo "Usage: $0 {web|android|ios|macos|windows|linux|watchos} [build options]" >&2; exit 2 ;; +esac +"$flutter_bin" pub get +case "$platform" in + web) "$flutter_bin" build web --release --no-web-resources-cdn "$@" ;; + android) "$flutter_bin" build apk --release "$@" ;; + ios) "$flutter_bin" build ios --release --no-codesign "$@" ;; + *) "$flutter_bin" build "$platform" --release "$@" ;; +esac diff --git a/BalanceFlutter/scripts/probe_web_lock.dart b/BalanceFlutter/scripts/probe_web_lock.dart new file mode 100644 index 0000000..375c5c5 --- /dev/null +++ b/BalanceFlutter/scripts/probe_web_lock.dart @@ -0,0 +1,73 @@ +// ignore_for_file: avoid_print +// Compile with `dart compile js scripts/probe_web_lock.dart -o /tmp/web-lock.js`. +// Run with Node 24.5+ (native navigator.locks) and isSecureContext=true. +// This tests JS Promise/ownership behavior, not browser/IndexedDB integration. +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:balance_flutter/services/runtime_lock_web.dart'; + +@JS('isSecureContext') +external set secureContext(bool value); + +@JS('navigator') +external JSObject get navigatorObject; + +@JS('navigator.locks') +external JSObject get lockManager; + +@JS('Object.defineProperty') +external JSObject defineProperty( + JSObject target, + JSString property, + JSObject descriptor, +); + +Future expectFailure( + Future Function() action, + String message, +) async { + try { + await action(); + } on StateError catch (error) { + if (error.message.toString().contains(message)) return; + rethrow; + } + throw StateError('Expected failure containing $message'); +} + +Future main() async { + secureContext = true; + final first = BrowserRuntimeLock(); + final second = BrowserRuntimeLock(); + await first.acquire(); + await expectFailure(first.acquire, 'already held'); + var acquired = false; + final next = second.acquire().then((_) => acquired = true); + await Future.delayed(const Duration(milliseconds: 25)); + if (acquired) throw StateError('Second owner bypassed the origin lock'); + await first.release(); + await next; + await second.release(); + await expectFailure(second.release, 'not held'); + + secureContext = false; + await expectFailure(BrowserRuntimeLock().acquire, 'HTTPS'); + secureContext = true; + final original = lockManager; + defineProperty( + navigatorObject, + 'locks'.toJS, + {'value': null, 'configurable': true}.jsify() as JSObject, + ); + await expectFailure(BrowserRuntimeLock().acquire, 'Web Locks API'); + defineProperty( + navigatorObject, + 'locks'.toJS, + {'value': original, 'configurable': true}.jsify() as JSObject, + ); + final restored = BrowserRuntimeLock(); + await restored.acquire(); + await restored.release(); + print('Web lock probe passed'); +} diff --git a/BalanceFlutter/test/app_store_test.dart b/BalanceFlutter/test/app_store_test.dart new file mode 100644 index 0000000..4b9f18b --- /dev/null +++ b/BalanceFlutter/test/app_store_test.dart @@ -0,0 +1,520 @@ +import 'dart:convert'; + +import 'package:balance_flutter/domain/finance.dart'; +import 'package:balance_flutter/services/app_store.dart'; +import 'package:balance_flutter/services/storage.dart'; +import 'package:balance_flutter/services/sync_api.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + }); + final date = DateTime.utc(2026, 1); + TransactionRecord transaction(String id) => TransactionRecord( + id: id, + amountMinor: 12345, + date: date, + categoryName: 'Еда', + updatedAt: date, + ); + + test('durable restart keeps edits and deletion tombstones', () async { + final storage = MemorySnapshotStorage(); + final store = AppStore(storage: storage); + await store.initialize(); + await store.saveTransaction(transaction('keep')); + await store.saveTransaction(transaction('delete')); + await store.deleteTransaction('delete'); + final reopened = AppStore(storage: storage); + await reopened.initialize(); + expect(reopened.transactions.single.id, 'keep'); + expect(reopened.transactions.single.amountMinor, 12345); + expect(reopened.pendingChangeCount, 2); + expect(storage.values.values.single['tombstones'], hasLength(1)); + reopened.dispose(); + store.dispose(); + }); + + test( + 'category rename cascades to transactions and budgets in one snapshot', + () async { + final storage = MemorySnapshotStorage(); + final store = AppStore(storage: storage); + await store.initialize(); + final category = CategoryRecord( + id: 'cat', + name: 'Еда', + icon: 'cart.fill', + colorName: 'orange', + createdAt: date, + updatedAt: date, + ); + await store.saveCategory(category); + await store.saveTransaction(transaction('tx')); + await store.saveBudget( + BudgetRecord( + id: 'budget', + categoryName: 'Еда', + limitMinor: 10000, + monthStart: date, + updatedAt: date, + ), + ); + await store.saveCategory( + category.copyWith(name: 'Продукты', emoji: '🍏'), + ); + expect(store.transactions.single.categoryName, 'Продукты'); + expect(store.budgets.single.categoryName, 'Продукты'); + expect(store.transactions.single.categoryEmoji, '🍏'); + expect(store.pendingChangeCount, 3); + store.dispose(); + }, + ); + + test( + 'pending delete survives newer remote edit and is queued with newer timestamp', + () async { + final storage = MemorySnapshotStorage(); + final remoteTime = DateTime.now().toUtc().add(const Duration(seconds: 2)); + var serveConflict = false; + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + if (request.url.path.endsWith('/login')) { + return http.Response( + jsonEncode({ + 'accessToken': 'access', + 'refreshToken': 'refresh', + 'expiresAt': DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(), + 'user': {'id': 'user', 'email': 'test@example.com'}, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + final conflict = serveConflict && body['pull'] == true; + return http.Response( + jsonEncode({ + 'cursor': conflict ? 1 : body['cursor'], + 'hasMore': false, + 'serverTime': DateTime.now().toUtc().toIso8601String(), + 'changes': conflict + ? [ + { + 'entity': 'transaction', + 'id': 'tx', + 'deleted': false, + 'updatedAt': remoteTime.toIso8601String(), + 'sequence': 1, + 'payload': transaction('tx').payload(), + }, + ] + : [], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + final store = AppStore( + storage: storage, + apiFactory: (server) => SyncApi(serverUrl: server, client: client), + ); + await store.initialize(); + await store.setServerUrl('https://balance.example.com'); + await store.signIn('test@example.com', 'password'); + await store.saveTransaction(transaction('tx')); + await store.deleteTransaction('tx'); + serveConflict = true; + await store.sync(); + expect(store.transactions, isEmpty); + expect(store.pendingChangeCount, 1); + expect(store.cursor, 1); + final saved = storage.values['https://balance.example.com|user']!; + final pending = (saved['dirty'] as List).single as Map; + expect(pending['deleted'], true); + expect( + DateTime.parse(pending['updatedAt'] as String).isAfter(remoteTime), + true, + ); + store.dispose(); + client.close(); + }, + ); + test( + 'expense category deletion removes budgets and preserves history', + () async { + final store = AppStore(storage: MemorySnapshotStorage()); + await store.initialize(); + await store.saveCategory( + CategoryRecord( + id: 'cat', + name: 'Еда', + icon: 'cart.fill', + colorName: 'orange', + createdAt: date, + updatedAt: date, + ), + ); + await store.saveTransaction(transaction('tx')); + await store.saveBudget( + BudgetRecord( + id: 'budget', + categoryName: 'Еда', + limitMinor: 10000, + monthStart: date, + updatedAt: date, + ), + ); + await store.deleteCategory('cat'); + expect(store.categories, isEmpty); + expect(store.budgets, isEmpty); + expect(store.transactions, hasLength(1)); + expect(store.pendingChangeCount, 3); + store.dispose(); + }, + ); + + test( + 'failed pull commit preserves old cursor and dirty local changes', + () async { + final storage = FailingStorage(); + var includeRemote = false; + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + if (request.url.path.endsWith('/login')) { + return http.Response( + jsonEncode({ + 'accessToken': 'a', + 'refreshToken': 'r', + 'expiresAt': DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(), + 'user': {'id': 'u', 'email': 'u@example.com'}, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + final pull = body['pull'] == true && includeRemote; + return http.Response( + jsonEncode({ + 'cursor': pull ? 1 : body['cursor'], + 'hasMore': false, + 'serverTime': DateTime.now().toUtc().toIso8601String(), + 'changes': pull + ? [ + { + 'entity': 'transaction', + 'id': 'remote', + 'deleted': false, + 'updatedAt': DateTime.now().toUtc().toIso8601String(), + 'sequence': 1, + 'payload': transaction('remote').payload(), + }, + ] + : [], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + final store = AppStore( + storage: storage, + apiFactory: (url) => SyncApi(serverUrl: url, client: client), + ); + await store.initialize(); + await store.setServerUrl('https://example.com'); + await store.signIn('u@example.com', 'password'); + await store.saveTransaction(transaction('local')); + includeRemote = true; + storage.failPage = true; + await expectLater(store.sync(), throwsStateError); + expect(store.cursor, 0); + expect(store.transactions.map((e) => e.id), ['local']); + expect(store.pendingChangeCount, 1); + expect(storage.values['https://example.com|u']!['cursor'], 0); + storage.failPage = false; + await store.sync(); + expect(store.cursor, 1); + expect(store.transactions, hasLength(2)); + expect(store.pendingChangeCount, 0); + store.dispose(); + client.close(); + }, + ); + + test( + 'first account imports guest once; accounts and servers stay isolated', + () async { + final storage = MemorySnapshotStorage(); + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + if (request.url.path.endsWith('/login')) { + return http.Response( + jsonEncode({ + 'accessToken': 'a', + 'refreshToken': 'r', + 'expiresAt': DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(), + 'user': {'id': body['email'], 'email': body['email']}, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + } + if (request.url.path.endsWith('/logout')) { + return http.Response('{}', 200); + } + return http.Response( + jsonEncode({ + 'cursor': body['cursor'], + 'changes': [], + 'hasMore': false, + 'serverTime': DateTime.now().toUtc().toIso8601String(), + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + final store = AppStore( + storage: storage, + apiFactory: (url) => SyncApi(serverUrl: url, client: client), + ); + await store.initialize(); + await store.setServerUrl('https://one.example.com'); + await store.saveTransaction(transaction('guest')); + await store.signIn('one@example.com', 'password'); + expect(store.transactions.single.id, 'guest'); + await store.signOut(); + await store.signIn('two@example.com', 'password'); + expect(store.transactions, isEmpty); + await store.saveTransaction(transaction('second')); + await store.setServerUrl('https://two.example.com'); + expect(store.session, isNull); + expect(store.cursor, 0); + expect(store.transactions, isEmpty); + await store.setServerUrl('https://one.example.com'); + await store.signIn('two@example.com', 'password'); + expect(store.transactions.single.id, 'second'); + store.dispose(); + client.close(); + }, + ); + test( + 'two stores reload latest ledger before writing instead of clobbering it', + () async { + final storage = MemorySnapshotStorage(); + final first = AppStore(storage: storage, background: true); + final second = AppStore(storage: storage, background: true); + await first.initialize(); + await second.initialize(); + await first.saveTransaction(transaction('one')); + await second.saveTransaction(transaction('two')); + await first.saveTransaction(transaction('three')); + expect(first.transactions.map((t) => t.id).toSet(), { + 'one', + 'two', + 'three', + }); + await second.sync(); + expect(second.transactions, hasLength(3)); + await first.close(); + await second.close(); + first.dispose(); + second.dispose(); + }, + ); + + test( + 'headless initialization skips automatic sync and reloads another engine logout', + () async { + final storage = MemorySnapshotStorage(); + var syncRequests = 0; + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + if (request.url.path.endsWith('/login')) { + return http.Response( + jsonEncode({ + 'accessToken': 'a', + 'refreshToken': 'r', + 'expiresAt': DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .toIso8601String(), + 'user': {'id': 'u', 'email': 'u@example.com'}, + }), + 200, + ); + } + if (request.url.path.endsWith('/logout')) { + return http.Response('{}', 200); + } + syncRequests++; + return http.Response( + jsonEncode({ + 'cursor': body['cursor'], + 'changes': [], + 'hasMore': false, + 'serverTime': DateTime.now().toUtc().toIso8601String(), + }), + 200, + ); + }); + final first = AppStore( + storage: storage, + background: true, + apiFactory: (url) => SyncApi(serverUrl: url, client: client), + ); + await first.initialize(); + await first.setServerUrl('https://example.com'); + await first.signIn('u@example.com', 'password'); + await first.saveTransaction(transaction('private')); + final countBeforeInitialize = syncRequests; + final second = AppStore( + storage: storage, + background: true, + apiFactory: (url) => SyncApi(serverUrl: url, client: client), + ); + await second.initialize(); + await Future.delayed(Duration.zero); + expect(syncRequests, countBeforeInitialize); + expect(second.transactions.single.id, 'private'); + await first.signOut(); + await second.sync(); + expect(second.session, isNull); + expect(second.transactions, isEmpty); + expect(syncRequests, countBeforeInitialize); + await first.close(); + await second.close(); + first.dispose(); + second.dispose(); + client.close(); + }, + ); + test( + 'second engine reloads rotated refresh session instead of reusing old token', + () async { + const server = 'https://example.com'; + Map sessionData(String suffix, DateTime expiry) => { + 'accessToken': 'access-$suffix', + 'refreshToken': 'refresh-$suffix', + 'expiresAt': expiry.toUtc().toIso8601String(), + 'user': {'id': 'u', 'email': 'u@example.com'}, + }; + SharedPreferences.setMockInitialValues({'serverUrl': server}); + FlutterSecureStorage.setMockInitialValues({ + 'balance.session.${Uri.encodeComponent(server)}': jsonEncode( + sessionData('old', DateTime.now().subtract(const Duration(hours: 1))), + ), + }); + var refreshes = 0; + final authorization = []; + final client = MockClient((request) async { + final body = jsonDecode(request.body) as Map; + if (request.url.path.endsWith('/refresh')) { + refreshes++; + expect(body['refreshToken'], 'refresh-old'); + return http.Response( + jsonEncode( + sessionData('new', DateTime.now().add(const Duration(hours: 1))), + ), + 200, + ); + } + authorization.add(request.headers['authorization']!); + return http.Response( + jsonEncode({ + 'cursor': body['cursor'], + 'changes': [], + 'hasMore': false, + 'serverTime': DateTime.now().toUtc().toIso8601String(), + }), + 200, + ); + }); + final storage = MemorySnapshotStorage(); + final first = AppStore( + storage: storage, + background: true, + apiFactory: (url) => SyncApi(serverUrl: url, client: client), + ); + final second = AppStore( + storage: storage, + background: true, + apiFactory: (url) => SyncApi(serverUrl: url, client: client), + ); + await first.initialize(); + await second.initialize(); + await first.sync(); + await second.sync(); + expect(refreshes, 1); + expect(authorization, ['Bearer access-new', 'Bearer access-new']); + expect(second.session!.refreshToken, 'refresh-new'); + await first.close(); + await second.close(); + first.dispose(); + second.dispose(); + client.close(); + }, + ); + test( + 'invalid server URL preserves the active account and its ledger', + () async { + final url = 'https://good.example'; + SharedPreferences.setMockInitialValues({'serverUrl': url}); + final credentials = { + 'accessToken': 'access', + 'refreshToken': 'refresh', + 'expiresAt': DateTime.now() + .add(const Duration(hours: 1)) + .toUtc() + .toIso8601String(), + 'user': {'id': 'account', 'email': 'a@example.com'}, + }; + FlutterSecureStorage.setMockInitialValues({ + 'balance.session.${Uri.encodeComponent(url)}': jsonEncode(credentials), + }); + final store = AppStore( + storage: MemorySnapshotStorage(), + background: true, + ); + await store.initialize(); + await store.saveTransaction(transaction('preserved')); + for (final invalid in [ + 'http://remote.example', + 'https://name:secret@remote.example', + 'https://remote.example?x=1', + 'https://remote.example#fragment', + '/relative', + ]) { + await expectLater(store.setServerUrl(invalid), throwsArgumentError); + expect(store.serverUrl, url); + expect(store.session!.userId, 'account'); + expect(store.transactions.single.id, 'preserved'); + } + await store.close(); + store.dispose(); + }, + ); +} + +class FailingStorage extends MemorySnapshotStorage { + bool failPage = false; + @override + Future write(String key, Map value) async { + if (failPage && value['cursor'] == 1) throw StateError('Disk full'); + await super.write(key, value); + } +} diff --git a/BalanceFlutter/test/finance_test.dart b/BalanceFlutter/test/finance_test.dart new file mode 100644 index 0000000..16bb118 --- /dev/null +++ b/BalanceFlutter/test/finance_test.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:balance_flutter/domain/finance.dart'; + +void main() { + final january = DateTime.utc(2026, 1); + final february = DateTime.utc(2026, 2); + TransactionRecord record( + String id, + int amount, { + String kind = 'expense', + DateTime? date, + bool adjustment = false, + String category = 'Продукты', + String note = '', + }) => TransactionRecord( + id: id, + amountMinor: amount, + date: date ?? january, + categoryName: category, + updatedAt: january, + kindRawValue: kind, + isBalanceAdjustment: adjustment, + note: note, + ); + + test('money input is exact and rejects invalid precision', () { + expect(parseMoney(' 123,45 '), 12345); + expect(parseMoney('0.1'), 10); + expect(parseMoney('10'), 1000); + expect(formatMinor(-101), '-1.01'); + for (final invalid in ['-1', 'NaN', '1.001', '1e3', '']) { + expect(() => parseMoney(invalid), throwsFormatException); + } + expect(() => moneyToMinor(double.infinity), throwsFormatException); + }); + + test( + 'wire payload preserves native keys, double money and UTC timestamps', + () { + final original = record('test', 12345, note: 'Кофе'); + final payload = original.payload(); + expect(payload['amount'], 123.45); + expect(payload['kindRawValue'], 'expense'); + expect(payload.containsKey('id'), isFalse); + expect(payload['date'], '2026-01-01T00:00:00.000Z'); + final roundTrip = TransactionRecord.fromJson( + jsonDecode(jsonEncode(original.toJson())) as Map, + ); + expect(roundTrip.amountMinor, 12345); + expect(roundTrip.note, 'Кофе'); + expect(roundTrip.updatedAt, january); + expect(roundTrip.copyWith(amountMinor: 10).id, 'test'); + expect(original.amountMinor, 12345); + }, + ); + + test('local JSON keeps exact minor integers independent of wire doubles', () { + const largeMinor = 9007199254740901; + final transaction = record('large', largeMinor); + final restored = TransactionRecord.fromJson( + jsonDecode(jsonEncode(transaction.toJson())) as Map, + ); + expect(restored.amountMinor, largeMinor); + expect(transaction.payload().containsKey('amountMinor'), isFalse); + final budget = BudgetRecord( + id: 'large-budget', + categoryName: 'Дом', + limitMinor: largeMinor, + monthStart: january, + updatedAt: january, + ); + expect( + BudgetRecord.fromJson( + jsonDecode(jsonEncode(budget.toJson())) as Map, + ).limitMinor, + largeMinor, + ); + expect(budget.payload().containsKey('limitMinor'), isFalse); + }); + + test('native fractional RFC3339 with timezone is supported', () { + final payload = record('test', 10).payload() + ..['date'] = '2026-01-01T03:00:00.123456789+03:00'; + final item = TransactionRecord.fromPayload( + id: 'test', + updatedAt: january, + payload: payload, + ); + expect(item.date.toUtc(), DateTime.utc(2026, 1, 1, 0, 0, 0, 123, 456)); + }); + + test( + 'summary uses half-open period and excludes adjustments; balance includes them', + () { + final items = [ + record('a', 10), + record('b', 20), + record('c', 100, kind: 'income'), + record('d', 200, kind: 'income', adjustment: true), + record('e', 1000, date: february), + record( + 'f', + 500, + date: january.subtract(const Duration(microseconds: 1)), + ), + ]; + expect(FinanceMath.totalBalance(items), -1230); + final summary = FinanceMath.summary(items, january, february); + expect(summary.incomeMinor, 100); + expect(summary.expensesMinor, 30); + expect(summary.balanceMinor, 70); + }, + ); + + test('decimal accumulation is exact for repeated hundredths', () { + final items = List.generate(100, (i) => record('$i', 10)); + expect(FinanceMath.summary(items, january, february).expensesMinor, 1000); + expect(FinanceMath.totalBalance(items), -1000); + }); + + test( + 'category spending groups names, orders amounts, excludes adjustments', + () { + final items = [ + record('a', 100, category: 'Дом'), + record('b', 150), + record('c', 100, category: 'Дом'), + record('d', 999, adjustment: true), + record('e', 999, kind: 'income'), + ]; + final totals = FinanceMath.spending(items, january, february); + expect(totals.map((x) => x.name), ['Дом', 'Продукты']); + expect(totals.map((x) => x.amountMinor), [200, 150]); + }, + ); + + test('monthly trend fills empty months and handles year boundary', () { + final trend = FinanceMath.monthlyTrend( + [record('a', 300)], + february, + months: 3, + ); + expect(trend.map((x) => x.month), [ + DateTime.utc(2025, 12), + january, + february, + ]); + expect(trend.map((x) => x.summary.expensesMinor), [0, 300, 0]); + expect( + FinanceMath.nextMonth(DateTime.utc(2024, 12)), + DateTime.utc(2025, 1), + ); + }); + + test('budget uses matching category expenses in its own calendar month', () { + final budget = BudgetRecord( + id: 'budget', + categoryName: 'Продукты', + limitMinor: 1000, + monthStart: DateTime.utc(2026, 1, 12), + updatedAt: january, + ); + final items = [ + record('a', 300), + record('b', 800, category: 'Дом'), + record('c', 999, kind: 'income'), + record('d', 999, date: february), + record('e', 999, adjustment: true), + ]; + expect(FinanceMath.budgetUsed(budget, items), 300); + expect(BudgetRecord.fromJson(budget.toJson()).limitMinor, 1000); + expect(budget.payload()['limit'], 10.0); + }); + + test('wire budget month is interpreted in the local calendar', () { + final localJanuary = DateTime(2026, 1); + final localExpense = DateTime(2026, 1, 15); + final localDecember = DateTime(2025, 12, 15); + final original = BudgetRecord( + id: 'wire-budget', + categoryName: 'Продукты', + limitMinor: 10000, + monthStart: localJanuary, + updatedAt: january, + ); + final imported = BudgetRecord.fromJson(original.toJson()); + expect(imported.monthStart.isUtc, isTrue); + final items = [ + record('jan', 300, date: localExpense.toUtc()), + record('dec', 900, date: localDecember.toUtc()), + ]; + expect(FinanceMath.budgetUsed(imported, items), 300); + }); + + test('category and tombstone round trip with protocol fields', () { + final category = BuiltInCategories.income.first; + expect( + CategoryRecord.fromJson(category.toJson()).kind, + TransactionKind.income, + ); + final tombstone = Tombstone( + entity: 'transaction', + id: 'deleted', + updatedAt: january, + ); + expect(tombstone.toJson()['deleted'], isTrue); + expect(tombstone.toJson().containsKey('payload'), isFalse); + expect(Tombstone.fromJson(tombstone.toJson()).recordID, 'deleted'); + }); + + test('design catalog matches native apps', () { + expect(CategoryDesignOptions.icons.length, 50); + expect(CategoryDesignOptions.colors.length, 24); + expect(CategoryDesignOptions.emojis.length, 30); + expect(BuiltInCategories.expense.length, 9); + expect(BuiltInCategories.income.length, 5); + }); + + test( + 'CSV quotes commas, double quotes, newlines and includes adjustments', + () { + final csv = FinanceMath.exportCsv([ + record('a', 123, note: 'кофе, "чай"\nеда'), + record( + 'b', + 400, + date: january.add(const Duration(days: 1)), + adjustment: true, + ), + ]); + expect(csv, startsWith('Дата,Тип,Категория,Сумма,Заметка\n')); + expect(csv, contains('"кофе, ""чай""\nеда"')); + expect(csv, contains('"1.23"')); + expect(csv.indexOf('Корректировка'), lessThan(csv.indexOf('кофе'))); + expect(FinanceMath.escapeCsv('a\rb'), '"a\rb"'); + }, + ); +} diff --git a/BalanceFlutter/test/runtime_lock_test.dart b/BalanceFlutter/test/runtime_lock_test.dart new file mode 100644 index 0000000..de1b000 --- /dev/null +++ b/BalanceFlutter/test/runtime_lock_test.dart @@ -0,0 +1,48 @@ +import 'package:balance_flutter/services/runtime_lock.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('balance/runtime'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + }); + + test('Android release carries the acquired engine token', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'acquireLock' ? 'owner-token' : null; + }); + final lock = RuntimeLock(); + await lock.acquire(); + await expectLater(lock.acquire(), throwsStateError); + await lock.release(); + expect(calls.map((call) => call.method), ['acquireLock', 'releaseLock']); + expect(calls.last.arguments, {'token': 'owner-token'}); + await expectLater(lock.release(), throwsStateError); + }); + + test('missing native ownership token fails closed', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + messenger.setMockMethodCallHandler(channel, (_) async => null); + await expectLater(RuntimeLock().acquire(), throwsStateError); + }); + + test('non-Android platforms never invoke the native lock channel', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + messenger.setMockMethodCallHandler( + channel, + (_) async => fail('Unexpected native channel call'), + ); + final lock = RuntimeLock(); + await lock.acquire(); + await lock.release(); + }); +} diff --git a/BalanceFlutter/test/screenshots_test.dart b/BalanceFlutter/test/screenshots_test.dart new file mode 100644 index 0000000..2aaa21c --- /dev/null +++ b/BalanceFlutter/test/screenshots_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:balance_flutter/ui/app.dart'; +import 'package:balance_flutter/ui/editors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'widget_test.dart' as fixtures; + +Future capture( + WidgetTester tester, + GlobalKey key, + Size size, + String name, +) async { + await tester.runAsync(() async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + final dir = Directory('/tmp/balance-ui')..createSync(recursive: true); + File( + '${dir.path}/${size.width.toInt()}x${size.height.toInt()}-$name.png', + ).writeAsBytesSync(bytes!.buffer.asUint8List()); + image.dispose(); + }); +} + +void main() { + setUpAll(fixtures.loadReadableFont); + setUp(fixtures.mockStorage); + testWidgets('capture dark phone overview', (tester) async { + final store = (await tester.runAsync(() => fixtures.createStore()))!; + await tester.runAsync(() => store.setTheme('dark')); + final key = GlobalKey(); + const size = Size(390, 844); + await fixtures.boot(tester, store, size, boundaryKey: key); + await capture(tester, key, size, 'overview-dark'); + expect(tester.takeException(), isNull); + }); + for (final size in fixtures.screenSizes) { + testWidgets( + 'capture screens ${size.width.toInt()}x${size.height.toInt()}', + (tester) async { + final store = (await tester.runAsync(() => fixtures.createStore()))!; + final key = GlobalKey(); + await fixtures.boot(tester, store, size, boundaryKey: key); + final names = [ + 'overview', + 'transactions', + 'analytics', + 'budgets', + 'settings', + ]; + for (var page = 0; page < names.length; page++) { + await fixtures.navigate(tester, page); + expect(tester.takeException(), isNull); + await capture(tester, key, size, names[page]); + } + final editors = { + 'transaction-form': TransactionEditor(categories: store.categories), + 'category-form': CategoryEditor(categories: store.categories), + 'budget-form': BudgetEditor( + categories: store.categoriesForKind('expense'), + month: DateTime.now(), + ), + }; + for (final entry in editors.entries) { + editDialog( + tester.element(find.byType(BalanceShell)), + entry.value, + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await capture(tester, key, size, entry.key); + await fixtures.reveal(tester, find.text('Отмена')); + await capture(tester, key, size, '${entry.key}-actions'); + await tester.tap(find.text('Отмена')); + await tester.pumpAndSettle(); + } + }, + ); + } +} diff --git a/BalanceFlutter/test/sync_api_test.dart b/BalanceFlutter/test/sync_api_test.dart new file mode 100644 index 0000000..f625a1e --- /dev/null +++ b/BalanceFlutter/test/sync_api_test.dart @@ -0,0 +1,255 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:balance_flutter/services/sync_api.dart'; + +void main() { + const time = '2026-01-01T00:00:00Z'; + WireMap change(int sequence) => { + 'entity': 'transaction', + 'id': 'id-$sequence', + 'deleted': false, + 'updatedAt': time, + 'payload': {'amount': 1.23}, + 'sequence': sequence, + }; + http.Response response(Object value, {int status = 200}) => http.Response( + jsonEncode(value), + status, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + WireMap page( + int cursor, { + bool more = false, + List changes = const [], + }) => { + 'cursor': cursor, + 'hasMore': more, + 'changes': changes, + 'serverTime': time, + }; + Future pull(SyncApi api, {int cursor = 0}) => api.sync( + accessToken: 'test-token', + cursor: cursor, + deviceId: 'test-device', + changes: [], + ); + + test('auth uses native routes, trims email and preserves password', () async { + final requests = []; + final session = { + 'accessToken': 'access', + 'refreshToken': 'refresh', + 'expiresAt': time, + 'user': {'id': 'user', 'email': 'test@example.com'}, + }; + final api = SyncApi( + serverUrl: 'https://example.com/base/', + client: MockClient((request) async { + requests.add(request); + if (request.url.path.endsWith('/me')) return response(session['user']!); + if (request.url.path.endsWith('/logout')) return http.Response('', 204); + return response(session); + }), + ); + expect( + (await api.register(' test@example.com ', ' password '))['accessToken'], + 'access', + ); + await api.login('test@example.com', ' password '); + await api.refresh('refresh'); + await api.me('access'); + await api.logout('refresh'); + expect(requests.map((r) => r.url.path), [ + '/base/v1/auth/register', + '/base/v1/auth/login', + '/base/v1/auth/refresh', + '/base/v1/me', + '/base/v1/auth/logout', + ]); + expect(jsonDecode(requests.first.body), { + 'email': 'test@example.com', + 'password': ' password ', + }); + expect(requests[3].method, 'GET'); + expect(requests[3].headers['Authorization'], 'Bearer access'); + expect(requests.every((r) => !r.followRedirects), isTrue); + }); + + test( + 'synchronize batches push-only by 100 then applies ordered pull pages', + () async { + final sent = []; + var pullCount = 0; + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient((request) async { + final body = jsonDecode(request.body) as WireMap; + sent.add(body); + if (body['pull'] == false) return response(page(0)); + pullCount++; + return response( + page(pullCount, more: pullCount == 1, changes: [change(pullCount)]), + ); + }), + ); + final applied = []; + final outgoing = List.generate(205, (i) => change(i)..remove('sequence')); + final result = await api.synchronize( + accessToken: () async => 'token', + cursor: 0, + deviceId: 'device', + changes: outgoing, + applyPage: (changes, cursor) async { + expect(changes.single['sequence'], cursor); + applied.add(cursor); + }, + ); + expect(sent.take(3).map((p) => (p['changes'] as List).length), [ + 100, + 100, + 5, + ]); + expect( + sent.take(3).every((p) => p['pull'] == false && p['cursor'] == 0), + isTrue, + ); + expect(sent.skip(3).map((p) => p['cursor']), [0, 1]); + expect(applied, [1, 2]); + expect(result, 2); + }, + ); + + test('failed durable apply stops pagination', () async { + var calls = 0; + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient((_) async { + calls++; + return response(page(1, more: true, changes: [change(1)])); + }), + ); + await expectLater( + api.synchronize( + accessToken: () async => 'token', + cursor: 0, + deviceId: 'device', + changes: [], + applyPage: (_, _) async => throw StateError('disk full'), + ), + throwsStateError, + ); + expect(calls, 1); + }); + + test('invalid response cursors, sequence and shape are rejected', () async { + final invalidPages = [ + page(0, more: true), + page(1), + page(2, changes: [change(1)]), + page(1, changes: [change(1), change(1)]), + {...page(0), 'hasMore': 'yes'}, + {...page(0), 'cursor': 0.5}, + {...page(0), 'serverTime': 'not-a-date'}, + [], + ]; + for (final invalid in invalidPages) { + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient((_) async => response(invalid)), + ); + await expectLater(pull(api), throwsFormatException, reason: '$invalid'); + } + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient((_) async => response(page(0))), + ); + await expectLater(pull(api, cursor: 1), throwsFormatException); + }); + + test( + 'HTTP errors preserve structured status and discard raw proxy bodies', + () async { + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient( + (_) async => response({ + 'error': {'code': 'invalid_token', 'message': 'Expired'}, + }, status: 401), + ), + ); + await expectLater( + pull(api), + throwsA( + isA() + .having((e) => e.statusCode, 'status', 401) + .having((e) => e.code, 'code', 'invalid_token'), + ), + ); + final proxy = SyncApi( + serverUrl: 'https://example.com', + client: MockClient( + (_) async => + http.Response('private proxy diagnostics', 502), + ), + ); + await expectLater( + pull(proxy), + throwsA( + isA().having( + (e) => e.toString().contains('private'), + 'no raw response body', + isFalse, + ), + ), + ); + }, + ); + + test('malformed sessions and unsafe server addresses are rejected', () async { + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient((_) async => response({'accessToken': 'token'})), + ); + await expectLater(api.login('a@b.com', 'password'), throwsFormatException); + for (final url in [ + 'http://example.com', + 'https://user:pass@example.com', + 'https://example.com?token=x', + 'file:///tmp/test', + ]) { + expect(() => SyncApi(serverUrl: url), throwsArgumentError); + } + expect( + SyncApi( + serverUrl: 'http://127.0.0.1:8080', + allowInsecureHttp: true, + ).baseUri.scheme, + 'http', + ); + }); + + test('invalid outgoing changes fail before any HTTP call', () async { + var calls = 0; + final api = SyncApi( + serverUrl: 'https://example.com', + client: MockClient((_) async { + calls++; + return response(page(0)); + }), + ); + final invalid = change(1)..['entity'] = 'unknown'; + await expectLater( + api.sync( + accessToken: 'token', + cursor: 0, + deviceId: 'device', + changes: [invalid], + ), + throwsFormatException, + ); + expect(calls, 0); + }); +} diff --git a/BalanceFlutter/test/widget_test.dart b/BalanceFlutter/test/widget_test.dart new file mode 100644 index 0000000..526e097 --- /dev/null +++ b/BalanceFlutter/test/widget_test.dart @@ -0,0 +1,502 @@ +import 'dart:io'; +import 'package:balance_flutter/domain/finance.dart'; +import 'package:balance_flutter/services/app_store.dart'; +import 'package:balance_flutter/services/storage.dart'; +import 'package:balance_flutter/ui/app.dart'; +import 'package:balance_flutter/ui/editors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const screenSizes = [Size(390, 844), Size(1280, 900), Size(320, 568)]; +const pageIcons = [ + Icons.space_dashboard_outlined, + Icons.receipt_long_outlined, + Icons.insights_outlined, + Icons.savings_outlined, + Icons.settings_outlined, +]; + +void mockStorage() { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); +} + +Future loadReadableFont() async { + // Use the exact offline app fonts; Ahem changes Cyrillic button widths. + final loader = FontLoader('BalanceRoboto'); + for (final weight in ['Regular', 'Medium', 'Bold']) { + loader.addFont(rootBundle.load('assets/fonts/Roboto-$weight.ttf')); + } + await loader.load(); + await (FontLoader( + 'MaterialIcons', + )..addFont(rootBundle.load('fonts/MaterialIcons-Regular.otf'))).load(); +} + +Future createStore({bool seeded = true}) async { + final store = AppStore(storage: MemorySnapshotStorage()); + await store.initialize(); + if (seeded) { + final now = DateTime.now(); + await store.saveCategory( + CategoryRecord( + id: 'custom', + name: 'Дом и повседневные покупки', + icon: 'house.fill', + colorName: 'teal', + createdAt: now, + updatedAt: now, + ), + ); + for (var i = 0; i < 6; i++) { + await store.saveTransaction( + TransactionRecord( + id: 'tx-$i', + amountMinor: i == 0 ? 25000000 : 12345 + i * 1000, + kindRawValue: i == 0 ? 'income' : 'expense', + date: now, + categoryName: i == 0 ? 'Зарплата' : 'Дом и повседневные покупки', + categoryIcon: i == 0 ? 'banknote.fill' : 'house.fill', + categoryColorName: 'teal', + note: 'Проверка длинного описания операции', + updatedAt: now, + ), + ); + } + await store.saveBudget( + BudgetRecord( + id: 'budget', + categoryName: 'Дом и повседневные покупки', + categoryIcon: 'house.fill', + limitMinor: 500000, + monthStart: DateTime(now.year, now.month), + updatedAt: now, + ), + ); + } + return store; +} + +Future boot( + WidgetTester tester, + AppStore store, + Size size, { + Key? boundaryKey, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(store.dispose); + await tester.pumpWidget( + boundaryKey == null + ? BalanceApp(store: store) + : RepaintBoundary( + key: boundaryKey, + child: BalanceApp(store: store), + ), + ); + await tester.pumpAndSettle(); +} + +Future navigate(WidgetTester tester, int page) async { + final navigation = find.byType(NavigationBar).evaluate().isNotEmpty + ? find.byType(NavigationBar) + : find.byType(NavigationRail); + await tester.tap( + find + .descendant(of: navigation, matching: find.byIcon(pageIcons[page])) + .first, + ); + await tester.pumpAndSettle(); +} + +Future reveal(WidgetTester tester, Finder finder) async { + await Scrollable.ensureVisible(tester.element(finder), alignment: 0.5); + await tester.pumpAndSettle(); +} + +Finder field(String label) => + find.ancestor(of: find.text(label), matching: find.byType(TextFormField)); + +Future saveDialog(WidgetTester tester) async { + tester.testTextInput.hide(); + await tester.pumpAndSettle(); + final save = find.widgetWithText(FilledButton, 'Сохранить'); + await reveal(tester, save); + await tester.tap(save); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 10)); + }); + await tester.pumpAndSettle(); +} + +void main() { + setUpAll(loadReadableFont); + setUp(mockStorage); + testWidgets('navigation and scrolling with large text on a small phone', ( + tester, + ) async { + tester.platformDispatcher.textScaleFactorTestValue = 1.5; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + final store = (await tester.runAsync(() => createStore()))!; + await boot(tester, store, const Size(320, 568)); + for (var page = 0; page < 5; page++) { + await navigate(tester, page); + expect(tester.takeException(), isNull, reason: 'large text page=$page'); + for (var step = 0; step < 6; step++) { + await tester.drag(find.byType(ListView).first, const Offset(0, -300)); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'large text page=$page scroll=$step', + ); + } + } + }); + + testWidgets('negative balance adjustment preserves analytics', ( + tester, + ) async { + final store = (await tester.runAsync(() => createStore()))!; + final now = DateTime.now(); + final start = DateTime(now.year, now.month); + final end = DateTime(now.year, now.month + 1); + final before = FinanceMath.summary(store.transactions, start, end); + final spending = FinanceMath.spending( + store.transactions, + start, + end, + ).map((e) => e.amountMinor).toList(); + await boot(tester, store, const Size(390, 844)); + await tester.tap(find.widgetWithText(TextButton, 'Изменить')); + await tester.pumpAndSettle(); + await tester.enterText(field('Фактический баланс, RUB'), '-150,25'); + await saveDialog(tester); + await tester.pump(const Duration(milliseconds: 350)); + expect(FinanceMath.totalBalance(store.transactions), -15025); + expect( + store.transactions.where((t) => t.isBalanceAdjustment), + hasLength(1), + ); + final after = FinanceMath.summary(store.transactions, start, end); + expect(after.incomeMinor, before.incomeMinor); + expect(after.expensesMinor, before.expensesMinor); + expect( + FinanceMath.spending( + store.transactions, + start, + end, + ).map((e) => e.amountMinor).toList(), + spending, + ); + expect(tester.takeException(), isNull); + }); + + for (final desktop in [true, false]) { + testWidgets( + 'CSV export reaches ${desktop ? 'save selector' : 'share sheet'}', + (tester) async { + debugDefaultTargetPlatformOverride = desktop + ? TargetPlatform.linux + : TargetPlatform.android; + try { + final store = (await tester.runAsync( + () => createStore(seeded: false), + ))!; + await tester.runAsync( + () => store.saveTransaction( + TransactionRecord( + id: 'csv', + amountMinor: 12345, + date: DateTime(2026, 10, 28), + categoryName: 'Покупки, дом', + note: 'Текст "в кавычках"\nНовая строка', + updatedAt: DateTime.now(), + ), + ), + ); + final expected = FinanceMath.exportCsv(store.transactions); + final directory = Directory('/tmp/balance-export') + ..createSync(recursive: true); + final destination = File('${directory.path}/desktop.csv'); + if (destination.existsSync()) destination.deleteSync(); + Map? selectorArguments; + String? sharedCsv; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/file_selector'), + (call) async { + expect(call.method, 'getSavePath'); + selectorArguments = call.arguments as Map; + return destination.path; + }, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (_) async => directory.path, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/share'), + (call) async { + expect(call.method, 'share'); + final args = call.arguments as Map; + expect(args['mimeTypes'], ['text/csv']); + final path = (args['paths'] as List).single as String; + expect(path.endsWith('balance-transactions.csv'), isTrue); + sharedCsv = await File(path).readAsString(); + return 'test-share-target'; + }, + ); + await boot(tester, store, const Size(390, 844)); + await navigate(tester, 4); + final export = find.text('Экспорт истории CSV'); + await tester.scrollUntilVisible( + export, + 300, + scrollable: find.byType(Scrollable).first, + ); + await reveal(tester, export); + await tester.tap(export); + await tester.pumpAndSettle(); + // Drain real filesystem work and test-zone channel continuations. + for (var i = 0; i < 50; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 10)); + if (desktop + ? destination.existsSync() && + destination.readAsStringSync() == expected + : sharedCsv != null) { + break; + } + } + await tester.pumpAndSettle(); + if (desktop) { + expect( + selectorArguments?['suggestedName'], + 'balance-transactions.csv', + ); + expect(destination.readAsStringSync(), expected); + } else { + expect(sharedCsv, expected); + } + expect(expected, contains('"Текст ""в кавычках""\nНовая строка"')); + expect(tester.takeException(), isNull); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + } + for (final size in screenSizes) { + testWidgets('account and server form validation at $size', (tester) async { + final store = (await tester.runAsync(() => createStore(seeded: false)))!; + await tester.runAsync(() => store.setServerUrl('https://example.com')); + await boot(tester, store, size); + await navigate(tester, 4); + for (final action in ['Войти', 'Регистрация']) { + final button = find.widgetWithText( + action == 'Войти' ? FilledButton : TextButton, + action, + ); + await tester.scrollUntilVisible( + button, + 200, + scrollable: find.byType(Scrollable).first, + ); + await reveal(tester, button); + await tester.tap(button); + await tester.pumpAndSettle(); + final submit = find + .widgetWithText( + FilledButton, + action == 'Войти' ? 'Войти' : 'Зарегистрироваться', + ) + .last; + await reveal(tester, submit); + await tester.tap(submit); + await tester.pumpAndSettle(); + expect(find.text('Введите email'), findsOneWidget); + expect(store.session, isNull); + expect(tester.takeException(), isNull); + await reveal(tester, find.text('Отмена')); + await tester.tap(find.text('Отмена')); + await tester.pumpAndSettle(); + } + final server = find.widgetWithText(OutlinedButton, 'Адрес сервера'); + await reveal(tester, server); + await tester.tap(server); + await tester.pumpAndSettle(); + await tester.enterText(field('https://balance.example.com'), 'invalid'); + await saveDialog(tester); + expect(find.text('Введите полный адрес сервера'), findsOneWidget); + expect(store.serverUrl, 'https://example.com'); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Отмена')); + await tester.pumpAndSettle(); + }); + for (final seeded in [false, true]) { + testWidgets('all pages scroll without overflow $size seeded=$seeded', ( + tester, + ) async { + final store = (await tester.runAsync( + () => createStore(seeded: seeded), + ))!; + await boot(tester, store, size); + for (var page = 0; page < 5; page++) { + await navigate(tester, page); + expect(tester.takeException(), isNull, reason: 'page=$page at $size'); + // Exercise lazy children and the lower settings/categories sections. + for (var step = 0; step < 8; step++) { + await tester.drag( + find.byType(ListView).first, + const Offset(0, -350), + ); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'page=$page scroll=$step at $size', + ); + } + } + }); + } + testWidgets('all editors are usable at $size', (tester) async { + final store = (await tester.runAsync(() => createStore(seeded: false)))!; + await boot(tester, store, size); + final context = tester.element(find.byType(BalanceShell)); + final widgets = [ + const TransactionEditor(categories: []), + const CategoryEditor(categories: []), + BudgetEditor( + categories: BuiltInCategories.expense, + month: DateTime.now(), + ), + ]; + for (final editor in widgets) { + editDialog(context, editor); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await saveDialog(tester); + expect( + find.byType(Form), + findsOneWidget, + ); // Invalid empty form stays open. + expect(tester.takeException(), isNull); + await reveal(tester, find.text('Отмена')); + await tester.tap(find.text('Отмена')); + await tester.pumpAndSettle(); + } + }); + } + + testWidgets('transaction add, edit and delete through the interface', ( + tester, + ) async { + final store = (await tester.runAsync(() => createStore(seeded: false)))!; + await boot(tester, store, screenSizes.first); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + await tester.enterText(field('Сумма'), '123,45'); + await tester.enterText(field('Заметка'), 'Покупка для теста'); + await saveDialog(tester); + expect(store.transactions.single.amountMinor, 12345); + expect(store.transactions.single.note, 'Покупка для теста'); + final id = store.transactions.single.id; + await navigate(tester, 1); + await reveal(tester, find.textContaining('Покупка для теста')); + await tester.tap(find.textContaining('Покупка для теста')); + await tester.pumpAndSettle(); + await tester.enterText(field('Сумма'), '250.50'); + await saveDialog(tester); + expect(store.transactions.single.id, id); + expect(store.transactions.single.amountMinor, 25050); + await reveal(tester, find.byTooltip('Действия с операцией')); + await tester.tap(find.byTooltip('Действия с операцией')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Удалить')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Удалить')); + await tester.pumpAndSettle(); + expect(store.transactions, isEmpty); + expect(tester.takeException(), isNull); + }); + + testWidgets('custom category and monthly budget CRUD through the interface', ( + tester, + ) async { + final store = (await tester.runAsync(() => createStore(seeded: false)))!; + await boot(tester, store, screenSizes.first); + await navigate(tester, 4); + await tester.scrollUntilVisible( + find.widgetWithText(FilledButton, 'Добавить'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await reveal(tester, find.widgetWithText(FilledButton, 'Добавить')); + await tester.tap(find.widgetWithText(FilledButton, 'Добавить')); + await tester.pumpAndSettle(); + await tester.enterText(field('Название'), 'Мой сад'); + await saveDialog(tester); + expect(store.categories.single.name, 'Мой сад'); + final id = store.categories.single.id; + await reveal(tester, find.text('Мой сад')); + await tester.tap(find.text('Мой сад')); + await tester.pumpAndSettle(); + await tester.enterText(field('Название'), 'Мой дом'); + await saveDialog(tester); + expect(store.categories.single.id, id); + expect(store.categories.single.name, 'Мой дом'); + await navigate(tester, 3); + await tester.tap(find.text('Добавить лимит')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Мой дом').last, + 180, + scrollable: find.byType(Scrollable).last, + ); + await tester.tap(find.text('Мой дом').last); + await tester.pumpAndSettle(); + await tester.enterText(field('Месячный лимит'), '5000'); + await saveDialog(tester); + expect(store.budgets.single.categoryName, 'Мой дом'); + expect(store.budgets.single.limitMinor, 500000); + await reveal(tester, find.byTooltip('Изменить лимит')); + await tester.tap(find.byTooltip('Изменить лимит')); + await tester.pumpAndSettle(); + await tester.enterText(field('Месячный лимит'), '7500'); + await saveDialog(tester); + expect(store.budgets.single.limitMinor, 750000); + await reveal(tester, find.byTooltip('Удалить лимит')); + await tester.tap(find.byTooltip('Удалить лимит')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Удалить')); + await tester.pumpAndSettle(); + expect(store.budgets, isEmpty); + await navigate(tester, 4); + await tester.scrollUntilVisible( + find.byTooltip('Удалить категорию'), + 250, + scrollable: find.byType(Scrollable).first, + ); + await reveal(tester, find.byTooltip('Удалить категорию')); + await tester.tap(find.byTooltip('Удалить категорию')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Удалить')); + await tester.pumpAndSettle(); + expect(store.categories, isEmpty); + expect(tester.takeException(), isNull); + }); +} diff --git a/BalanceFlutter/tool/integration_sync.dart b/BalanceFlutter/tool/integration_sync.dart new file mode 100644 index 0000000..dd6c4dd --- /dev/null +++ b/BalanceFlutter/tool/integration_sync.dart @@ -0,0 +1,272 @@ +// Run against a disposable BalanceServer instance: +// dart run tool/integration_sync.dart http://127.0.0.1:18080 +// Creates two test accounts; never use a production database. +import 'dart:convert'; +import 'dart:io'; + +import 'package:balance_flutter/domain/finance.dart'; +import 'package:balance_flutter/services/sync_api.dart'; + +void check(bool condition, String message) { + if (!condition) throw StateError(message); +} + +Future unauthorized( + Future Function() action, + String message, +) async { + try { + await action(); + } on SyncApiException catch (error) { + check(error.statusCode == 401, '$message: expected HTTP 401'); + return; + } + throw StateError('$message: unexpectedly accepted'); +} + +Map wire( + String entity, + String id, + DateTime updatedAt, + Map? payload, +) => { + 'entity': entity, + 'id': id, + 'updatedAt': updatedAt.toUtc().toIso8601String(), + 'deleted': payload == null, + 'payload': ?payload, +}; + +Future main(List arguments) async { + final url = arguments.isEmpty ? 'http://127.0.0.1:18080' : arguments.single; + final uri = Uri.parse(url); + check( + ['localhost', '127.0.0.1', '::1'].contains(uri.host), + 'Integration accounts must use a local disposable server', + ); + final primary = SyncApi(serverUrl: url, allowInsecureHttp: true); + final secondDevice = SyncApi(serverUrl: url, allowInsecureHttp: true); + final outsider = SyncApi(serverUrl: url, allowInsecureHttp: true); + final suffix = DateTime.now().microsecondsSinceEpoch; + final email = 'flutter-integration-$suffix@example.com'; + const password = 'Balance integration password 2026!'; + final ledger = >{}; + final pages = []; + var cursor = 0; + try { + final registered = await primary.register(email, password); + final user = await primary.me(registered['accessToken'] as String); + check(user['email'] == email, 'register/me account mismatch'); + var session = await secondDevice.login(email, password); + check( + (session['user'] as Map)['id'] == user['id'], + 'login account mismatch', + ); + final other = await outsider.register( + 'other-$suffix@example.com', + password, + ); + final timestamp = DateTime.now().toUtc().subtract( + const Duration(seconds: 5), + ); + final transactions = List.generate( + 1100, + (index) => TransactionRecord( + id: 'transaction-$index', + amountMinor: 10000 + index, + date: timestamp, + categoryName: 'Тестовая категория 🍏', + updatedAt: timestamp, + note: 'UTF-8 roundtrip $index', + kindRawValue: index.isEven ? 'expense' : 'income', + ), + ); + final category = CategoryRecord( + id: 'integration-category', + name: 'Тестовая категория 🍏', + icon: 'cart.fill', + colorName: 'green', + emoji: '🍏', + createdAt: timestamp, + updatedAt: timestamp, + ); + final budget = BudgetRecord( + id: 'integration-budget', + categoryName: category.name, + limitMinor: 999999, + monthStart: DateTime.utc(timestamp.year, timestamp.month), + updatedAt: timestamp, + ); + final changes = [ + for (final record in transactions) + wire('transaction', record.id, record.updatedAt, record.payload()), + wire('category', category.id, category.updatedAt, category.payload()), + wire('budget', budget.id, budget.updatedAt, budget.payload()), + ]; + await primary.synchronize( + accessToken: () async => registered['accessToken'] as String, + cursor: 0, + deviceId: 'integration-device-one', + changes: changes, + applyPage: (_, _) async {}, + ); + cursor = await secondDevice.synchronize( + accessToken: () async => session['accessToken'] as String, + cursor: 0, + deviceId: 'integration-device-two', + changes: [], + applyPage: (page, nextCursor) async { + check(nextCursor > cursor, 'Non-advancing pagination cursor'); + pages.add(page.length); + for (final change in page) { + ledger['${change['entity']}:${change['id']}'] = change; + } + cursor = nextCursor; + }, + ); + check( + jsonEncode(pages) == '[500,500,102]', + 'Expected 3 pages [500,500,102], got $pages', + ); + check(ledger.length == 1102, 'Missing records after pagination'); + final roundtrip = ledger['transaction:transaction-99']!; + final decoded = TransactionRecord.fromPayload( + id: roundtrip['id'] as String, + updatedAt: DateTime.parse(roundtrip['updatedAt'] as String), + payload: Map.from(roundtrip['payload'] as Map), + ); + check( + decoded.amountMinor == 10099 && decoded.categoryName == category.name, + 'Money or UTF-8 payload changed', + ); + final changedAt = DateTime.now().toUtc(); + final edited = transactions.first.copyWith( + amountMinor: 1234567, + updatedAt: changedAt, + ); + final updates = [ + wire('transaction', edited.id, edited.updatedAt, edited.payload()), + wire('transaction', transactions[1].id, changedAt, null), + ]; + await primary.synchronize( + accessToken: () async => registered['accessToken'] as String, + cursor: cursor, + deviceId: 'integration-device-one', + changes: updates, + applyPage: (_, _) async {}, + ); + var updateCount = 0; + cursor = await secondDevice.synchronize( + accessToken: () async => session['accessToken'] as String, + cursor: cursor, + deviceId: 'integration-device-two', + changes: [], + applyPage: (page, nextCursor) async { + updateCount += page.length; + for (final change in page) { + ledger['${change['entity']}:${change['id']}'] = change; + } + cursor = nextCursor; + }, + ); + check(updateCount == 2, 'Expected update and tombstone'); + check( + ledger['transaction:transaction-1']!['deleted'] == true, + 'Deletion was not propagated', + ); + final updatedPayload = + ledger['transaction:transaction-0']!['payload'] as Map; + check( + moneyToMinor(updatedPayload['amount'] as num) == 1234567, + 'Update was not propagated', + ); + final oldCursor = cursor; + // Replaying stale active rows must not overwrite the newer value/tombstone. + await primary.synchronize( + accessToken: () async => registered['accessToken'] as String, + cursor: cursor, + deviceId: 'integration-device-one', + changes: changes.take(2).toList(), + applyPage: (page, nextCursor) async { + check( + page.isEmpty && nextCursor == oldCursor, + 'Stale replay changed LWW state', + ); + }, + ); + // The second account shares record IDs but must see no first-account rows. + final isolated = await outsider.sync( + accessToken: other['accessToken'] as String, + cursor: 0, + deviceId: 'integration-outsider', + changes: [], + ); + check( + (isolated['changes'] as List).isEmpty && isolated['cursor'] == 0, + 'Cross-account data leak', + ); + await outsider.sync( + accessToken: other['accessToken'] as String, + cursor: 0, + deviceId: 'integration-outsider', + pull: false, + changes: [ + wire( + 'transaction', + transactions.first.id, + changedAt, + transactions.first.copyWith(amountMinor: 1).payload(), + ), + ], + ); + final unchanged = await primary.sync( + accessToken: registered['accessToken'] as String, + cursor: cursor, + deviceId: 'integration-device-one', + changes: [], + ); + check( + (unchanged['changes'] as List).isEmpty, + 'Other-account edit affected first account', + ); + final oldRefresh = session['refreshToken'] as String; + session = await secondDevice.refresh(oldRefresh); + check( + session['refreshToken'] != oldRefresh, + 'Refresh token was not rotated', + ); + await unauthorized( + () => secondDevice.refresh(oldRefresh), + 'Rotated refresh token', + ); + check( + (await secondDevice.me(session['accessToken'] as String))['id'] == + user['id'], + 'Refreshed token invalid', + ); + await secondDevice.logout(session['refreshToken'] as String); + await unauthorized( + () => secondDevice.refresh(session['refreshToken'] as String), + 'Logged-out refresh token', + ); + await primary.logout(registered['refreshToken'] as String); + await outsider.logout(other['refreshToken'] as String); + stdout.writeln( + jsonEncode({ + 'result': 'PASS', + 'server': url, + 'records': 1102, + 'pullPages': pages, + 'updateAndDelete': true, + 'staleReplayLww': true, + 'twoAccountIsolation': true, + 'refreshRotation': true, + 'logoutRevocation': true, + }), + ); + } finally { + primary.close(); + secondDevice.close(); + outsider.close(); + } +} diff --git a/BalanceFlutter/tool/run_integration.sh b/BalanceFlutter/tool/run_integration.sh new file mode 100755 index 0000000..cf9eba2 --- /dev/null +++ b/BalanceFlutter/tool/run_integration.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Disposable real-server integration run. No original server files are changed. +set -euo pipefail +project_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +server_source="${project_dir}/../BalanceServer" +GO_BIN="${GO_BIN:-$(command -v go || true)}" +DART_BIN="${DART_BIN:-$(command -v dart || true)}" +if [[ -z "$GO_BIN" && -x /tmp/go/bin/go ]]; then GO_BIN=/tmp/go/bin/go; fi +if [[ -z "$DART_BIN" && -x /tmp/flutter/bin/dart ]]; then DART_BIN=/tmp/flutter/bin/dart; fi +[[ -n "$GO_BIN" && -x "$GO_BIN" ]] || { echo 'Go 1.25+ required (set GO_BIN).' >&2; exit 1; } +[[ -n "$DART_BIN" && -x "$DART_BIN" ]] || { echo 'Dart required (set DART_BIN).' >&2; exit 1; } +go_version="$("$GO_BIN" version)" +if [[ ! "$go_version" =~ go([0-9]+)\.([0-9]+) ]] || (( BASH_REMATCH[1] < 1 || (BASH_REMATCH[1] == 1 && BASH_REMATCH[2] < 25) )); then + echo "Go 1.25+ required; found $go_version" >&2; exit 1 +fi +scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/balance-integration.XXXXXX")" +server_pid='' +cleanup() { + if [[ -n "$server_pid" ]]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi + rm -rf -- "$scratch_dir" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +cp -R -- "$server_source" "$scratch_dir/server" +mkdir -p "$scratch_dir/server/cmd/flutter-integration" +cat > "$scratch_dir/server/cmd/flutter-integration/main.go" <<'GO' +package main + +import ( + "balance-server/internal/config" + "balance-server/internal/database" + "balance-server/internal/httpapi" + "log" + "log/slog" + "net" + "net/http" + "os" + "time" +) +func main() { + cfg, err := config.Load(); if err != nil { log.Fatal(err) } + db, err := database.Open(cfg.DatabasePath); if err != nil { log.Fatal(err) }; defer db.Close() + listener, err := net.Listen("tcp", "127.0.0.1:0"); if err != nil { log.Fatal(err) } + if err = os.WriteFile(os.Getenv("BALANCE_READY_FILE"), []byte("http://"+listener.Addr().String()), 0600); err != nil { log.Fatal(err) } + server := &http.Server{Handler: httpapi.New(cfg, db, slog.Default()), ReadHeaderTimeout: 5*time.Second} + log.Fatal(server.Serve(listener)) +} +GO +(cd "$scratch_dir/server" && "$GO_BIN" build -o "$scratch_dir/server-bin" ./cmd/flutter-integration) +BALANCE_DB_PATH="$scratch_dir/balance.db" \ +BALANCE_JWT_SECRET='integration-only-disposable-secret-2026-balance' \ +BALANCE_ALLOW_REGISTRATION=true BALANCE_READY_FILE="$scratch_dir/ready" \ + "$scratch_dir/server-bin" >"$scratch_dir/server.log" 2>&1 & +server_pid=$! +for ((attempt=0; attempt<100; attempt++)); do + [[ -s "$scratch_dir/ready" ]] && break + if ! kill -0 "$server_pid" 2>/dev/null; then cat "$scratch_dir/server.log" >&2; exit 1; fi + sleep 0.1 +done +[[ -s "$scratch_dir/ready" ]] || { cat "$scratch_dir/server.log" >&2; echo 'Server startup timed out.' >&2; exit 1; } +server_url="$(cat "$scratch_dir/ready")" +cd "$project_dir" +"$DART_BIN" run tool/integration_sync.dart "$server_url" diff --git a/BalanceFlutter/tool/update_icons.py b/BalanceFlutter/tool/update_icons.py new file mode 100644 index 0000000..a17996b --- /dev/null +++ b/BalanceFlutter/tool/update_icons.py @@ -0,0 +1,23 @@ +"""Resize the original Balance icon into platform assets (requires Pillow).""" +import json +from pathlib import Path +from PIL import Image +root = Path(__file__).resolve().parents[1] +icon = Image.open(root / 'assets/app_icon.png').convert('RGB') +def save(path, size): + icon.resize((size,size), Image.Resampling.LANCZOS).save(path) +for target in ['ios','macos']: + folder = root / target / 'Runner/Assets.xcassets/AppIcon.appiconset' + for asset in json.loads((folder/'Contents.json').read_text())['images']: + if 'filename' in asset: + size = round(float(asset['size'].split('x')[0]) * float(asset['scale'].rstrip('x'))) + save(folder/asset['filename'],size) +for density,size in [('mdpi',48),('hdpi',72),('xhdpi',96),('xxhdpi',144),('xxxhdpi',192)]: + save(root/f'android/app/src/main/res/mipmap-{density}/ic_launcher.png',size) +for name,size in [('Icon-192',192),('Icon-512',512),('Icon-maskable-192',192),('Icon-maskable-512',512)]: + save(root/f'web/icons/{name}.png',size) +save(root/'web/favicon.png',32) +icon.save(root/'windows/runner/resources/app_icon.ico',sizes=[(16,16),(32,32),(48,48),(64,64),(128,128),(256,256)]) +manifest=root/'web/manifest.json' +data=json.loads(manifest.read_text());data.update(background_color='#f4faf6',theme_color='#356859',orientation='any') +manifest.write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n') diff --git a/BalanceFlutter/web/favicon.png b/BalanceFlutter/web/favicon.png new file mode 100644 index 0000000..86a3f53 Binary files /dev/null and b/BalanceFlutter/web/favicon.png differ diff --git a/BalanceFlutter/web/icons/Icon-192.png b/BalanceFlutter/web/icons/Icon-192.png new file mode 100644 index 0000000..164eb48 Binary files /dev/null and b/BalanceFlutter/web/icons/Icon-192.png differ diff --git a/BalanceFlutter/web/icons/Icon-512.png b/BalanceFlutter/web/icons/Icon-512.png new file mode 100644 index 0000000..2636d62 Binary files /dev/null and b/BalanceFlutter/web/icons/Icon-512.png differ diff --git a/BalanceFlutter/web/icons/Icon-maskable-192.png b/BalanceFlutter/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..164eb48 Binary files /dev/null and b/BalanceFlutter/web/icons/Icon-maskable-192.png differ diff --git a/BalanceFlutter/web/icons/Icon-maskable-512.png b/BalanceFlutter/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..2636d62 Binary files /dev/null and b/BalanceFlutter/web/icons/Icon-maskable-512.png differ diff --git a/BalanceFlutter/web/index.html b/BalanceFlutter/web/index.html new file mode 100644 index 0000000..91eac4b --- /dev/null +++ b/BalanceFlutter/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + Balance + + + + + + + diff --git a/BalanceFlutter/web/manifest.json b/BalanceFlutter/web/manifest.json new file mode 100644 index 0000000..f806a2e --- /dev/null +++ b/BalanceFlutter/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Balance", + "short_name": "Balance", + "start_url": ".", + "display": "standalone", + "background_color": "#f4faf6", + "theme_color": "#356859", + "description": "Local-first personal finance with BalanceServer sync.", + "orientation": "any", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/BalanceFlutter/windows/.gitignore b/BalanceFlutter/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/BalanceFlutter/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/BalanceFlutter/windows/CMakeLists.txt b/BalanceFlutter/windows/CMakeLists.txt new file mode 100644 index 0000000..9854be6 --- /dev/null +++ b/BalanceFlutter/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(balance LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "balance") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/BalanceFlutter/windows/flutter/CMakeLists.txt b/BalanceFlutter/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/BalanceFlutter/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/BalanceFlutter/windows/flutter/generated_plugin_registrant.cc b/BalanceFlutter/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..23bcdc8 --- /dev/null +++ b/BalanceFlutter/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/BalanceFlutter/windows/flutter/generated_plugin_registrant.h b/BalanceFlutter/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/BalanceFlutter/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/BalanceFlutter/windows/flutter/generated_plugins.cmake b/BalanceFlutter/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..bb6d400 --- /dev/null +++ b/BalanceFlutter/windows/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + flutter_secure_storage_windows + share_plus + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/BalanceFlutter/windows/runner/CMakeLists.txt b/BalanceFlutter/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/BalanceFlutter/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/BalanceFlutter/windows/runner/Runner.rc b/BalanceFlutter/windows/runner/Runner.rc new file mode 100644 index 0000000..e068568 --- /dev/null +++ b/BalanceFlutter/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.balance" "\0" + VALUE "FileDescription", "balance" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "balance" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.balance. All rights reserved." "\0" + VALUE "OriginalFilename", "balance.exe" "\0" + VALUE "ProductName", "balance" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/BalanceFlutter/windows/runner/flutter_window.cpp b/BalanceFlutter/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/BalanceFlutter/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/BalanceFlutter/windows/runner/flutter_window.h b/BalanceFlutter/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/BalanceFlutter/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/BalanceFlutter/windows/runner/main.cpp b/BalanceFlutter/windows/runner/main.cpp new file mode 100644 index 0000000..732eeb8 --- /dev/null +++ b/BalanceFlutter/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"balance", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/BalanceFlutter/windows/runner/resource.h b/BalanceFlutter/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/BalanceFlutter/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/BalanceFlutter/windows/runner/resources/app_icon.ico b/BalanceFlutter/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..fc6c2cb Binary files /dev/null and b/BalanceFlutter/windows/runner/resources/app_icon.ico differ diff --git a/BalanceFlutter/windows/runner/runner.exe.manifest b/BalanceFlutter/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/BalanceFlutter/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/BalanceFlutter/windows/runner/utils.cpp b/BalanceFlutter/windows/runner/utils.cpp new file mode 100644 index 0000000..3cb7146 --- /dev/null +++ b/BalanceFlutter/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/BalanceFlutter/windows/runner/utils.h b/BalanceFlutter/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/BalanceFlutter/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/BalanceFlutter/windows/runner/win32_window.cpp b/BalanceFlutter/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/BalanceFlutter/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/BalanceFlutter/windows/runner/win32_window.h b/BalanceFlutter/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/BalanceFlutter/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/BalanceServer/.gitignore b/BalanceServer/.gitignore index e048d13..161f7a8 100644 --- a/BalanceServer/.gitignore +++ b/BalanceServer/.gitignore @@ -4,5 +4,5 @@ dist/ *.db *.db-shm *.db-wal -# balance-server +balance-server balance-server.exe diff --git a/BalanceServer/README.md b/BalanceServer/README.md index 271e0c7..b8282d9 100644 --- a/BalanceServer/README.md +++ b/BalanceServer/README.md @@ -110,17 +110,3 @@ The sync endpoint accepts transactions, custom categories and monthly budgets. T - Back up the SQLite database and its `-wal`/`-shm` files together, or stop the server before copying the database. - Changing `BALANCE_JWT_SECRET` invalidates access tokens. Existing refresh sessions remain valid and will receive new access tokens after refresh. - The in-process authentication limiter allows 30 authentication requests per IP per minute. Use a reverse proxy for stronger public rate limiting and request logging. - -## Документация проекта - -- [`AGENTS.md`](AGENTS.md) — правила для AI-агентов и разработчиков. -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — архитектура backend. -- [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) — модель данных. -- [`docs/API.md`](docs/API.md) — HTTP API. -- [`docs/SYNC.md`](docs/SYNC.md) — протокол синхронизации. -- [`docs/SECURITY.md`](docs/SECURITY.md) — требования безопасности. -- [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) — локальная разработка и проверки. -- [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) — deployment/production. -- [`docs/PRODUCT.md`](docs/PRODUCT.md) — функциональная роль сервера. - -BalanceServer является общим backend для Apple- и Android-клиентов Balance. diff --git a/README.md b/README.md index dc18c1c..b68bd6e 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,12 @@ -# Balance — все платформы +# Balance -Balance — приложение для учёта личных финансов с нативными клиентами -для iOS, macOS, watchOS, Android и встроенным web-интерфейсом, а также -self-hosted сервер синхронизации на Go. В архиве находится -монорепозиторий из трёх независимо собираемых проектов. +Основной новый клиент — **[BalanceFlutter](BalanceFlutter/README.md)**: Flutter с Material 3 для Android, iPhone/iPad, macOS, Windows, Linux и Web. Для Apple Watch сохранён полноценный независимый SwiftUI-клиент. Синхронизация выполняется только через существующий Go-сервер; CloudKit удалён. -| Папка | Назначение | Стек | -| --- | --- | --- | -| [`Balance/`](Balance/README.md) | iOS 17+, macOS 14+ и watchOS 10+ | SwiftUI + SwiftData | -| [`BalanceAndroid/`](BalanceAndroid/README.md) | Android 8.0+ | Kotlin + Jetpack Compose + Room | -| [`BalanceServer/`](BalanceServer/README.md) | Go-сервер авторизации, синхронизации и встроенный web-клиент | Go + SQLite | - -Все пять клиентов — iOS, macOS, watchOS, Android и web — используют -одну модель данных и один протокол сервера: операции, пользовательские -категории, бюджеты, удаления, регистрация, вход, refresh-токены и -постраничная двусторонняя синхронизация. Web-интерфейс открывается по -адресу запущенного сервера и не требует отдельной установки. - -## Быстрый старт - -1. **Поднять сервер** — см. [`BalanceServer/README.md`](BalanceServer/README.md#quick-start-with-docker) - (Docker или локальная сборка Go-бинарника). -2. **Открыть Apple-проект** — `open Balance/Balance.xcodeproj`, см. - [`Balance/README.md`](Balance/README.md). -3. **Собрать Android-приложение** — `cd BalanceAndroid && ./gradlew assembleDebug`, - см. [`BalanceAndroid/README.md`](BalanceAndroid/README.md). -4. В любом клиенте укажите адрес запущенного сервера в настройках - аккаунта, чтобы включить синхронизацию между устройствами. - -## Документация - -Документация организована на двух уровнях: - -- **Корневой уровень** (`docs/`) — то, что относится ко всем платформам - сразу: как устроена система в целом, единый протокол синхронизации, - сквозная модель данных и требования безопасности. -- **Уровень подпроекта** (`Balance/docs/`, `BalanceAndroid/docs/`, - `BalanceServer/docs/`) — детали конкретной платформы: код, схемы, - сборка, деплой. - -| Документ | Описание | +| Папка | Назначение | | --- | --- | -| [`AGENTS.md`](AGENTS.md) | Правила для AI-агентов и разработчиков, работающих во всём репозитории | -| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Как связаны клиенты и сервер, общая схема системы | -| [`docs/SYNC_PROTOCOL.md`](docs/SYNC_PROTOCOL.md) | Единый протокол синхронизации между всеми клиентами и сервером | -| [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | Сопоставление доменных сущностей между платформами | -| [`docs/SECURITY.md`](docs/SECURITY.md) | Сквозные требования безопасности (токены, HTTPS, хранение секретов) | -| [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) | Как собрать, запустить и протестировать все три проекта | +| `BalanceFlutter` | Новый единый Flutter-клиент, платформенные runners, тесты и сборочные скрипты | +| `Balance` | Исходные Apple-клиенты и активный нативный target `BalanceWatch` | +| `BalanceAndroid` | Исходный Android-клиент для переноса данных и сравнения поведения | +| `BalanceServer` | Существующий Go-сервер авторизации и синхронизации, без изменений | -### Документация по подпроектам - -- **Apple (`Balance/`):** [`AGENTS.md`](Balance/AGENTS.md) · - [`docs/ARCHITECTURE.md`](Balance/docs/ARCHITECTURE.md) · - [`docs/DATA_MODEL.md`](Balance/docs/DATA_MODEL.md) · - [`docs/SYNC.md`](Balance/docs/SYNC.md) · - [`docs/DEVELOPMENT.md`](Balance/docs/DEVELOPMENT.md) · - [`docs/PRODUCT.md`](Balance/docs/PRODUCT.md) -- **Android (`BalanceAndroid/`):** [`AGENTS.md`](BalanceAndroid/AGENTS.md) · - [`docs/ARCHITECTURE.md`](BalanceAndroid/docs/ARCHITECTURE.md) · - [`docs/DATA_MODEL.md`](BalanceAndroid/docs/DATA_MODEL.md) · - [`docs/SYNC.md`](BalanceAndroid/docs/SYNC.md) · - [`docs/DEVELOPMENT.md`](BalanceAndroid/docs/DEVELOPMENT.md) · - [`docs/PRODUCT.md`](BalanceAndroid/docs/PRODUCT.md) -- **Server (`BalanceServer/`):** [`AGENTS.md`](BalanceServer/AGENTS.md) · - [`docs/ARCHITECTURE.md`](BalanceServer/docs/ARCHITECTURE.md) · - [`docs/API.md`](BalanceServer/docs/API.md) · - [`docs/DATA_MODEL.md`](BalanceServer/docs/DATA_MODEL.md) · - [`docs/SYNC.md`](BalanceServer/docs/SYNC.md) · - [`docs/SECURITY.md`](BalanceServer/docs/SECURITY.md) · - [`docs/DEVELOPMENT.md`](BalanceServer/docs/DEVELOPMENT.md) · - [`docs/DEPLOYMENT.md`](BalanceServer/docs/DEPLOYMENT.md) · - [`docs/PRODUCT.md`](BalanceServer/docs/PRODUCT.md) - -## Структура репозитория - -```text -Balance/ -├── AGENTS.md # общие правила для агентов по всему репо -├── README.md # этот файл -├── docs/ # сводная документация по всем платформам -├── Balance/ # Xcode-проект: iOS + macOS + watchOS -├── BalanceAndroid/ # Android (Gradle/Kotlin) -└── BalanceServer/ # Go-сервер + встроенный web-клиент -``` +Начало работы, сборки и перенос данных: [BalanceFlutter/README.md](BalanceFlutter/README.md). Выполненные проверки: [VALIDATION.md](BalanceFlutter/VALIDATION.md). Аудит функций: [FEATURE_PARITY.md](BalanceFlutter/FEATURE_PARITY.md).