commit ea6abfc139ec71e33545f09526976f0a5fad60fb Author: wt Date: Fri Oct 30 12:24:11 2026 +0700 initial commit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f89f6c6 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,60 @@ +name: Build + +on: + push: + branches: [main, develop] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build (${{ matrix.name }}) + runs-on: macos-15 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: watchOS Simulator + destination: generic/platform=watchOS Simulator + - name: watchOS Device + destination: generic/platform=watchOS + + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show environment + run: | + xcodebuild -version + xcodebuild -showsdks | grep -i watch + + - name: Build + run: | + set -o pipefail + mkdir -p build + xcodebuild \ + -project BalanceWatch.xcodeproj \ + -scheme BalanceWatch \ + -configuration Debug \ + -destination '${{ matrix.destination }}' \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + build | tee build/xcodebuild.log + + - name: Upload build log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: xcodebuild-log-${{ strategy.job-index }} + path: build/xcodebuild.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b6154d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Xcode +DerivedData/ +build/ +*.xcuserstate +xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# macOS +.DS_Store diff --git a/BalanceWatch.xcodeproj/project.pbxproj b/BalanceWatch.xcodeproj/project.pbxproj new file mode 100644 index 0000000..90f89ec --- /dev/null +++ b/BalanceWatch.xcodeproj/project.pbxproj @@ -0,0 +1,313 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + F01C0000000000000000001C /* BalanceWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BalanceWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + G00200000000000000000002 /* BalanceWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BalanceWatch; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + S00B0000000000000000000B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + G00100000000000000000001 = { + isa = PBXGroup; + children = ( + G00200000000000000000002 /* BalanceWatch */, + G00B0000000000000000000B /* Products */, + ); + sourceTree = ""; + }; + G00B0000000000000000000B /* Products */ = { + isa = PBXGroup; + children = ( + F01C0000000000000000001C /* BalanceWatch.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + T00400000000000000000004 /* BalanceWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = L00500000000000000000005 /* Build configuration list for PBXNativeTarget "BalanceWatch" */; + buildPhases = ( + S00A0000000000000000000A /* Sources */, + S00B0000000000000000000B /* Frameworks */, + S00C0000000000000000000C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + G00200000000000000000002 /* BalanceWatch */, + ); + name = BalanceWatch; + packageProductDependencies = ( + ); + productName = BalanceWatch; + productReference = F01C0000000000000000001C /* BalanceWatch.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + P00100000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + T00400000000000000000004 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = L00100000000000000000001 /* Build configuration list for PBXProject "BalanceWatch" */; + developmentRegion = ru; + hasScannedForEncodings = 0; + knownRegions = ( + ru, + en, + Base, + ); + mainGroup = G00100000000000000000001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = G00B0000000000000000000B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + T00400000000000000000004 /* BalanceWatch */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + S00C0000000000000000000C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + S00A0000000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + C00100000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + 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_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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = watchos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C00200000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + 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_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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = watchos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C00900000000000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + DEVELOPMENT_TEAM = K3AS449A74; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; + INFOPLIST_KEY_WKWatchKitApp = YES; + INFOPLIST_KEY_WKWatchOnly = YES; + MARKETING_VERSION = 3.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = NO; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + C00A0000000000000000000A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + DEVELOPMENT_TEAM = K3AS449A74; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; + INFOPLIST_KEY_WKWatchKitApp = YES; + INFOPLIST_KEY_WKWatchOnly = YES; + MARKETING_VERSION = 3.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = NO; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + L00100000000000000000001 /* Build configuration list for PBXProject "BalanceWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00100000000000000000001 /* Debug */, + C00200000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + L00500000000000000000005 /* Build configuration list for PBXNativeTarget "BalanceWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00900000000000000000009 /* Debug */, + C00A0000000000000000000A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = P00100000000000000000001 /* Project object */; +} diff --git a/BalanceWatch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/BalanceWatch.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/BalanceWatch.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/BalanceWatch.xcodeproj/xcshareddata/xcschemes/BalanceWatch.xcscheme b/BalanceWatch.xcodeproj/xcshareddata/xcschemes/BalanceWatch.xcscheme new file mode 100644 index 0000000..608e139 --- /dev/null +++ b/BalanceWatch.xcodeproj/xcshareddata/xcschemes/BalanceWatch.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BalanceWatch/App/BalanceWatchApp.swift b/BalanceWatch/App/BalanceWatchApp.swift new file mode 100644 index 0000000..896f9c3 --- /dev/null +++ b/BalanceWatch/App/BalanceWatchApp.swift @@ -0,0 +1,17 @@ +import SwiftData +import SwiftUI + +@main +struct BalanceWatchApp: App { + private let modelContainer = BalanceModelContainer.make() + @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + + var body: some Scene { + WindowGroup { + WatchRootView() + .tint(.indigo) + .preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme) + } + .modelContainer(modelContainer) + } +} diff --git a/BalanceWatch/App/WatchAddTransactionView.swift b/BalanceWatch/App/WatchAddTransactionView.swift new file mode 100644 index 0000000..49bfceb --- /dev/null +++ b/BalanceWatch/App/WatchAddTransactionView.swift @@ -0,0 +1,110 @@ +import SwiftData +import SwiftUI + +struct WatchAddTransactionView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query(sort: \CustomCategory.createdAt) + private var customCategories: [CustomCategory] + + private let transaction: FinanceTransaction? + @State private var kind: TransactionKind + @State private var amount: Double + @State private var categoryName: String + @State private var date: Date + @State private var note: String + + init(transaction: FinanceTransaction? = nil) { + self.transaction = transaction + let initialKind = transaction?.kind ?? .expense + _kind = State(initialValue: initialKind) + _amount = State(initialValue: transaction?.amount ?? 0) + _categoryName = State(initialValue: transaction?.categoryName + ?? FinanceCategory.categories(for: initialKind)[0].name) + _date = State(initialValue: transaction?.date ?? .now) + _note = State(initialValue: transaction?.note ?? "") + } + + private var categories: [FinanceCategory] { + var values = FinanceCategory.categories(for: kind) + + customCategories.filter { $0.kind == kind }.map(\.financeCategory) + if let transaction, + !values.contains(where: { $0.name == transaction.categoryName }) { + values.append(FinanceCategory( + id: "archived-\(transaction.id)", + name: transaction.categoryName, + icon: transaction.categoryIcon, + emoji: transaction.categoryEmoji, + colorName: transaction.categoryColorName + )) + } + return values + } + + var body: some View { + NavigationStack { + Form { + Picker("Тип", selection: $kind) { + ForEach(TransactionKind.allCases) { Text($0.title).tag($0) } + } + .disabled(transaction?.isBalanceAdjustment == true) + .onChange(of: kind) { _, newKind in + categoryName = FinanceCategory.categories(for: newKind)[0].name + } + + TextField("Сумма", value: $amount, format: .number) + + Picker("Категория", selection: $categoryName) { + ForEach(categories) { category in + Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name) + .tag(category.name) + } + } + .disabled(transaction?.isBalanceAdjustment == true) + + DatePicker("Дата", selection: $date, displayedComponents: .date) + TextField("Заметка", text: $note) + + Button("Сохранить") { + save() + } + .disabled(amount <= 0) + } + .navigationTitle(transaction == nil ? "Операция" : "Изменить") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + } + } + } + + private func save() { + guard amount > 0 else { return } + let category = categories.first { $0.name == categoryName } + ?? FinanceCategory.categories(for: kind)[0] + if let transaction { + transaction.amount = amount + transaction.kind = kind + transaction.categoryName = category.name + transaction.categoryIcon = category.icon + transaction.categoryEmoji = category.emoji + transaction.categoryColorName = category.colorName + transaction.date = date + transaction.note = note + transaction.syncUpdatedAt = .now + } else { + modelContext.insert(FinanceTransaction( + amount: amount, + date: date, + note: note, + categoryName: category.name, + categoryIcon: category.icon, + categoryEmoji: category.emoji, + categoryColorName: category.colorName, + kind: kind + )) + } + dismiss() + } +} diff --git a/BalanceWatch/App/WatchFeatures.swift b/BalanceWatch/App/WatchFeatures.swift new file mode 100644 index 0000000..162b6f7 --- /dev/null +++ b/BalanceWatch/App/WatchFeatures.swift @@ -0,0 +1,493 @@ +import Foundation +import SwiftData +import SwiftUI + +private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable { + case month + case threeMonths + case year + + var id: String { rawValue } + var title: String { + switch self { + case .month: "Месяц" + case .threeMonths: "3 мес." + case .year: "12 мес." + } + } + + func interval() -> DateInterval { + let calendar = Calendar.current + let current = calendar.dateInterval(of: .month, for: .now) + ?? DateInterval(start: .now, duration: 30 * 86_400) + let offset = self == .month ? 0 : (self == .threeMonths ? -2 : -11) + let start = calendar.date(byAdding: .month, value: offset, to: current.start) + ?? current.start + return DateInterval(start: start, end: current.end) + } +} + +struct WatchAnalyticsView: View { + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var period = WatchAnalyticsPeriod.month + + private var interval: DateInterval { period.interval() } + private var summary: FinancePeriodSummary { + FinanceCalculations.summary(transactions: transactions, in: interval) + } + private var spending: [CategorySpending] { + Array(FinanceCalculations.spendingByCategory( + transactions: transactions, + in: interval + ).prefix(6)) + } + private var savingsRate: Double { + summary.income > 0 ? summary.balance / summary.income * 100 : 0 + } + + var body: some View { + List { + Picker("Период", selection: $period) { + ForEach(WatchAnalyticsPeriod.allCases) { Text($0.title).tag($0) } + } + + Section("Итоги") { + metric("Доходы", summary.income, .green) + metric("Расходы", summary.expenses, .red) + LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode)) + LabeledContent( + "Сбережения", + value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%" + ) + } + + Section("Категории") { + if spending.isEmpty { + Text("Нет расходов").foregroundStyle(.secondary) + } else { + ForEach(spending) { item in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text((item.emoji.isEmpty ? "" : item.emoji + " ") + item.name) + .lineLimit(1) + Spacer() + Text(item.amount.formattedCurrency(code: currencyCode)) + .font(.caption2) + } + ProgressView( + value: spending.first.map { item.amount / max($0.amount, 1) } ?? 0 + ) + .tint(tint(for: item)) + } + } + } + } + } + .navigationTitle("Аналитика") + } + + private func metric(_ title: String, _ amount: Double, _ tint: Color) -> some View { + LabeledContent(title, value: amount.formattedCurrency(code: currencyCode)) + .foregroundStyle(tint) + } + + private func tint(for item: CategorySpending) -> Color { + item.colorName == "auto" + ? FinanceCategory.find(named: item.name)?.tint ?? .indigo + : FinanceCategory.color(named: item.colorName) + } +} + +struct WatchTransactionsView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \FinanceTransaction.date, order: .reverse) + private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var selectedKind: TransactionKind? + @State private var searchText = "" + @State private var showingAdd = false + @State private var editing: FinanceTransaction? + + private var filtered: [FinanceTransaction] { + transactions.filter { + (selectedKind == nil || $0.kind == selectedKind) + && (searchText.isEmpty + || $0.categoryName.localizedCaseInsensitiveContains(searchText) + || $0.note.localizedCaseInsensitiveContains(searchText)) + } + } + + var body: some View { + List { + Picker("Фильтр", selection: $selectedKind) { + Text("Все").tag(TransactionKind?.none) + Text("Расходы").tag(TransactionKind?.some(.expense)) + Text("Доходы").tag(TransactionKind?.some(.income)) + } + + ForEach(filtered) { transaction in + Button { + editing = transaction + } label: { + HStack { + CategoryIconView( + systemName: transaction.categoryIcon, + emoji: transaction.categoryEmoji, + tint: tint(transaction), + size: transaction.categoryEmoji.isEmpty ? 13 : 17, + containerSize: 30 + ) + VStack(alignment: .leading) { + Text(transaction.categoryName).lineLimit(1) + Text((transaction.kind == .income ? "+" : "−") + + transaction.amount.formattedCurrency(code: currencyCode)) + .font(.caption2) + } + } + } + .swipeActions { + Button(role: .destructive) { modelContext.deleteForSync(transaction) } label: { + Label("Удалить", systemImage: "trash") + } + } + } + } + .navigationTitle("Операции") + .searchable(text: $searchText, prompt: "Поиск") + .toolbar { + Button { showingAdd = true } label: { Image(systemName: "plus") } + } + .sheet(isPresented: $showingAdd) { WatchAddTransactionView() } + .sheet(item: $editing) { WatchAddTransactionView(transaction: $0) } + } + + private func tint(_ transaction: FinanceTransaction) -> Color { + transaction.categoryColorName == "auto" + ? FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo + : FinanceCategory.color(named: transaction.categoryColorName) + } +} + +struct WatchBudgetsView: View { + @Query private var budgets: [MonthlyBudget] + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var showingEditor = false + + private var interval: DateInterval { + FinanceCalculations.monthInterval(containing: .now) + } + private var current: [MonthlyBudget] { + budgets.filter { interval.contains($0.monthStart) } + } + private var spending: [String: Double] { + Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory( + transactions: transactions, + in: interval + ).map { ($0.name, $0.amount) }) + } + + var body: some View { + List { + if current.isEmpty { + Text("Лимиты не заданы").foregroundStyle(.secondary) + } + ForEach(current) { budget in + let spent = spending[budget.categoryName, default: 0] + VStack(alignment: .leading, spacing: 5) { + HStack { + Text((budget.categoryEmoji.isEmpty ? "" : budget.categoryEmoji + " ") + budget.categoryName) + .lineLimit(1) + Spacer() + Text(spent.formattedCurrency(code: currencyCode)).font(.caption2) + } + ProgressView(value: min(budget.limit > 0 ? spent / budget.limit : 0, 1)) + Text("Лимит: \(budget.limit.formattedCurrency(code: currencyCode))") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + .navigationTitle("Бюджеты") + .toolbar { + Button { showingEditor = true } label: { Image(systemName: "pencil") } + } + .sheet(isPresented: $showingEditor) { + WatchBudgetEditor(budgets: current, monthStart: interval.start) + } + } +} + +private struct WatchBudgetEditor: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query(sort: \CustomCategory.createdAt) private var customCategories: [CustomCategory] + let budgets: [MonthlyBudget] + let monthStart: Date + @State private var limits: [String: Double] + + init(budgets: [MonthlyBudget], monthStart: Date) { + self.budgets = budgets + self.monthStart = monthStart + _limits = State(initialValue: Dictionary(uniqueKeysWithValues: budgets.map { + ($0.categoryName, $0.limit) + })) + } + + private var categories: [FinanceCategory] { + FinanceCategory.expenseCategories + + customCategories.filter { $0.kind == .expense }.map(\.financeCategory) + } + + var body: some View { + Form { + ForEach(categories) { category in + TextField(category.name, value: binding(category.name), format: .number) + } + Button("Сохранить", action: save) + } + .navigationTitle("Лимиты") + } + + private func binding(_ name: String) -> Binding { + Binding(get: { limits[name, default: 0] }, set: { limits[name] = max(0, $0) }) + } + + private func save() { + for category in categories { + let value = limits[category.name, default: 0] + let existing = budgets.first { $0.categoryName == category.name } + if value > 0 { + if let existing { + existing.limit = value + existing.syncUpdatedAt = .now + } else { + modelContext.insert(MonthlyBudget( + categoryName: category.name, + categoryIcon: category.icon, + categoryEmoji: category.emoji, + limit: value, + monthStart: monthStart + )) + } + } else if let existing { + modelContext.deleteForSync(existing) + } + } + dismiss() + } +} + +struct WatchCategoriesView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \CustomCategory.createdAt) private var categories: [CustomCategory] + @Query private var budgets: [MonthlyBudget] + @State private var showingAdd = false + @State private var editing: CustomCategory? + + var body: some View { + List { + if categories.isEmpty { + Text("Нет своих категорий").foregroundStyle(.secondary) + } + ForEach(categories) { category in + Button { + editing = category + } label: { + HStack { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: FinanceCategory.color(named: category.colorName), + size: category.emoji.isEmpty ? 13 : 17, + containerSize: 30 + ) + Text(category.name) + } + } + .swipeActions { + Button(role: .destructive) { + budgets.filter { $0.categoryName == category.name } + .forEach { modelContext.deleteForSync($0) } + modelContext.deleteForSync(category) + } label: { + Label("Удалить", systemImage: "trash") + } + } + } + } + .navigationTitle("Категории") + .toolbar { Button { showingAdd = true } label: { Image(systemName: "plus") } } + .sheet(isPresented: $showingAdd) { WatchCategoryEditor() } + .sheet(item: $editing) { WatchCategoryEditor(category: $0) } + } +} + +private struct WatchCategoryEditor: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query private var transactions: [FinanceTransaction] + @Query private var budgets: [MonthlyBudget] + @Query private var customCategories: [CustomCategory] + let category: CustomCategory? + @State private var name: String + @State private var kind: TransactionKind + @State private var usesEmoji: Bool + @State private var emoji: String + @State private var icon: String + @State private var colorName: String + + init(category: CustomCategory? = nil) { + self.category = category + _name = State(initialValue: category?.name ?? "") + _kind = State(initialValue: category?.kind ?? .expense) + _usesEmoji = State(initialValue: category?.emoji.isEmpty == false) + _emoji = State(initialValue: category?.emoji ?? "⭐️") + _icon = State(initialValue: category?.icon ?? "star.fill") + _colorName = State(initialValue: category?.colorName ?? "indigo") + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isDuplicate: Bool { + let names = (FinanceCategory.expenseCategories + FinanceCategory.incomeCategories).map(\.name) + + customCategories.filter { $0.id != category?.id }.map(\.name) + return names.contains { + $0.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + } + + var body: some View { + Form { + TextField("Название", text: $name) + if isDuplicate && !trimmedName.isEmpty { + Text("Название уже занято").foregroundStyle(.red) + } + Picker("Тип", selection: $kind) { + ForEach(TransactionKind.allCases) { Text($0.title).tag($0) } + } + .disabled(category != nil) + Toggle("Эмодзи", isOn: $usesEmoji) + if usesEmoji { + TextField("Эмодзи", text: $emoji) + Picker("Готовые", selection: $emoji) { + ForEach(CategoryDesignOptions.emojis, id: \.self) { Text($0).tag($0) } + } + } else { + Picker("Иконка", selection: $icon) { + ForEach(CategoryDesignOptions.icons, id: \.self) { Text($0).tag($0) } + } + } + Picker("Цвет", selection: $colorName) { + ForEach(CategoryDesignOptions.colors, id: \.self) { Text($0).tag($0) } + } + Button("Сохранить", action: save) + .disabled(trimmedName.isEmpty || isDuplicate) + } + .navigationTitle(category == nil ? "Новая" : "Изменить") + } + + private func save() { + guard !trimmedName.isEmpty && !isDuplicate else { return } + let finalEmoji = usesEmoji ? emoji.first.map(String.init) ?? "" : "" + if let category { + let oldName = category.name + category.name = trimmedName + category.icon = icon + category.emoji = finalEmoji + category.colorName = colorName + category.syncUpdatedAt = .now + transactions.filter { $0.categoryName == oldName }.forEach { + $0.categoryName = trimmedName + $0.categoryIcon = icon + $0.categoryEmoji = finalEmoji + $0.categoryColorName = colorName + $0.syncUpdatedAt = .now + } + budgets.filter { $0.categoryName == oldName }.forEach { + $0.categoryName = trimmedName + $0.categoryIcon = icon + $0.categoryEmoji = finalEmoji + $0.syncUpdatedAt = .now + } + } else { + modelContext.insert(CustomCategory( + name: trimmedName, + icon: icon, + emoji: finalEmoji, + colorName: colorName, + kind: kind + )) + } + dismiss() + } +} + +struct WatchBalanceAdjustmentView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var desired = 0.0 + @State private var note = "" + + private var current: Double { FinanceCalculations.totalBalance(transactions: transactions) } + private var difference: Double { desired - current } + + var body: some View { + Form { + LabeledContent("Сейчас", value: current.formattedCurrency(code: currencyCode)) + TextField("Фактически", value: $desired, format: .number) + if abs(difference) >= 0.005 { + LabeledContent("Разница", value: difference.formattedCurrency(code: currencyCode)) + } + TextField("Комментарий", text: $note) + Button("Сохранить", action: save).disabled(abs(difference) < 0.005) + } + .navigationTitle("Баланс") + .onAppear { desired = current } + } + + private func save() { + guard abs(difference) >= 0.005 else { return } + modelContext.insert(FinanceTransaction( + amount: abs(difference), + note: note.isEmpty ? "Ручная корректировка баланса" : note, + categoryName: "Корректировка", + categoryIcon: "slider.horizontal.3", + categoryColorName: "indigo", + isBalanceAdjustment: true, + kind: difference > 0 ? .income : .expense + )) + dismiss() + } +} + +struct WatchSettingsView: View { + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + + var body: some View { + Form { + Picker("Валюта", selection: $currencyCode) { + ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) } + } + Picker("Тема", selection: $appTheme) { + ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) } + } + NavigationLink { + ServerAccountView() + } label: { + Label("Сервер", systemImage: "server.rack") + } + Label( + "Локально; синхронизация через сервер", + systemImage: "internaldrive.fill" + ) + LabeledContent("Версия", value: "3.1.1") + } + .navigationTitle("Настройки") + } +} diff --git a/BalanceWatch/App/WatchRootView.swift b/BalanceWatch/App/WatchRootView.swift new file mode 100644 index 0000000..fdd89fb --- /dev/null +++ b/BalanceWatch/App/WatchRootView.swift @@ -0,0 +1,158 @@ +import SwiftData +import SwiftUI + +struct WatchRootView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \FinanceTransaction.date, order: .reverse) + private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var showingAddTransaction = false + + private var monthSummary: FinancePeriodSummary { + FinanceCalculations.summary( + transactions: transactions, + in: FinanceCalculations.monthInterval(containing: .now) + ) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text("Общий баланс") + .font(.caption) + .foregroundStyle(.secondary) + Text(FinanceCalculations.totalBalance(transactions: transactions) + .formattedCurrency(code: currencyCode)) + .font(.title3.bold()) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + LinearGradient( + colors: [.indigo, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 16) + ) + .foregroundStyle(.white) + + HStack(spacing: 8) { + watchMetric( + title: "Доходы", + amount: monthSummary.income, + tint: .green + ) + watchMetric( + title: "Расходы", + amount: monthSummary.expenses, + tint: .red + ) + } + + Button { + showingAddTransaction = true + } label: { + Label("Добавить операцию", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + VStack(spacing: 4) { + NavigationLink { + WatchAnalyticsView() + } label: { + Label("Аналитика", systemImage: "chart.xyaxis.line") + } + NavigationLink { + WatchTransactionsView() + } label: { + Label("Все операции", systemImage: "list.bullet.rectangle") + } + NavigationLink { + WatchBudgetsView() + } label: { + Label("Бюджеты", systemImage: "target") + } + NavigationLink { + WatchCategoriesView() + } label: { + Label("Категории", systemImage: "square.grid.2x2") + } + NavigationLink { + WatchBalanceAdjustmentView() + } label: { + Label("Корректировка", systemImage: "slider.horizontal.3") + } + NavigationLink { + WatchSettingsView() + } label: { + Label("Настройки", systemImage: "gearshape") + } + } + + if !transactions.isEmpty { + Text("Последние") + .font(.headline) + ForEach(transactions.prefix(4)) { transaction in + HStack(spacing: 8) { + CategoryIconView( + systemName: transaction.categoryIcon, + emoji: transaction.categoryEmoji, + tint: transactionTint(transaction), + size: transaction.categoryEmoji.isEmpty ? 13 : 17, + containerSize: 30 + ) + VStack(alignment: .leading, spacing: 1) { + Text(transaction.categoryName) + .font(.caption) + .lineLimit(1) + Text((transaction.kind == .income ? "+" : "−") + + transaction.amount.formattedCurrency(code: currencyCode)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(transaction.kind == .income ? .green : .secondary) + } + Spacer() + } + } + } + } + } + .navigationTitle("Баланс") + .sheet(isPresented: $showingAddTransaction) { + WatchAddTransactionView() + } + } + .task { + while !Task.isCancelled { + await ServerAccountStore.shared.sync(context: modelContext) + try? await Task.sleep(nanoseconds: 120_000_000_000) + } + } + } + + private func watchMetric(title: String, amount: Double, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.caption2) + .foregroundStyle(.secondary) + Text(amount.formattedCurrency(code: currencyCode)) + .font(.caption.bold()) + .lineLimit(1) + .minimumScaleFactor(0.65) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(9) + .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + } + + private func transactionTint(_ transaction: FinanceTransaction) -> Color { + if transaction.categoryColorName == "auto" { + return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo + } + return FinanceCategory.color(named: transaction.categoryColorName) + } +} diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png new file mode 100644 index 0000000..5375cd4 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png new file mode 100644 index 0000000..bd70565 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png new file mode 100644 index 0000000..2c212db Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png new file mode 100644 index 0000000..6c375f4 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png new file mode 100644 index 0000000..043000f Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png new file mode 100644 index 0000000..3983c3d Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png new file mode 100644 index 0000000..2f00659 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png new file mode 100644 index 0000000..7dc91af Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png new file mode 100644 index 0000000..6be4a87 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png new file mode 100644 index 0000000..990b9a8 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png new file mode 100644 index 0000000..9abc1e5 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png new file mode 100644 index 0000000..726cb0d Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png new file mode 100644 index 0000000..73f3023 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png new file mode 100644 index 0000000..4608e52 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png new file mode 100644 index 0000000..b81ecc8 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png new file mode 100644 index 0000000..4a41132 Binary files /dev/null and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..b533b78 --- /dev/null +++ b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,139 @@ +{ + "images" : [ + { + "filename" : "BalanceIcon-48.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "BalanceIcon-55.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "BalanceIcon-58.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "BalanceIcon-87.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "BalanceIcon-80.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "BalanceIcon-88.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "BalanceIcon-92.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "BalanceIcon-100.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "filename" : "BalanceIcon-102.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "filename" : "BalanceIcon-108.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "BalanceIcon-172.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "BalanceIcon-196.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "BalanceIcon-216.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "filename" : "BalanceIcon-234.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "BalanceIcon-258.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "BalanceIcon-1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BalanceWatch/Assets.xcassets/Contents.json b/BalanceWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/BalanceWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/BalanceWatch/Models/CustomCategory.swift b/BalanceWatch/Models/CustomCategory.swift new file mode 100644 index 0000000..e2e32ae --- /dev/null +++ b/BalanceWatch/Models/CustomCategory.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +@Model +final class CustomCategory { + var id: UUID = UUID() + var name: String = "" + var icon: String = "star.fill" + var emoji: String = "" + var colorName: String = "indigo" + var kindRawValue: String = "expense" + var createdAt: Date = Date.now + var syncUpdatedAt: Date = Date.now + + init( + id: UUID = UUID(), + name: String, + icon: String, + emoji: String = "", + colorName: String, + kind: TransactionKind, + createdAt: Date = .now, + syncUpdatedAt: Date = .now + ) { + self.id = id + self.name = name + self.icon = icon + self.emoji = emoji + self.colorName = colorName + self.kindRawValue = kind.rawValue + self.createdAt = createdAt + self.syncUpdatedAt = syncUpdatedAt + } + + var kind: TransactionKind { + get { TransactionKind(rawValue: kindRawValue) ?? .expense } + set { kindRawValue = newValue.rawValue } + } + + var financeCategory: FinanceCategory { + FinanceCategory( + id: "custom-\(id.uuidString)", + name: name, + icon: icon, + emoji: emoji, + colorName: colorName + ) + } +} diff --git a/BalanceWatch/Models/FinanceCategory.swift b/BalanceWatch/Models/FinanceCategory.swift new file mode 100644 index 0000000..70efa31 --- /dev/null +++ b/BalanceWatch/Models/FinanceCategory.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct FinanceCategory: Identifiable, Hashable { + let id: String + let name: String + let icon: String + let emoji: String + let colorName: String + + var tint: Color { Self.color(named: colorName) } + + init( + id: String, + name: String, + icon: String, + emoji: String = "", + colorName: String + ) { + self.id = id + self.name = name + self.icon = icon + self.emoji = emoji + self.colorName = colorName + } + + static let expenseCategories: [FinanceCategory] = [ + .init(id: "food", name: "Продукты", icon: "cart.fill", colorName: "orange"), + .init(id: "transport", name: "Транспорт", icon: "car.fill", colorName: "blue"), + .init(id: "home", name: "Дом", icon: "house.fill", colorName: "indigo"), + .init(id: "entertainment", name: "Развлечения", icon: "gamecontroller.fill", colorName: "pink"), + .init(id: "health", name: "Здоровье", icon: "cross.case.fill", colorName: "red"), + .init(id: "shopping", name: "Покупки", icon: "bag.fill", colorName: "purple"), + .init(id: "subscriptions", name: "Подписки", icon: "repeat", colorName: "cyan"), + .init(id: "education", name: "Образование", icon: "book.fill", colorName: "mint"), + .init(id: "other-expense", name: "Другое", icon: "ellipsis.circle.fill", colorName: "gray") + ] + + static let incomeCategories: [FinanceCategory] = [ + .init(id: "salary", name: "Зарплата", icon: "banknote.fill", colorName: "green"), + .init(id: "freelance", name: "Подработка", icon: "laptopcomputer", colorName: "teal"), + .init(id: "investments", name: "Инвестиции", icon: "chart.line.uptrend.xyaxis", colorName: "blue"), + .init(id: "gift", name: "Подарок", icon: "gift.fill", colorName: "pink"), + .init(id: "other-income", name: "Другое", icon: "plus.circle.fill", colorName: "gray") + ] + + static func categories(for kind: TransactionKind) -> [FinanceCategory] { + kind == .expense ? expenseCategories : incomeCategories + } + + static func find(named name: String) -> FinanceCategory? { + (expenseCategories + incomeCategories).first { $0.name == name } + } + + static func color(named name: String) -> Color { + switch name { + case "red": .red + case "orange": .orange + case "yellow": .yellow + case "green": .green + case "mint": .mint + case "teal": .teal + case "cyan": .cyan + case "blue": .blue + case "purple": .purple + case "pink": .pink + case "gray": .gray + case "coral": Color(red: 0.98, green: 0.38, blue: 0.38) + case "peach": Color(red: 1.00, green: 0.58, blue: 0.38) + case "gold": Color(red: 0.90, green: 0.64, blue: 0.08) + case "lime": Color(red: 0.52, green: 0.78, blue: 0.16) + case "forest": Color(red: 0.10, green: 0.48, blue: 0.30) + case "turquoise": Color(red: 0.05, green: 0.67, blue: 0.63) + case "sky": Color(red: 0.18, green: 0.62, blue: 0.91) + case "navy": Color(red: 0.12, green: 0.25, blue: 0.52) + case "lavender": Color(red: 0.55, green: 0.40, blue: 0.85) + case "magenta": Color(red: 0.82, green: 0.16, blue: 0.55) + case "brown": Color(red: 0.48, green: 0.31, blue: 0.20) + case "slate": Color(red: 0.30, green: 0.36, blue: 0.45) + default: .indigo + } + } +} diff --git a/BalanceWatch/Models/FinanceTransaction.swift b/BalanceWatch/Models/FinanceTransaction.swift new file mode 100644 index 0000000..abe46c8 --- /dev/null +++ b/BalanceWatch/Models/FinanceTransaction.swift @@ -0,0 +1,62 @@ +import Foundation +import SwiftData + +enum TransactionKind: String, Codable, CaseIterable, Identifiable { + case expense + case income + + var id: String { rawValue } + + var title: String { + switch self { + case .expense: "Расход" + case .income: "Доход" + } + } +} + +@Model +final class FinanceTransaction { + var id: UUID = UUID() + var amount: Double = 0 + var date: Date = Date.now + var note: String = "" + var categoryName: String = "" + var categoryIcon: String = "circle.fill" + var categoryEmoji: String = "" + var categoryColorName: String = "auto" + var isBalanceAdjustment: Bool = false + var kindRawValue: String = "expense" + var syncUpdatedAt: Date = Date.now + + init( + id: UUID = UUID(), + amount: Double, + date: Date = .now, + note: String = "", + categoryName: String, + categoryIcon: String, + categoryEmoji: String = "", + categoryColorName: String = "auto", + isBalanceAdjustment: Bool = false, + kind: TransactionKind, + syncUpdatedAt: Date = .now + ) { + self.id = id + self.amount = amount + self.date = date + self.note = note + self.categoryName = categoryName + self.categoryIcon = categoryIcon + self.categoryEmoji = categoryEmoji + self.categoryColorName = categoryColorName + self.isBalanceAdjustment = isBalanceAdjustment + self.kindRawValue = kind.rawValue + self.syncUpdatedAt = syncUpdatedAt + } + + var kind: TransactionKind { + get { TransactionKind(rawValue: kindRawValue) ?? .expense } + set { kindRawValue = newValue.rawValue } + } +} diff --git a/BalanceWatch/Models/MonthlyBudget.swift b/BalanceWatch/Models/MonthlyBudget.swift new file mode 100644 index 0000000..df51cef --- /dev/null +++ b/BalanceWatch/Models/MonthlyBudget.swift @@ -0,0 +1,31 @@ +import Foundation +import SwiftData + +@Model +final class MonthlyBudget { + var id: UUID = UUID() + var categoryName: String = "" + var categoryIcon: String = "circle.fill" + var categoryEmoji: String = "" + var limit: Double = 0 + var monthStart: Date = Date.now + var syncUpdatedAt: Date = Date.now + + init( + id: UUID = UUID(), + categoryName: String, + categoryIcon: String, + categoryEmoji: String = "", + limit: Double, + monthStart: Date, + syncUpdatedAt: Date = .now + ) { + self.id = id + self.categoryName = categoryName + self.categoryIcon = categoryIcon + self.categoryEmoji = categoryEmoji + self.limit = limit + self.monthStart = monthStart + self.syncUpdatedAt = syncUpdatedAt + } +} diff --git a/BalanceWatch/Models/SyncTombstone.swift b/BalanceWatch/Models/SyncTombstone.swift new file mode 100644 index 0000000..a37460c --- /dev/null +++ b/BalanceWatch/Models/SyncTombstone.swift @@ -0,0 +1,109 @@ +import Foundation +import SwiftData + +@Model +final class SyncTombstone { + var id: UUID = UUID() + var entity: String = "" + var recordID: String = "" + var updatedAt: Date = Date.now + + init(entity: String, recordID: String, updatedAt: Date = .now) { + self.entity = entity + self.recordID = recordID + self.updatedAt = updatedAt + } +} + +struct SyncDeletionRecord: Codable, Identifiable { + let id: UUID + let entity: String + let recordID: String + let updatedAt: Date + + init(entity: String, recordID: String, updatedAt: Date = .now) { + self.id = UUID() + self.entity = entity + self.recordID = recordID + self.updatedAt = updatedAt + } +} + +enum SyncDeletionStore { + private static let storageKey = "balance.server.deletionJournal.v1" + private static let quarantineKey = "balance.server.deletionJournal.corrupt" + private static let lock = NSLock() + + static func add(entity: String, recordID: String, updatedAt: Date = .now) { + guard supportedEntities.contains(entity), UUID(uuidString:recordID) != nil else { return } + lock.lock() + defer { lock.unlock() } + var records = loadUnlocked() + records.removeAll { $0.entity == entity && $0.recordID == recordID } + records.append(SyncDeletionRecord(entity:entity, recordID:recordID, updatedAt:updatedAt)) + saveUnlocked(records) + } + + static func page(through cutoff: Date, offset: Int, limit: Int) -> [SyncDeletionRecord] { + guard offset >= 0, limit > 0 else { return [] } + lock.lock() + defer { lock.unlock() } + let records = loadUnlocked() + .filter { $0.updatedAt <= cutoff && supportedEntities.contains($0.entity) && UUID(uuidString:$0.recordID) != nil } + .sorted { $0.updatedAt < $1.updatedAt } + guard offset < records.count else { return [] } + return Array(records[offset.. [SyncDeletionRecord] { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey:storageKey) else { return [] } + do { + return try JSONDecoder().decode([SyncDeletionRecord].self, from:data) + } catch { + defaults.set(data, forKey:quarantineKey) + defaults.removeObject(forKey:storageKey) + return [] + } + } + + private static func saveUnlocked(_ records: [SyncDeletionRecord]) { + let defaults = UserDefaults.standard + guard !records.isEmpty else { + defaults.removeObject(forKey:storageKey) + return + } + if let data = try? JSONEncoder().encode(records) { + defaults.set(data, forKey:storageKey) + } + } +} + +extension ModelContext { + func deleteForSync(_ transaction: FinanceTransaction) { + SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString) + delete(transaction) + } + + func deleteForSync(_ category: CustomCategory) { + SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString) + delete(category) + } + + func deleteForSync(_ budget: MonthlyBudget) { + SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString) + delete(budget) + } +} diff --git a/BalanceWatch/Shared/AppTheme.swift b/BalanceWatch/Shared/AppTheme.swift new file mode 100644 index 0000000..56347e6 --- /dev/null +++ b/BalanceWatch/Shared/AppTheme.swift @@ -0,0 +1,25 @@ +import SwiftUI + +enum AppTheme: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "Системная" + case .light: "Светлая" + case .dark: "Тёмная" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} diff --git a/BalanceWatch/Shared/BalanceModelContainer.swift b/BalanceWatch/Shared/BalanceModelContainer.swift new file mode 100644 index 0000000..96222c0 --- /dev/null +++ b/BalanceWatch/Shared/BalanceModelContainer.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +enum BalanceModelContainer { + static let schema = Schema([ + FinanceTransaction.self, + MonthlyBudget.self, + CustomCategory.self, + SyncTombstone.self + ]) + + static func make() -> ModelContainer { + let localConfiguration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: false, + cloudKitDatabase: .none + ) + + do { + return try ModelContainer( + for: schema, + configurations: [localConfiguration] + ) + } catch { + let recoveryConfiguration = ModelConfiguration( + "BalanceRecovery", + schema: schema, + isStoredInMemoryOnly: true, + cloudKitDatabase: .none + ) + + do { + return try ModelContainer( + for: schema, + configurations: [recoveryConfiguration] + ) + } catch { + fatalError("Не удалось создать хранилище Balance: \(error.localizedDescription)") + } + } + } +} diff --git a/BalanceWatch/Shared/CategoryDesignOptions.swift b/BalanceWatch/Shared/CategoryDesignOptions.swift new file mode 100644 index 0000000..f3e4632 --- /dev/null +++ b/BalanceWatch/Shared/CategoryDesignOptions.swift @@ -0,0 +1,26 @@ +enum CategoryDesignOptions { + static let 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 let emojis = [ + "🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮", + "🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁", + "💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️" + ] + + static let 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" + ] +} diff --git a/BalanceWatch/Shared/ServerSync.swift b/BalanceWatch/Shared/ServerSync.swift new file mode 100644 index 0000000..75b54c9 --- /dev/null +++ b/BalanceWatch/Shared/ServerSync.swift @@ -0,0 +1,535 @@ +import Foundation +import Security +import SwiftData +import SwiftUI + +private struct ServerUser: Codable { let id: String; let email: String } +private struct ServerSession: Codable { + let accessToken: String + let refreshToken: String + let expiresAt: Date + let user: ServerUser +} +private struct ServerErrorEnvelope: Decodable { + struct Detail: Decodable { let code: String; let message: String } + let error: Detail +} +private struct SyncChange: Codable { + let entity: String + let id: String + let deleted: Bool + let updatedAt: Date + let payload: Data? + 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 + } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + entity = try values.decode(String.self, forKey: .entity) + id = try values.decode(String.self, forKey: .id) + deleted = try values.decode(Bool.self, forKey: .deleted) + updatedAt = try values.decode(Date.self, forKey: .updatedAt) + version = try values.decodeIfPresent(Int64.self, forKey: .version) + sequence = try values.decodeIfPresent(Int64.self, forKey: .sequence) + if values.contains(.payload) { + let object = try values.decode(JSONValue.self, forKey: .payload) + payload = try ServerCoding.encoder.encode(object) + } 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) } + } +} +private enum JSONValue: Codable { + 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)) } + } + func encode(to encoder: Encoder) throws { + var value = encoder.singleValueContainer() + switch self { + case .string(let item): try value.encode(item) + case .number(let item): try value.encode(item) + case .bool(let item): try value.encode(item) + case .object(let item): try value.encode(item) + case .array(let item): try value.encode(item) + case .null: try value.encodeNil() + } + } +} +private struct SyncRequest: Encodable { + let cursor: Int64 + let deviceId: String + let changes: [SyncChange] + let pull: Bool +} +private struct SyncResponse: Decodable { + let cursor: Int64 + let changes: [SyncChange] + let serverTime: Date + let hasMore: Bool? +} + +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 +} +private struct CategoryPayload: Codable { + 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 +} + +private enum ServerCoding { + static let encoder: JSONEncoder = { + let value = JSONEncoder() + value.dateEncodingStrategy = .custom { date, encoder in + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var container = encoder.singleValueContainer() + try container.encode(formatter.string(from:date)) + } + return value + }() + static let decoder: JSONDecoder = { + let value = JSONDecoder() + value.dateDecodingStrategy = .custom { decoder in + let text = try decoder.singleValueContainer().decode(String.self) + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let regular = ISO8601DateFormatter() + guard let date = fractional.date(from: text) ?? regular.date(from: text) else { + throw DecodingError.dataCorruptedError( + in: try decoder.singleValueContainer(), + debugDescription: "Invalid ISO-8601 date" + ) + } + return date + } + return value + }() +} + +enum ServerSyncError: LocalizedError { + case invalidURL, insecureURL, response(String), notSignedIn + var errorDescription: String? { + switch self { + case .invalidURL: "Укажите полный адрес сервера, например https://balance.example.com" + case .insecureURL: "Для удалённого сервера требуется HTTPS. HTTP разрешён только для localhost." + case .response(let message): message + case .notSignedIn: "Сначала войдите в аккаунт." + } + } +} + +@MainActor +final class ServerAccountStore: ObservableObject { + static let shared = ServerAccountStore() + @Published private(set) var signedInEmail: String? + @Published private(set) var isBusy = false + @Published private(set) var lastSyncAt: Date? + @Published var message: String? + + private let defaults = UserDefaults.standard + private var session: ServerSession? + private let sessionKey = "balance.server.session" + private let serverKey = "balance.server.url" + private let syncBatchSize = 100 + + var serverURL: String { defaults.string(forKey: serverKey) ?? "" } + 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 + } + lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date + } + + func saveServer(_ value: String) throws { + guard !isBusy else { + throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.") + } + let normalized = try normalizedServer(value) + 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 logout() async { + 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 + 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) + ? lastPush.addingTimeInterval(-5) + : .distantPast + 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 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") + message = "Синхронизация завершена." + } catch { message = error.localizedDescription } + } + + func resetSynchronization(context: ModelContext) async { + guard !isBusy else { return } + defaults.removeObject(forKey:cursorKey()) + defaults.removeObject(forKey:pushKey()) + message = "Запущена полная пересинхронизация…" + 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 } + 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) + message = "Вход выполнен. Запускаю синхронизацию…" + 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 { + 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" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + 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 (200..<300).contains(http.statusCode) else { + 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(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 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) + } + + 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 } + return trimmed + } + + 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.") { + 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 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 + } + + 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 + }, + sortBy: [SortDescriptor(\FinanceTransaction.syncUpdatedAt)] + ) + descriptor.fetchLimit = syncBatchSize + descriptor.fetchOffset = offset + 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)) + } + try await sendPush(changes, cursor: cursor) + offset += items.count + if items.count < syncBatchSize { break } + await Task.yield() + } + } + + 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 + }, + sortBy: [SortDescriptor(\CustomCategory.syncUpdatedAt)] + ) + descriptor.fetchLimit = syncBatchSize + descriptor.fetchOffset = offset + 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)) + } + try await sendPush(changes, cursor: cursor) + offset += items.count + if items.count < syncBatchSize { break } + await Task.yield() + } + } + + 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 + }, + sortBy: [SortDescriptor(\MonthlyBudget.syncUpdatedAt)] + ) + descriptor.fetchLimit = syncBatchSize + descriptor.fetchOffset = offset + 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)) + } + try await sendPush(changes, cursor: cursor) + offset += items.count + if items.count < syncBatchSize { break } + await Task.yield() + } + } + + 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 + ) + guard !items.isEmpty else { break } + 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 } + await Task.yield() + } + } + + 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("Одна из операций содержит слишком большую заметку для синхронизации.") + } + 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("Одна запись слишком велика для отправки на сервер.") + } + let middle = changes.count / 2 + try await sendPush(Array(changes[..= cursor else { + throw ServerSyncError.response("Сервер вернул некорректный курсор синхронизации.") + } + try apply(response.changes, context:context) + try context.save() + defaults.set(response.cursor, forKey:cursorKey()) + guard response.hasMore == true else { break } + guard response.cursor > cursor else { + throw ServerSyncError.response("Сервер не смог продолжить постраничную синхронизацию.") + } + cursor = response.cursor + await Task.yield() + } + } + + 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) { + 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) } + 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)) } + 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)) } + 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)) } + default: break + } + } + } + SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt) + } + } + + private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? { + let recordID = id + 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? { + let recordID = id + 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? { + let recordID = id + 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 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 + } + 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) } +} + +@MainActor +struct ServerAccountView: View { + @Environment(\.modelContext) private var modelContext + @StateObject private var account = ServerAccountStore.shared + @State private var server = ServerAccountStore.shared.serverURL + @State private var email = ServerAccountStore.shared.signedInEmail ?? "" + @State private var password = "" + + var body: some View { + Form { + Section("Сервер") { + TextField("https://balance.example.com", text:$server) + .serverTextEntryConfiguration() + Button("Сохранить адрес") { do { try account.saveServer(server); server = account.serverURL; account.message = "Адрес сохранён. Войдите заново." } catch { account.message = error.localizedDescription } } + .disabled(account.isBusy) + } + Section(account.isSignedIn ? "Аккаунт" : "Вход или регистрация") { + if let value = account.signedInEmail { LabeledContent("Выполнен вход", value:value) } + else { + TextField("Email", text:$email) + .serverTextEntryConfiguration() + SecureField("Пароль (минимум 8 символов)", text:$password) + Button("Войти") { Task { await account.login(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.isEmpty || account.isBusy) + Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy) + } + } + if account.isSignedIn { + Section("Синхронизация") { + Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy) + Button("Полная пересинхронизация") { Task { await account.resetSynchronization(context:modelContext) } } + .disabled(account.isBusy) + if let date = account.lastSyncAt { LabeledContent("Последняя", value:date.formatted(date:.abbreviated, time:.shortened)) } + Button("Выйти", role:.destructive) { Task { await account.logout() } } + .disabled(account.isBusy) + } + } + if account.isBusy { ProgressView() } + if let message = account.message { Section { Text(message).font(.footnote).foregroundStyle(.secondary) } } + } + .navigationTitle("Сервер") + } +} + +private extension View { + @ViewBuilder + func serverTextEntryConfiguration() -> some View { + #if os(macOS) + self + #else + self + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + } +} diff --git a/BalanceWatch/Utilities/CurrencyFormatting.swift b/BalanceWatch/Utilities/CurrencyFormatting.swift new file mode 100644 index 0000000..04b78d8 --- /dev/null +++ b/BalanceWatch/Utilities/CurrencyFormatting.swift @@ -0,0 +1,26 @@ +import Foundation + +enum SupportedCurrency: String, CaseIterable, Identifiable { + case RUB + case EUR + case USD + case SEK + + var id: String { rawValue } +} + +extension Double { + func formattedCurrency(code: String) -> String { + formatted( + .currency(code: code) + .precision(.fractionLength(0 ... 2)) + .presentation(.narrow) + ) + } +} + +extension Date { + var monthTitle: String { + formatted(.dateTime.month(.wide).year()) + } +} diff --git a/BalanceWatch/Utilities/FinanceCalculations.swift b/BalanceWatch/Utilities/FinanceCalculations.swift new file mode 100644 index 0000000..b0ccd88 --- /dev/null +++ b/BalanceWatch/Utilities/FinanceCalculations.swift @@ -0,0 +1,71 @@ +import Foundation + +struct FinancePeriodSummary { + let income: Double + let expenses: Double + + var balance: Double { income - expenses } +} + +struct CategorySpending: Identifiable { + let name: String + let icon: String + let emoji: String + let colorName: String + let amount: Double + + var id: String { name } +} + +enum FinanceCalculations { + static func totalBalance(transactions: [FinanceTransaction]) -> Double { + transactions.reduce(0) { balance, transaction in + balance + (transaction.kind == .income ? transaction.amount : -transaction.amount) + } + } + + static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval { + calendar.dateInterval(of: .month, for: date) + ?? DateInterval(start: date, duration: 30 * 24 * 60 * 60) + } + + static func summary( + transactions: [FinanceTransaction], + in interval: DateInterval + ) -> FinancePeriodSummary { + let periodTransactions = transactions.filter { + interval.contains($0.date) && !$0.isBalanceAdjustment + } + let income = periodTransactions + .filter { $0.kind == .income } + .reduce(0) { $0 + $1.amount } + let expenses = periodTransactions + .filter { $0.kind == .expense } + .reduce(0) { $0 + $1.amount } + + return FinancePeriodSummary(income: income, expenses: expenses) + } + + static func spendingByCategory( + transactions: [FinanceTransaction], + in interval: DateInterval + ) -> [CategorySpending] { + let expenses = transactions.filter { + $0.kind == .expense + && !$0.isBalanceAdjustment + && interval.contains($0.date) + } + let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName) + + return grouped.map { name, items in + CategorySpending( + name: name, + icon: items.first?.categoryIcon ?? "circle.fill", + emoji: items.first?.categoryEmoji ?? "", + colorName: items.first?.categoryColorName ?? "auto", + amount: items.reduce(0) { $0 + $1.amount } + ) + } + .sorted { $0.amount > $1.amount } + } +} diff --git a/BalanceWatch/Views/Components/CategoryIconView.swift b/BalanceWatch/Views/Components/CategoryIconView.swift new file mode 100644 index 0000000..522a85d --- /dev/null +++ b/BalanceWatch/Views/Components/CategoryIconView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct CategoryIconView: View { + let systemName: String + let emoji: String + let tint: Color + var size: CGFloat = 18 + var containerSize: CGFloat? + + var body: some View { + Group { + if let containerSize { + glyph + .frame(width: containerSize, height: containerSize) + .background(tint.opacity(0.12), in: Circle()) + } else { + glyph + .frame(width: size + 12, height: size + 12) + } + } + .accessibilityHidden(true) + } + + @ViewBuilder + private var glyph: some View { + if emoji.isEmpty { + Image(systemName: systemName) + .font(.system(size: size, weight: .medium)) + .foregroundStyle(tint) + } else { + Text(emoji) + .font(.system(size: size)) + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..02b3178 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# BalanceWatch + +Самостоятельное (watch-only) приложение Balance для watchOS 10+, выделенное +из монорепозитория Balance (`Balance/BalanceWatch` + общий код). + +Стек: SwiftUI + SwiftData, синхронизация через self-hosted BalanceServer. + +## Структура + +```text +BalanceWatch/ +├── BalanceWatch.xcodeproj/ +└── BalanceWatch/ + ├── App/ # точка входа и экраны часов + ├── Models/ # SwiftData-модели (копия из монорепы) + ├── Shared/ # контейнер SwiftData, тема, ServerSync + ├── Utilities/ # FinanceCalculations, форматирование валют + ├── Views/Components/ # CategoryIconView + └── Assets.xcassets # иконка приложения +``` + +Проект использует папки, синхронизируемые с файловой системой (Xcode 16+): +любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в +таргет — править `project.pbxproj` вручную не нужно. + +## Сборка + +1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее. +2. Выбрать схему `BalanceWatch` и симулятор Apple Watch. +3. В Signing & Capabilities при необходимости поменять Team и + bundle id (`com.tolamironcenko.Balance.watchkitapp`). + +Из командной строки: + +```bash +xcodebuild -project BalanceWatch.xcodeproj -scheme BalanceWatch \ + -destination 'generic/platform=watchOS Simulator' build +``` + +## CI + +GitHub Actions (`.github/workflows/build.yml`) собирает проект на +`macos-15` с последним стабильным Xcode для двух назначений — watchOS +Simulator и watchOS device (без подписи). Запускается на push в +`main`/`develop`, на pull request и вручную (Run workflow). При падении +лог `xcodebuild` прикладывается к запуску как артефакт. + +## Важно + +Модели, `FinanceCalculations.swift` и `ServerSync.swift` — копии общего +кода из монорепозитория. Протокол синхронизации и правила расчётов должны +совпадать с остальными клиентами Balance: при изменении контракта на +сервере переносите правки и сюда.