commit bd90fe1d65030780219ba4eba851b1d6f77f7e9b Author: TolaMironcenko Date: Tue Jul 14 16:20:39 2026 +0700 4.1.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03bd412 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.env diff --git a/Balance/.gitignore b/Balance/.gitignore new file mode 100644 index 0000000..2b6154d --- /dev/null +++ b/Balance/.gitignore @@ -0,0 +1,12 @@ +# Xcode +DerivedData/ +build/ +*.xcuserstate +xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/ + +# macOS +.DS_Store diff --git a/Balance/Balance.xcodeproj/project.pbxproj b/Balance/Balance.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4b7c284 --- /dev/null +++ b/Balance/Balance.xcodeproj/project.pbxproj @@ -0,0 +1,957 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + B00100000000000000000001 /* BalanceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00100000000000000000001 /* BalanceApp.swift */; }; + B00200000000000000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00200000000000000000002 /* ContentView.swift */; }; + B00300000000000000000003 /* FinanceCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00300000000000000000003 /* FinanceCategory.swift */; }; + B00400000000000000000004 /* FinanceTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00400000000000000000004 /* FinanceTransaction.swift */; }; + B00500000000000000000005 /* MonthlyBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00500000000000000000005 /* MonthlyBudget.swift */; }; + B00600000000000000000006 /* CurrencyFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00600000000000000000006 /* CurrencyFormatting.swift */; }; + B00700000000000000000007 /* FinanceCalculations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00700000000000000000007 /* FinanceCalculations.swift */; }; + B00800000000000000000008 /* BudgetEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00800000000000000000008 /* BudgetEditorView.swift */; }; + B00900000000000000000009 /* BudgetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00900000000000000000009 /* BudgetsView.swift */; }; + B00A0000000000000000000A /* EmptyStateCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00A0000000000000000000A /* EmptyStateCard.swift */; }; + B00B0000000000000000000B /* SummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00B0000000000000000000B /* SummaryCard.swift */; }; + B00C0000000000000000000C /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00C0000000000000000000C /* DashboardView.swift */; }; + B00D0000000000000000000D /* CSVDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00D0000000000000000000D /* CSVDocument.swift */; }; + B00E0000000000000000000E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00E0000000000000000000E /* SettingsView.swift */; }; + B00F0000000000000000000F /* NewTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00F0000000000000000000F /* NewTransactionView.swift */; }; + B01000000000000000000010 /* TransactionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01000000000000000000010 /* TransactionRow.swift */; }; + B01100000000000000000011 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01100000000000000000011 /* TransactionsView.swift */; }; + B01200000000000000000012 /* FinanceCalculationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01200000000000000000012 /* FinanceCalculationsTests.swift */; }; + B01300000000000000000013 /* CustomCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01400000000000000000014 /* CustomCategory.swift */; }; + B01400000000000000000014 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01500000000000000000015 /* AnalyticsView.swift */; }; + B01500000000000000000015 /* CategoriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01600000000000000000016 /* CategoriesView.swift */; }; + B01600000000000000000016 /* BalanceAdjustmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01700000000000000000017 /* BalanceAdjustmentView.swift */; }; + B01700000000000000000017 /* CategoryIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01800000000000000000018 /* CategoryIconView.swift */; }; + B01800000000000000000018 /* BalanceModelContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02000000000000000000020 /* BalanceModelContainer.swift */; }; + B02000000000000000000020 /* BalanceMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02100000000000000000021 /* BalanceMacApp.swift */; }; + B02100000000000000000021 /* MacContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02200000000000000000022 /* MacContentView.swift */; }; + B02200000000000000000022 /* MacEditors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02300000000000000000023 /* MacEditors.swift */; }; + B02300000000000000000023 /* FinanceCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00300000000000000000003 /* FinanceCategory.swift */; }; + B02400000000000000000024 /* FinanceTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00400000000000000000004 /* FinanceTransaction.swift */; }; + B02500000000000000000025 /* MonthlyBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00500000000000000000005 /* MonthlyBudget.swift */; }; + B02600000000000000000026 /* CustomCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01400000000000000000014 /* CustomCategory.swift */; }; + B02700000000000000000027 /* CurrencyFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00600000000000000000006 /* CurrencyFormatting.swift */; }; + B02800000000000000000028 /* FinanceCalculations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00700000000000000000007 /* FinanceCalculations.swift */; }; + B02900000000000000000029 /* CategoryIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01800000000000000000018 /* CategoryIconView.swift */; }; + B02A0000000000000000002A /* BalanceModelContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02000000000000000000020 /* BalanceModelContainer.swift */; }; + B03000000000000000000030 /* BalanceWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02400000000000000000024 /* BalanceWatchApp.swift */; }; + B03100000000000000000031 /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02500000000000000000025 /* WatchRootView.swift */; }; + B03200000000000000000032 /* WatchAddTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02600000000000000000026 /* WatchAddTransactionView.swift */; }; + B03300000000000000000033 /* FinanceCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00300000000000000000003 /* FinanceCategory.swift */; }; + B03400000000000000000034 /* FinanceTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00400000000000000000004 /* FinanceTransaction.swift */; }; + B03500000000000000000035 /* MonthlyBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00500000000000000000005 /* MonthlyBudget.swift */; }; + B03600000000000000000036 /* CustomCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01400000000000000000014 /* CustomCategory.swift */; }; + B03700000000000000000037 /* CurrencyFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00600000000000000000006 /* CurrencyFormatting.swift */; }; + B03800000000000000000038 /* FinanceCalculations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00700000000000000000007 /* FinanceCalculations.swift */; }; + B03900000000000000000039 /* CategoryIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F01800000000000000000018 /* CategoryIconView.swift */; }; + B03A0000000000000000003A /* BalanceModelContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02000000000000000000020 /* BalanceModelContainer.swift */; }; + B04000000000000000000040 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02A0000000000000000002A /* AppTheme.swift */; }; + B04100000000000000000041 /* CategoryDesignOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02B0000000000000000002B /* CategoryDesignOptions.swift */; }; + B04200000000000000000042 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02A0000000000000000002A /* AppTheme.swift */; }; + B04300000000000000000043 /* CategoryDesignOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02B0000000000000000002B /* CategoryDesignOptions.swift */; }; + B04400000000000000000044 /* MacFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02C0000000000000000002C /* MacFeatures.swift */; }; + B04500000000000000000045 /* CSVDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00D0000000000000000000D /* CSVDocument.swift */; }; + B04600000000000000000046 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02A0000000000000000002A /* AppTheme.swift */; }; + B04700000000000000000047 /* CategoryDesignOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02B0000000000000000002B /* CategoryDesignOptions.swift */; }; + B04800000000000000000048 /* WatchFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = F02D0000000000000000002D /* WatchFeatures.swift */; }; + B04900000000000000000049 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F02E0000000000000000002E /* Assets.xcassets */; }; + B04A0000000000000000004A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F02F0000000000000000002F /* Assets.xcassets */; }; + B04B0000000000000000004B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F03000000000000000000030 /* Assets.xcassets */; }; + B04C0000000000000000004C /* SyncTombstone.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03100000000000000000031 /* SyncTombstone.swift */; }; + B04D0000000000000000004D /* SyncTombstone.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03100000000000000000031 /* SyncTombstone.swift */; }; + B04E0000000000000000004E /* SyncTombstone.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03100000000000000000031 /* SyncTombstone.swift */; }; + B04F0000000000000000004F /* ServerSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03200000000000000000032 /* ServerSync.swift */; }; + B05000000000000000000050 /* ServerSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03200000000000000000032 /* ServerSync.swift */; }; + B05100000000000000000051 /* ServerSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03200000000000000000032 /* ServerSync.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + X00100000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = P00100000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = T00100000000000000000001; + remoteInfo = Balance; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + F00100000000000000000001 /* BalanceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceApp.swift; sourceTree = ""; }; + F00200000000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + F00300000000000000000003 /* FinanceCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceCategory.swift; sourceTree = ""; }; + F00400000000000000000004 /* FinanceTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceTransaction.swift; sourceTree = ""; }; + F00500000000000000000005 /* MonthlyBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthlyBudget.swift; sourceTree = ""; }; + F00600000000000000000006 /* CurrencyFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyFormatting.swift; sourceTree = ""; }; + F00700000000000000000007 /* FinanceCalculations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceCalculations.swift; sourceTree = ""; }; + F00800000000000000000008 /* BudgetEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetEditorView.swift; sourceTree = ""; }; + F00900000000000000000009 /* BudgetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsView.swift; sourceTree = ""; }; + F00A0000000000000000000A /* EmptyStateCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateCard.swift; sourceTree = ""; }; + F00B0000000000000000000B /* SummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryCard.swift; sourceTree = ""; }; + F00C0000000000000000000C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + F00D0000000000000000000D /* CSVDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVDocument.swift; sourceTree = ""; }; + F00E0000000000000000000E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + F00F0000000000000000000F /* NewTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTransactionView.swift; sourceTree = ""; }; + F01000000000000000000010 /* TransactionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRow.swift; sourceTree = ""; }; + F01100000000000000000011 /* TransactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsView.swift; sourceTree = ""; }; + F01200000000000000000012 /* FinanceCalculationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceCalculationsTests.swift; sourceTree = ""; }; + F01300000000000000000013 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + F01400000000000000000014 /* CustomCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCategory.swift; sourceTree = ""; }; + F01500000000000000000015 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; }; + F01600000000000000000016 /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = ""; }; + F01700000000000000000017 /* BalanceAdjustmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceAdjustmentView.swift; sourceTree = ""; }; + F01800000000000000000018 /* CategoryIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryIconView.swift; sourceTree = ""; }; + F01900000000000000000019 /* Balance.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Balance.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F01A0000000000000000001A /* BalanceTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BalanceTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F01B0000000000000000001B /* BalanceMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BalanceMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F01C0000000000000000001C /* BalanceWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BalanceWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F02000000000000000000020 /* BalanceModelContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceModelContainer.swift; sourceTree = ""; }; + F02100000000000000000021 /* BalanceMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceMacApp.swift; sourceTree = ""; }; + F02200000000000000000022 /* MacContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacContentView.swift; sourceTree = ""; }; + F02300000000000000000023 /* MacEditors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacEditors.swift; sourceTree = ""; }; + F02400000000000000000024 /* BalanceWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceWatchApp.swift; sourceTree = ""; }; + F02500000000000000000025 /* WatchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = ""; }; + F02600000000000000000026 /* WatchAddTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchAddTransactionView.swift; sourceTree = ""; }; + F02700000000000000000027 /* Balance.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Balance.entitlements; sourceTree = ""; }; + F02800000000000000000028 /* BalanceMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BalanceMac.entitlements; sourceTree = ""; }; + F02900000000000000000029 /* BalanceWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BalanceWatch.entitlements; sourceTree = ""; }; + F02A0000000000000000002A /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + F02B0000000000000000002B /* CategoryDesignOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDesignOptions.swift; sourceTree = ""; }; + F02C0000000000000000002C /* MacFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacFeatures.swift; sourceTree = ""; }; + F02D0000000000000000002D /* WatchFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFeatures.swift; sourceTree = ""; }; + F02E0000000000000000002E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F02F0000000000000000002F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F03000000000000000000030 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F03100000000000000000031 /* SyncTombstone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTombstone.swift; sourceTree = ""; }; + F03200000000000000000032 /* ServerSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSync.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + S00200000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00500000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00800000000000000000008 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00B0000000000000000000B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + G00100000000000000000001 = { + isa = PBXGroup; + children = ( + G00200000000000000000002 /* Balance */, + G01000000000000000000010 /* BalanceMac */, + G01100000000000000000011 /* BalanceWatch */, + G01200000000000000000012 /* Config */, + G00A0000000000000000000A /* BalanceTests */, + F01300000000000000000013 /* README.md */, + G00B0000000000000000000B /* Products */, + ); + sourceTree = ""; + }; + G00200000000000000000002 /* Balance */ = { + isa = PBXGroup; + children = ( + G00300000000000000000003 /* App */, + G00400000000000000000004 /* Models */, + G00500000000000000000005 /* Utilities */, + G00F0000000000000000000F /* Shared */, + G01300000000000000000013 /* Resources */, + G00600000000000000000006 /* Views */, + ); + path = Balance; + sourceTree = ""; + }; + G00300000000000000000003 /* App */ = { + isa = PBXGroup; + children = ( + F00100000000000000000001 /* BalanceApp.swift */, + F00200000000000000000002 /* ContentView.swift */, + ); + path = App; + sourceTree = ""; + }; + G00400000000000000000004 /* Models */ = { + isa = PBXGroup; + children = ( + F00300000000000000000003 /* FinanceCategory.swift */, + F00400000000000000000004 /* FinanceTransaction.swift */, + F00500000000000000000005 /* MonthlyBudget.swift */, + F01400000000000000000014 /* CustomCategory.swift */, + F03100000000000000000031 /* SyncTombstone.swift */, + ); + path = Models; + sourceTree = ""; + }; + G00500000000000000000005 /* Utilities */ = { + isa = PBXGroup; + children = ( + F00600000000000000000006 /* CurrencyFormatting.swift */, + F00700000000000000000007 /* FinanceCalculations.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + G00600000000000000000006 /* Views */ = { + isa = PBXGroup; + children = ( + G00700000000000000000007 /* Components */, + G00800000000000000000008 /* Dashboard */, + G00E0000000000000000000E /* Analytics */, + G00900000000000000000009 /* Transactions */, + G00C0000000000000000000C /* Budgets */, + G00D0000000000000000000D /* Settings */, + ); + path = Views; + sourceTree = ""; + }; + G00700000000000000000007 /* Components */ = { + isa = PBXGroup; + children = ( + F00A0000000000000000000A /* EmptyStateCard.swift */, + F00B0000000000000000000B /* SummaryCard.swift */, + F01800000000000000000018 /* CategoryIconView.swift */, + ); + path = Components; + sourceTree = ""; + }; + G00800000000000000000008 /* Dashboard */ = { + isa = PBXGroup; + children = ( + F00C0000000000000000000C /* DashboardView.swift */, + F01700000000000000000017 /* BalanceAdjustmentView.swift */, + ); + path = Dashboard; + sourceTree = ""; + }; + G00900000000000000000009 /* Transactions */ = { + isa = PBXGroup; + children = ( + F00F0000000000000000000F /* NewTransactionView.swift */, + F01000000000000000000010 /* TransactionRow.swift */, + F01100000000000000000011 /* TransactionsView.swift */, + ); + path = Transactions; + sourceTree = ""; + }; + G00A0000000000000000000A /* BalanceTests */ = { + isa = PBXGroup; + children = ( + F01200000000000000000012 /* FinanceCalculationsTests.swift */, + ); + path = BalanceTests; + sourceTree = ""; + }; + G00B0000000000000000000B /* Products */ = { + isa = PBXGroup; + children = ( + F01900000000000000000019 /* Balance.app */, + F01A0000000000000000001A /* BalanceTests.xctest */, + F01B0000000000000000001B /* BalanceMac.app */, + F01C0000000000000000001C /* BalanceWatch.app */, + ); + name = Products; + sourceTree = ""; + }; + G00C0000000000000000000C /* Budgets */ = { + isa = PBXGroup; + children = ( + F00800000000000000000008 /* BudgetEditorView.swift */, + F00900000000000000000009 /* BudgetsView.swift */, + ); + path = Budgets; + sourceTree = ""; + }; + G00D0000000000000000000D /* Settings */ = { + isa = PBXGroup; + children = ( + F00D0000000000000000000D /* CSVDocument.swift */, + F00E0000000000000000000E /* SettingsView.swift */, + F01600000000000000000016 /* CategoriesView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + G00E0000000000000000000E /* Analytics */ = { + isa = PBXGroup; + children = ( + F01500000000000000000015 /* AnalyticsView.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + G00F0000000000000000000F /* Shared */ = { + isa = PBXGroup; + children = ( + F02000000000000000000020 /* BalanceModelContainer.swift */, + F02A0000000000000000002A /* AppTheme.swift */, + F02B0000000000000000002B /* CategoryDesignOptions.swift */, + F03200000000000000000032 /* ServerSync.swift */, + ); + path = Shared; + sourceTree = ""; + }; + G01000000000000000000010 /* BalanceMac */ = { + isa = PBXGroup; + children = ( + F02100000000000000000021 /* BalanceMacApp.swift */, + F02200000000000000000022 /* MacContentView.swift */, + F02300000000000000000023 /* MacEditors.swift */, + F02C0000000000000000002C /* MacFeatures.swift */, + F02F0000000000000000002F /* Assets.xcassets */, + ); + path = BalanceMac; + sourceTree = ""; + }; + G01100000000000000000011 /* BalanceWatch */ = { + isa = PBXGroup; + children = ( + F02400000000000000000024 /* BalanceWatchApp.swift */, + F02500000000000000000025 /* WatchRootView.swift */, + F02600000000000000000026 /* WatchAddTransactionView.swift */, + F02D0000000000000000002D /* WatchFeatures.swift */, + F03000000000000000000030 /* Assets.xcassets */, + ); + path = BalanceWatch; + sourceTree = ""; + }; + G01200000000000000000012 /* Config */ = { + isa = PBXGroup; + children = ( + F02700000000000000000027 /* Balance.entitlements */, + F02800000000000000000028 /* BalanceMac.entitlements */, + F02900000000000000000029 /* BalanceWatch.entitlements */, + ); + path = Config; + sourceTree = ""; + }; + G01300000000000000000013 /* Resources */ = { + isa = PBXGroup; + children = ( + F02E0000000000000000002E /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + T00100000000000000000001 /* Balance */ = { + isa = PBXNativeTarget; + buildConfigurationList = L00200000000000000000002 /* Build configuration list for PBXNativeTarget "Balance" */; + buildPhases = ( + S00100000000000000000001 /* Sources */, + S00200000000000000000002 /* Frameworks */, + S00300000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Balance; + productName = Balance; + productReference = F01900000000000000000019 /* Balance.app */; + productType = "com.apple.product-type.application"; + }; + T00200000000000000000002 /* BalanceTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = L00300000000000000000003 /* Build configuration list for PBXNativeTarget "BalanceTests" */; + buildPhases = ( + S00400000000000000000004 /* Sources */, + S00500000000000000000005 /* Frameworks */, + S00600000000000000000006 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D00100000000000000000001 /* PBXTargetDependency */, + ); + name = BalanceTests; + productName = BalanceTests; + productReference = F01A0000000000000000001A /* BalanceTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + T00300000000000000000003 /* BalanceMac */ = { + isa = PBXNativeTarget; + buildConfigurationList = L00400000000000000000004 /* Build configuration list for PBXNativeTarget "BalanceMac" */; + buildPhases = ( + S00700000000000000000007 /* Sources */, + S00800000000000000000008 /* Frameworks */, + S00900000000000000000009 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BalanceMac; + productName = BalanceMac; + productReference = F01B0000000000000000001B /* BalanceMac.app */; + productType = "com.apple.product-type.application"; + }; + T00400000000000000000004 /* BalanceWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = L00500000000000000000005 /* Build configuration list for PBXNativeTarget "BalanceWatch" */; + buildPhases = ( + S00A0000000000000000000A /* Sources */, + S00B0000000000000000000B /* Frameworks */, + S00C0000000000000000000C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BalanceWatch; + 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 = { + T00100000000000000000001 = { + CreatedOnToolsVersion = 16.0; + }; + T00200000000000000000002 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = T00100000000000000000001; + }; + T00300000000000000000003 = { + CreatedOnToolsVersion = 16.0; + }; + T00400000000000000000004 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = L00100000000000000000001 /* Build configuration list for PBXProject "Balance" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = ru; + hasScannedForEncodings = 0; + knownRegions = ( + ru, + en, + Base, + ); + mainGroup = G00100000000000000000001; + productRefGroup = G00B0000000000000000000B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + T00100000000000000000001 /* Balance */, + T00200000000000000000002 /* BalanceTests */, + T00300000000000000000003 /* BalanceMac */, + T00400000000000000000004 /* BalanceWatch */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + S00300000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B04900000000000000000049 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00600000000000000000006 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00900000000000000000009 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B04A0000000000000000004A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00C0000000000000000000C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B04B0000000000000000004B /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + S00100000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B00100000000000000000001 /* BalanceApp.swift in Sources */, + B00200000000000000000002 /* ContentView.swift in Sources */, + B00300000000000000000003 /* FinanceCategory.swift in Sources */, + B00400000000000000000004 /* FinanceTransaction.swift in Sources */, + B00500000000000000000005 /* MonthlyBudget.swift in Sources */, + B00600000000000000000006 /* CurrencyFormatting.swift in Sources */, + B00700000000000000000007 /* FinanceCalculations.swift in Sources */, + B00800000000000000000008 /* BudgetEditorView.swift in Sources */, + B00900000000000000000009 /* BudgetsView.swift in Sources */, + B00A0000000000000000000A /* EmptyStateCard.swift in Sources */, + B00B0000000000000000000B /* SummaryCard.swift in Sources */, + B00C0000000000000000000C /* DashboardView.swift in Sources */, + B00D0000000000000000000D /* CSVDocument.swift in Sources */, + B00E0000000000000000000E /* SettingsView.swift in Sources */, + B00F0000000000000000000F /* NewTransactionView.swift in Sources */, + B01000000000000000000010 /* TransactionRow.swift in Sources */, + B01100000000000000000011 /* TransactionsView.swift in Sources */, + B01300000000000000000013 /* CustomCategory.swift in Sources */, + B01400000000000000000014 /* AnalyticsView.swift in Sources */, + B01500000000000000000015 /* CategoriesView.swift in Sources */, + B01600000000000000000016 /* BalanceAdjustmentView.swift in Sources */, + B01700000000000000000017 /* CategoryIconView.swift in Sources */, + B01800000000000000000018 /* BalanceModelContainer.swift in Sources */, + B04000000000000000000040 /* AppTheme.swift in Sources */, + B04100000000000000000041 /* CategoryDesignOptions.swift in Sources */, + B04C0000000000000000004C /* SyncTombstone.swift in Sources */, + B04F0000000000000000004F /* ServerSync.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00400000000000000000004 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B01200000000000000000012 /* FinanceCalculationsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00700000000000000000007 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B02000000000000000000020 /* BalanceMacApp.swift in Sources */, + B02100000000000000000021 /* MacContentView.swift in Sources */, + B02200000000000000000022 /* MacEditors.swift in Sources */, + B02300000000000000000023 /* FinanceCategory.swift in Sources */, + B02400000000000000000024 /* FinanceTransaction.swift in Sources */, + B02500000000000000000025 /* MonthlyBudget.swift in Sources */, + B02600000000000000000026 /* CustomCategory.swift in Sources */, + B02700000000000000000027 /* CurrencyFormatting.swift in Sources */, + B02800000000000000000028 /* FinanceCalculations.swift in Sources */, + B02900000000000000000029 /* CategoryIconView.swift in Sources */, + B02A0000000000000000002A /* BalanceModelContainer.swift in Sources */, + B04200000000000000000042 /* AppTheme.swift in Sources */, + B04300000000000000000043 /* CategoryDesignOptions.swift in Sources */, + B04400000000000000000044 /* MacFeatures.swift in Sources */, + B04500000000000000000045 /* CSVDocument.swift in Sources */, + B04D0000000000000000004D /* SyncTombstone.swift in Sources */, + B05000000000000000000050 /* ServerSync.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + S00A0000000000000000000A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B03000000000000000000030 /* BalanceWatchApp.swift in Sources */, + B03100000000000000000031 /* WatchRootView.swift in Sources */, + B03200000000000000000032 /* WatchAddTransactionView.swift in Sources */, + B03300000000000000000033 /* FinanceCategory.swift in Sources */, + B03400000000000000000034 /* FinanceTransaction.swift in Sources */, + B03500000000000000000035 /* MonthlyBudget.swift in Sources */, + B03600000000000000000036 /* CustomCategory.swift in Sources */, + B03700000000000000000037 /* CurrencyFormatting.swift in Sources */, + B03800000000000000000038 /* FinanceCalculations.swift in Sources */, + B03900000000000000000039 /* CategoryIconView.swift in Sources */, + B03A0000000000000000003A /* BalanceModelContainer.swift in Sources */, + B04600000000000000000046 /* AppTheme.swift in Sources */, + B04700000000000000000047 /* CategoryDesignOptions.swift in Sources */, + B04800000000000000000048 /* WatchFeatures.swift in Sources */, + B04E0000000000000000004E /* SyncTombstone.swift in Sources */, + B05100000000000000000051 /* ServerSync.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + D00100000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = T00100000000000000000001 /* Balance */; + targetProxy = X00100000000000000000001 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C00300000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + MARKETING_VERSION = 3.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + C00400000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; + MARKETING_VERSION = 3.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + C00500000000000000000005 /* 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.example.BalanceTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Balance.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Balance"; + }; + name = Debug; + }; + C00600000000000000000006 /* 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.example.BalanceTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Balance.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Balance"; + }; + name = Release; + }; + C00700000000000000000007 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 3.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance.mac; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C00800000000000000000008 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 3.1.1; + PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance.mac; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SUPPORTED_PLATFORMS = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C00900000000000000000009 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 14; + 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.example.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; + 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.example.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 "Balance" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00100000000000000000001 /* Debug */, + C00200000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + L00200000000000000000002 /* Build configuration list for PBXNativeTarget "Balance" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00300000000000000000003 /* Debug */, + C00400000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + L00300000000000000000003 /* Build configuration list for PBXNativeTarget "BalanceTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00500000000000000000005 /* Debug */, + C00600000000000000000006 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + L00400000000000000000004 /* Build configuration list for PBXNativeTarget "BalanceMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C00700000000000000000007 /* Debug */, + C00800000000000000000008 /* 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/Balance/Balance.xcodeproj/xcshareddata/xcschemes/Balance.xcscheme b/Balance/Balance.xcodeproj/xcshareddata/xcschemes/Balance.xcscheme new file mode 100644 index 0000000..f4595ee --- /dev/null +++ b/Balance/Balance.xcodeproj/xcshareddata/xcschemes/Balance.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Balance/Balance.xcodeproj/xcshareddata/xcschemes/BalanceMac.xcscheme b/Balance/Balance.xcodeproj/xcshareddata/xcschemes/BalanceMac.xcscheme new file mode 100644 index 0000000..afd8bf5 --- /dev/null +++ b/Balance/Balance.xcodeproj/xcshareddata/xcschemes/BalanceMac.xcscheme @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Balance/Balance.xcodeproj/xcshareddata/xcschemes/BalanceWatch.xcscheme b/Balance/Balance.xcodeproj/xcshareddata/xcschemes/BalanceWatch.xcscheme new file mode 100644 index 0000000..667ee8d --- /dev/null +++ b/Balance/Balance.xcodeproj/xcshareddata/xcschemes/BalanceWatch.xcscheme @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Balance/Balance/App/BalanceApp.swift b/Balance/Balance/App/BalanceApp.swift new file mode 100644 index 0000000..fde9ed8 --- /dev/null +++ b/Balance/Balance/App/BalanceApp.swift @@ -0,0 +1,19 @@ +import SwiftData +import SwiftUI + +@main +struct BalanceApp: App { + @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + private let modelContainer = BalanceModelContainer.make() + + var body: some Scene { + WindowGroup { + ContentView() + .tint(.indigo) + .preferredColorScheme( + AppTheme(rawValue: appTheme)?.colorScheme + ) + } + .modelContainer(modelContainer) + } +} diff --git a/Balance/Balance/App/ContentView.swift b/Balance/Balance/App/ContentView.swift new file mode 100644 index 0000000..dd17df6 --- /dev/null +++ b/Balance/Balance/App/ContentView.swift @@ -0,0 +1,40 @@ +import SwiftData +import SwiftUI + +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + var body: some View { + TabView { + DashboardView() + .tabItem { + Label("Обзор", systemImage: "chart.pie.fill") + } + + AnalyticsView() + .tabItem { + Label("Аналитика", systemImage: "chart.xyaxis.line") + } + + TransactionsView() + .tabItem { + Label("Операции", systemImage: "list.bullet.rectangle") + } + + BudgetsView() + .tabItem { + Label("Бюджеты", systemImage: "target") + } + + SettingsView() + .tabItem { + Label("Настройки", systemImage: "gearshape.fill") + } + } + .task { + while !Task.isCancelled { + await ServerAccountStore.shared.sync(context: modelContext) + try? await Task.sleep(nanoseconds: 120_000_000_000) + } + } + } +} diff --git a/Balance/Balance/Models/CustomCategory.swift b/Balance/Balance/Models/CustomCategory.swift new file mode 100644 index 0000000..e2e32ae --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Models/FinanceCategory.swift b/Balance/Balance/Models/FinanceCategory.swift new file mode 100644 index 0000000..70efa31 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Models/FinanceTransaction.swift b/Balance/Balance/Models/FinanceTransaction.swift new file mode 100644 index 0000000..abe46c8 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Models/MonthlyBudget.swift b/Balance/Balance/Models/MonthlyBudget.swift new file mode 100644 index 0000000..df51cef --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Models/SyncTombstone.swift b/Balance/Balance/Models/SyncTombstone.swift new file mode 100644 index 0000000..a37460c --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Resources/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png b/Balance/Balance/Resources/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png new file mode 100644 index 0000000..f12b5f8 Binary files /dev/null and b/Balance/Balance/Resources/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png differ diff --git a/Balance/Balance/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Balance/Balance/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4a525a2 --- /dev/null +++ b/Balance/Balance/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "BalanceIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Balance/Balance/Resources/Assets.xcassets/Contents.json b/Balance/Balance/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Balance/Balance/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Balance/Balance/Shared/AppTheme.swift b/Balance/Balance/Shared/AppTheme.swift new file mode 100644 index 0000000..56347e6 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Shared/BalanceModelContainer.swift b/Balance/Balance/Shared/BalanceModelContainer.swift new file mode 100644 index 0000000..603e540 --- /dev/null +++ b/Balance/Balance/Shared/BalanceModelContainer.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftData + +enum BalanceModelContainer { + static let schema = Schema([ + FinanceTransaction.self, + MonthlyBudget.self, + CustomCategory.self, + 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, + 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/Balance/Balance/Shared/CategoryDesignOptions.swift b/Balance/Balance/Shared/CategoryDesignOptions.swift new file mode 100644 index 0000000..f3e4632 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Shared/ServerSync.swift b/Balance/Balance/Shared/ServerSync.swift new file mode 100644 index 0000000..75b54c9 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Utilities/CurrencyFormatting.swift b/Balance/Balance/Utilities/CurrencyFormatting.swift new file mode 100644 index 0000000..04b78d8 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Utilities/FinanceCalculations.swift b/Balance/Balance/Utilities/FinanceCalculations.swift new file mode 100644 index 0000000..b0ccd88 --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Views/Analytics/AnalyticsView.swift b/Balance/Balance/Views/Analytics/AnalyticsView.swift new file mode 100644 index 0000000..d947871 --- /dev/null +++ b/Balance/Balance/Views/Analytics/AnalyticsView.swift @@ -0,0 +1,357 @@ +import Charts +import SwiftData +import SwiftUI + +private enum AnalyticsPeriod: 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(now: Date = .now, calendar: Calendar = .current) -> DateInterval { + let currentMonth = calendar.dateInterval(of: .month, for: now) + ?? DateInterval(start: now, duration: 30 * 24 * 60 * 60) + let start: Date + + switch self { + case .month: + start = currentMonth.start + case .threeMonths: + start = calendar.date(byAdding: .month, value: -2, to: currentMonth.start) + ?? currentMonth.start + case .year: + start = calendar.date(byAdding: .month, value: -11, to: currentMonth.start) + ?? currentMonth.start + } + + return DateInterval(start: start, end: currentMonth.end) + } + + var bucketComponent: Calendar.Component { + switch self { + case .month: .day + case .threeMonths: .weekOfYear + case .year: .month + } + } + + var axisDateFormat: Date.FormatStyle { + switch self { + case .month: + .dateTime.day().month(.abbreviated) + case .threeMonths: + .dateTime.day().month(.abbreviated) + case .year: + .dateTime.month(.abbreviated) + } + } +} + +private struct CashFlowBucketKey: Hashable { + let date: Date + let kind: TransactionKind +} + +private struct CashFlowPoint: Identifiable { + let date: Date + let kind: TransactionKind + let amount: Double + + var id: String { "\(date.timeIntervalSince1970)-\(kind.rawValue)" } +} + +struct AnalyticsView: View { + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var period = AnalyticsPeriod.month + + private var interval: DateInterval { period.interval() } + + private var periodTransactions: [FinanceTransaction] { + transactions.filter { + interval.contains($0.date) && !$0.isBalanceAdjustment + } + } + + private var summary: FinancePeriodSummary { + FinanceCalculations.summary(transactions: transactions, in: interval) + } + + private var categorySpending: [CategorySpending] { + Array( + FinanceCalculations + .spendingByCategory(transactions: transactions, in: interval) + .prefix(6) + ) + } + + private var savingsRate: Double { + guard summary.income > 0 else { return 0 } + return summary.balance / summary.income * 100 + } + + private var averageDailyExpense: Double { + let end = min(Date.now, interval.end) + let days = max( + 1, + (Calendar.current.dateComponents([.day], from: interval.start, to: end).day ?? 0) + 1 + ) + return summary.expenses / Double(days) + } + + private var cashFlowPoints: [CashFlowPoint] { + let calendar = Calendar.current + var buckets: [CashFlowBucketKey: Double] = [:] + + for transaction in periodTransactions { + let bucketDate = calendar.dateInterval( + of: period.bucketComponent, + for: transaction.date + )?.start ?? transaction.date + let key = CashFlowBucketKey(date: bucketDate, kind: transaction.kind) + buckets[key, default: 0] += transaction.amount + } + + return buckets.map { key, amount in + CashFlowPoint(date: key.date, kind: key.kind, amount: amount) + } + .sorted { $0.date < $1.date } + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + Picker("Период", selection: $period) { + ForEach(AnalyticsPeriod.allCases) { period in + Text(period.title).tag(period) + } + } + .pickerStyle(.segmented) + + if periodTransactions.isEmpty { + EmptyStateCard( + icon: "chart.xyaxis.line", + title: "Недостаточно данных", + message: "Добавьте операции за выбранный период, чтобы увидеть аналитику." + ) + } else { + metricsGrid + cashFlowSection + categorySection + insightsSection + } + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Аналитика") + } + } + + private var metricsGrid: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + AnalyticsMetricCard( + title: "Доходы", + value: summary.income.formattedCurrency(code: currencyCode), + icon: "arrow.down.left", + tint: .green + ) + AnalyticsMetricCard( + title: "Расходы", + value: summary.expenses.formattedCurrency(code: currencyCode), + icon: "arrow.up.right", + tint: .red + ) + AnalyticsMetricCard( + title: "Результат", + value: summary.balance.formattedCurrency(code: currencyCode), + icon: "equal.circle.fill", + tint: summary.balance >= 0 ? .indigo : .red + ) + AnalyticsMetricCard( + title: "Сбережения", + value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%", + icon: "percent", + tint: savingsRate >= 0 ? .teal : .red + ) + } + } + + private var cashFlowSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Динамика") + .font(.title3.bold()) + + Chart(cashFlowPoints) { point in + LineMark( + x: .value("Дата", point.date), + y: .value("Сумма", point.amount) + ) + .foregroundStyle(by: .value("Тип", point.kind.title)) + .interpolationMethod(.catmullRom) + + PointMark( + x: .value("Дата", point.date), + y: .value("Сумма", point.amount) + ) + .foregroundStyle(by: .value("Тип", point.kind.title)) + .symbolSize(22) + } + .chartForegroundStyleScale([ + TransactionKind.income.title: Color.green, + TransactionKind.expense.title: Color.red + ]) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { _ in + AxisGridLine() + AxisValueLabel(format: period.axisDateFormat) + } + } + .chartYAxis { + AxisMarks(position: .leading) + } + .frame(height: 230) + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + } + + @ViewBuilder + private var categorySection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Структура расходов") + .font(.title3.bold()) + + if categorySpending.isEmpty { + Text("За выбранный период расходов нет.") + .font(.subheadline) + .foregroundStyle(.secondary) + } else { + HStack(alignment: .center, spacing: 18) { + Chart(categorySpending) { item in + SectorMark( + angle: .value("Сумма", item.amount), + innerRadius: .ratio(0.58), + angularInset: 2 + ) + .cornerRadius(4) + .foregroundStyle(tint(for: item)) + } + .frame(width: 130, height: 130) + + VStack(alignment: .leading, spacing: 9) { + ForEach(categorySpending.prefix(5)) { item in + HStack(spacing: 7) { + Circle() + .fill(tint(for: item)) + .frame(width: 8, height: 8) + Text(item.name) + .font(.caption) + .lineLimit(1) + Spacer() + Text(item.amount.formattedCurrency(code: currencyCode)) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + } + } + } + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + } + } + + private var insightsSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Главное") + .font(.title3.bold()) + + VStack(spacing: 0) { + insightRow( + icon: "calendar", + tint: .orange, + title: "Средний расход в день", + value: averageDailyExpense.formattedCurrency(code: currencyCode) + ) + + if let largestCategory = categorySpending.first { + Divider().padding(.leading, 54) + insightRow( + icon: largestCategory.icon, + emoji: largestCategory.emoji, + tint: tint(for: largestCategory), + title: "Крупнейшая категория", + value: largestCategory.name + ) + } + } + .padding(.horizontal) + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + } + + private func insightRow( + icon: String, + emoji: String = "", + tint: Color, + title: String, + value: String + ) -> some View { + HStack(spacing: 12) { + CategoryIconView( + systemName: icon, + emoji: emoji, + tint: tint, + size: emoji.isEmpty ? 16 : 20, + containerSize: 38 + ) + Text(title) + .font(.subheadline) + Spacer() + Text(value) + .font(.subheadline.weight(.semibold)) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 11) + } + + private func tint(for item: CategorySpending) -> Color { + if item.colorName == "auto" { + return FinanceCategory.find(named: item.name)?.tint ?? .indigo + } + return FinanceCategory.color(named: item.colorName) + } +} + +private struct AnalyticsMetricCard: View { + let title: String + let value: String + let icon: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Label(title, systemImage: icon) + .font(.caption.weight(.medium)) + .foregroundStyle(tint) + Text(value) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } +} diff --git a/Balance/Balance/Views/Budgets/BudgetEditorView.swift b/Balance/Balance/Views/Budgets/BudgetEditorView.swift new file mode 100644 index 0000000..ed0a254 --- /dev/null +++ b/Balance/Balance/Views/Budgets/BudgetEditorView.swift @@ -0,0 +1,113 @@ +import Foundation +import SwiftData +import SwiftUI + +struct BudgetEditorView: 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: String] + + init(budgets: [MonthlyBudget], monthStart: Date) { + self.budgets = budgets + self.monthStart = monthStart + _limits = State(initialValue: Dictionary(uniqueKeysWithValues: budgets.map { + ($0.categoryName, String(format: "%.0f", $0.limit)) + })) + } + + private var expenseCategories: [FinanceCategory] { + FinanceCategory.expenseCategories + customCategories + .filter { $0.kind == .expense } + .map(\.financeCategory) + } + + var body: some View { + NavigationStack { + Form { + Section { + Text("Введите месячный лимит. Пустое поле или 0 удалит бюджет категории.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Section("Расходы") { + ForEach(expenseCategories) { category in + HStack(spacing: 12) { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: category.tint, + size: 17 + ) + Text(category.name) + Spacer() + TextField("0", text: binding(for: category.name)) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(width: 110) + } + } + } + } + .navigationTitle("Лимиты") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Сохранить", action: save) + .fontWeight(.semibold) + } + } + } + } + + private func binding(for categoryName: String) -> Binding { + Binding( + get: { limits[categoryName, default: ""] }, + set: { limits[categoryName] = $0 } + ) + } + + private func parsedLimit(for categoryName: String) -> Double { + let text = limits[categoryName, default: ""] + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: ",", with: ".") + return max(0, Double(text) ?? 0) + } + + private func save() { + for category in expenseCategories { + let value = parsedLimit(for: category.name) + let existing = budgets.first { $0.categoryName == category.name } + + if value > 0 { + if let existing { + existing.limit = value + existing.categoryIcon = category.icon + existing.categoryEmoji = category.emoji + 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() + } +} diff --git a/Balance/Balance/Views/Budgets/BudgetsView.swift b/Balance/Balance/Views/Budgets/BudgetsView.swift new file mode 100644 index 0000000..7a2f46d --- /dev/null +++ b/Balance/Balance/Views/Budgets/BudgetsView.swift @@ -0,0 +1,118 @@ +import SwiftData +import SwiftUI + +struct BudgetsView: View { + @Query private var budgets: [MonthlyBudget] + @Query private var transactions: [FinanceTransaction] + @Query private var customCategories: [CustomCategory] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var showingEditor = false + + private var monthInterval: DateInterval { + FinanceCalculations.monthInterval(containing: .now) + } + + private var monthStart: Date { monthInterval.start } + + private var currentBudgets: [MonthlyBudget] { + budgets + .filter { monthInterval.contains($0.monthStart) } + .sorted { $0.categoryName < $1.categoryName } + } + + private var categorySpending: [String: Double] { + Dictionary( + uniqueKeysWithValues: FinanceCalculations + .spendingByCategory(transactions: transactions, in: monthInterval) + .map { ($0.name, $0.amount) } + ) + } + + var body: some View { + NavigationStack { + Group { + if currentBudgets.isEmpty { + ContentUnavailableView { + Label("Бюджеты не заданы", systemImage: "target") + } description: { + Text("Установите лимиты по категориям и следите за прогрессом в течение месяца.") + } actions: { + Button("Настроить бюджеты") { showingEditor = true } + .buttonStyle(.borderedProminent) + } + } else { + List { + Section { + ForEach(currentBudgets) { budget in + budgetRow(budget) + } + } header: { + Text(Date.now.monthTitle) + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Бюджеты") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Изменить") { showingEditor = true } + } + } + .sheet(isPresented: $showingEditor) { + BudgetEditorView(budgets: currentBudgets, monthStart: monthStart) + } + } + } + + private func budgetRow(_ budget: MonthlyBudget) -> some View { + let spent = categorySpending[budget.categoryName, default: 0] + let progress = budget.limit > 0 ? spent / budget.limit : 0 + let progressTint: Color = progress >= 1 ? .red : (progress >= 0.8 ? .orange : .indigo) + + return VStack(alignment: .leading, spacing: 10) { + HStack { + HStack(spacing: 6) { + CategoryIconView( + systemName: budget.categoryIcon, + emoji: budget.categoryEmoji, + tint: tint(for: budget), + size: 16 + ) + Text(budget.categoryName) + .font(.headline) + } + Spacer() + Text(spent.formattedCurrency(code: currencyCode)) + .font(.subheadline.weight(.semibold)) + Text("из \(budget.limit.formattedCurrency(code: currencyCode))") + .font(.caption) + .foregroundStyle(.secondary) + } + + ProgressView(value: min(progress, 1)) + .tint(progressTint) + + if progress >= 1 { + Text("Лимит превышен на \((spent - budget.limit).formattedCurrency(code: currencyCode))") + .font(.caption) + .foregroundStyle(.red) + } else { + Text("Осталось \(max(0, budget.limit - spent).formattedCurrency(code: currencyCode))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 7) + } + + private func tint(for budget: MonthlyBudget) -> Color { + if let builtIn = FinanceCategory.find(named: budget.categoryName) { + return builtIn.tint + } + if let custom = customCategories.first(where: { $0.name == budget.categoryName }) { + return FinanceCategory.color(named: custom.colorName) + } + return .indigo + } +} diff --git a/Balance/Balance/Views/Components/CategoryIconView.swift b/Balance/Balance/Views/Components/CategoryIconView.swift new file mode 100644 index 0000000..522a85d --- /dev/null +++ b/Balance/Balance/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/Balance/Balance/Views/Components/EmptyStateCard.swift b/Balance/Balance/Views/Components/EmptyStateCard.swift new file mode 100644 index 0000000..521beea --- /dev/null +++ b/Balance/Balance/Views/Components/EmptyStateCard.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct EmptyStateCard: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 34)) + .foregroundStyle(.indigo) + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(28) + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} diff --git a/Balance/Balance/Views/Components/SummaryCard.swift b/Balance/Balance/Views/Components/SummaryCard.swift new file mode 100644 index 0000000..2f0acf5 --- /dev/null +++ b/Balance/Balance/Views/Components/SummaryCard.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct SummaryCard: View { + let title: String + let amount: Double + let currencyCode: String + let icon: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: icon) + .font(.headline) + .foregroundStyle(tint) + .frame(width: 34, height: 34) + .background(tint.opacity(0.12), in: Circle()) + + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(amount.formattedCurrency(code: currencyCode)) + .font(.title3.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + .contentTransition(.numericText()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(color: .black.opacity(0.05), radius: 12, y: 5) + } +} diff --git a/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift b/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift new file mode 100644 index 0000000..ed779ba --- /dev/null +++ b/Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift @@ -0,0 +1,107 @@ +import Foundation +import SwiftData +import SwiftUI + +struct BalanceAdjustmentView: 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 desiredBalanceText = "" + @State private var note = "" + + private var currentBalance: Double { + FinanceCalculations.totalBalance(transactions: transactions) + } + + private var desiredBalance: Double? { + let normalized = desiredBalanceText + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: ",", with: ".") + return Double(normalized) + } + + private var difference: Double? { + guard let desiredBalance else { return nil } + return desiredBalance - currentBalance + } + + private var canSave: Bool { + guard let difference else { return false } + return abs(difference) >= 0.005 + } + + var body: some View { + NavigationStack { + Form { + Section { + LabeledContent( + "Расчётный баланс", + value: currentBalance.formattedCurrency(code: currencyCode) + ) + + HStack(alignment: .firstTextBaseline) { + TextField("Фактический баланс", text: $desiredBalanceText) + .font(.title2.bold()) + .keyboardType(.numbersAndPunctuation) + Text(currencyCode) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("Баланс") + } footer: { + Text("Приложение создаст отдельную операцию на разницу между расчётным и фактическим балансом.") + } + + if let difference, abs(difference) >= 0.005 { + Section("Корректировка") { + LabeledContent( + difference > 0 ? "Будет добавлен доход" : "Будет добавлен расход", + value: abs(difference).formattedCurrency(code: currencyCode) + ) + TextField("Комментарий (необязательно)", text: $note) + } + } + } + .navigationTitle("Корректировка баланса") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if desiredBalanceText.isEmpty { + desiredBalanceText = String(format: "%.2f", currentBalance) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Сохранить", action: save) + .fontWeight(.semibold) + .disabled(!canSave) + } + } + } + } + + private func save() { + guard let difference, canSave else { return } + let kind: TransactionKind = difference > 0 ? .income : .expense + modelContext.insert( + FinanceTransaction( + amount: abs(difference), + note: note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Ручная корректировка баланса" + : note.trimmingCharacters(in: .whitespacesAndNewlines), + categoryName: "Корректировка", + categoryIcon: "slider.horizontal.3", + categoryEmoji: "", + categoryColorName: "indigo", + isBalanceAdjustment: true, + kind: kind + ) + ) + dismiss() + } +} diff --git a/Balance/Balance/Views/Dashboard/DashboardView.swift b/Balance/Balance/Views/Dashboard/DashboardView.swift new file mode 100644 index 0000000..e4b5bad --- /dev/null +++ b/Balance/Balance/Views/Dashboard/DashboardView.swift @@ -0,0 +1,213 @@ +import Charts +import SwiftData +import SwiftUI + +struct DashboardView: View { + @Query(sort: \FinanceTransaction.date, order: .reverse) + private var transactions: [FinanceTransaction] + + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var showingNewTransaction = false + @State private var showingBalanceAdjustment = false + @State private var editingTransaction: FinanceTransaction? + + private var monthInterval: DateInterval { + FinanceCalculations.monthInterval(containing: .now) + } + + private var summary: FinancePeriodSummary { + FinanceCalculations.summary(transactions: transactions, in: monthInterval) + } + + private var totalBalance: Double { + FinanceCalculations.totalBalance(transactions: transactions) + } + + private var categorySpending: [CategorySpending] { + Array( + FinanceCalculations + .spendingByCategory(transactions: transactions, in: monthInterval) + .prefix(5) + ) + } + + private var recentTransactions: [FinanceTransaction] { + Array(transactions.prefix(4)) + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 22) { + balanceHero + summaryGrid + spendingSection + recentSection + } + .padding() + } + .background(Color(.systemGroupedBackground)) + .navigationTitle("Обзор") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showingNewTransaction = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Добавить операцию") + + Menu { + Button { + showingBalanceAdjustment = true + } label: { + Label("Скорректировать баланс", systemImage: "slider.horizontal.3") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .sheet(isPresented: $showingNewTransaction) { + NewTransactionView() + } + .sheet(isPresented: $showingBalanceAdjustment) { + BalanceAdjustmentView() + } + .sheet(item: $editingTransaction) { transaction in + NewTransactionView(transaction: transaction) + } + } + } + + private var balanceHero: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Текущий момент", systemImage: "creditcard.fill") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white.opacity(0.8)) + Spacer() + Image(systemName: "waveform.path.ecg") + .foregroundStyle(.white.opacity(0.75)) + } + + Text("Общий баланс") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.75)) + Text(totalBalance.formattedCurrency(code: currencyCode)) + .font(.system(size: 36, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Button { + showingBalanceAdjustment = true + } label: { + Label("Скорректировать", systemImage: "slider.horizontal.3") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.white.opacity(0.16), in: Capsule()) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(22) + .background( + LinearGradient( + colors: [.indigo, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 26, style: .continuous) + ) + .shadow(color: .indigo.opacity(0.25), radius: 18, y: 9) + } + + private var summaryGrid: some View { + HStack(spacing: 12) { + SummaryCard( + title: "Доходы", + amount: summary.income, + currencyCode: currencyCode, + icon: "arrow.down.left", + tint: .green + ) + SummaryCard( + title: "Расходы", + amount: summary.expenses, + currencyCode: currencyCode, + icon: "arrow.up.right", + tint: .red + ) + } + } + + @ViewBuilder + private var spendingSection: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Расходы по категориям") + .font(.title3.bold()) + + if categorySpending.isEmpty { + EmptyStateCard( + icon: "chart.bar.xaxis", + title: "Пока нет расходов", + message: "Добавьте первую операцию — здесь появится аналитика за месяц." + ) + } else { + Chart(categorySpending) { item in + BarMark( + x: .value("Сумма", item.amount), + y: .value("Категория", item.name) + ) + .foregroundStyle(tint(for: item)) + .cornerRadius(5) + } + .chartXAxis(.hidden) + .frame(height: CGFloat(categorySpending.count * 44 + 20)) + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + } + } + + @ViewBuilder + private var recentSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Последние операции") + .font(.title3.bold()) + + if recentTransactions.isEmpty { + EmptyStateCard( + icon: "tray", + title: "История пуста", + message: "Доходы и расходы будут собраны здесь." + ) + } else { + VStack(spacing: 0) { + ForEach(recentTransactions) { transaction in + TransactionRow(transaction: transaction, currencyCode: currencyCode) + .contentShape(Rectangle()) + .onTapGesture { + editingTransaction = transaction + } + if transaction.id != recentTransactions.last?.id { + Divider().padding(.leading, 54) + } + } + } + .padding(.horizontal) + .background(.background, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + } + } + + private func tint(for item: CategorySpending) -> Color { + if item.colorName == "auto" { + return FinanceCategory.find(named: item.name)?.tint ?? .indigo + } + return FinanceCategory.color(named: item.colorName) + } +} diff --git a/Balance/Balance/Views/Settings/CSVDocument.swift b/Balance/Balance/Views/Settings/CSVDocument.swift new file mode 100644 index 0000000..6d9f28d --- /dev/null +++ b/Balance/Balance/Views/Settings/CSVDocument.swift @@ -0,0 +1,37 @@ +import CoreTransferable +import Foundation +import UniformTypeIdentifiers + +struct CSVDocument: Transferable { + let data: Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(exportedContentType: .commaSeparatedText) { document in + document.data + } + .suggestedFileName("balance-transactions.csv") + } + + init(transactions: [FinanceTransaction]) { + var rows = ["Дата,Тип,Категория,Сумма,Заметка"] + let formatter = ISO8601DateFormatter() + + for transaction in transactions.sorted(by: { $0.date > $1.date }) { + let values = [ + formatter.string(from: transaction.date), + transaction.isBalanceAdjustment ? "Корректировка" : transaction.kind.title, + transaction.categoryName, + String(transaction.amount), + transaction.note + ] + rows.append(values.map(Self.escape).joined(separator: ",")) + } + + data = rows.joined(separator: "\n").data(using: .utf8) ?? Data() + } + + private static func escape(_ value: String) -> String { + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } +} diff --git a/Balance/Balance/Views/Settings/CategoriesView.swift b/Balance/Balance/Views/Settings/CategoriesView.swift new file mode 100644 index 0000000..2c4aa63 --- /dev/null +++ b/Balance/Balance/Views/Settings/CategoriesView.swift @@ -0,0 +1,340 @@ +import SwiftData +import SwiftUI + +struct CategoriesView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \CustomCategory.createdAt) + private var customCategories: [CustomCategory] + @Query private var budgets: [MonthlyBudget] + @State private var showingNewCategory = false + @State private var editingCategory: CustomCategory? + + var body: some View { + List { + Section("Собственные") { + if customCategories.isEmpty { + Text("Добавьте категории под свой способ ведения бюджета.") + .foregroundStyle(.secondary) + } else { + ForEach(customCategories) { category in + HStack(spacing: 12) { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: FinanceCategory.color(named: category.colorName), + size: category.emoji.isEmpty ? 16 : 21, + containerSize: 34 + ) + + Text(category.name) + Spacer() + Text(category.kind.title) + .font(.caption) + .foregroundStyle(.secondary) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + .onTapGesture { + editingCategory = category + } + } + .onDelete(perform: deleteCategories) + } + } + + Section("Встроенные") { + ForEach(FinanceCategory.expenseCategories + FinanceCategory.incomeCategories) { category in + HStack(spacing: 12) { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: category.tint, + size: 16 + ) + Text(category.name) + Spacer() + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + .navigationTitle("Категории") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewCategory = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Добавить категорию") + } + } + .sheet(isPresented: $showingNewCategory) { + CategoryEditorView() + } + .sheet(item: $editingCategory) { category in + CategoryEditorView(category: category) + } + } + + private func deleteCategories(at offsets: IndexSet) { + for index in offsets { + let category = customCategories[index] + if category.kind == .expense { + budgets + .filter { $0.categoryName == category.name } + .forEach { modelContext.deleteForSync($0) } + } + modelContext.deleteForSync(category) + } + } +} + +private enum CategoryVisualMode: String, CaseIterable, Identifiable { + case icon + case emoji + + var id: String { rawValue } + var title: String { self == .icon ? "Иконка" : "Эмодзи" } +} + +private struct CategoryEditorView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query private var customCategories: [CustomCategory] + @Query private var transactions: [FinanceTransaction] + @Query private var budgets: [MonthlyBudget] + + private let categoryToEdit: CustomCategory? + + @State private var name: String + @State private var kind: TransactionKind + @State private var selectedIcon: String + @State private var selectedEmoji: String + @State private var selectedColor: String + @State private var visualMode: CategoryVisualMode + + private let icons = CategoryDesignOptions.icons + private let emojis = CategoryDesignOptions.emojis + private let colors = CategoryDesignOptions.colors + + init(category: CustomCategory? = nil) { + categoryToEdit = category + let existingEmoji = category?.emoji ?? "" + _name = State(initialValue: category?.name ?? "") + _kind = State(initialValue: category?.kind ?? .expense) + _selectedIcon = State(initialValue: category?.icon ?? "star.fill") + _selectedEmoji = State(initialValue: existingEmoji.isEmpty ? "⭐️" : existingEmoji) + _selectedColor = State(initialValue: category?.colorName ?? "indigo") + _visualMode = State(initialValue: existingEmoji.isEmpty ? .icon : .emoji) + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var isDuplicate: Bool { + let existingNames = ( + FinanceCategory.expenseCategories + + FinanceCategory.incomeCategories + ).map(\.name) + customCategories + .filter { $0.id != categoryToEdit?.id } + .map(\.name) + return existingNames.contains { + $0.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + } + + private var canSave: Bool { + !trimmedName.isEmpty + && !isDuplicate + && (visualMode == .icon || !selectedEmoji.isEmpty) + } + + var body: some View { + NavigationStack { + Form { + Section("Основное") { + TextField("Название", text: $name) + Picker("Тип", selection: $kind) { + ForEach(TransactionKind.allCases) { kind in + Text(kind.title).tag(kind) + } + } + .pickerStyle(.segmented) + .disabled(categoryToEdit != nil) + + if categoryToEdit != nil { + Text("Тип категории нельзя изменить, чтобы сохранить корректность старых операций.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if isDuplicate && !trimmedName.isEmpty { + Label("Категория с таким названием уже существует", systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.red) + } + } + + Section("Оформление") { + HStack(spacing: 12) { + CategoryIconView( + systemName: selectedIcon, + emoji: visualMode == .emoji ? selectedEmoji : "", + tint: FinanceCategory.color(named: selectedColor), + size: visualMode == .emoji ? 24 : 19, + containerSize: 46 + ) + Text(trimmedName.isEmpty ? "Предпросмотр категории" : trimmedName) + .font(.headline) + .foregroundStyle(trimmedName.isEmpty ? .secondary : .primary) + } + + Picker("Вид", selection: $visualMode) { + ForEach(CategoryVisualMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if visualMode == .icon { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 12) { + ForEach(icons, id: \.self) { icon in + Button { + selectedIcon = icon + } label: { + Image(systemName: icon) + .font(.body) + .foregroundStyle(selectedIcon == icon ? .white : .primary) + .frame(width: 42, height: 40) + .background( + selectedIcon == icon + ? FinanceCategory.color(named: selectedColor) + : Color.secondary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 11) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } else { + TextField("Введите своё эмодзи", text: $selectedEmoji) + .font(.title2) + .onChange(of: selectedEmoji) { _, newValue in + let oneCharacter = newValue.first.map(String.init) ?? "" + if newValue != oneCharacter { + selectedEmoji = oneCharacter + } + } + + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 12) { + ForEach(emojis, id: \.self) { emoji in + Button { + selectedEmoji = emoji + } label: { + Text(emoji) + .font(.title2) + .frame(width: 38, height: 38) + .background( + selectedEmoji == emoji + ? FinanceCategory.color(named: selectedColor).opacity(0.22) + : Color.secondary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 10) + ) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + + Section("Цвет") { + LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 14) { + ForEach(colors, id: \.self) { colorName in + let color = FinanceCategory.color(named: colorName) + Button { + selectedColor = colorName + } label: { + Circle() + .fill(color) + .frame(width: 32, height: 32) + .overlay { + if selectedColor == colorName { + Image(systemName: "checkmark") + .font(.caption.bold()) + .foregroundStyle(.white) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 4) + } + } + .navigationTitle(categoryToEdit == nil ? "Новая категория" : "Редактирование") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(categoryToEdit == nil ? "Добавить" : "Готово", action: save) + .fontWeight(.semibold) + .disabled(!canSave) + } + } + } + } + + private func save() { + guard canSave else { return } + if let categoryToEdit { + let previousName = categoryToEdit.name + categoryToEdit.name = trimmedName + categoryToEdit.icon = selectedIcon + categoryToEdit.emoji = visualMode == .emoji ? selectedEmoji : "" + categoryToEdit.colorName = selectedColor + categoryToEdit.syncUpdatedAt = .now + + transactions + .filter { $0.categoryName == previousName && $0.kind == categoryToEdit.kind } + .forEach { transaction in + transaction.categoryName = trimmedName + transaction.categoryIcon = selectedIcon + transaction.categoryEmoji = visualMode == .emoji ? selectedEmoji : "" + transaction.categoryColorName = selectedColor + transaction.syncUpdatedAt = .now + } + + if categoryToEdit.kind == .expense { + budgets + .filter { $0.categoryName == previousName } + .forEach { budget in + budget.categoryName = trimmedName + budget.categoryIcon = selectedIcon + budget.categoryEmoji = visualMode == .emoji ? selectedEmoji : "" + budget.syncUpdatedAt = .now + } + } + } else { + modelContext.insert( + CustomCategory( + name: trimmedName, + icon: selectedIcon, + emoji: visualMode == .emoji ? selectedEmoji : "", + colorName: selectedColor, + kind: kind + ) + ) + } + dismiss() + } +} diff --git a/Balance/Balance/Views/Settings/SettingsView.swift b/Balance/Balance/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..8f15992 --- /dev/null +++ b/Balance/Balance/Views/Settings/SettingsView.swift @@ -0,0 +1,86 @@ +import SwiftData +import SwiftUI + +struct SettingsView: View { + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + + private var exportDocument: CSVDocument { + CSVDocument(transactions: transactions) + } + + var body: some View { + NavigationStack { + Form { + Section("Отображение") { + Picker("Валюта", selection: $currencyCode) { + ForEach(SupportedCurrency.allCases) { currency in + Text(currency.rawValue).tag(currency.rawValue) + } + } + + Picker("Тема", selection: $appTheme) { + ForEach(AppTheme.allCases) { theme in + Text(theme.title).tag(theme.rawValue) + } + } + } + + Section("Категории") { + NavigationLink { + CategoriesView() + } label: { + Label("Управление категориями", systemImage: "square.grid.2x2") + } + } + + Section("Данные") { + NavigationLink { + ServerAccountView() + } label: { + Label("Сервер и синхронизация", systemImage: "server.rack") + } + + ShareLink( + item: exportDocument, + preview: SharePreview( + "Операции", + image: Image(systemName: "tablecells") + ) + ) { + Label("Экспортировать CSV", systemImage: "square.and.arrow.up") + } + .disabled(transactions.isEmpty) + + LabeledContent("Операций сохранено", value: transactions.count.formatted()) + } + + Section("Приватность") { + Label { + Text(syncDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + } icon: { + Image(systemName: "lock.shield.fill") + .foregroundStyle(.green) + } + } + + Section { + LabeledContent("Версия", value: "3.1.1") + } footer: { + Text("Баланс помогает видеть картину расходов, но не является финансовой консультацией.") + } + } + .navigationTitle("Настройки") + } + } + + private var syncDescription: String { + if BalanceModelContainer.isCloudSyncEnabled { + return "Данные синхронизируются через приватную базу CloudKit вашего Apple ID." + } + return "Без входа данные остаются на устройстве. Собственный сервер можно подключить выше без iCloud и платной Apple Developer Team." + } +} diff --git a/Balance/Balance/Views/Transactions/NewTransactionView.swift b/Balance/Balance/Views/Transactions/NewTransactionView.swift new file mode 100644 index 0000000..875cb24 --- /dev/null +++ b/Balance/Balance/Views/Transactions/NewTransactionView.swift @@ -0,0 +1,177 @@ +import Foundation +import SwiftData +import SwiftUI + +struct NewTransactionView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + @Query(sort: \CustomCategory.createdAt) + private var customCategories: [CustomCategory] + + private let transactionToEdit: FinanceTransaction? + + @State private var kind: TransactionKind + @State private var amountText: String + @State private var categoryName: String + @State private var date: Date + @State private var note: String + + init(transaction: FinanceTransaction? = nil) { + transactionToEdit = transaction + let initialKind = transaction?.kind ?? .expense + _kind = State(initialValue: initialKind) + _amountText = State(initialValue: transaction.map { + String(format: "%.2f", $0.amount) + } ?? "") + _categoryName = State(initialValue: transaction?.categoryName + ?? FinanceCategory.categories(for: initialKind)[0].name) + _date = State(initialValue: transaction?.date ?? .now) + _note = State(initialValue: transaction?.note ?? "") + } + + private var parsedAmount: Double? { + let normalized = amountText + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: ",", with: ".") + guard let value = Double(normalized), value > 0 else { return nil } + return value + } + + private var availableCategories: [FinanceCategory] { + var categories = FinanceCategory.categories(for: kind) + customCategories + .filter { $0.kind == kind } + .map(\.financeCategory) + + if let transactionToEdit, + transactionToEdit.kind == kind, + !categories.contains(where: { $0.name == transactionToEdit.categoryName }) { + categories.append( + FinanceCategory( + id: "archived-\(transactionToEdit.id.uuidString)", + name: transactionToEdit.categoryName, + icon: transactionToEdit.categoryIcon, + emoji: transactionToEdit.categoryEmoji, + colorName: transactionToEdit.categoryColorName + ) + ) + } + return categories + } + + private var selectedCategory: FinanceCategory { + availableCategories.first { $0.name == categoryName } + ?? FinanceCategory.categories(for: kind)[0] + } + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Тип", selection: $kind) { + ForEach(TransactionKind.allCases) { kind in + Text(kind.title).tag(kind) + } + } + .pickerStyle(.segmented) + .disabled(transactionToEdit?.isBalanceAdjustment == true) + .onChange(of: kind) { _, newKind in + categoryName = FinanceCategory.categories(for: newKind)[0].name + } + + HStack(alignment: .firstTextBaseline) { + TextField("0", text: $amountText) + .font(.system(size: 34, weight: .bold, design: .rounded)) + .keyboardType(.decimalPad) + Text("сумма") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("Категория") { + Picker("Категория", selection: $categoryName) { + ForEach(availableCategories) { category in + HStack { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: category.tint, + size: 16 + ) + Text(category.name) + } + .tag(category.name) + } + } + .disabled(transactionToEdit?.isBalanceAdjustment == true) + } + + if transactionToEdit?.isBalanceAdjustment == true { + Section { + Label( + "Корректировка влияет только на общий баланс и не учитывается в аналитике доходов и расходов.", + systemImage: "info.circle" + ) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + Section("Детали") { + DatePicker("Дата", selection: $date, displayedComponents: .date) + TextField("Заметка (необязательно)", text: $note) + } + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(transactionToEdit == nil ? "Сохранить" : "Готово", action: save) + .fontWeight(.semibold) + .disabled(parsedAmount == nil) + } + } + } + } + + private func save() { + guard let amount = parsedAmount else { return } + let category = selectedCategory + let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) + + if let transactionToEdit { + transactionToEdit.amount = amount + transactionToEdit.date = date + transactionToEdit.note = trimmedNote + transactionToEdit.categoryName = category.name + transactionToEdit.categoryIcon = category.icon + transactionToEdit.categoryEmoji = category.emoji + transactionToEdit.categoryColorName = category.colorName + transactionToEdit.kind = kind + transactionToEdit.syncUpdatedAt = .now + } else { + modelContext.insert( + FinanceTransaction( + amount: amount, + date: date, + note: trimmedNote, + categoryName: category.name, + categoryIcon: category.icon, + categoryEmoji: category.emoji, + categoryColorName: category.colorName, + kind: kind + ) + ) + } + dismiss() + } + + private var navigationTitle: String { + if transactionToEdit?.isBalanceAdjustment == true { return "Корректировка" } + if transactionToEdit != nil { return "Редактирование" } + return kind == .expense ? "Новый расход" : "Новый доход" + } +} diff --git a/Balance/Balance/Views/Transactions/TransactionRow.swift b/Balance/Balance/Views/Transactions/TransactionRow.swift new file mode 100644 index 0000000..461dea5 --- /dev/null +++ b/Balance/Balance/Views/Transactions/TransactionRow.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct TransactionRow: View { + let transaction: FinanceTransaction + let currencyCode: String + + var body: some View { + HStack(spacing: 12) { + CategoryIconView( + systemName: transaction.categoryIcon, + emoji: transaction.categoryEmoji, + tint: categoryTint, + size: transaction.categoryEmoji.isEmpty ? 15 : 21, + containerSize: 40 + ) + + VStack(alignment: .leading, spacing: 3) { + Text(transaction.categoryName) + .font(.body.weight(.medium)) + Text(transaction.note.isEmpty ? transaction.date.formatted(date: .abbreviated, time: .omitted) : transaction.note) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + Text(signedAmount) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(transaction.kind == .income ? .green : .primary) + } + .padding(.vertical, 10) + } + + private var categoryTint: Color { + if transaction.categoryColorName == "auto" { + return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo + } + return FinanceCategory.color(named: transaction.categoryColorName) + } + + private var signedAmount: String { + let prefix = transaction.kind == .income ? "+" : "−" + return prefix + transaction.amount.formattedCurrency(code: currencyCode) + } +} diff --git a/Balance/Balance/Views/Transactions/TransactionsView.swift b/Balance/Balance/Views/Transactions/TransactionsView.swift new file mode 100644 index 0000000..763a22e --- /dev/null +++ b/Balance/Balance/Views/Transactions/TransactionsView.swift @@ -0,0 +1,104 @@ +import SwiftData +import SwiftUI + +struct TransactionsView: 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 showingNewTransaction = false + @State private var editingTransaction: FinanceTransaction? + + private var filteredTransactions: [FinanceTransaction] { + transactions.filter { transaction in + let matchesKind = selectedKind == nil || transaction.kind == selectedKind + let matchesSearch = searchText.isEmpty + || transaction.categoryName.localizedCaseInsensitiveContains(searchText) + || transaction.note.localizedCaseInsensitiveContains(searchText) + return matchesKind && matchesSearch + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + filterBar + + Group { + if filteredTransactions.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет операций" : "Ничего не найдено", + systemImage: searchText.isEmpty ? "list.bullet.rectangle" : "magnifyingglass", + description: Text(searchText.isEmpty + ? "Добавьте доход или расход, чтобы начать вести бюджет." + : "Измените запрос или фильтр.") + ) + } else { + List { + ForEach(filteredTransactions) { transaction in + TransactionRow(transaction: transaction, currencyCode: currencyCode) + .contentShape(Rectangle()) + .onTapGesture { + editingTransaction = transaction + } + .accessibilityHint("Открывает редактирование операции") + } + .onDelete(perform: deleteTransactions) + } + .listStyle(.plain) + } + } + } + .navigationTitle("Операции") + .navigationBarTitleDisplayMode(.large) + .searchable(text: $searchText, prompt: "Категория или заметка") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewTransaction = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Добавить операцию") + } + } + .sheet(isPresented: $showingNewTransaction) { + NewTransactionView() + } + .sheet(item: $editingTransaction) { transaction in + NewTransactionView(transaction: transaction) + } + } + } + + private var filterBar: some View { + HStack(spacing: 8) { + filterButton(title: "Все", kind: nil) + filterButton(title: "Расходы", kind: .expense) + filterButton(title: "Доходы", kind: .income) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + + private func filterButton(title: String, kind: TransactionKind?) -> some View { + Button(title) { + withAnimation { selectedKind = kind } + } + .font(.subheadline.weight(.medium)) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .tint(selectedKind == kind ? .indigo : .secondary) + } + + private func deleteTransactions(at offsets: IndexSet) { + for index in offsets { + modelContext.deleteForSync(filteredTransactions[index]) + } + } +} diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png new file mode 100644 index 0000000..7b766f4 Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-128.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-128.png new file mode 100644 index 0000000..f10c42c Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-128.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-16.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-16.png new file mode 100644 index 0000000..5311015 Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-16.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-256.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-256.png new file mode 100644 index 0000000..d6f79e9 Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-256.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-32.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-32.png new file mode 100644 index 0000000..249ec86 Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-32.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-512.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-512.png new file mode 100644 index 0000000..f99ddad Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-512.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-64.png b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-64.png new file mode 100644 index 0000000..b64bc42 Binary files /dev/null and b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/BalanceIcon-64.png differ diff --git a/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..42d6c7d --- /dev/null +++ b/Balance/BalanceMac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { "filename" : "BalanceIcon-16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, + { "filename" : "BalanceIcon-32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, + { "filename" : "BalanceIcon-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, + { "filename" : "BalanceIcon-64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, + { "filename" : "BalanceIcon-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, + { "filename" : "BalanceIcon-256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, + { "filename" : "BalanceIcon-256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, + { "filename" : "BalanceIcon-512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, + { "filename" : "BalanceIcon-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, + { "filename" : "BalanceIcon-1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Balance/BalanceMac/Assets.xcassets/Contents.json b/Balance/BalanceMac/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Balance/BalanceMac/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Balance/BalanceMac/BalanceMacApp.swift b/Balance/BalanceMac/BalanceMacApp.swift new file mode 100644 index 0000000..7f48e79 --- /dev/null +++ b/Balance/BalanceMac/BalanceMacApp.swift @@ -0,0 +1,19 @@ +import SwiftData +import SwiftUI + +@main +struct BalanceMacApp: App { + private let modelContainer = BalanceModelContainer.make() + @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + + var body: some Scene { + WindowGroup { + MacContentView() + .frame(minWidth: 900, minHeight: 600) + .tint(.indigo) + .preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme) + } + .modelContainer(modelContainer) + .defaultSize(width: 1100, height: 720) + } +} diff --git a/Balance/BalanceMac/MacContentView.swift b/Balance/BalanceMac/MacContentView.swift new file mode 100644 index 0000000..489e406 --- /dev/null +++ b/Balance/BalanceMac/MacContentView.swift @@ -0,0 +1,436 @@ +import Charts +import SwiftData +import SwiftUI + +private enum MacSection: String, CaseIterable, Identifiable { + case overview + case analytics + case transactions + case budgets + case categories + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .overview: "Обзор" + case .analytics: "Аналитика" + case .transactions: "Операции" + case .budgets: "Бюджеты" + case .categories: "Категории" + case .settings: "Настройки" + } + } + + var icon: String { + switch self { + case .overview: "chart.pie.fill" + case .analytics: "chart.xyaxis.line" + case .transactions: "list.bullet.rectangle" + case .budgets: "target" + case .categories: "square.grid.2x2" + case .settings: "gearshape.fill" + } + } +} + +struct MacContentView: View { + @Environment(\.modelContext) private var modelContext + @State private var selection: MacSection? = .overview + + var body: some View { + NavigationSplitView { + List(MacSection.allCases, selection: $selection) { section in + Label(section.title, systemImage: section.icon) + .tag(section) + } + .navigationTitle("Баланс") + .navigationSplitViewColumnWidth(min: 190, ideal: 220) + } detail: { + switch selection ?? .overview { + case .overview: + MacOverviewView() + case .analytics: + MacAnalyticsView() + case .transactions: + MacTransactionsView() + case .budgets: + MacBudgetsView() + case .categories: + MacCategoriesView() + case .settings: + NavigationStack { + MacSettingsView() + } + } + } + .task { + while !Task.isCancelled { + await ServerAccountStore.shared.sync(context: modelContext) + try? await Task.sleep(nanoseconds: 120_000_000_000) + } + } + } +} + +private struct MacOverviewView: View { + @Query(sort: \FinanceTransaction.date, order: .reverse) + private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var showingBalanceAdjustment = false + + private var interval: DateInterval { + FinanceCalculations.monthInterval(containing: .now) + } + + private var summary: FinancePeriodSummary { + FinanceCalculations.summary(transactions: transactions, in: interval) + } + + private var spending: [CategorySpending] { + Array(FinanceCalculations.spendingByCategory( + transactions: transactions, + in: interval + ).prefix(8)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack { + Text("Обзор") + .font(.largeTitle.bold()) + Spacer() + Button { + showingBalanceAdjustment = true + } label: { + Label("Скорректировать баланс", systemImage: "slider.horizontal.3") + } + .buttonStyle(.bordered) + } + + HStack(spacing: 14) { + MacMetricCard( + title: "Общий баланс", + value: FinanceCalculations.totalBalance(transactions: transactions) + .formattedCurrency(code: currencyCode), + icon: "creditcard.fill", + tint: .indigo + ) + MacMetricCard( + title: "Доходы за месяц", + value: summary.income.formattedCurrency(code: currencyCode), + icon: "arrow.down.left", + tint: .green + ) + MacMetricCard( + title: "Расходы за месяц", + value: summary.expenses.formattedCurrency(code: currencyCode), + icon: "arrow.up.right", + tint: .red + ) + } + + GroupBox("Расходы по категориям") { + if spending.isEmpty { + ContentUnavailableView( + "Нет данных", + systemImage: "chart.bar.xaxis", + description: Text("Добавьте операции на iPhone, Mac или Apple Watch.") + ) + .frame(height: 260) + } else { + Chart(spending) { item in + BarMark( + x: .value("Категория", item.name), + y: .value("Сумма", item.amount) + ) + .foregroundStyle(tint(for: item)) + .cornerRadius(5) + } + .frame(height: 300) + .padding() + } + } + } + .padding(28) + } + .background(Color(nsColor: .windowBackgroundColor)) + .sheet(isPresented: $showingBalanceAdjustment) { + MacBalanceAdjustmentView() + } + } + + private func tint(for item: CategorySpending) -> Color { + if item.colorName == "auto" { + return FinanceCategory.find(named: item.name)?.tint ?? .indigo + } + return FinanceCategory.color(named: item.colorName) + } +} + +private struct MacMetricCard: View { + let title: String + let value: String + let icon: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon) + .font(.subheadline.weight(.medium)) + .foregroundStyle(tint) + Text(value) + .font(.title2.bold()) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(.background, in: RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.06), radius: 8, y: 3) + } +} + +private struct MacTransactionsView: 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 searchText = "" + @State private var selectedKind: TransactionKind? + @State private var showingNewTransaction = false + @State private var editingTransaction: 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 { + VStack(spacing: 0) { + HStack { + Text("Операции") + .font(.largeTitle.bold()) + Picker("Тип", selection: $selectedKind) { + Text("Все").tag(TransactionKind?.none) + Text("Расходы").tag(TransactionKind?.some(.expense)) + Text("Доходы").tag(TransactionKind?.some(.income)) + } + .pickerStyle(.segmented) + .frame(width: 260) + Spacer() + Button { + showingNewTransaction = true + } label: { + Label("Добавить", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + .padding(24) + + if filtered.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет операций" : "Ничего не найдено", + systemImage: "list.bullet.rectangle" + ) + } else { + List(filtered) { transaction in + Button { + editingTransaction = transaction + } label: { + HStack(spacing: 12) { + CategoryIconView( + systemName: transaction.categoryIcon, + emoji: transaction.categoryEmoji, + tint: transactionTint(transaction), + size: transaction.categoryEmoji.isEmpty ? 15 : 20, + containerSize: 38 + ) + VStack(alignment: .leading, spacing: 3) { + Text(transaction.categoryName) + .font(.headline) + Text(transaction.note.isEmpty + ? transaction.date.formatted(date: .abbreviated, time: .omitted) + : transaction.note) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text((transaction.kind == .income ? "+" : "−") + + transaction.amount.formattedCurrency(code: currencyCode)) + .font(.body.weight(.semibold)) + .foregroundStyle(transaction.kind == .income ? .green : .primary) + } + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button("Редактировать") { editingTransaction = transaction } + Button("Удалить", role: .destructive) { + modelContext.deleteForSync(transaction) + } + } + } + } + } + .searchable(text: $searchText, prompt: "Категория или заметка") + .sheet(isPresented: $showingNewTransaction) { + MacTransactionEditor() + } + .sheet(item: $editingTransaction) { transaction in + MacTransactionEditor(transaction: transaction) + } + } + + private func transactionTint(_ transaction: FinanceTransaction) -> Color { + if transaction.categoryColorName == "auto" { + return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo + } + return FinanceCategory.color(named: transaction.categoryColorName) + } +} + +private struct MacBudgetsView: 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 currentBudgets: [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 { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Бюджеты") + .font(.largeTitle.bold()) + Spacer() + Button { + showingEditor = true + } label: { + Label("Изменить лимиты", systemImage: "pencil") + } + .buttonStyle(.borderedProminent) + } + + if currentBudgets.isEmpty { + ContentUnavailableView( + "Бюджеты не заданы", + systemImage: "target", + description: Text("Настройте лимиты на iPhone — они появятся здесь через iCloud.") + ) + } else { + List(currentBudgets) { budget in + let spent = spending[budget.categoryName, default: 0] + VStack(alignment: .leading, spacing: 8) { + HStack { + CategoryIconView( + systemName: budget.categoryIcon, + emoji: budget.categoryEmoji, + tint: .indigo, + size: 17 + ) + Text(budget.categoryName).font(.headline) + Spacer() + Text("\(spent.formattedCurrency(code: currencyCode)) из \(budget.limit.formattedCurrency(code: currencyCode))") + .foregroundStyle(.secondary) + } + ProgressView(value: min(budget.limit > 0 ? spent / budget.limit : 0, 1)) + } + .padding(.vertical, 6) + } + } + } + .padding(24) + .sheet(isPresented: $showingEditor) { + MacBudgetEditor(budgets: currentBudgets, monthStart: interval.start) + } + } +} + +private struct MacCategoriesView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \CustomCategory.createdAt) + private var categories: [CustomCategory] + @Query private var budgets: [MonthlyBudget] + @State private var showingNewCategory = false + @State private var editingCategory: CustomCategory? + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Категории") + .font(.largeTitle.bold()) + Spacer() + Button { + showingNewCategory = true + } label: { + Label("Добавить", systemImage: "plus") + } + .buttonStyle(.borderedProminent) + } + + if categories.isEmpty { + ContentUnavailableView( + "Нет собственных категорий", + systemImage: "square.grid.2x2" + ) + } else { + List(categories) { category in + Button { + editingCategory = category + } label: { + HStack { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: FinanceCategory.color(named: category.colorName), + size: category.emoji.isEmpty ? 16 : 20, + containerSize: 36 + ) + Text(category.name) + Spacer() + Text(category.kind.title) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .contextMenu { + Button("Редактировать") { editingCategory = category } + Button("Удалить", role: .destructive) { + budgets.filter { $0.categoryName == category.name } + .forEach { modelContext.deleteForSync($0) } + modelContext.deleteForSync(category) + } + } + } + } + } + .padding(24) + .sheet(isPresented: $showingNewCategory) { + MacCategoryEditor() + } + .sheet(item: $editingCategory) { category in + MacCategoryEditor(category: category) + } + } +} diff --git a/Balance/BalanceMac/MacEditors.swift b/Balance/BalanceMac/MacEditors.swift new file mode 100644 index 0000000..c000816 --- /dev/null +++ b/Balance/BalanceMac/MacEditors.swift @@ -0,0 +1,233 @@ +import SwiftData +import SwiftUI + +struct MacTransactionEditor: 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 kind = transaction?.kind ?? .expense + _kind = State(initialValue: kind) + _amount = State(initialValue: transaction?.amount ?? 0) + _categoryName = State(initialValue: transaction?.categoryName + ?? FinanceCategory.categories(for: kind)[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 { + VStack(alignment: .leading, spacing: 18) { + Text(transaction == nil ? "Новая операция" : "Редактирование операции") + .font(.title.bold()) + + Picker("Тип", selection: $kind) { + ForEach(TransactionKind.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .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) { Text($0.name).tag($0.name) } + } + .disabled(transaction?.isBalanceAdjustment == true) + + DatePicker("Дата", selection: $date, displayedComponents: .date) + TextField("Заметка", text: $note) + + HStack { + Spacer() + Button("Отмена") { dismiss() } + Button("Сохранить", action: save) + .buttonStyle(.borderedProminent) + .disabled(amount <= 0) + } + } + .padding(24) + .frame(width: 440) + } + + 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() + } +} + +struct MacCategoryEditor: 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] + + private let category: CustomCategory? + @State private var name: String + @State private var kind: TransactionKind + @State private var emoji: String + @State private var icon: String + @State private var colorName: String + @State private var usesEmoji: Bool + + init(category: CustomCategory? = nil) { + self.category = category + _name = State(initialValue: category?.name ?? "") + _kind = State(initialValue: category?.kind ?? .expense) + _emoji = State(initialValue: category?.emoji ?? "") + _icon = State(initialValue: category?.icon ?? "star.fill") + _colorName = State(initialValue: category?.colorName ?? "indigo") + _usesEmoji = State(initialValue: category?.emoji.isEmpty == false) + } + + 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 { + VStack(alignment: .leading, spacing: 18) { + Text(category == nil ? "Новая категория" : "Редактирование категории") + .font(.title.bold()) + TextField("Название", text: $name) + if isDuplicate && !trimmedName.isEmpty { + Text("Категория с таким названием уже существует") + .font(.caption).foregroundStyle(.red) + } + Picker("Тип", selection: $kind) { + ForEach(TransactionKind.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .disabled(category != nil) + Picker("Оформление", selection: $usesEmoji) { + Text("Иконка").tag(false) + Text("Эмодзи").tag(true) + } + .pickerStyle(.segmented) + 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) { value in + Label(value, systemImage: value).tag(value) + } + } + } + Picker("Цвет", selection: $colorName) { + ForEach(CategoryDesignOptions.colors, id: \.self) { value in + HStack { + Circle().fill(FinanceCategory.color(named: value)).frame(width: 10, height: 10) + Text(value.capitalized) + } + .tag(value) + } + } + HStack { + Spacer() + Button("Отмена") { dismiss() } + Button("Сохранить", action: save) + .buttonStyle(.borderedProminent) + .disabled(trimmedName.isEmpty || isDuplicate) + } + } + .padding(24) + .frame(width: 440) + } + + private func save() { + guard !trimmedName.isEmpty else { return } + let oneEmoji = usesEmoji ? emoji.first.map(String.init) ?? "" : "" + if let category { + let oldName = category.name + category.name = trimmedName + category.icon = icon.isEmpty ? "star.fill" : icon + category.emoji = oneEmoji + category.colorName = colorName + category.syncUpdatedAt = .now + transactions.filter { $0.categoryName == oldName }.forEach { + $0.categoryName = trimmedName + $0.categoryIcon = category.icon + $0.categoryEmoji = oneEmoji + $0.categoryColorName = colorName + $0.syncUpdatedAt = .now + } + budgets.filter { $0.categoryName == oldName }.forEach { + $0.categoryName = trimmedName + $0.categoryIcon = category.icon + $0.categoryEmoji = oneEmoji + $0.syncUpdatedAt = .now + } + } else { + modelContext.insert(CustomCategory( + name: trimmedName, + icon: icon.isEmpty ? "star.fill" : icon, + emoji: oneEmoji, + colorName: colorName, + kind: kind + )) + } + dismiss() + } +} diff --git a/Balance/BalanceMac/MacFeatures.swift b/Balance/BalanceMac/MacFeatures.swift new file mode 100644 index 0000000..2f70fd5 --- /dev/null +++ b/Balance/BalanceMac/MacFeatures.swift @@ -0,0 +1,354 @@ +import Charts +import Foundation +import SwiftData +import SwiftUI + +private enum MacAnalyticsPeriod: 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 месяцев" + } + } + + var component: Calendar.Component { + switch self { + case .month: .day + case .threeMonths: .weekOfYear + case .year: .month + } + } + + func interval(now: Date = .now) -> 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) + } +} + +private struct MacCashPoint: Identifiable { + let date: Date + let kind: TransactionKind + let amount: Double + var id: String { "\(date.timeIntervalSince1970)-\(kind.rawValue)" } +} + +struct MacAnalyticsView: View { + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @State private var period = MacAnalyticsPeriod.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(8)) + } + private var savingsRate: Double { + summary.income > 0 ? summary.balance / summary.income * 100 : 0 + } + private var cashPoints: [MacCashPoint] { + let calendar = Calendar.current + let filtered = transactions.filter { + interval.contains($0.date) && !$0.isBalanceAdjustment + } + let grouped = Dictionary(grouping: filtered) { transaction in + let date = calendar.dateInterval(of: period.component, for: transaction.date)?.start + ?? transaction.date + return "\(date.timeIntervalSince1970)|\(transaction.kind.rawValue)" + } + return grouped.compactMap { _, values in + guard let first = values.first else { return nil } + let date = calendar.dateInterval(of: period.component, for: first.date)?.start + ?? first.date + return MacCashPoint( + date: date, + kind: first.kind, + amount: values.reduce(0) { $0 + $1.amount } + ) + }.sorted { $0.date < $1.date } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack { + Text("Аналитика") + .font(.largeTitle.bold()) + Spacer() + Picker("Период", selection: $period) { + ForEach(MacAnalyticsPeriod.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .frame(width: 330) + } + + HStack(spacing: 14) { + metric("Доходы", summary.income.formattedCurrency(code: currencyCode), .green) + metric("Расходы", summary.expenses.formattedCurrency(code: currencyCode), .red) + metric("Результат", summary.balance.formattedCurrency(code: currencyCode), .indigo) + metric("Сбережения", savingsRate.formatted(.number.precision(.fractionLength(0))) + "%", .teal) + } + + HStack(alignment: .top, spacing: 18) { + GroupBox("Динамика доходов и расходов") { + if cashPoints.isEmpty { + ContentUnavailableView("Нет данных", systemImage: "chart.xyaxis.line") + .frame(height: 280) + } else { + Chart(cashPoints) { point in + LineMark( + x: .value("Дата", point.date), + y: .value("Сумма", point.amount) + ) + .foregroundStyle(by: .value("Тип", point.kind.title)) + .interpolationMethod(.catmullRom) + PointMark( + x: .value("Дата", point.date), + y: .value("Сумма", point.amount) + ) + .foregroundStyle(by: .value("Тип", point.kind.title)) + } + .chartForegroundStyleScale([ + TransactionKind.income.title: Color.green, + TransactionKind.expense.title: Color.red + ]) + .frame(height: 280) + .padding() + } + } + .frame(maxWidth: .infinity) + + GroupBox("Структура расходов") { + if spending.isEmpty { + ContentUnavailableView("Нет расходов", systemImage: "chart.pie") + .frame(height: 280) + } else { + Chart(spending) { item in + SectorMark( + angle: .value("Сумма", item.amount), + innerRadius: .ratio(0.55), + angularInset: 2 + ) + .foregroundStyle(tint(for: item)) + } + .frame(height: 280) + .padding() + } + } + .frame(width: 360) + } + } + .padding(28) + } + } + + private func metric(_ title: String, _ value: String, _ tint: Color) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title).font(.caption).foregroundStyle(tint) + Text(value).font(.title3.bold()).lineLimit(1).minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.background, in: RoundedRectangle(cornerRadius: 14)) + } + + private func tint(for item: CategorySpending) -> Color { + item.colorName == "auto" + ? FinanceCategory.find(named: item.name)?.tint ?? .indigo + : FinanceCategory.color(named: item.colorName) + } +} + +struct MacBalanceAdjustmentView: 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 desiredBalance = 0.0 + @State private var note = "" + + private var current: Double { + FinanceCalculations.totalBalance(transactions: transactions) + } + private var difference: Double { desiredBalance - current } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + Text("Корректировка баланса").font(.title.bold()) + LabeledContent("Расчётный баланс", value: current.formattedCurrency(code: currencyCode)) + TextField("Фактический баланс", value: $desiredBalance, format: .number) + if abs(difference) >= 0.005 { + LabeledContent( + difference > 0 ? "Добавится доход" : "Добавится расход", + value: abs(difference).formattedCurrency(code: currencyCode) + ) + } + TextField("Комментарий", text: $note) + Text("Корректировка изменяет общий баланс, но не влияет на аналитику.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Spacer() + Button("Отмена") { dismiss() } + Button("Сохранить", action: save) + .buttonStyle(.borderedProminent) + .disabled(abs(difference) < 0.005) + } + } + .padding(24) + .frame(width: 440) + .onAppear { desiredBalance = 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 MacBudgetEditor: 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 { + VStack(alignment: .leading, spacing: 16) { + Text("Месячные лимиты").font(.title.bold()) + List(categories) { category in + HStack { + CategoryIconView( + systemName: category.icon, + emoji: category.emoji, + tint: category.tint, + size: 16 + ) + Text(category.name) + Spacer() + TextField("0", value: binding(for: category.name), format: .number) + .frame(width: 120) + } + } + HStack { + Spacer() + Button("Отмена") { dismiss() } + Button("Сохранить", action: save).buttonStyle(.borderedProminent) + } + } + .padding(24) + .frame(width: 520, height: 600) + } + + private func binding(for 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.categoryIcon = category.icon + existing.categoryEmoji = category.emoji + 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 MacSettingsView: View { + @Query private var transactions: [FinanceTransaction] + @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + + var body: some View { + Form { + Section("Отображение") { + Picker("Валюта", selection: $currencyCode) { + ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) } + } + Picker("Тема", selection: $appTheme) { + ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) } + } + } + Section("Данные") { + NavigationLink { + ServerAccountView() + } label: { + Label("Сервер и синхронизация", systemImage: "server.rack") + } + ShareLink(item: CSVDocument(transactions: transactions)) { + Label("Экспортировать CSV", systemImage: "square.and.arrow.up") + } + .disabled(transactions.isEmpty) + LabeledContent("Операций", value: transactions.count.formatted()) + } + Section("Хранение") { + Label( + BalanceModelContainer.isCloudSyncEnabled + ? "Синхронизация CloudKit включена" + : "Локальный режим для Personal Team", + systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill" + ) + } + Section { + LabeledContent("Версия", value: "3.1.1") + } + } + .formStyle(.grouped) + .navigationTitle("Настройки") + .padding(24) + } +} diff --git a/Balance/BalanceTests/FinanceCalculationsTests.swift b/Balance/BalanceTests/FinanceCalculationsTests.swift new file mode 100644 index 0000000..2e2a269 --- /dev/null +++ b/Balance/BalanceTests/FinanceCalculationsTests.swift @@ -0,0 +1,166 @@ +import Foundation +import XCTest +@testable import Balance + +final class FinanceCalculationsTests: XCTestCase { + func testSummarySeparatesIncomeAndExpenses() { + let interval = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 1_000) + ) + let transactions = [ + FinanceTransaction( + amount: 100_000, + date: Date(timeIntervalSince1970: 100), + categoryName: "Зарплата", + categoryIcon: "banknote.fill", + kind: .income + ), + FinanceTransaction( + amount: 25_000, + date: Date(timeIntervalSince1970: 200), + categoryName: "Продукты", + categoryIcon: "cart.fill", + kind: .expense + ) + ] + + let summary = FinanceCalculations.summary( + transactions: transactions, + in: interval + ) + + XCTAssertEqual(summary.income, 100_000) + XCTAssertEqual(summary.expenses, 25_000) + XCTAssertEqual(summary.balance, 75_000) + } + + func testSummaryIgnoresTransactionsOutsidePeriod() { + let interval = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 1_000) + ) + let transaction = FinanceTransaction( + amount: 5_000, + date: Date(timeIntervalSince1970: 2_000), + categoryName: "Покупки", + categoryIcon: "bag.fill", + kind: .expense + ) + + let summary = FinanceCalculations.summary( + transactions: [transaction], + in: interval + ) + + XCTAssertEqual(summary.income, 0) + XCTAssertEqual(summary.expenses, 0) + } + + func testSpendingGroupsCustomCategoryAndKeepsColor() { + let interval = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 1_000) + ) + let transactions = [100.0, 250.0].map { amount in + FinanceTransaction( + amount: amount, + date: Date(timeIntervalSince1970: 100), + categoryName: "Питомец", + categoryIcon: "pawprint.fill", + categoryEmoji: "🐾", + categoryColorName: "teal", + kind: .expense + ) + } + + let spending = FinanceCalculations.spendingByCategory( + transactions: transactions, + in: interval + ) + + XCTAssertEqual(spending.count, 1) + XCTAssertEqual(spending.first?.name, "Питомец") + XCTAssertEqual(spending.first?.amount, 350) + XCTAssertEqual(spending.first?.emoji, "🐾") + XCTAssertEqual(spending.first?.colorName, "teal") + } + + func testTotalBalanceUsesAllIncomeAndExpenses() { + let transactions = [ + FinanceTransaction( + amount: 80_000, + categoryName: "Зарплата", + categoryIcon: "banknote.fill", + kind: .income + ), + FinanceTransaction( + amount: 30_000, + categoryName: "Дом", + categoryIcon: "house.fill", + kind: .expense + ), + FinanceTransaction( + amount: 2_500, + categoryName: "Корректировка", + categoryIcon: "slider.horizontal.3", + isBalanceAdjustment: true, + kind: .income + ) + ] + + XCTAssertEqual( + FinanceCalculations.totalBalance(transactions: transactions), + 52_500 + ) + } + + func testPeriodSummaryIgnoresBalanceAdjustment() { + let interval = DateInterval( + start: Date(timeIntervalSince1970: 0), + end: Date(timeIntervalSince1970: 1_000) + ) + let adjustment = FinanceTransaction( + amount: 12_000, + date: Date(timeIntervalSince1970: 100), + categoryName: "Корректировка", + categoryIcon: "slider.horizontal.3", + isBalanceAdjustment: true, + kind: .income + ) + + let summary = FinanceCalculations.summary( + transactions: [adjustment], + in: interval + ) + + XCTAssertEqual(summary.income, 0) + XCTAssertEqual(summary.expenses, 0) + XCTAssertEqual( + FinanceCalculations.totalBalance(transactions: [adjustment]), + 12_000 + ) + } + + func testDeletionJournalDeduplicatesAndRemovesRecords() { + let recordID = UUID().uuidString + let firstDate = Date.now.addingTimeInterval(-2) + let secondDate = Date.now.addingTimeInterval(-1) + + SyncDeletionStore.add(entity:"transaction", recordID:recordID, updatedAt:firstDate) + SyncDeletionStore.add(entity:"transaction", recordID:recordID, updatedAt:secondDate) + + let records = SyncDeletionStore.page( + through:.now, + offset:0, + limit:10_000 + ).filter { $0.recordID == recordID } + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records.first?.updatedAt, secondDate) + + SyncDeletionStore.remove(entity:"transaction", recordID:recordID, through:.now) + XCTAssertFalse(SyncDeletionStore.page(through:.now, offset:0, limit:10_000).contains { + $0.recordID == recordID + }) + } +} diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png new file mode 100644 index 0000000..5375cd4 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png new file mode 100644 index 0000000..bd70565 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png new file mode 100644 index 0000000..2c212db Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png new file mode 100644 index 0000000..6c375f4 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png new file mode 100644 index 0000000..043000f Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png new file mode 100644 index 0000000..3983c3d Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png new file mode 100644 index 0000000..2f00659 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png new file mode 100644 index 0000000..7dc91af Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png new file mode 100644 index 0000000..6be4a87 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png new file mode 100644 index 0000000..990b9a8 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png new file mode 100644 index 0000000..9abc1e5 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png new file mode 100644 index 0000000..726cb0d Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png new file mode 100644 index 0000000..73f3023 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png new file mode 100644 index 0000000..4608e52 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png new file mode 100644 index 0000000..b81ecc8 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png new file mode 100644 index 0000000..4a41132 Binary files /dev/null and b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png differ diff --git a/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..51c20b1 --- /dev/null +++ b/Balance/BalanceWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,24 @@ +{ + "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" }, + { "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/Balance/BalanceWatch/Assets.xcassets/Contents.json b/Balance/BalanceWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Balance/BalanceWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Balance/BalanceWatch/BalanceWatchApp.swift b/Balance/BalanceWatch/BalanceWatchApp.swift new file mode 100644 index 0000000..896f9c3 --- /dev/null +++ b/Balance/BalanceWatch/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/Balance/BalanceWatch/WatchAddTransactionView.swift b/Balance/BalanceWatch/WatchAddTransactionView.swift new file mode 100644 index 0000000..49bfceb --- /dev/null +++ b/Balance/BalanceWatch/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/Balance/BalanceWatch/WatchFeatures.swift b/Balance/BalanceWatch/WatchFeatures.swift new file mode 100644 index 0000000..ccd5f8a --- /dev/null +++ b/Balance/BalanceWatch/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( + BalanceModelContainer.isCloudSyncEnabled ? "CloudKit" : "Локально", + systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill" + ) + LabeledContent("Версия", value: "3.1.1") + } + .navigationTitle("Настройки") + } +} diff --git a/Balance/BalanceWatch/WatchRootView.swift b/Balance/BalanceWatch/WatchRootView.swift new file mode 100644 index 0000000..fdd89fb --- /dev/null +++ b/Balance/BalanceWatch/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/Balance/Config/Balance.entitlements b/Balance/Config/Balance.entitlements new file mode 100644 index 0000000..77072f1 --- /dev/null +++ b/Balance/Config/Balance.entitlements @@ -0,0 +1,16 @@ + + + + + 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 new file mode 100644 index 0000000..c04e099 --- /dev/null +++ b/Balance/Config/BalanceMac.entitlements @@ -0,0 +1,20 @@ + + + + + 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 new file mode 100644 index 0000000..77072f1 --- /dev/null +++ b/Balance/Config/BalanceWatch.entitlements @@ -0,0 +1,16 @@ + + + + + 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 new file mode 100644 index 0000000..a8f7595 --- /dev/null +++ b/Balance/README.md @@ -0,0 +1,96 @@ +# Баланс + +Мультиплатформенное приложение для личного бюджета. Apple-клиенты написаны на SwiftUI и SwiftData, Android-клиент находится в соседней папке `BalanceAndroid`. + +## Возможности + +- учёт доходов и расходов; +- редактирование суммы, типа, категории, даты и заметки операции; +- ручная корректировка общего баланса с сохранением отдельной операции, не искажающей аналитику; +- категории и заметки к операциям; +- обзор баланса за текущий месяц; +- диаграмма расходов по категориям; +- отдельная аналитика за месяц, 3 месяца или 12 месяцев; +- график динамики доходов и расходов, структура трат и норма сбережений; +- месячные лимиты и индикация превышения бюджета; +- собственные категории с выбором типа, одного из 50 системных значков или эмодзи; +- ввод собственного эмодзи и расширенная палитра из 24 цветов; +- редактирование собственных категорий с обновлением связанных операций и бюджетов; +- поиск и фильтрация операций; +- выбор валюты: RUB, EUR, USD или SEK; +- системная, светлая и тёмная тема; +- экспорт истории в CSV; +- опциональная синхронизация финансовых данных через приватную базу iCloud CloudKit; +- синхронизация операций, категорий и бюджетов через собственный Go-сервер; +- регистрация, вход, безопасное хранение сессии в Keychain, ручная и автоматическая синхронизация; +- смена сервера с обязательной повторной авторизацией; +- отдельные приложения для iPhone, macOS и Apple Watch; +- нативное Android-приложение с тем же дизайном, функциями и протоколом синхронизации; +- Apple Watch: обзор, аналитика, операции с редактированием и удалением, бюджеты, категории, корректировка баланса и настройки; +- macOS: обзор, полная аналитика, операции, редактирование бюджетов и категорий, корректировка баланса, темы и CSV-экспорт. +- индивидуальная иконка приложения с единым знаком для iOS, macOS и watchOS. + +CSV-экспорт доступен на iPhone и Mac. На Apple Watch доступны все функции управления финансами, адаптированные под компактный экран. + +## Запуск + +1. Откройте `Balance.xcodeproj` в Xcode 16 или новее. +2. Выберите схему `Balance`, `BalanceMac` или `BalanceWatch`. +3. Выберите подходящий симулятор или устройство. +4. Нажмите Run (`⌘R`). + +Поддерживаемые системы: iOS 17+, macOS 14+ и watchOS 10+. + +## Personal Team — режим по умолчанию + +Проект по умолчанию использует локальное SwiftData-хранилище и собирается с бесплатной Personal Team без iCloud и Push Notifications capabilities. Выберите свою Team в **Signing & Capabilities** и запускайте нужную схему. + +В этом режиме можно подключить self-hosted сервер без iCloud-capabilities. Пока сервер не указан, каждое приложение хранит данные только локально. + +## Собственный сервер + +Сервер находится рядом с проектом в папке `BalanceServer`. Он написан на Go, хранит данные в SQLite и запускается на Linux, Windows и macOS напрямую или через Docker. + +1. Запустите сервер по инструкции `BalanceServer/README.md`. +2. Для реального устройства настройте HTTPS. Незащищённый HTTP разрешён приложением только для `localhost` и `127.0.0.1`. +3. Откройте **Настройки → Сервер и синхронизация** (на Watch — **Настройки → Сервер**). +4. Введите полный адрес, сохраните его и зарегистрируйтесь либо войдите. +5. Повторите вход на остальных устройствах с тем же адресом и аккаунтом. + +При изменении адреса токены и курсор предыдущего сервера удаляются, после чего приложение просит войти заново. Пароли не сохраняются; 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 сервер +- Kotlin, Jetpack Compose, Room, WorkManager и Android Keystore + +Проект не требует сторонних зависимостей. + +## Устранение проблем сборки + +После перехода с версии 2.0–2.0.2 выполните **Product → Clean Build Folder**. Если Xcode продолжает использовать старую конфигурацию watchOS target, закройте Xcode и удалите DerivedData для проекта `Balance`, затем снова откройте проект. diff --git a/BalanceAndroid/.gitignore b/BalanceAndroid/.gitignore new file mode 100644 index 0000000..85d5742 --- /dev/null +++ b/BalanceAndroid/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.kotlin/ +.idea/ +local.properties +**/build/ +*.iml +captures/ diff --git a/BalanceAndroid/README.md b/BalanceAndroid/README.md new file mode 100644 index 0000000..564bbdb --- /dev/null +++ b/BalanceAndroid/README.md @@ -0,0 +1,96 @@ +# Баланс для Android + +Нативное Android-приложение личных финансов в дизайне основного проекта Balance. Написано на Kotlin и Jetpack Compose, использует Room для локального хранения и тот же self-hosted Go-сервер для синхронизации с iOS, macOS и watchOS. + +## Возможности + +- доходы, расходы, поиск, фильтрация, добавление, редактирование и удаление операций; +- общий баланс и ручная корректировка, не влияющая на аналитику; +- аналитика за месяц, 3 и 12 месяцев, структура расходов и норма сбережений; +- месячные бюджеты; +- собственные категории, 50 вариантов иконок, ввод эмодзи, 30 готовых эмодзи и палитра из 24 цветов; +- светлая, тёмная и системная тема; +- RUB, EUR, USD и SEK; +- локальная работа без сервера; +- указание и смена сервера, регистрация, вход, выход и полная пересинхронизация; +- пакетная двусторонняя синхронизация операций, категорий, бюджетов и удалений; +- access/refresh-токены шифруются ключом Android Keystore; +- фоновая синхронизация через WorkManager раз в 15 минут при наличии сети. + +## Запуск + +1. Откройте папку `BalanceAndroid` в Android Studio Quail 1 (2026.1.1) или новее. +2. Дождитесь Gradle Sync и установки Android SDK 36. +3. Запустите конфигурацию `app` на устройстве с Android 8.0 (API 26) или новее. + +Для запуска Gradle проекту нужен JDK 17–23; байткод собирается с целевым уровнем Java 17. Используются Gradle 8.13, AGP 8.13.0, Kotlin 2.3.21 и Compose BOM 2026.06.00. + +Kotlin и Compose compiler plugin закреплены на 2.3.21, потому что стабильный Room 2.8.4 поддерживает Kotlin metadata до версии 2.3. Обновлять Kotlin до 2.4 можно после выхода совместимой стабильной версии Room. + +Lifecycle закреплён на ветке 2.10.0, совместимой с compileSdk 36 и AGP 8.13. Lifecycle 2.11 требует API 37 и AGP 9.1, поэтому не обновляйте эти две зависимости отдельно без одновременного перехода всего проекта на новый Android toolchain. + +Перед публикацией в Google Play замените примерный `applicationId` `com.example.balanceandroid` в `app/build.gradle.kts` на собственный уникальный идентификатор. + +## Подключение сервера + +Откройте **Настройки → Сервер и синхронизация**, введите адрес и сохраните его, затем зарегистрируйтесь или войдите. На эмуляторе сервер, запущенный на компьютере, доступен по адресу: + +```text +http://10.0.2.2:8080 +``` + +Для физического устройства используйте HTTPS. Незащищённый HTTP разрешён только для `localhost`, `127.0.0.1` и специального адреса Android Emulator `10.0.2.2`. + +Android совместим с протоколом `BalanceServer` 3.1. Сервер из общего архива менять не нужно. + +## Сборка из командной строки + +Скрипт автоматически ищет SDK в переменных `ANDROID_HOME`/`ANDROID_SDK_ROOT` и стандартных каталогах: + +- Linux: `$HOME/Android/Sdk`; +- macOS: `$HOME/Library/Android/sdk`; +- Windows: `%LOCALAPPDATA%\Android\Sdk`. + +После установки Android SDK 36 выполните: + +```sh +./gradlew test assembleDebug +``` + +Если SDK ещё не установлен, откройте **Tools → SDK Manager** и установите **Android SDK Platform 36**, **Android SDK Build-Tools** и **Android SDK Platform-Tools**. Путь указан в верхней строке `Android SDK Location`. + +Для нестандартного пути на Linux или macOS: + +```sh +export ANDROID_HOME="/путь/к/Android/Sdk" +export ANDROID_SDK_ROOT="$ANDROID_HOME" +./gradlew test assembleDebug +``` + +Вместо переменных можно создать файл `BalanceAndroid/local.properties`: + +```properties +sdk.dir=/home/USER/Android/Sdk +``` + +На macOS скрипт автоматически использует JBR из стандартной установки Android Studio, если `JAVA_HOME` указывает на устаревшую Java 8. Проверить активную версию можно командой `./gradlew --version` — в строке `Launcher JVM` должна быть Java 17–23. + +Если Android Studio установлена в нестандартную папку, задайте её JBR вручную: + +```sh +export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +./gradlew test assembleDebug +``` + +В Android Studio выберите **Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK → Embedded JDK**. + +Если ранее Gradle запускался с недостаточной памятью, остановите старый daemon перед повторной сборкой: + +```sh +./gradlew --stop +./gradlew clean test assembleDebug +``` + +Проект задаёт для Gradle 2 ГБ heap, 1 ГБ metaspace и не более двух параллельных workers. Эти параметры находятся в `gradle.properties` и применяются также при сборке из Android Studio. + +Debug APK появится в `app/build/outputs/apk/debug/`. diff --git a/BalanceAndroid/app/build.gradle.kts b/BalanceAndroid/app/build.gradle.kts new file mode 100644 index 0000000..9acfa43 --- /dev/null +++ b/BalanceAndroid/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.kapt") +} + +val lifecycleVersion = "2.10.0" + +configurations.configureEach { + resolutionStrategy.eachDependency { + if (requested.group == "androidx.lifecycle") { + useVersion(lifecycleVersion) + because("Lifecycle 2.11 requires compileSdk 37 and AGP 9.1") + } + } +} + +android { + namespace = "com.example.balanceandroid" + compileSdk = 36 + + defaultConfig { + applicationId = "com.example.balanceandroid" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } + packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +kapt { + arguments { arg("room.schemaLocation", "$projectDir/schemas") } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2026.06.00") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") + implementation("androidx.navigation:navigation-compose:2.9.8") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + implementation("androidx.room:room-runtime:2.8.4") + implementation("androidx.room:room-ktx:2.8.4") + kapt("androidx.room:room-compiler:2.8.4") + implementation("androidx.work:work-runtime-ktx:2.11.2") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/BalanceAndroid/app/proguard-rules.pro b/BalanceAndroid/app/proguard-rules.pro new file mode 100644 index 0000000..ad61dea --- /dev/null +++ b/BalanceAndroid/app/proguard-rules.pro @@ -0,0 +1,3 @@ +-keepattributes *Annotation* +-keep class * extends androidx.room.RoomDatabase { *; } +-dontwarn org.conscrypt.** diff --git a/BalanceAndroid/app/schemas/.gitkeep b/BalanceAndroid/app/schemas/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/BalanceAndroid/app/schemas/.gitkeep @@ -0,0 +1 @@ + diff --git a/BalanceAndroid/app/src/main/AndroidManifest.xml b/BalanceAndroid/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ed2f098 --- /dev/null +++ b/BalanceAndroid/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/BalanceApplication.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/BalanceApplication.kt new file mode 100644 index 0000000..cc2823d --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/BalanceApplication.kt @@ -0,0 +1,33 @@ +package com.example.balanceandroid + +import android.app.Application +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.example.balanceandroid.data.AppDatabase +import com.example.balanceandroid.sync.SecureSessionStore +import com.example.balanceandroid.sync.ServerPreferences +import com.example.balanceandroid.sync.SyncRepository +import com.example.balanceandroid.sync.SyncWorker +import java.util.concurrent.TimeUnit + +class BalanceApplication : Application() { + val database by lazy { AppDatabase.get(this) } + val preferences by lazy { ServerPreferences(this) } + val sessions by lazy { SecureSessionStore(this) } + val syncRepository by lazy { SyncRepository(database, preferences, sessions) } + + override fun onCreate() { + super.onCreate() + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + "balance-periodic-sync", + ExistingPeriodicWorkPolicy.UPDATE, + request, + ) + } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/MainActivity.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/MainActivity.kt new file mode 100644 index 0000000..7e7f8c5 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/MainActivity.kt @@ -0,0 +1,25 @@ +package com.example.balanceandroid + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.balanceandroid.ui.BalanceRoot +import com.example.balanceandroid.ui.theme.BalanceTheme + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + val model: MainViewModel = viewModel(factory = MainViewModel.Factory(application as BalanceApplication)) + val state by model.state.collectAsStateWithLifecycle() + BalanceTheme(theme = state.theme) { + BalanceRoot(model, state) + } + } + } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/MainViewModel.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/MainViewModel.kt new file mode 100644 index 0000000..294e92b --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/MainViewModel.kt @@ -0,0 +1,172 @@ +package com.example.balanceandroid + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.balanceandroid.data.BudgetEntity +import com.example.balanceandroid.data.BuiltInCategories +import com.example.balanceandroid.data.CategoryDesign +import com.example.balanceandroid.data.CategoryEntity +import com.example.balanceandroid.data.FinanceDao +import com.example.balanceandroid.data.TransactionEntity +import com.example.balanceandroid.sync.ServerPreferences +import com.example.balanceandroid.sync.ServerSession +import com.example.balanceandroid.sync.SyncRepository +import com.example.balanceandroid.sync.SyncStatus +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId + +data class FinanceData( + val transactions: List = emptyList(), + val categories: List = emptyList(), + val budgets: List = emptyList(), +) + +data class FinanceUiState( + val data: FinanceData = FinanceData(), + val sync: SyncStatus = SyncStatus(), + val session: ServerSession? = null, + val currency: String = "RUB", + val theme: String = "system", + val serverUrl: String = "", + val serverEmail: String = "", +) + +class MainViewModel( + private val dao: FinanceDao, + private val preferences: ServerPreferences, + private val syncRepository: SyncRepository, +) : ViewModel() { + private val settingsRevision = MutableStateFlow(0) + private val financeData = combine( + dao.observeTransactions(), + dao.observeCategories(), + dao.observeBudgets(), + ) { transactions, categories, budgets -> FinanceData(transactions, categories, budgets) } + + val state: StateFlow = combine( + financeData, + syncRepository.status, + syncRepository.session, + settingsRevision, + ) { data, sync, session, _ -> + FinanceUiState( + data = data, + sync = sync, + session = session, + currency = preferences.currency, + theme = preferences.theme, + serverUrl = preferences.serverUrl, + serverEmail = preferences.email, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FinanceUiState()) + + init { viewModelScope.launch { syncRepository.sync() } } + + fun categories(kind: String, data: FinanceData = state.value.data): List { + val builtIn = if (kind == "income") BuiltInCategories.incomes else BuiltInCategories.expenses + return builtIn + data.categories.filter { it.kindRawValue == kind }.map { + CategoryDesign(it.name, it.icon, it.emoji, it.colorName, it.kindRawValue) + } + } + + fun saveTransaction( + existing: TransactionEntity?, amount: Double, kind: String, category: CategoryDesign, + note: String, date: Long, + ) = viewModelScope.launch { + val now = System.currentTimeMillis() + dao.upsertTransaction((existing ?: TransactionEntity(amount = amount, categoryName = category.name)).copy( + amount = amount, + date = date, + note = note.trim(), + categoryName = category.name, + categoryIcon = category.icon, + categoryEmoji = category.emoji, + categoryColorName = category.colorName, + kindRawValue = kind, + updatedAt = now, + )) + } + + fun deleteTransaction(value: TransactionEntity) = viewModelScope.launch { dao.deleteTransaction(value) } + + fun adjustBalance(target: Double, note: String) = viewModelScope.launch { + val current = com.example.balanceandroid.data.FinanceMath.totalBalance(state.value.data.transactions) + val difference = target - current + if (kotlin.math.abs(difference) < 0.005) return@launch + dao.upsertTransaction(TransactionEntity( + amount = kotlin.math.abs(difference), + note = note.ifBlank { "Ручная корректировка баланса" }, + categoryName = "Корректировка", + categoryIcon = "tune", + categoryColorName = "indigo", + isBalanceAdjustment = true, + kindRawValue = if (difference > 0) "income" else "expense", + )) + } + + fun saveCategory(existing: CategoryEntity?, name: String, kind: String, icon: String, emoji: String, color: String) = viewModelScope.launch { + val trimmed = name.trim() + if (trimmed.isEmpty()) return@launch + val now = System.currentTimeMillis() + if (existing != null) { + val oldName = existing.name + val updated = existing.copy(name = trimmed, icon = icon, emoji = emoji, colorName = color, updatedAt = now) + dao.upsertCategory(updated) + dao.upsertTransactions(dao.transactionsForCategory(oldName).map { + it.copy(categoryName = trimmed, categoryIcon = icon, categoryEmoji = emoji, categoryColorName = color, updatedAt = now) + }) + dao.upsertBudgets(dao.budgetsForCategory(oldName).map { + it.copy(categoryName = trimmed, categoryIcon = icon, categoryEmoji = emoji, updatedAt = now) + }) + } else { + dao.upsertCategory(CategoryEntity(name = trimmed, icon = icon, emoji = emoji, colorName = color, kindRawValue = kind)) + } + } + + fun deleteCategory(value: CategoryEntity) = viewModelScope.launch { dao.deleteCategory(value) } + + fun saveBudget(category: CategoryDesign, amount: Double, monthStart: Long = currentMonthStart()) = viewModelScope.launch { + val existing = state.value.data.budgets.firstOrNull { it.categoryName == category.name && it.monthStart == monthStart } + if (amount <= 0 && existing != null) dao.deleteBudget(existing) + else if (amount > 0) dao.upsertBudget((existing ?: BudgetEntity( + categoryName = category.name, + categoryIcon = category.icon, + categoryEmoji = category.emoji, + limitAmount = amount, + monthStart = monthStart, + )).copy(limitAmount = amount, categoryIcon = category.icon, categoryEmoji = category.emoji, updatedAt = System.currentTimeMillis())) + } + + fun setCurrency(value: String) { preferences.currency = value; settingsRevision.value++ } + fun setTheme(value: String) { preferences.theme = value; settingsRevision.value++ } + + fun changeServer(value: String) = viewModelScope.launch { + runCatching { syncRepository.changeServer(value) } + .onSuccess { settingsRevision.value++ } + } + fun login(email: String, password: String) = viewModelScope.launch { syncRepository.login(email, password) } + fun register(email: String, password: String) = viewModelScope.launch { syncRepository.register(email, password) } + fun logout() = viewModelScope.launch { syncRepository.logout() } + fun sync() = viewModelScope.launch { syncRepository.sync() } + fun resetSync() = viewModelScope.launch { syncRepository.resetAndSync() } + + companion object { + fun currentMonthStart(now: Long = System.currentTimeMillis()): Long = Instant.ofEpochMilli(now) + .atZone(ZoneId.systemDefault()).withDayOfMonth(1).toLocalDate().atStartOfDay(ZoneId.systemDefault()) + .toInstant().toEpochMilli() + } + + class Factory(private val application: BalanceApplication) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MainViewModel( + application.database.financeDao(), application.preferences, application.syncRepository, + ) as T + } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/AppDatabase.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/AppDatabase.kt new file mode 100644 index 0000000..e63cd57 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/AppDatabase.kt @@ -0,0 +1,27 @@ +package com.example.balanceandroid.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database( + entities = [TransactionEntity::class, CategoryEntity::class, BudgetEntity::class, DeletionEntity::class], + version = 1, + exportSchema = true, +) +abstract class AppDatabase : RoomDatabase() { + abstract fun financeDao(): FinanceDao + + companion object { + @Volatile private var instance: AppDatabase? = null + + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { + instance ?: Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "balance-android.db", + ).build().also { instance = it } + } + } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/Entities.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/Entities.kt new file mode 100644 index 0000000..dde1666 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/Entities.kt @@ -0,0 +1,86 @@ +package com.example.balanceandroid.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import java.util.UUID + +@Entity(tableName = "transactions", indices = [Index(value = ["updatedAt"]), Index(value = ["date"])]) +data class TransactionEntity( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val amount: Double, + val date: Long = System.currentTimeMillis(), + val note: String = "", + val categoryName: String, + val categoryIcon: String = "circle", + val categoryEmoji: String = "", + val categoryColorName: String = "indigo", + val isBalanceAdjustment: Boolean = false, + val kindRawValue: String = "expense", + val updatedAt: Long = System.currentTimeMillis(), +) + +@Entity(tableName = "categories", indices = [Index(value = ["updatedAt"]), Index(value = ["name", "kindRawValue"])]) +data class CategoryEntity( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val name: String, + val icon: String = "star", + val emoji: String = "", + val colorName: String = "indigo", + val kindRawValue: String = "expense", + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), +) + +@Entity(tableName = "budgets", indices = [Index(value = ["updatedAt"]), Index(value = ["monthStart"])]) +data class BudgetEntity( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val categoryName: String, + val categoryIcon: String = "circle", + val categoryEmoji: String = "", + val limitAmount: Double, + val monthStart: Long, + val updatedAt: Long = System.currentTimeMillis(), +) + +@Entity(tableName = "deletion_journal", indices = [Index(value = ["updatedAt"])]) +data class DeletionEntity( + @PrimaryKey val id: String, + val entity: String, + val recordId: String, + val updatedAt: Long = System.currentTimeMillis(), +) { + companion object { + fun create(entity: String, recordId: String, updatedAt: Long = System.currentTimeMillis()) = + DeletionEntity("$entity:$recordId", entity, recordId, updatedAt) + } +} + +data class CategoryDesign( + val name: String, + val icon: String, + val emoji: String = "", + val colorName: String, + val kind: String, +) + +object BuiltInCategories { + val expenses = listOf( + CategoryDesign("Продукты", "cart.fill", "", "orange", "expense"), + CategoryDesign("Транспорт", "car.fill", "", "blue", "expense"), + CategoryDesign("Дом", "house.fill", "", "indigo", "expense"), + CategoryDesign("Развлечения", "gamecontroller.fill", "", "pink", "expense"), + CategoryDesign("Здоровье", "cross.case.fill", "", "red", "expense"), + CategoryDesign("Покупки", "bag.fill", "", "purple", "expense"), + CategoryDesign("Подписки", "repeat", "", "cyan", "expense"), + CategoryDesign("Образование", "book.fill", "", "mint", "expense"), + CategoryDesign("Другое", "ellipsis.circle.fill", "", "gray", "expense"), + ) + val incomes = listOf( + CategoryDesign("Зарплата", "banknote.fill", "", "green", "income"), + CategoryDesign("Подработка", "laptopcomputer", "", "teal", "income"), + CategoryDesign("Инвестиции", "chart.line.uptrend.xyaxis", "", "blue", "income"), + CategoryDesign("Подарок", "gift.fill", "", "pink", "income"), + CategoryDesign("Другое", "plus.circle.fill", "", "gray", "income"), + ) +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/FinanceDao.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/FinanceDao.kt new file mode 100644 index 0000000..810ca2e --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/FinanceDao.kt @@ -0,0 +1,73 @@ +package com.example.balanceandroid.data + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface FinanceDao { + @Query("SELECT * FROM transactions ORDER BY date DESC") + fun observeTransactions(): Flow> + + @Query("SELECT * FROM categories ORDER BY createdAt") + fun observeCategories(): Flow> + + @Query("SELECT * FROM budgets ORDER BY monthStart DESC, categoryName") + fun observeBudgets(): Flow> + + @Upsert suspend fun upsertTransaction(value: TransactionEntity) + @Upsert suspend fun upsertTransactions(values: List) + @Upsert suspend fun upsertCategory(value: CategoryEntity) + @Upsert suspend fun upsertCategories(values: List) + @Upsert suspend fun upsertBudget(value: BudgetEntity) + @Upsert suspend fun upsertBudgets(values: List) + @Upsert suspend fun upsertDeletion(value: DeletionEntity) + + @Query("SELECT * FROM transactions WHERE id = :id LIMIT 1") + suspend fun transaction(id: String): TransactionEntity? + @Query("SELECT * FROM categories WHERE id = :id LIMIT 1") + suspend fun category(id: String): CategoryEntity? + @Query("SELECT * FROM budgets WHERE id = :id LIMIT 1") + suspend fun budget(id: String): BudgetEntity? + + @Query("SELECT * FROM transactions WHERE categoryName = :name") + suspend fun transactionsForCategory(name: String): List + @Query("SELECT * FROM budgets WHERE categoryName = :name") + suspend fun budgetsForCategory(name: String): List + + @Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteTransactionDirect(id: String) + @Query("DELETE FROM categories WHERE id = :id") suspend fun deleteCategoryDirect(id: String) + @Query("DELETE FROM budgets WHERE id = :id") suspend fun deleteBudgetDirect(id: String) + @Query("DELETE FROM deletion_journal WHERE entity = :entity AND recordId = :recordId AND updatedAt <= :through") + suspend fun clearDeletion(entity: String, recordId: String, through: Long) + + @Query("SELECT * FROM transactions WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset") + suspend fun transactionChanges(after: Long, through: Long, limit: Int, offset: Int): List + @Query("SELECT * FROM categories WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset") + suspend fun categoryChanges(after: Long, through: Long, limit: Int, offset: Int): List + @Query("SELECT * FROM budgets WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset") + suspend fun budgetChanges(after: Long, through: Long, limit: Int, offset: Int): List + @Query("SELECT * FROM deletion_journal WHERE updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset") + suspend fun deletionChanges(through: Long, limit: Int, offset: Int): List + + @Transaction + suspend fun deleteTransaction(value: TransactionEntity) { + upsertDeletion(DeletionEntity.create("transaction", value.id)) + deleteTransactionDirect(value.id) + } + + @Transaction + suspend fun deleteBudget(value: BudgetEntity) { + upsertDeletion(DeletionEntity.create("budget", value.id)) + deleteBudgetDirect(value.id) + } + + @Transaction + suspend fun deleteCategory(value: CategoryEntity) { + budgetsForCategory(value.name).forEach { deleteBudget(it) } + upsertDeletion(DeletionEntity.create("category", value.id)) + deleteCategoryDirect(value.id) + } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/FinanceMath.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/FinanceMath.kt new file mode 100644 index 0000000..b10706a --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/data/FinanceMath.kt @@ -0,0 +1,37 @@ +package com.example.balanceandroid.data + +data class FinanceSummary(val income: Double, val expenses: Double) { + val balance: Double get() = income - expenses +} + +data class CategoryTotal( + val name: String, + val icon: String, + val emoji: String, + val colorName: String, + val amount: Double, +) + +object FinanceMath { + fun totalBalance(items: List): Double = items.sumOf { + if (it.kindRawValue == "income") it.amount else -it.amount + } + + fun summary(items: List, start: Long, endExclusive: Long): FinanceSummary { + val filtered = items.filter { !it.isBalanceAdjustment && it.date >= start && it.date < endExclusive } + return FinanceSummary( + income = filtered.filter { it.kindRawValue == "income" }.sumOf { it.amount }, + expenses = filtered.filter { it.kindRawValue == "expense" }.sumOf { it.amount }, + ) + } + + fun spending(items: List, start: Long, endExclusive: Long): List = + items.asSequence() + .filter { it.kindRawValue == "expense" && !it.isBalanceAdjustment && it.date >= start && it.date < endExclusive } + .groupBy { it.categoryName } + .map { (name, values) -> + val first = values.first() + CategoryTotal(name, first.categoryIcon, first.categoryEmoji, first.categoryColorName, values.sumOf { it.amount }) + } + .sortedByDescending { it.amount } +} 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 new file mode 100644 index 0000000..d66f790 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SecureSessionStore.kt @@ -0,0 +1,80 @@ +package com.example.balanceandroid.sync + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import org.json.JSONObject +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +data class ServerSession( + val accessToken: String, + val refreshToken: String, + val expiresAt: Long, + val userId: String, + val email: String, +) + +class SecureSessionStore(context: Context) { + private val preferences = context.getSharedPreferences("balance_secure_session", Context.MODE_PRIVATE) + private val alias = "balance.server.session.key" + + fun save(session: ServerSession) { + val source = JSONObject() + .put("accessToken", session.accessToken) + .put("refreshToken", session.refreshToken) + .put("expiresAt", session.expiresAt) + .put("userId", session.userId) + .put("email", session.email) + .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)) + .apply() + } + + fun load(): ServerSession? = runCatching { + val iv = Base64.decode(preferences.getString("iv", null), Base64.NO_WRAP) + val encrypted = Base64.decode(preferences.getString("data", null), Base64.NO_WRAP) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv)) + val json = JSONObject(String(cipher.doFinal(encrypted))) + ServerSession( + accessToken = json.getString("accessToken"), + refreshToken = json.getString("refreshToken"), + expiresAt = json.getLong("expiresAt"), + userId = json.getString("userId"), + email = json.getString("email"), + ) + }.getOrElse { + clear() + null + } + + fun clear() { + preferences.edit().clear().apply() + } + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(alias, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .build() + ) + generateKey() + } + } +} 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 new file mode 100644 index 0000000..02fd6a6 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerApi.kt @@ -0,0 +1,111 @@ +package com.example.balanceandroid.sync + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.time.Instant + +class ServerException(message: String, val status: Int = 0) : Exception(message) + +data class SyncPage( + val cursor: Long, + val changes: JSONArray, + val hasMore: Boolean, +) + +class ServerApi( + private val preferences: ServerPreferences, + private val sessions: SecureSessionStore, +) { + suspend fun register(email: String, password: String): ServerSession = authenticate("v1/auth/register", email, password) + suspend fun login(email: String, password: String): ServerSession = authenticate("v1/auth/login", email, password) + + suspend fun logout() { + sessions.load()?.let { session -> + runCatching { post("v1/auth/logout", JSONObject().put("refreshToken", session.refreshToken), null) } + } + sessions.clear() + } + + suspend fun sync(cursor: Long, changes: JSONArray, pull: Boolean): SyncPage { + val body = JSONObject() + .put("cursor", cursor) + .put("deviceId", preferences.deviceId) + .put("changes", changes) + .put("pull", pull) + val response = authorizedPost("v1/sync", body) + return SyncPage( + cursor = response.optLong("cursor", cursor), + changes = response.optJSONArray("changes") ?: JSONArray(), + hasMore = response.optBoolean("hasMore", false), + ) + } + + private suspend fun authenticate(path: String, email: String, password: String): ServerSession { + val response = post(path, JSONObject().put("email", email.trim()).put("password", password), null) + return parseSession(response).also { sessions.save(it) } + } + + private suspend fun authorizedPost(path: String, body: JSONObject, retry: Boolean = true): JSONObject { + var session = sessions.load() ?: throw ServerException("Сначала войдите в аккаунт") + if (session.expiresAt - System.currentTimeMillis() < 60_000) session = refresh(session) + return try { + post(path, body, session.accessToken) + } catch (error: ServerException) { + if (error.status == 401 && retry) { + session = refresh(session) + post(path, body, session.accessToken) + } else throw error + } + } + + private suspend fun refresh(session: ServerSession): ServerSession { + return try { + parseSession(post("v1/auth/refresh", JSONObject().put("refreshToken", session.refreshToken), null)) + .also { sessions.save(it) } + } catch (error: Throwable) { + sessions.clear() + throw ServerException("Сессия истекла. Войдите снова.") + } + } + + private fun parseSession(json: JSONObject): ServerSession { + val user = json.getJSONObject("user") + return ServerSession( + accessToken = json.getString("accessToken"), + refreshToken = json.getString("refreshToken"), + expiresAt = Instant.parse(json.getString("expiresAt")).toEpochMilli(), + userId = user.getString("id"), + email = user.getString("email"), + ) + } + + private suspend fun post(path: String, body: JSONObject, token: String?): JSONObject = withContext(Dispatchers.IO) { + val base = preferences.normalizeServer(preferences.serverUrl) + val connection = (URL("$base/$path").openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = 15_000 + readTimeout = 30_000 + doOutput = true + setRequestProperty("Content-Type", "application/json; charset=utf-8") + if (token != null) setRequestProperty("Authorization", "Bearer $token") + } + try { + connection.outputStream.use { it.write(body.toString().toByteArray()) } + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + if (status !in 200..299) { + val message = runCatching { JSONObject(text).getJSONObject("error").getString("message") } + .getOrDefault("Ошибка сервера: HTTP $status") + throw ServerException(message, status) + } + if (text.isBlank()) JSONObject() else JSONObject(text) + } finally { + connection.disconnect() + } + } +} 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 new file mode 100644 index 0000000..9f058a0 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/ServerPreferences.kt @@ -0,0 +1,57 @@ +package com.example.balanceandroid.sync + +import android.content.Context +import java.net.URI +import java.security.MessageDigest +import java.util.UUID + +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).apply() } + var email: String + get() = values.getString("server_email", "") ?: "" + 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).apply() } + var theme: String + get() = values.getString("theme", "system") ?: "system" + 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).apply() + } + + fun normalizeServer(input: String): String { + val trimmed = input.trim().trimEnd('/') + val uri = runCatching { URI(trimmed) }.getOrNull() + ?: error("Укажите полный адрес сервера") + val host = uri.host?.lowercase() ?: error("Укажите полный адрес сервера") + val scheme = uri.scheme?.lowercase() + require(scheme == "https" || scheme == "http") { "Адрес должен начинаться с https://" } + require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) { + "Адрес сервера не должен содержать логин, параметры или фрагмент" + } + if (scheme == "http") { + require(host in setOf("localhost", "127.0.0.1", "10.0.2.2")) { + "Для удалённого сервера требуется HTTPS" + } + } + return trimmed + } + + 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).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).apply() + fun resetSync(scope: String) { + values.edit().remove("cursor_${hash(scope)}").remove("push_${hash(scope)}").apply() + } + + private fun hash(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray()).take(12).joinToString("") { "%02x".format(it) } +} 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 new file mode 100644 index 0000000..d7f36e6 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncRepository.kt @@ -0,0 +1,290 @@ +package com.example.balanceandroid.sync + +import androidx.room.withTransaction +import com.example.balanceandroid.data.AppDatabase +import com.example.balanceandroid.data.BudgetEntity +import com.example.balanceandroid.data.CategoryEntity +import com.example.balanceandroid.data.TransactionEntity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.json.JSONArray +import org.json.JSONObject +import java.time.Instant + +data class SyncStatus( + val running: Boolean = false, + val message: String? = null, + val lastSyncAt: Long? = null, +) + +class SyncRepository( + private val database: AppDatabase, + private val preferences: ServerPreferences, + private val sessions: SecureSessionStore, +) { + private val dao = database.financeDao() + private val api = ServerApi(preferences, sessions) + private val mutex = Mutex() + private val mutableStatus = MutableStateFlow(SyncStatus()) + private val mutableSession = MutableStateFlow(sessions.load()) + val status: StateFlow = mutableStatus + val session: StateFlow = mutableSession + + fun serverUrl(): String = preferences.serverUrl + fun savedEmail(): String = preferences.email + + suspend fun changeServer(value: String): String { + return try { + val normalized = preferences.normalizeServer(value) + val changed = normalized != preferences.serverUrl + if (changed) { + runCatching { api.logout() } + sessions.clear() + mutableSession.value = null + preferences.serverUrl = normalized + } + mutableStatus.value = SyncStatus(message = if (changed) "Адрес сохранён. Войдите заново." else "Адрес сохранён") + normalized + } catch (error: Throwable) { + mutableStatus.value = SyncStatus(message = error.message ?: "Некорректный адрес сервера") + throw error + } + } + + suspend fun register(email: String, password: String) = authenticate(email) { api.register(email, password) } + suspend fun login(email: String, password: String) = authenticate(email) { api.login(email, password) } + + private suspend fun authenticate(email: String, action: suspend () -> ServerSession) { + mutableStatus.value = SyncStatus(running = true) + runCatching { action() } + .onSuccess { + preferences.email = email.trim() + mutableSession.value = it + mutableStatus.value = SyncStatus(message = "Вход выполнен") + sync() + } + .onFailure { mutableStatus.value = SyncStatus(message = it.message ?: "Ошибка входа") } + } + + suspend fun logout() { + if (mutableStatus.value.running) return + runCatching { api.logout() } + sessions.clear() + mutableSession.value = null + mutableStatus.value = SyncStatus(message = "Вы вышли из аккаунта") + } + + suspend fun resetAndSync() { + val session = sessions.load() ?: return + preferences.resetSync(scope(session)) + sync() + } + + suspend fun sync(): Boolean = mutex.withLock { + val session = sessions.load() ?: return@withLock false + if (preferences.serverUrl.isBlank()) return@withLock false + mutableSession.value = session + mutableStatus.value = mutableStatus.value.copy(running = true, message = null) + val result = runCatching { + val scope = scope(session) + val cutoff = System.currentTimeMillis() + val lastPush = preferences.lastPush(scope) + val threshold = if (lastPush <= cutoff + 5_000) (lastPush - 5_000).coerceAtLeast(0) else 0 + val cursor = preferences.cursor(scope) + + pushTransactions(threshold, cutoff, cursor) + pushCategories(threshold, cutoff, cursor) + pushBudgets(threshold, cutoff, cursor) + pushDeletions(cutoff, cursor) + pull(scope, cursor) + preferences.setLastPush(scope, cutoff) + } + return@withLock result.fold( + onSuccess = { + val now = System.currentTimeMillis() + mutableStatus.value = SyncStatus(message = "Синхронизация завершена", lastSyncAt = now) + true + }, + onFailure = { + if (it is ServerException && it.status == 401) { + sessions.clear() + mutableSession.value = null + } + mutableStatus.value = SyncStatus(message = it.message ?: "Ошибка синхронизации") + false + }, + ) + } + + private suspend fun pushTransactions(after: Long, through: Long, cursor: Long) { + var offset = 0 + while (true) { + val items = dao.transactionChanges(after, through, BATCH_SIZE, offset) + if (items.isEmpty()) break + push(cursor, items.map(::transactionChange)) + offset += items.size + if (items.size < BATCH_SIZE) break + } + } + + private suspend fun pushCategories(after: Long, through: Long, cursor: Long) { + var offset = 0 + while (true) { + val items = dao.categoryChanges(after, through, BATCH_SIZE, offset) + if (items.isEmpty()) break + push(cursor, items.map(::categoryChange)) + offset += items.size + if (items.size < BATCH_SIZE) break + } + } + + private suspend fun pushBudgets(after: Long, through: Long, cursor: Long) { + var offset = 0 + while (true) { + val items = dao.budgetChanges(after, through, BATCH_SIZE, offset) + if (items.isEmpty()) break + push(cursor, items.map(::budgetChange)) + offset += items.size + if (items.size < BATCH_SIZE) break + } + } + + private suspend fun pushDeletions(through: Long, cursor: Long) { + var offset = 0 + while (true) { + val items = dao.deletionChanges(through, BATCH_SIZE, offset) + if (items.isEmpty()) break + push(cursor, items.map { + JSONObject().put("entity", it.entity).put("id", it.recordId) + .put("deleted", true).put("updatedAt", iso(it.updatedAt)) + }) + offset += items.size + if (items.size < BATCH_SIZE) break + } + } + + private suspend fun push(cursor: Long, changes: List) { + if (changes.isEmpty()) return + val array = JSONArray().apply { changes.forEach { put(it) } } + if (array.toString().toByteArray().size > 1_500_000 && changes.size > 1) { + val middle = changes.size / 2 + push(cursor, changes.subList(0, middle)) + push(cursor, changes.subList(middle, changes.size)) + } else { + api.sync(cursor, array, pull = false) + } + } + + private suspend fun pull(scope: String, initialCursor: Long) { + var cursor = initialCursor + do { + val page = api.sync(cursor, JSONArray(), pull = true) + require(page.cursor >= cursor) { "Сервер вернул некорректный курсор" } + database.withTransaction { apply(page.changes) } + preferences.setCursor(scope, page.cursor) + if (!page.hasMore) return + require(page.cursor > cursor) { "Сервер не смог продолжить синхронизацию" } + cursor = page.cursor + } while (true) + } + + private suspend fun apply(changes: JSONArray) { + val sorted = (0 until changes.length()).mapNotNull { changes.optJSONObject(it) } + .sortedBy { it.optLong("sequence") } + for (change in sorted) { + val entity = change.optString("entity") + val id = change.optString("id") + val updatedAt = parseTime(change.optString("updatedAt")) ?: continue + if (change.optBoolean("deleted")) { + when (entity) { + "transaction" -> dao.transaction(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteTransactionDirect(id) } + "category" -> dao.category(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteCategoryDirect(id) } + "budget" -> dao.budget(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteBudgetDirect(id) } + } + } else { + val payload = change.optJSONObject("payload") ?: continue + runCatching { + when (entity) { + "transaction" -> applyTransaction(id, updatedAt, payload) + "category" -> applyCategory(id, updatedAt, payload) + "budget" -> applyBudget(id, updatedAt, payload) + } + } + } + dao.clearDeletion(entity, id, updatedAt) + } + } + + private suspend fun applyTransaction(id: String, updatedAt: Long, value: JSONObject) { + if ((dao.transaction(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return + dao.upsertTransaction(TransactionEntity( + id = id, + amount = value.getDouble("amount"), + date = requireTime(value.getString("date")), + note = value.optString("note"), + categoryName = value.getString("categoryName"), + categoryIcon = value.optString("categoryIcon", "circle"), + categoryEmoji = value.optString("categoryEmoji"), + categoryColorName = value.optString("categoryColorName", "indigo"), + isBalanceAdjustment = value.optBoolean("isBalanceAdjustment"), + kindRawValue = value.optString("kindRawValue", "expense"), + updatedAt = updatedAt, + )) + } + + private suspend fun applyCategory(id: String, updatedAt: Long, value: JSONObject) { + if ((dao.category(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return + dao.upsertCategory(CategoryEntity( + id = id, + name = value.getString("name"), + icon = value.optString("icon", "star"), + emoji = value.optString("emoji"), + colorName = value.optString("colorName", "indigo"), + kindRawValue = value.optString("kindRawValue", "expense"), + createdAt = requireTime(value.getString("createdAt")), + updatedAt = updatedAt, + )) + } + + private suspend fun applyBudget(id: String, updatedAt: Long, value: JSONObject) { + if ((dao.budget(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return + dao.upsertBudget(BudgetEntity( + id = id, + categoryName = value.getString("categoryName"), + categoryIcon = value.optString("categoryIcon", "circle"), + categoryEmoji = value.optString("categoryEmoji"), + limitAmount = value.getDouble("limit"), + monthStart = requireTime(value.getString("monthStart")), + updatedAt = updatedAt, + )) + } + + private fun transactionChange(value: TransactionEntity) = change("transaction", value.id, value.updatedAt, JSONObject() + .put("amount", value.amount).put("date", iso(value.date)).put("note", value.note) + .put("categoryName", value.categoryName).put("categoryIcon", value.categoryIcon) + .put("categoryEmoji", value.categoryEmoji).put("categoryColorName", value.categoryColorName) + .put("isBalanceAdjustment", value.isBalanceAdjustment).put("kindRawValue", value.kindRawValue)) + + private fun categoryChange(value: CategoryEntity) = change("category", value.id, value.updatedAt, JSONObject() + .put("name", value.name).put("icon", value.icon).put("emoji", value.emoji) + .put("colorName", value.colorName).put("kindRawValue", value.kindRawValue) + .put("createdAt", iso(value.createdAt))) + + private fun budgetChange(value: BudgetEntity) = change("budget", value.id, value.updatedAt, JSONObject() + .put("categoryName", value.categoryName).put("categoryIcon", value.categoryIcon) + .put("categoryEmoji", value.categoryEmoji).put("limit", value.limitAmount) + .put("monthStart", iso(value.monthStart))) + + private fun change(entity: String, id: String, updatedAt: Long, payload: JSONObject) = JSONObject() + .put("entity", entity).put("id", id).put("deleted", false) + .put("updatedAt", iso(updatedAt)).put("payload", payload) + + private fun scope(session: ServerSession) = "${preferences.serverUrl}|${session.userId}" + private fun iso(value: Long): String = Instant.ofEpochMilli(value).toString() + private fun parseTime(value: String): Long? = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() + private fun requireTime(value: String): Long = Instant.parse(value).toEpochMilli() + + companion object { private const val BATCH_SIZE = 100 } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncWorker.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncWorker.kt new file mode 100644 index 0000000..78fad21 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/sync/SyncWorker.kt @@ -0,0 +1,14 @@ +package com.example.balanceandroid.sync + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.example.balanceandroid.BalanceApplication + +class SyncWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result { + val app = applicationContext as BalanceApplication + if (app.sessions.load() == null || app.preferences.serverUrl.isBlank()) return Result.success() + return if (app.syncRepository.sync()) Result.success() else Result.retry() + } +} 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 new file mode 100644 index 0000000..9721d7f --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt @@ -0,0 +1,399 @@ +package com.example.balanceandroid.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.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 +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +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.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.FinanceUiState +import com.example.balanceandroid.MainViewModel +import com.example.balanceandroid.data.CategoryDesign +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 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.Default.List), + Destination("budgets", "Бюджеты", Icons.Default.Savings), + Destination("settings", "Настройки", Icons.Default.Settings), +) + +@Composable +fun BalanceRoot(model: MainViewModel, state: FinanceUiState) { + val nav = rememberNavController() + val route = nav.currentBackStackEntryAsState().value?.destination?.route ?: "overview" + Scaffold( + bottomBar = { + 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, + ) + } + } + }, + ) { padding -> + NavHost(nav, "overview", Modifier.padding(padding)) { + composable("overview") { OverviewScreen(model, state) } + composable("analytics") { AnalyticsScreen(state) } + composable("transactions") { TransactionsScreen(model, state) } + composable("budgets") { BudgetsScreen(model, state) } + composable("settings") { SettingsScreen(model, state, nav) } + composable("categories") { CategoriesScreen(model, state, nav) } + composable("server") { ServerScreen(model, state, nav) } + } + } +} + +@Composable +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) + val spending = FinanceMath.spending(state.data.transactions, start, end) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(18.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { ScreenTitle("Обзор") } + item { + Column( + 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) + OutlinedButton(onClick = { adjustment = true }) { + 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)) + } + } + 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) + 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) } + } + } + if (adjustment) BalanceAdjustmentDialog( + current = FinanceMath.totalBalance(state.data.transactions), + currency = state.currency, + onDismiss = { adjustment = false }, + onSave = { target, note -> model.adjustBalance(target, note); adjustment = false }, + ) +} + +@Composable +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)) { + 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) }) + } + } + } + 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)) + } + } + 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)) + } + } + item { + Card { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("Структура расходов", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + SpendingBars(spending, Modifier.fillMaxWidth()) + } } + } + } +} + +@Composable +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)) + } + Scaffold( + floatingActionButton = { + 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)) { + item { ScreenTitle("Операции") } + 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) }) + } + } + } + if (filtered.isEmpty()) item { EmptyMessage("Операций пока нет") } + items(filtered, key = { it.id }) { transaction -> + 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) } + } + } + } + item { Spacer(Modifier.height(72.dp)) } + } + } + if (showingEditor) TransactionEditorDialog(model, state, editing, onDismiss = { showingEditor = false }) +} + +@Composable +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)) { + 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("Лимиты") } + } + } + 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) + } } + } + } + if (editing) BudgetEditorDialog(model, state, onDismiss = { editing = false }) +} + +@Composable +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"), 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 +private fun CategoriesScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) { + var editing by remember { mutableStateOf(null) } + var showEditor by remember { mutableStateOf(false) } + Scaffold(floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { editing = null; showEditor = true }, + icon = { Icon(Icons.Default.Add, null) }, + text = { Text("Категория") } + ) + }) { padding -> + 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 }) { + 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) } + } + } + } + item { Spacer(Modifier.height(72.dp)) } + } + } + if (showEditor) CategoryEditorDialog(model, state, editing, onDismiss = { showEditor = false }) +} + +@Composable +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 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)) { + 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("Полная пересинхронизация") } + } + 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 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 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) + 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) + } +} + +@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 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) } + androidx.compose.material3.DropdownMenu(expanded, { expanded = false }) { + values.forEach { value -> androidx.compose.material3.DropdownMenuItem({ Text(label(value)) }, { onSelect(value); expanded = false }) } + } + } +} 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 new file mode 100644 index 0000000..0fc22bb --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Components.kt @@ -0,0 +1,229 @@ +package com.example.balanceandroid.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.filled.AccountBalanceWallet +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Bed +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Cake +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.CardGiftcard +import androidx.compose.material.icons.filled.Checkroom +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.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 +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.Laptop +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.LocalFireDepartment +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 +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.PedalBike +import androidx.compose.material.icons.filled.Pets +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Print +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material.icons.filled.ShoppingBag +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.Work +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +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.unit.dp +import androidx.compose.ui.unit.sp +import com.example.balanceandroid.data.CategoryDesign +import com.example.balanceandroid.data.CategoryTotal +import java.text.NumberFormat +import java.time.Instant +import java.time.ZoneId +import java.util.Currency +import java.util.Locale + +fun formatMoney(value: Double, currency: String): String = runCatching { + NumberFormat.getCurrencyInstance(Locale.getDefault()).apply { + this.currency = Currency.getInstance(currency) + maximumFractionDigits = if (value % 1.0 == 0.0) 0 else 2 + }.format(value) +}.getOrElse { "%.2f %s".format(value, currency) } + +fun colorFor(name: String): Color = when (name) { + "red" -> Color(0xFFE05252) + "orange" -> Color(0xFFF28C38) + "yellow" -> Color(0xFFE0AC24) + "green" -> Color(0xFF16A36A) + "mint" -> Color(0xFF31B99A) + "teal" -> Color(0xFF159EAF) + "cyan" -> Color(0xFF209BCC) + "blue" -> Color(0xFF3979E9) + "purple" -> Color(0xFF8B5CF6) + "pink" -> Color(0xFFE35C9A) + "brown" -> Color(0xFF9B6A4A) + "gray" -> Color(0xFF7A7A85) + "coral" -> Color(0xFFFA6161) + "peach" -> Color(0xFFFF9461) + "gold" -> Color(0xFFE6A314) + "lime" -> Color(0xFF85C729) + "forest" -> Color(0xFF1A7A4D) + "turquoise" -> Color(0xFF0DABA1) + "sky" -> Color(0xFF2E9EE8) + "navy" -> Color(0xFF1F4085) + "lavender" -> Color(0xFF8C66D9) + "magenta" -> Color(0xFFD1298C) + "slate" -> Color(0xFF4D5C73) + else -> Color(0xFF5B5BD6) +} + +fun iconFor(name: String): ImageVector = when (name) { + "star", "star.fill" -> Icons.Default.Star + "heart.fill", "health" -> Icons.Default.Favorite + "sparkles" -> Icons.Default.AutoAwesome + "bolt.fill" -> Icons.Default.Bolt + "flame.fill" -> Icons.Default.LocalFireDepartment + "drop.fill" -> Icons.Default.WaterDrop + "cart", "cart.fill" -> Icons.Default.LocalGroceryStore + "bag", "bag.fill" -> Icons.Default.ShoppingBag + "creditcard.fill" -> Icons.Default.CreditCard + "money", "banknote.fill" -> Icons.Default.AccountBalanceWallet + "gift", "gift.fill" -> Icons.Default.CardGiftcard + "tag.fill" -> Icons.Default.Sell + "cup.and.saucer.fill" -> Icons.Default.Coffee + "fork.knife" -> Icons.Default.Restaurant + "takeoutbag.and.cup.and.straw.fill" -> Icons.Default.Fastfood + "birthday.cake.fill" -> Icons.Default.Cake + "car", "car.fill", "bus.fill", "tram.fill" -> Icons.Default.DirectionsCar + "bicycle" -> Icons.Default.PedalBike + "airplane" -> Icons.Default.Flight + "fuelpump.fill" -> Icons.Default.LocalGasStation + "home", "house.fill" -> Icons.Default.Home + "bed.double.fill" -> Icons.Default.Bed + "lightbulb.fill" -> Icons.Default.Lightbulb + "wifi" -> Icons.Default.Wifi + "phone.fill" -> Icons.Default.Phone + "printer.fill" -> Icons.Default.Print + "pawprint.fill" -> Icons.Default.Pets + "leaf.fill" -> Icons.Default.Eco + "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.Default.MenuBook + "school", "graduationcap.fill" -> Icons.Default.School + "laptopcomputer" -> Icons.Default.Laptop + "briefcase.fill" -> Icons.Default.Work + "gamecontroller.fill" -> Icons.Default.SportsEsports + "music.note" -> Icons.Default.MusicNote + "movie", "film.fill" -> Icons.Default.Movie + "camera.fill" -> Icons.Default.CameraAlt + "paintpalette.fill" -> Icons.Default.Palette + "tshirt.fill" -> Icons.Default.Checkroom + "hammer.fill", "wrench.and.screwdriver.fill" -> Icons.Default.Construction + "shippingbox.fill" -> Icons.Default.Inventory2 + "globe.europe.africa.fill" -> Icons.Default.Public + "chart", "chart.line.uptrend.xyaxis" -> Icons.Default.BarChart + "tune" -> Icons.Default.Tune + "plus.circle.fill" -> Icons.Default.Add + "repeat", "ellipsis.circle.fill", "more" -> Icons.Default.MoreHoriz + else -> Icons.Default.MoreHoriz +} + +@Composable +fun CategoryBadge(category: CategoryDesign, modifier: Modifier = Modifier, size: Int = 42) { + Box( + modifier.size(size.dp).background(colorFor(category.colorName).copy(alpha = .16f), RoundedCornerShape(13.dp)), + contentAlignment = Alignment.Center, + ) { + if (category.emoji.isNotBlank()) Text(category.emoji, fontSize = (size * .46f).sp) + else Icon(iconFor(category.icon), null, tint = colorFor(category.colorName), modifier = Modifier.size((size * .48f).dp)) + } +} + +@Composable +fun MetricCard(title: String, value: String, tint: Color, modifier: Modifier = Modifier) { + Card(modifier, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(title, style = MaterialTheme.typography.labelMedium, color = tint) + Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1) + } + } +} + +@Composable +fun SpendingBars(items: List, modifier: Modifier = Modifier) { + if (items.isEmpty()) { + Box(modifier.height(170.dp), contentAlignment = Alignment.Center) { Text("Нет расходов", color = MaterialTheme.colorScheme.onSurfaceVariant) } + return + } + val maximum = items.maxOf { it.amount }.coerceAtLeast(1.0) + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + items.take(7).forEach { item -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(if (item.emoji.isBlank()) item.name else "${item.emoji} ${item.name}", Modifier.weight(1f), maxLines = 1) + Text("%.0f%%".format(item.amount / items.sumOf { it.amount } * 100), style = MaterialTheme.typography.labelMedium) + } + Canvas(Modifier.fillMaxWidth().height(8.dp)) { + drawRoundRect(Color.Gray.copy(alpha = .18f), cornerRadius = CornerRadius(size.height / 2)) + drawRoundRect( + colorFor(item.colorName), + size = Size(size.width * (item.amount / maximum).toFloat(), size.height), + cornerRadius = CornerRadius(size.height / 2), + ) + } + } + } +} + +fun periodBounds(months: Int, now: Long = System.currentTimeMillis()): Pair { + val zone = ZoneId.systemDefault() + val date = Instant.ofEpochMilli(now).atZone(zone) + val currentStart = date.withDayOfMonth(1).toLocalDate().atStartOfDay(zone) + val start = currentStart.minusMonths((months - 1).toLong()) + val end = currentStart.plusMonths(1) + return start.toInstant().toEpochMilli() to end.toInstant().toEpochMilli() +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Editors.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Editors.kt new file mode 100644 index 0000000..620d08d --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/Editors.kt @@ -0,0 +1,275 @@ +package com.example.balanceandroid.ui + +import android.app.DatePickerDialog +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.example.balanceandroid.FinanceUiState +import com.example.balanceandroid.MainViewModel +import com.example.balanceandroid.data.CategoryDesign +import com.example.balanceandroid.data.CategoryEntity +import com.example.balanceandroid.data.TransactionEntity +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun TransactionEditorDialog( + model: MainViewModel, + state: FinanceUiState, + existing: TransactionEntity?, + onDismiss: () -> 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) { 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() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if(existing == null) "Новая операция" else "Редактирование") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(kind == "expense", { kind = "expense"; categoryName = model.categories("expense").first().name }, label = { Text("Расход") }) + FilterChip(kind == "income", { kind = "income"; categoryName = model.categories("income").first().name }, label = { Text("Доход") }) + } + OutlinedTextField(amount, { amount = it }, Modifier.fillMaxWidth(), label = { Text("Сумма") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true) + DialogChoice("Категория", categoryName, categories.map { it.name }) { categoryName = it } + DateChoice(date) { date = it } + OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), label = { Text("Заметка") }) + } + }, + confirmButton = { + Button({ model.saveTransaction(existing, amount.replace(',', '.').toDoubleOrNull() ?: 0.0, kind, selected, note, date); onDismiss() }, enabled = (amount.replace(',', '.').toDoubleOrNull() ?: 0.0) > 0) { Text("Сохранить") } + }, + dismissButton = { TextButton(onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +fun CategoryEditorDialog( + model: MainViewModel, + state: FinanceUiState, + existing: CategoryEntity?, + onDismiss: () -> Unit, +) { + var name by remember(existing?.id) { mutableStateOf(existing?.name.orEmpty()) } + var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") } + var icon by remember(existing?.id) { mutableStateOf(existing?.icon ?: "star") } + var emoji by remember(existing?.id) { mutableStateOf(existing?.emoji.orEmpty()) } + var color by remember(existing?.id) { mutableStateOf(existing?.colorName ?: "indigo") } + var useEmoji by remember(existing?.id) { mutableStateOf(existing?.emoji?.isNotBlank() == true) } + val duplicate = state.data.categories.any { it.id != existing?.id && it.kindRawValue == kind && it.name.equals(name.trim(), true) } + val icons = listOf( + "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", + ) + val emojis = listOf( + "🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮", + "🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁", + "💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️", + ) + val colors = listOf( + "red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", + "indigo", "purple", "pink", "gray", "coral", "peach", "gold", "lime", + "forest", "turquoise", "sky", "navy", "lavender", "magenta", "brown", "slate", + ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if(existing == null) "Новая категория" else "Редактирование") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Название") }, singleLine = true) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(kind == "expense", { if(existing == null) kind = "expense" }, label = { Text("Расход") }) + FilterChip(kind == "income", { if(existing == null) kind = "income" }, label = { Text("Доход") }) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(!useEmoji, { useEmoji = false }, label = { Text("Иконка") }) + FilterChip(useEmoji, { useEmoji = true }, label = { Text("Эмодзи") }) + } + if (useEmoji) { + OutlinedTextField(emoji, { emoji = it }, Modifier.fillMaxWidth(), label = { Text("Эмодзи") }, singleLine = true) + EmojiChoice(emoji, emojis) { emoji = it } + } else IconChoice(icon, color, kind, icons) { icon = it } + ColorChoice(color, colors) { color = it } + if (duplicate) Text("Категория с таким названием уже существует", color = MaterialTheme.colorScheme.error) + } + }, + confirmButton = { + Button({ model.saveCategory(existing, name, kind, icon, if(useEmoji) emoji.trim() else "", color); onDismiss() }, enabled = name.isNotBlank() && !duplicate && (!useEmoji || emoji.isNotBlank())) { Text("Сохранить") } + }, + dismissButton = { TextButton(onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +private fun IconChoice(current: String, color: String, kind: String, values: List, onSelect: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Text("Иконка", style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(values, key = { it }) { value -> + Box( + Modifier + .border(if (value == current) 2.dp else 0.dp, MaterialTheme.colorScheme.primary, CircleShape) + .padding(3.dp) + .clickable { onSelect(value) } + ) { + CategoryBadge(CategoryDesign("", value, "", color, kind), size = 40) + } + } + } + } +} + +@Composable +private fun EmojiChoice(current: String, values: List, onSelect: (String) -> Unit) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(values, key = { it }) { value -> + Box( + Modifier + .size(44.dp) + .border(if (value == current) 2.dp else 1.dp, if (value == current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape) + .clickable { onSelect(value) }, + contentAlignment = Alignment.Center, + ) { Text(value) } + } + } +} + +@Composable +private fun ColorChoice(current: String, values: List, onSelect: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Text("Цвет", style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(9.dp)) { + items(values, key = { it }) { value -> + Box( + Modifier + .size(34.dp) + .border(if (value == current) 3.dp else 1.dp, if (value == current) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape) + .padding(4.dp) + .background(colorFor(value), CircleShape) + .clickable { onSelect(value) } + ) + } + } + } +} + +@Composable +fun BudgetEditorDialog(model: MainViewModel, state: FinanceUiState, onDismiss: () -> Unit) { + val categories = model.categories("expense") + val month = MainViewModel.currentMonthStart() + val limits = remember(state.data.budgets) { mutableStateMapOf().apply { + categories.forEach { category -> + put(category.name, state.data.budgets.firstOrNull { it.categoryName == category.name && it.monthStart == month }?.limitAmount?.toString().orEmpty()) + } + } } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Месячные лимиты") }, + text = { + Column(Modifier.heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(9.dp)) { + categories.forEach { category -> + Row(verticalAlignment = Alignment.CenterVertically) { + CategoryBadge(category, size = 34); Spacer(Modifier.size(9.dp)); Text(category.name, Modifier.weight(1f), maxLines = 1) + OutlinedTextField(limits[category.name].orEmpty(), { limits[category.name] = it }, Modifier.fillMaxWidth(.38f), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true) + } + } + } + }, + confirmButton = { Button({ categories.forEach { model.saveBudget(it, limits[it.name].orEmpty().replace(',', '.').toDoubleOrNull() ?: 0.0, month) }; onDismiss() }) { Text("Сохранить") } }, + dismissButton = { TextButton(onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +fun BalanceAdjustmentDialog(current: Double, currency: String, onDismiss: () -> Unit, onSave: (Double, String) -> Unit) { + var target by remember { mutableStateOf(current.toString()) } + var note by remember { mutableStateOf("") } + val value = target.replace(',', '.').toDoubleOrNull() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Корректировка баланса") }, + text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Расчётный баланс: ${formatMoney(current, currency)}") + OutlinedTextField(target, { target = it }, Modifier.fillMaxWidth(), label = { Text("Фактический баланс") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true) + OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), label = { Text("Комментарий") }) + Text("Корректировка не влияет на аналитику.", style = MaterialTheme.typography.bodySmall) + } }, + confirmButton = { Button({ value?.let { onSave(it, note) } }, enabled = value != null && kotlin.math.abs(value - current) >= .005) { Text("Сохранить") } }, + dismissButton = { TextButton(onDismiss) { Text("Отмена") } }, + ) +} + +@Composable +private fun DialogChoice(title: String, current: String, values: List, onSelect: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + OutlinedButton({ expanded = true }, Modifier.fillMaxWidth()) { Text("$title: ${current.ifBlank { "Выбрать" }}", Modifier.weight(1f)) } + DropdownMenu(expanded, { expanded = false }) { + values.forEach { value -> DropdownMenuItem({ Text(value) }, { onSelect(value); expanded = false }) } + } + } +} + +@Composable +private fun DateChoice(value: Long, onChange: (Long) -> Unit) { + val context = LocalContext.current + val zone = ZoneId.systemDefault() + val date = Instant.ofEpochMilli(value).atZone(zone).toLocalDate() + OutlinedButton({ + DatePickerDialog(context, { _, year, month, day -> + onChange(LocalDate.of(year, month + 1, day).atStartOfDay(zone).toInstant().toEpochMilli()) + }, date.year, date.monthValue - 1, date.dayOfMonth).show() + }, Modifier.fillMaxWidth()) { + Text("Дата: ${date.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))}") + } +} diff --git a/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/theme/Theme.kt b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/theme/Theme.kt new file mode 100644 index 0000000..40ae1d0 --- /dev/null +++ b/BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/theme/Theme.kt @@ -0,0 +1,53 @@ +package com.example.balanceandroid.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +val BalanceIndigo = Color(0xFF5B5BD6) +val BalancePurple = Color(0xFF8B5CF6) +val BalanceGreen = Color(0xFF16A36A) +val BalanceRed = Color(0xFFE05252) + +private val LightColors = lightColorScheme( + primary = BalanceIndigo, + secondary = BalancePurple, + tertiary = BalanceGreen, + background = Color(0xFFF7F7FB), + surface = Color.White, + surfaceVariant = Color(0xFFECECF5), +) + +private val DarkColors = darkColorScheme( + primary = Color(0xFFB8B5FF), + secondary = Color(0xFFD0BCFF), + tertiary = Color(0xFF6DD7A8), + background = Color(0xFF111116), + surface = Color(0xFF1A1A22), + surfaceVariant = Color(0xFF292936), +) + +@Composable +fun BalanceTheme(theme: String, content: @Composable () -> Unit) { + val dark = when (theme) { + "dark" -> true + "light" -> false + else -> isSystemInDarkTheme() + } + val view = LocalView.current + val activity = view.context as? Activity + if (!view.isInEditMode && activity != null) { + SideEffect { + val controller = WindowCompat.getInsetsController(activity.window, view) + controller.isAppearanceLightStatusBars = !dark + } + } + MaterialTheme(colorScheme = if (dark) DarkColors else LightColors, content = content) +} diff --git a/BalanceAndroid/app/src/main/res/drawable/balance_icon.png b/BalanceAndroid/app/src/main/res/drawable/balance_icon.png new file mode 100644 index 0000000..c5fea16 Binary files /dev/null and b/BalanceAndroid/app/src/main/res/drawable/balance_icon.png differ diff --git a/BalanceAndroid/app/src/main/res/values/strings.xml b/BalanceAndroid/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6361961 --- /dev/null +++ b/BalanceAndroid/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Баланс + diff --git a/BalanceAndroid/app/src/main/res/values/themes.xml b/BalanceAndroid/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..02d09aa --- /dev/null +++ b/BalanceAndroid/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + diff --git a/BalanceAndroid/app/src/main/res/xml/backup_rules.xml b/BalanceAndroid/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..db55e4b --- /dev/null +++ b/BalanceAndroid/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/BalanceAndroid/app/src/main/res/xml/data_extraction_rules.xml b/BalanceAndroid/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..49decf2 --- /dev/null +++ b/BalanceAndroid/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/BalanceAndroid/app/src/main/res/xml/network_security_config.xml b/BalanceAndroid/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..e5a84f4 --- /dev/null +++ b/BalanceAndroid/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + localhost + 127.0.0.1 + 10.0.2.2 + + diff --git a/BalanceAndroid/app/src/test/java/com/example/balanceandroid/FinanceMathTest.kt b/BalanceAndroid/app/src/test/java/com/example/balanceandroid/FinanceMathTest.kt new file mode 100644 index 0000000..3e5a219 --- /dev/null +++ b/BalanceAndroid/app/src/test/java/com/example/balanceandroid/FinanceMathTest.kt @@ -0,0 +1,33 @@ +package com.example.balanceandroid + +import com.example.balanceandroid.data.FinanceMath +import com.example.balanceandroid.data.TransactionEntity +import org.junit.Assert.assertEquals +import org.junit.Test + +class FinanceMathTest { + @Test + fun summarySeparatesIncomeExpensesAndAdjustments() { + val items = listOf( + TransactionEntity(amount = 1000.0, date = 100, categoryName = "Зарплата", kindRawValue = "income"), + TransactionEntity(amount = 250.0, date = 200, categoryName = "Продукты", kindRawValue = "expense"), + TransactionEntity(amount = 50.0, date = 300, categoryName = "Корректировка", kindRawValue = "income", isBalanceAdjustment = true), + ) + val summary = FinanceMath.summary(items, 0, 1000) + assertEquals(1000.0, summary.income, 0.001) + assertEquals(250.0, summary.expenses, 0.001) + assertEquals(750.0, summary.balance, 0.001) + assertEquals(800.0, FinanceMath.totalBalance(items), 0.001) + } + + @Test + fun spendingGroupsCategories() { + val items = listOf( + TransactionEntity(amount = 100.0, date = 100, categoryName = "Дом", categoryColorName = "indigo"), + TransactionEntity(amount = 200.0, date = 200, categoryName = "Дом", categoryColorName = "indigo"), + ) + val spending = FinanceMath.spending(items, 0, 1000) + assertEquals(1, spending.size) + assertEquals(300.0, spending.first().amount, 0.001) + } +} diff --git a/BalanceAndroid/build.gradle.kts b/BalanceAndroid/build.gradle.kts new file mode 100644 index 0000000..db09d62 --- /dev/null +++ b/BalanceAndroid/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + 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 new file mode 100644 index 0000000..a907044 --- /dev/null +++ b/BalanceAndroid/gradle.properties @@ -0,0 +1,8 @@ +android.useAndroidX=true +kotlin.code.style=official +kapt.incremental.apt=true +android.nonTransitiveRClass=true +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 diff --git a/BalanceAndroid/gradle/wrapper/gradle-wrapper.jar b/BalanceAndroid/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/BalanceAndroid/gradle/wrapper/gradle-wrapper.jar differ diff --git a/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties b/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/BalanceAndroid/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/BalanceAndroid/gradlew b/BalanceAndroid/gradlew new file mode 100755 index 0000000..362aa12 --- /dev/null +++ b/BalanceAndroid/gradlew @@ -0,0 +1,359 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +java_major_version () { + java_version=$( "$1" -version 2>&1 | sed -n '1s/.*version "\([^"]*\)".*/\1/p' ) + case $java_version in + 1.*) + java_version=${java_version#1.} + java_major=${java_version%%[._-]*} + ;; + *) + java_major=${java_version%%[._-]*} + ;; + esac + case $java_major in + '' | *[!0-9]*) printf '%s\n' 0 ;; + *) printf '%s\n' "$java_major" ;; + esac +} + +compatible_java_home () { + [ -x "$1/bin/java" ] || return 1 + candidate_major=$( java_major_version "$1/bin/java" ) + [ "$candidate_major" -ge 17 ] && [ "$candidate_major" -le 23 ] +} + +find_macos_java_home () { + for candidate in \ + "/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "$HOME/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ + "/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home" \ + "$HOME/Applications/Android Studio Preview.app/Contents/jbr/Contents/Home" + do + if compatible_java_home "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + + if [ -x /usr/libexec/java_home ]; then + for requested_version in 17 21 22 23 + do + candidate=$( /usr/libexec/java_home -v "$requested_version" 2>/dev/null ) || continue + if compatible_java_home "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + fi + return 1 +} + +android_sdk_is_valid () { + [ -n "$1" ] && [ -d "$1" ] && { + [ -d "$1/platforms" ] || + [ -d "$1/platform-tools" ] || + [ -d "$1/cmdline-tools" ] + } +} + +find_android_sdk_home () { + for candidate in \ + "$ANDROID_HOME" \ + "$ANDROID_SDK_ROOT" \ + "$HOME/Android/Sdk" \ + "$HOME/Android/sdk" \ + "$HOME/Library/Android/sdk" \ + "/opt/android-sdk" \ + "/usr/local/lib/android/sdk" + do + if android_sdk_is_valid "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +SELECTED_ANDROID_SDK=$( find_android_sdk_home ) || SELECTED_ANDROID_SDK= +if [ -n "$SELECTED_ANDROID_SDK" ]; then + if [ "$ANDROID_HOME" != "$SELECTED_ANDROID_SDK" ] || [ "$ANDROID_SDK_ROOT" != "$SELECTED_ANDROID_SDK" ]; then + warn "Using Android SDK from $SELECTED_ANDROID_SDK" + fi + ANDROID_HOME=$SELECTED_ANDROID_SDK + ANDROID_SDK_ROOT=$SELECTED_ANDROID_SDK + export ANDROID_HOME ANDROID_SDK_ROOT +else + warn "Android SDK was not found. Install SDK Platform 36 in Android Studio, or set ANDROID_HOME." +fi + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. On macOS, an obsolete +# JAVA_HOME is automatically replaced with Android Studio's bundled JBR. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi +else + JAVACMD=java +fi + +CURRENT_JAVA_MAJOR=0 +if [ -x "$JAVACMD" ] || command -v "$JAVACMD" >/dev/null 2>&1; then + CURRENT_JAVA_MAJOR=$( java_major_version "$JAVACMD" ) +fi + +if [ "$CURRENT_JAVA_MAJOR" -lt 17 ] || [ "$CURRENT_JAVA_MAJOR" -gt 23 ]; then + if "$darwin"; then + SELECTED_JAVA_HOME=$( find_macos_java_home ) || SELECTED_JAVA_HOME= + if [ -n "$SELECTED_JAVA_HOME" ]; then + JAVA_HOME=$SELECTED_JAVA_HOME + export JAVA_HOME + JAVACMD=$JAVA_HOME/bin/java + CURRENT_JAVA_MAJOR=$( java_major_version "$JAVACMD" ) + warn "Using JDK $CURRENT_JAVA_MAJOR from $JAVA_HOME" + fi + fi +fi + +if [ ! -x "$JAVACMD" ] && ! command -v "$JAVACMD" >/dev/null 2>&1; then + die "ERROR: No compatible Java installation was found. + +Install Android Studio or JDK 17, then set JAVA_HOME to that JDK." +fi + +if [ "$CURRENT_JAVA_MAJOR" -lt 17 ] || [ "$CURRENT_JAVA_MAJOR" -gt 23 ]; then + die "ERROR: This project requires JDK 17 through 23, but Java $CURRENT_JAVA_MAJOR is active. + +On macOS with Android Studio installed, run: +export JAVA_HOME=\"/Applications/Android Studio.app/Contents/jbr/Contents/Home\" + +Then run ./gradlew again." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/BalanceAndroid/gradlew.bat b/BalanceAndroid/gradlew.bat new file mode 100644 index 0000000..c6b48aa --- /dev/null +++ b/BalanceAndroid/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find the Android SDK in the environment or the standard Windows location. +if not defined ANDROID_HOME if defined ANDROID_SDK_ROOT set "ANDROID_HOME=%ANDROID_SDK_ROOT%" +if not defined ANDROID_HOME if exist "%LOCALAPPDATA%\Android\Sdk" set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk" +if defined ANDROID_HOME set "ANDROID_SDK_ROOT=%ANDROID_HOME%" +if not defined ANDROID_HOME echo WARNING: Android SDK was not found. Install SDK Platform 36 or set ANDROID_HOME. 1>&2 + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/BalanceAndroid/local.properties.example b/BalanceAndroid/local.properties.example new file mode 100644 index 0000000..0afd411 --- /dev/null +++ b/BalanceAndroid/local.properties.example @@ -0,0 +1,5 @@ +# Copy this file to local.properties only when the SDK is installed in a +# non-standard directory. Use forward slashes, including on Windows. +# Linux: sdk.dir=/home/USER/Android/Sdk +# macOS: sdk.dir=/Users/USER/Library/Android/sdk +# Windows: sdk.dir=C:/Users/USER/AppData/Local/Android/Sdk diff --git a/BalanceAndroid/settings.gradle.kts b/BalanceAndroid/settings.gradle.kts new file mode 100644 index 0000000..75b6947 --- /dev/null +++ b/BalanceAndroid/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "BalanceAndroid" +include(":app") diff --git a/BalanceServer/.dockerignore b/BalanceServer/.dockerignore new file mode 100644 index 0000000..84a8e1d --- /dev/null +++ b/BalanceServer/.dockerignore @@ -0,0 +1,7 @@ +.git +.env +data +*.db +*.db-shm +*.db-wal +balance-server diff --git a/BalanceServer/.env.example b/BalanceServer/.env.example new file mode 100644 index 0000000..b100fd5 --- /dev/null +++ b/BalanceServer/.env.example @@ -0,0 +1,6 @@ +# Generate with: openssl rand -base64 48 +BALANCE_JWT_SECRET=replace-with-at-least-32-random-characters +BALANCE_ALLOW_REGISTRATION=true +BALANCE_ACCESS_TTL=15m +BALANCE_REFRESH_TTL=720h +BALANCE_CORS_ORIGINS= diff --git a/BalanceServer/.gitignore b/BalanceServer/.gitignore new file mode 100644 index 0000000..161f7a8 --- /dev/null +++ b/BalanceServer/.gitignore @@ -0,0 +1,8 @@ +.env +data/ +dist/ +*.db +*.db-shm +*.db-wal +balance-server +balance-server.exe diff --git a/BalanceServer/Dockerfile b/BalanceServer/Dockerfile new file mode 100644 index 0000000..6004c7e --- /dev/null +++ b/BalanceServer/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25.12-alpine AS build +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/balance-server ./cmd/balance-server + +FROM alpine:3.22 +RUN addgroup -S balance && adduser -S -G balance balance && mkdir /data && chown balance:balance /data +USER balance +COPY --from=build /out/balance-server /usr/local/bin/balance-server +ENV BALANCE_ADDR=:8080 BALANCE_DB_PATH=/data/balance.db +EXPOSE 8080 +VOLUME ["/data"] +ENTRYPOINT ["balance-server"] diff --git a/BalanceServer/README.md b/BalanceServer/README.md new file mode 100644 index 0000000..b8282d9 --- /dev/null +++ b/BalanceServer/README.md @@ -0,0 +1,112 @@ +# Balance Server + +Self-hosted synchronization, account and web server for the Balance iOS, macOS, watchOS and Android applications. It is a single Go process with an embedded SQLite database and a complete browser client, and runs on Linux, Windows and macOS. + +## Quick start with Docker + +```sh +cp .env.example .env +openssl rand -base64 48 +# Paste the generated value into BALANCE_JWT_SECRET in .env +docker compose up -d --build +curl http://localhost:8080/health +``` + +Open `http://localhost:8080` to register or sign in to the web application. Data is kept in the `balance-data` Docker volume. Back it up while the container is stopped. + +## Run without Docker + +Install Go 1.25 or newer, then run: + +```sh +go mod tidy +go build -o balance-server ./cmd/balance-server +``` + +On Linux or macOS: + +```sh +export BALANCE_JWT_SECRET="replace-with-a-long-random-secret" +export BALANCE_DB_PATH="./data/balance.db" +./balance-server +``` + +On Windows PowerShell: + +```powershell +$env:BALANCE_JWT_SECRET = "replace-with-a-long-random-secret" +$env:BALANCE_DB_PATH = ".\data\balance.db" +.\balance-server.exe +``` + +Cross-compile release binaries from any Go installation: + +```sh +GOOS=linux GOARCH=amd64 go build -o dist/balance-server-linux-amd64 ./cmd/balance-server +GOOS=darwin GOARCH=arm64 go build -o dist/balance-server-macos-arm64 ./cmd/balance-server +GOOS=windows GOARCH=amd64 go build -o dist/balance-server-windows-amd64.exe ./cmd/balance-server +``` + +After starting the binary, open `http://localhost:8080` in a browser. The web assets are embedded into the executable, so there is no separate frontend build, Node.js process or static directory to deploy. + +## Web application + +The responsive web client includes: + +- registration, login, automatic session refresh and logout; +- dashboard, current balance and manual balance adjustment; +- adding, editing, deleting, searching and filtering transactions; +- analytics for 1, 3 and 12 months, spending breakdown and savings rate; +- monthly category budgets; +- custom categories with 50 icons, custom emoji and 24 colors; +- light, dark and system themes, plus RUB, EUR, USD and SEK formatting; +- automatic synchronization every 30 seconds and whenever the browser tab becomes active; +- installable PWA shell for desktop and mobile browsers. + +The browser session uses `HttpOnly`, `SameSite=Strict` cookies. Native applications continue to use Bearer access tokens and are fully backward compatible. Only display preferences are saved in browser local storage; tokens and financial records are not stored there. + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `BALANCE_ADDR` | `:8080` | Listen address | +| `BALANCE_DB_PATH` | `./data/balance.db` | SQLite file path | +| `BALANCE_JWT_SECRET` | required | Random secret of at least 32 characters | +| `BALANCE_ALLOW_REGISTRATION` | `true` | Allows new accounts | +| `BALANCE_ACCESS_TTL` | `15m` | Access-token lifetime | +| `BALANCE_REFRESH_TTL` | `720h` | Refresh-token lifetime | +| `BALANCE_CORS_ORIGINS` | empty | Comma-separated browser origins; native apps do not need CORS | + +## HTTPS + +Use HTTPS before exposing the server to a network. The Apple applications accept plain HTTP only for `localhost` and `127.0.0.1`; a physical device should connect through an HTTPS reverse proxy such as Caddy, nginx or a private VPN with TLS. Example Caddy configuration: + +```caddyfile +balance.example.com { + reverse_proxy 127.0.0.1:8080 +} +``` + +Then enter `https://balance.example.com` in each application's Settings → Server account. + +## API + +| Method | Endpoint | Purpose | +| --- | --- | --- | +| `GET` | `/health` | Health check | +| `POST` | `/v1/auth/register` | Create account | +| `POST` | `/v1/auth/login` | Sign in | +| `POST` | `/v1/auth/refresh` | Rotate session tokens | +| `POST` | `/v1/auth/logout` | Revoke refresh token | +| `GET` | `/v1/me` | Current account | +| `POST` | `/v1/sync` | Push and pull changes | + +The sync endpoint accepts transactions, custom categories and monthly budgets. The server isolates records by account, stores an append-only change cursor, and resolves competing edits with last-write-wins timestamps. Current Apple and Android clients use push-only requests followed by paginated pulls; update the server and applications together. + +## Operational notes + +- Registration can be disabled after the required accounts have been created. +- Passwords are stored as salted Argon2id hashes. Refresh tokens are stored only as SHA-256 hashes. +- 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. diff --git a/BalanceServer/docker-compose.yml b/BalanceServer/docker-compose.yml new file mode 100644 index 0000000..f4a171b --- /dev/null +++ b/BalanceServer/docker-compose.yml @@ -0,0 +1,19 @@ +services: + balance-server: + build: . + restart: always + ports: + - "8123:8080" + environment: + BALANCE_ADDR: ":8080" + BALANCE_DB_PATH: "/data/balance.db" + BALANCE_JWT_SECRET: "${BALANCE_JWT_SECRET:?Set BALANCE_JWT_SECRET in .env}" + BALANCE_ALLOW_REGISTRATION: "${BALANCE_ALLOW_REGISTRATION:-true}" + BALANCE_ACCESS_TTL: "${BALANCE_ACCESS_TTL:-15m}" + BALANCE_REFRESH_TTL: "${BALANCE_REFRESH_TTL:-720h}" + BALANCE_CORS_ORIGINS: "${BALANCE_CORS_ORIGINS:-}" + volumes: + - balance-data:/data + +volumes: + balance-data: diff --git a/BalanceServer/go.mod b/BalanceServer/go.mod new file mode 100644 index 0000000..dcfe93b --- /dev/null +++ b/BalanceServer/go.mod @@ -0,0 +1,21 @@ +module balance-server + +go 1.25.0 + +require ( + golang.org/x/crypto v0.41.0 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/BalanceServer/go.sum b/BalanceServer/go.sum new file mode 100644 index 0000000..3b7389a --- /dev/null +++ b/BalanceServer/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/BalanceServer/internal/auth/password.go b/BalanceServer/internal/auth/password.go new file mode 100644 index 0000000..97c9ece --- /dev/null +++ b/BalanceServer/internal/auth/password.go @@ -0,0 +1,61 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +const ( + argonMemory = 64 * 1024 + argonTime = 3 + argonThreads = 2 + argonKeyLen = 32 +) + +func HashPassword(password string) (string, error) { + if len(password) < 8 { + return "", errors.New("password must contain at least 8 characters") + } + if len(password) > 1024 { + return "", errors.New("password is too long") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(hash)), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" { + return false + } + var memory uint32 + var iterations uint32 + var threads uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, &threads); err != nil { + return false + } + if memory > 256*1024 || iterations > 10 || threads > 16 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + expected, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(expected) == 0 { + return false + } + actual := argon2.IDKey([]byte(password), salt, iterations, memory, threads, uint32(len(expected))) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} diff --git a/BalanceServer/internal/auth/tokens.go b/BalanceServer/internal/auth/tokens.go new file mode 100644 index 0000000..a037b46 --- /dev/null +++ b/BalanceServer/internal/auth/tokens.go @@ -0,0 +1,90 @@ +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" +) + +type Claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + Issuer string `json:"iss"` + Issued int64 `json:"iat"` + Expires int64 `json:"exp"` + ID string `json:"jti"` +} + +type TokenManager struct { + secret []byte + accessTTL time.Duration +} + +func NewTokenManager(secret string, accessTTL time.Duration) TokenManager { + return TokenManager{secret: []byte(secret), accessTTL: accessTTL} +} + +func (manager TokenManager) AccessToken(userID, email string) (string, time.Time, error) { + now := time.Now().UTC() + expires := now.Add(manager.accessTTL) + id, err := RandomToken(16) + if err != nil { + return "", time.Time{}, err + } + header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) + claims, _ := json.Marshal(Claims{Subject: userID, Email: email, Issuer: "balance-server", Issued: now.Unix(), Expires: expires.Unix(), ID: id}) + unsigned := encode(header) + "." + encode(claims) + signature := manager.sign(unsigned) + return unsigned + "." + signature, expires, nil +} + +func (manager TokenManager) Verify(token string) (Claims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return Claims{}, errors.New("invalid token") + } + unsigned := parts[0] + "." + parts[1] + expected := manager.sign(unsigned) + if !hmac.Equal([]byte(parts[2]), []byte(expected)) { + return Claims{}, errors.New("invalid token signature") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return Claims{}, errors.New("invalid token payload") + } + var claims Claims + if err := json.Unmarshal(payload, &claims); err != nil { + return Claims{}, errors.New("invalid token claims") + } + if claims.Issuer != "balance-server" || claims.Subject == "" || time.Now().UTC().Unix() >= claims.Expires { + return Claims{}, errors.New("expired or invalid token") + } + return claims, nil +} + +func RandomToken(bytes int) (string, error) { + value := make([]byte, bytes) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func TokenHash(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func (manager TokenManager) sign(value string) string { + mac := hmac.New(sha256.New, manager.secret) + _, _ = mac.Write([]byte(value)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) } diff --git a/BalanceServer/internal/config/config.go b/BalanceServer/internal/config/config.go new file mode 100644 index 0000000..5ddc8ec --- /dev/null +++ b/BalanceServer/internal/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "errors" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Address string + DatabasePath string + JWTSecret string + AccessTTL time.Duration + RefreshTTL time.Duration + AllowRegistration bool + CORSOrigins []string +} + +func Load() (Config, error) { + accessTTL, err := duration("BALANCE_ACCESS_TTL", 15*time.Minute) + if err != nil { + return Config{}, err + } + refreshTTL, err := duration("BALANCE_REFRESH_TTL", 30*24*time.Hour) + if err != nil { + return Config{}, err + } + allowRegistration, err := boolean("BALANCE_ALLOW_REGISTRATION", true) + if err != nil { + return Config{}, err + } + + cfg := Config{ + Address: env("BALANCE_ADDR", ":8080"), + DatabasePath: env("BALANCE_DB_PATH", "./data/balance.db"), + JWTSecret: strings.TrimSpace(os.Getenv("BALANCE_JWT_SECRET")), + AccessTTL: accessTTL, + RefreshTTL: refreshTTL, + AllowRegistration: allowRegistration, + CORSOrigins: split(os.Getenv("BALANCE_CORS_ORIGINS")), + } + if len(cfg.JWTSecret) < 32 { + return Config{}, errors.New("BALANCE_JWT_SECRET must contain at least 32 characters") + } + return cfg, nil +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func duration(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, errors.New(key + " must be a Go duration such as 15m or 720h") + } + return parsed, nil +} + +func boolean(key string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, errors.New(key + " must be true or false") + } + return parsed, nil +} + +func split(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if item := strings.TrimSpace(part); item != "" { + result = append(result, item) + } + } + return result +} diff --git a/BalanceServer/internal/database/database.go b/BalanceServer/internal/database/database.go new file mode 100644 index 0000000..b1c001c --- /dev/null +++ b/BalanceServer/internal/database/database.go @@ -0,0 +1,80 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +func Open(path string) (*sql.DB, error) { + directory := filepath.Dir(path) + if directory != "." { + if err := os.MkdirAll(directory, 0o750); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + } + db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + if err := migrate(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +func migrate(ctx context.Context, db *sql.DB) error { + const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx ON refresh_tokens(user_id); +CREATE TABLE IF NOT EXISTS records ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity TEXT NOT NULL, + record_id TEXT NOT NULL, + payload TEXT, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL, + PRIMARY KEY (user_id, entity, record_id) +); +CREATE TABLE IF NOT EXISTS changes ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + entity TEXT NOT NULL, + record_id TEXT NOT NULL, + payload TEXT, + updated_at TEXT NOT NULL, + deleted INTEGER NOT NULL, + version INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS changes_user_sequence_idx ON changes(user_id, sequence); +` + _, err := db.ExecContext(ctx, schema) + return err +} diff --git a/BalanceServer/internal/httpapi/auth_handlers.go b/BalanceServer/internal/httpapi/auth_handlers.go new file mode 100644 index 0000000..0b192d3 --- /dev/null +++ b/BalanceServer/internal/httpapi/auth_handlers.go @@ -0,0 +1,245 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "net/http" + "strings" + "time" + + "balance-server/internal/auth" +) + +type credentials struct { + Email string `json:"email"` + Password string `json:"password"` +} +type refreshRequest struct { + RefreshToken string `json:"refreshToken"` +} +type userResponse struct { + ID string `json:"id"` + Email string `json:"email"` +} +type sessionResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt time.Time `json:"expiresAt"` + User userResponse `json:"user"` +} + +func (api *API) register(w http.ResponseWriter, r *http.Request) { + if !api.cfg.AllowRegistration { + writeError(w, http.StatusForbidden, "registration_disabled", "Registration is disabled") + return + } + var request credentials + if !decodeJSON(w, r, &request) { + return + } + email, err := normalizeEmail(request.Email) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_email", "Enter a valid email address") + return + } + passwordHash, err := auth.HashPassword(request.Password) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_password", err.Error()) + return + } + userID, err := identifier() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account") + return + } + _, err = api.db.ExecContext(r.Context(), `INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, userID, email, passwordHash, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + if containsUnique(err) { + writeError(w, http.StatusConflict, "email_exists", "An account with this email already exists") + return + } + api.logger.Error("create user", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create account") + return + } + response, err := api.createSession(r, userID, email) + if err != nil { + api.logger.Error("create session", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session") + return + } + api.setSessionCookies(w, r, response) + writeJSON(w, http.StatusCreated, response) +} + +func (api *API) login(w http.ResponseWriter, r *http.Request) { + var request credentials + if !decodeJSON(w, r, &request) { + return + } + email, err := normalizeEmail(request.Email) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect") + return + } + var userID, passwordHash string + err = api.db.QueryRowContext(r.Context(), `SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&userID, &passwordHash) + if err != nil || !auth.VerifyPassword(passwordHash, request.Password) { + writeError(w, http.StatusUnauthorized, "invalid_credentials", "Email or password is incorrect") + return + } + response, err := api.createSession(r, userID, email) + if err != nil { + api.logger.Error("create session", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not create session") + return + } + api.setSessionCookies(w, r, response) + writeJSON(w, http.StatusOK, response) +} + +func (api *API) refresh(w http.ResponseWriter, r *http.Request) { + var request refreshRequest + if !decodeJSON(w, r, &request) { + return + } + request.RefreshToken = api.refreshToken(r, request.RefreshToken) + if request.RefreshToken == "" { + writeError(w, http.StatusBadRequest, "refresh_token_required", "Refresh token is required") + return + } + tx, err := api.db.BeginTx(r.Context(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") + return + } + defer tx.Rollback() + var userID, email, expiresText string + err = tx.QueryRowContext(r.Context(), `SELECT u.id,u.email,t.expires_at FROM refresh_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=? AND t.revoked_at IS NULL`, auth.TokenHash(request.RefreshToken)).Scan(&userID, &email, &expiresText) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired") + return + } + expires, err := time.Parse(time.RFC3339Nano, expiresText) + if err != nil || !time.Now().UTC().Before(expires) { + writeError(w, http.StatusUnauthorized, "invalid_refresh_token", "Refresh token is invalid or expired") + return + } + _, err = tx.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=?`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") + return + } + response, err := api.createSessionWithExecutor(r, userID, email, tx) + if err != nil || tx.Commit() != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Could not refresh session") + return + } + api.setSessionCookies(w, r, response) + writeJSON(w, http.StatusOK, response) +} + +func (api *API) logout(w http.ResponseWriter, r *http.Request) { + var request refreshRequest + if !decodeJSON(w, r, &request) { + return + } + request.RefreshToken = api.refreshToken(r, request.RefreshToken) + if request.RefreshToken != "" { + _, _ = api.db.ExecContext(r.Context(), `UPDATE refresh_tokens SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL`, time.Now().UTC().Format(time.RFC3339Nano), auth.TokenHash(request.RefreshToken)) + } + api.clearSessionCookies(w, r) + w.WriteHeader(http.StatusNoContent) +} + +func (api *API) me(w http.ResponseWriter, r *http.Request) { + value := claims(r) + writeJSON(w, http.StatusOK, userResponse{ID: value.Subject, Email: value.Email}) +} + +func (api *API) createSession(r *http.Request, userID, email string) (sessionResponse, error) { + return api.createSessionWithExecutor(r, userID, email, api.db) +} + +func (api *API) createSessionWithExecutor(r *http.Request, userID, email string, executor interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}) (sessionResponse, error) { + accessToken, expiresAt, err := api.tokens.AccessToken(userID, email) + if err != nil { + return sessionResponse{}, err + } + refreshToken, err := auth.RandomToken(48) + if err != nil { + return sessionResponse{}, err + } + refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL) + _, err = executor.ExecContext(r.Context(), `INSERT INTO refresh_tokens(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, auth.TokenHash(refreshToken), userID, refreshExpires.Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return sessionResponse{}, err + } + return sessionResponse{AccessToken: accessToken, RefreshToken: refreshToken, ExpiresAt: expiresAt, User: userResponse{ID: userID, Email: email}}, nil +} + +func identifier() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} +func containsUnique(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} + +const ( + accessCookieName = "balance_access" + refreshCookieName = "balance_refresh" +) + +func (api *API) setSessionCookies(w http.ResponseWriter, r *http.Request, session sessionResponse) { + secure := requestIsSecure(r) + http.SetCookie(w, &http.Cookie{ + Name: accessCookieName, Value: session.AccessToken, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteStrictMode, + Expires: session.ExpiresAt, MaxAge: maxAge(session.ExpiresAt), + }) + refreshExpires := time.Now().UTC().Add(api.cfg.RefreshTTL) + http.SetCookie(w, &http.Cookie{ + Name: refreshCookieName, Value: session.RefreshToken, Path: "/", HttpOnly: true, + Secure: secure, SameSite: http.SameSiteStrictMode, + Expires: refreshExpires, MaxAge: maxAge(refreshExpires), + }) +} + +func (api *API) clearSessionCookies(w http.ResponseWriter, r *http.Request) { + for _, name := range []string{accessCookieName, refreshCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", HttpOnly: true, Secure: requestIsSecure(r), + SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(1, 0), + }) + } +} + +func (api *API) refreshToken(r *http.Request, value string) string { + if value = strings.TrimSpace(value); value != "" { + return value + } + if cookie, err := r.Cookie(refreshCookieName); err == nil { + return strings.TrimSpace(cookie.Value) + } + return "" +} + +func requestIsSecure(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") +} + +func maxAge(expires time.Time) int { + seconds := int(time.Until(expires).Seconds()) + if seconds < 1 { + return 1 + } + return seconds +} diff --git a/BalanceServer/internal/httpapi/server.go b/BalanceServer/internal/httpapi/server.go new file mode 100644 index 0000000..27c1591 --- /dev/null +++ b/BalanceServer/internal/httpapi/server.go @@ -0,0 +1,185 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net" + "net/http" + "strings" + "sync" + "time" + + "balance-server/internal/auth" + "balance-server/internal/config" +) + +type contextKey string + +const claimsKey contextKey = "claims" + +type API struct { + cfg config.Config + db *sql.DB + tokens auth.TokenManager + logger *slog.Logger + limiter *rateLimiter +} + +func New(cfg config.Config, db *sql.DB, logger *slog.Logger) http.Handler { + api := &API{cfg: cfg, db: db, tokens: auth.NewTokenManager(cfg.JWTSecret, cfg.AccessTTL), logger: logger, limiter: newRateLimiter()} + mux := http.NewServeMux() + mux.HandleFunc("GET /health", api.health) + mux.Handle("POST /v1/auth/register", api.limited(http.HandlerFunc(api.register))) + mux.Handle("POST /v1/auth/login", api.limited(http.HandlerFunc(api.login))) + mux.Handle("POST /v1/auth/refresh", api.limited(http.HandlerFunc(api.refresh))) + mux.HandleFunc("POST /v1/auth/logout", api.logout) + mux.Handle("GET /v1/me", api.authorized(http.HandlerFunc(api.me))) + mux.Handle("POST /v1/sync", api.authorized(http.HandlerFunc(api.sync))) + mux.HandleFunc("GET /", api.webApp) + return api.recover(api.security(api.cors(mux))) +} + +func (api *API) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "time": time.Now().UTC()}) +} + +func (api *API) authorized(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := strings.TrimSpace(r.Header.Get("Authorization")) + token := "" + if strings.HasPrefix(header, "Bearer ") { + token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + } else if cookie, err := r.Cookie(accessCookieName); err == nil { + token = strings.TrimSpace(cookie.Value) + } + if token == "" { + writeError(w, http.StatusUnauthorized, "authorization_required", "Authorization Bearer token is required") + return + } + claims, err := api.tokens.Verify(token) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid_token", "Access token is invalid or expired") + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims))) + }) +} + +func claims(r *http.Request) auth.Claims { + value, _ := r.Context().Value(claimsKey).(auth.Claims) + return value +} + +func (api *API) security(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; manifest-src 'self'; script-src 'self'; style-src 'self'; worker-src 'self'") + if strings.HasPrefix(r.URL.Path, "/v1/") { + w.Header().Set("Cache-Control", "no-store") + } + next.ServeHTTP(w, r) + }) +} + +func (api *API) cors(next http.Handler) http.Handler { + allowed := make(map[string]bool, len(api.cfg.CORSOrigins)) + for _, origin := range api.cfg.CORSOrigins { + allowed[origin] = true + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" && (allowed[origin] || allowed["*"]) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func (api *API) recover(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if value := recover(); value != nil { + api.logger.Error("request panic", "value", value) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Request body is not valid JSON") + return false + } + return true +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) +} + +type rateLimiter struct { + mu sync.Mutex + clients map[string]*rateBucket +} +type rateBucket struct { + started time.Time + count int +} + +func newRateLimiter() *rateLimiter { return &rateLimiter{clients: make(map[string]*rateBucket)} } +func (api *API) limited(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + now := time.Now() + api.limiter.mu.Lock() + bucket := api.limiter.clients[host] + if bucket == nil || now.Sub(bucket.started) >= time.Minute { + bucket = &rateBucket{started: now} + api.limiter.clients[host] = bucket + } + bucket.count++ + blocked := bucket.count > 30 + api.limiter.mu.Unlock() + if blocked { + w.Header().Set("Retry-After", "60") + writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many authentication requests") + return + } + next.ServeHTTP(w, r) + }) +} + +func normalizeEmail(value string) (string, error) { + email := strings.ToLower(strings.TrimSpace(value)) + if len(email) < 3 || len(email) > 254 || !strings.Contains(email, "@") { + return "", errors.New("invalid email") + } + return email, nil +} diff --git a/BalanceServer/internal/httpapi/server_test.go b/BalanceServer/internal/httpapi/server_test.go new file mode 100644 index 0000000..07b4028 --- /dev/null +++ b/BalanceServer/internal/httpapi/server_test.go @@ -0,0 +1,204 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "balance-server/internal/config" + "balance-server/internal/database" +) + +func TestRegisterAndSynchronize(t *testing.T) { + db, err := database.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true} + server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer server.Close() + + register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "person@example.com", "password": "correct horse battery staple"}, "") + if register.StatusCode != http.StatusCreated { + t.Fatalf("register status: %d", register.StatusCode) + } + var session sessionResponse + decode(t, register, &session) + if session.AccessToken == "" || session.RefreshToken == "" { + t.Fatal("session tokens are empty") + } + + now := time.Now().UTC().Add(-time.Second) + pull := false + syncBody := syncRequest{DeviceID: "test", Pull: &pull, Changes: []syncChange{{Entity: "category", ID: "category-1", UpdatedAt: now, Payload: json.RawMessage(`{"name":"Food"}`)}}} + synchronized := request(t, http.MethodPost, server.URL+"/v1/sync", syncBody, session.AccessToken) + if synchronized.StatusCode != http.StatusOK { + t.Fatalf("sync status: %d", synchronized.StatusCode) + } + var result syncResponse + decode(t, synchronized, &result) + if result.Cursor != 0 || len(result.Changes) != 0 { + t.Fatalf("push-only response included pulled changes: %+v", result) + } + + pull = true + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull}, session.AccessToken) + if synchronized.StatusCode != http.StatusOK { + t.Fatalf("pull status: %d", synchronized.StatusCode) + } + decode(t, synchronized, &result) + if result.Cursor == 0 || len(result.Changes) != 1 || result.Changes[0].ID != "category-1" { + t.Fatalf("unexpected sync response: %+v", result) + } + + pageStart := result.Cursor + pageChanges := make([]syncChange, syncPageSize+1) + for index := range pageChanges { + pageChanges[index] = syncChange{ + Entity: "category", ID: "page-" + time.Unix(int64(index), 0).UTC().Format("150405"), + UpdatedAt: now.Add(time.Duration(index+1) * time.Millisecond), Payload: json.RawMessage(`{"name":"Paged"}`), + } + } + pull = false + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{DeviceID: "test", Pull: &pull, Changes: pageChanges}, session.AccessToken) + if synchronized.StatusCode != http.StatusOK { + t.Fatalf("paged push status: %d", synchronized.StatusCode) + } + synchronized.Body.Close() + + pull = true + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: pageStart, DeviceID: "test", Pull: &pull}, session.AccessToken) + decode(t, synchronized, &result) + if len(result.Changes) != syncPageSize || !result.HasMore { + t.Fatalf("first page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore) + } + synchronized = request(t, http.MethodPost, server.URL+"/v1/sync", syncRequest{Cursor: result.Cursor, DeviceID: "test", Pull: &pull}, session.AccessToken) + decode(t, synchronized, &result) + if len(result.Changes) != 1 || result.HasMore { + t.Fatalf("last page mismatch: count=%d hasMore=%v", len(result.Changes), result.HasMore) + } +} + +func TestEmbeddedWebAppAndCookieSession(t *testing.T) { + db, err := database.Open(filepath.Join(t.TempDir(), "web.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + cfg := config.Config{JWTSecret: "test-secret-that-is-longer-than-thirty-two-characters", AccessTTL: 15 * time.Minute, RefreshTTL: time.Hour, AllowRegistration: true} + server := httptest.NewServer(New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)))) + defer server.Close() + + home, err := http.Get(server.URL + "/") + if err != nil { + t.Fatal(err) + } + homeBody, err := io.ReadAll(home.Body) + home.Body.Close() + if err != nil { + t.Fatal(err) + } + if home.StatusCode != http.StatusOK || !strings.Contains(string(homeBody), "id=\"app-view\"") { + t.Fatalf("web app response: status=%d body=%q", home.StatusCode, string(homeBody)) + } + if !strings.Contains(home.Header.Get("Content-Security-Policy"), "script-src 'self'") { + t.Fatal("web app is missing its content security policy") + } + + register := request(t, http.MethodPost, server.URL+"/v1/auth/register", map[string]any{"email": "web@example.com", "password": "correct horse battery staple"}, "") + if register.StatusCode != http.StatusCreated { + t.Fatalf("register status: %d", register.StatusCode) + } + cookies := register.Cookies() + var session sessionResponse + decode(t, register, &session) + var accessCookie, refreshCookie *http.Cookie + for _, cookie := range cookies { + switch cookie.Name { + case accessCookieName: + accessCookie = cookie + case refreshCookieName: + refreshCookie = cookie + } + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("insecure session cookie: %+v", cookie) + } + } + if accessCookie == nil || refreshCookie == nil { + t.Fatal("session cookies were not returned") + } + + meRequest, err := http.NewRequest(http.MethodGet, server.URL+"/v1/me", nil) + if err != nil { + t.Fatal(err) + } + meRequest.AddCookie(accessCookie) + me, err := http.DefaultClient.Do(meRequest) + if err != nil { + t.Fatal(err) + } + if me.StatusCode != http.StatusOK { + t.Fatalf("cookie authentication status: %d", me.StatusCode) + } + var user userResponse + decode(t, me, &user) + if user.Email != "web@example.com" { + t.Fatalf("unexpected web user: %+v", user) + } + + refreshData, err := json.Marshal(refreshRequest{}) + if err != nil { + t.Fatal(err) + } + refreshHTTP, err := http.NewRequest(http.MethodPost, server.URL+"/v1/auth/refresh", bytes.NewReader(refreshData)) + if err != nil { + t.Fatal(err) + } + refreshHTTP.Header.Set("Content-Type", "application/json") + refreshHTTP.AddCookie(refreshCookie) + refreshed, err := http.DefaultClient.Do(refreshHTTP) + if err != nil { + t.Fatal(err) + } + if refreshed.StatusCode != http.StatusOK { + t.Fatalf("cookie refresh status: %d", refreshed.StatusCode) + } + refreshed.Body.Close() +} + +func request(t *testing.T, method, url string, value any, token string) *http.Response { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(method, url, bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + return response +} + +func decode(t *testing.T, response *http.Response, value any) { + t.Helper() + defer response.Body.Close() + if err := json.NewDecoder(response.Body).Decode(value); err != nil { + t.Fatal(err) + } +} diff --git a/BalanceServer/internal/httpapi/sync_handler.go b/BalanceServer/internal/httpapi/sync_handler.go new file mode 100644 index 0000000..d3c8f55 --- /dev/null +++ b/BalanceServer/internal/httpapi/sync_handler.go @@ -0,0 +1,169 @@ +package httpapi + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "time" +) + +const maxSyncChanges = 5000 +const syncPageSize = 500 + +type syncChange struct { + Entity string `json:"entity"` + ID string `json:"id"` + Deleted bool `json:"deleted"` + UpdatedAt time.Time `json:"updatedAt"` + Payload json.RawMessage `json:"payload,omitempty"` + Version int64 `json:"version,omitempty"` + Sequence int64 `json:"sequence,omitempty"` +} +type syncRequest struct { + Cursor int64 `json:"cursor"` + DeviceID string `json:"deviceId"` + Changes []syncChange `json:"changes"` + Pull *bool `json:"pull,omitempty"` +} +type syncResponse struct { + Cursor int64 `json:"cursor"` + Changes []syncChange `json:"changes"` + ServerTime time.Time `json:"serverTime"` + HasMore bool `json:"hasMore"` +} + +func (api *API) sync(w http.ResponseWriter, r *http.Request) { + var request syncRequest + if !decodeJSON(w, r, &request) { + return + } + if request.Cursor < 0 || len(request.Changes) > maxSyncChanges { + writeError(w, http.StatusBadRequest, "invalid_sync_request", "Cursor or change count is invalid") + return + } + for index := range request.Changes { + if err := validateChange(request.Changes[index]); err != nil { + writeError(w, http.StatusBadRequest, "invalid_change", err.Error()) + return + } + } + userID := claims(r).Subject + tx, err := api.db.BeginTx(r.Context(), nil) + if err != nil { + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not begin synchronization") + return + } + defer tx.Rollback() + for _, change := range request.Changes { + if err := applyChange(r, tx, userID, change); err != nil { + api.logger.Error("apply sync change", "error", err) + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not apply changes") + return + } + } + changes := make([]syncChange, 0) + cursor := request.Cursor + hasMore := false + shouldPull := request.Pull == nil || *request.Pull + if shouldPull { + changes, cursor, hasMore, err = loadChanges(r, tx, userID, request.Cursor) + if err != nil { + api.logger.Error("load sync changes", "error", err) + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not load changes") + return + } + } + if err := tx.Commit(); err != nil { + writeError(w, http.StatusInternalServerError, "sync_failed", "Could not complete synchronization") + return + } + writeJSON(w, http.StatusOK, syncResponse{Cursor: cursor, Changes: changes, ServerTime: time.Now().UTC(), HasMore: hasMore}) +} + +func validateChange(change syncChange) error { + if change.Entity != "transaction" && change.Entity != "category" && change.Entity != "budget" { + return errors.New("entity must be transaction, category, or budget") + } + if len(change.ID) < 1 || len(change.ID) > 128 { + return errors.New("record id is invalid") + } + if change.UpdatedAt.IsZero() { + return errors.New("updatedAt is required") + } + if change.UpdatedAt.After(time.Now().UTC().Add(24 * time.Hour)) { + return errors.New("updatedAt is too far in the future") + } + if !change.Deleted && (len(change.Payload) == 0 || !json.Valid(change.Payload)) { + return errors.New("payload is required for active records") + } + if len(change.Payload) > 256*1024 { + return errors.New("payload is too large") + } + return nil +} + +func applyChange(r *http.Request, tx *sql.Tx, userID string, incoming syncChange) error { + var currentUpdated string + var currentVersion int64 + err := tx.QueryRowContext(r.Context(), `SELECT updated_at,version FROM records WHERE user_id=? AND entity=? AND record_id=?`, userID, incoming.Entity, incoming.ID).Scan(¤tUpdated, ¤tVersion) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil { + parsed, parseErr := time.Parse(time.RFC3339Nano, currentUpdated) + if parseErr != nil { + return parseErr + } + if !incoming.UpdatedAt.After(parsed) { + return nil + } + } + version := currentVersion + 1 + updated := incoming.UpdatedAt.UTC().Format(time.RFC3339Nano) + var payload any + if !incoming.Deleted { + payload = string(incoming.Payload) + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO records(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?) + ON CONFLICT(user_id,entity,record_id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at,deleted=excluded.deleted,version=excluded.version`, + userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version) + if err != nil { + return err + } + _, err = tx.ExecContext(r.Context(), `INSERT INTO changes(user_id,entity,record_id,payload,updated_at,deleted,version) VALUES(?,?,?,?,?,?,?)`, userID, incoming.Entity, incoming.ID, payload, updated, incoming.Deleted, version) + return err +} + +func loadChanges(r *http.Request, tx *sql.Tx, userID string, after int64) ([]syncChange, int64, bool, error) { + rows, err := tx.QueryContext(r.Context(), `SELECT sequence,entity,record_id,payload,updated_at,deleted,version FROM changes WHERE user_id=? AND sequence>? ORDER BY sequence ASC LIMIT ?`, userID, after, syncPageSize+1) + if err != nil { + return nil, after, false, err + } + defer rows.Close() + result := make([]syncChange, 0) + cursor := after + hasMore := false + for rows.Next() { + if len(result) == syncPageSize { + hasMore = true + break + } + var item syncChange + var payload sql.NullString + var updated string + if err := rows.Scan(&item.Sequence, &item.Entity, &item.ID, &payload, &updated, &item.Deleted, &item.Version); err != nil { + return nil, after, false, err + } + item.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated) + if err != nil { + return nil, after, false, err + } + if payload.Valid { + item.Payload = json.RawMessage(payload.String) + } + cursor = item.Sequence + result = append(result, item) + } + return result, cursor, hasMore, rows.Err() +} diff --git a/BalanceServer/internal/httpapi/web.go b/BalanceServer/internal/httpapi/web.go new file mode 100644 index 0000000..d593821 --- /dev/null +++ b/BalanceServer/internal/httpapi/web.go @@ -0,0 +1,34 @@ +package httpapi + +import ( + "embed" + "io/fs" + "net/http" + "path" + "strings" +) + +//go:embed web/* +var embeddedWeb embed.FS + +var webContent = func() http.Handler { + content, err := fs.Sub(embeddedWeb, "web") + if err != nil { + panic(err) + } + return http.FileServer(http.FS(content)) +}() + +func (api *API) webApp(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v1/") { + writeError(w, http.StatusNotFound, "not_found", "Endpoint not found") + return + } + cleaned := path.Clean(r.URL.Path) + if cleaned == "." || cleaned == "/" || cleaned == "/index.html" || cleaned == "/service-worker.js" { + w.Header().Set("Cache-Control", "no-cache") + } else { + w.Header().Set("Cache-Control", "public, max-age=3600") + } + webContent.ServeHTTP(w, r) +} diff --git a/BalanceServer/internal/httpapi/web/app.js b/BalanceServer/internal/httpapi/web/app.js new file mode 100644 index 0000000..e5bed6a --- /dev/null +++ b/BalanceServer/internal/httpapi/web/app.js @@ -0,0 +1,1058 @@ +"use strict"; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; + +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", +]; + +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", "◎"], +]; + +const EMOJIS = [ + "🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮", + "🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁", + "💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️", +]; + +const BUILT_IN = { + expense: [ + ["Продукты", "cart.fill", "orange"], ["Транспорт", "car.fill", "blue"], + ["Дом", "house.fill", "indigo"], ["Развлечения", "gamecontroller.fill", "pink"], + ["Здоровье", "cross.case.fill", "red"], ["Покупки", "bag.fill", "purple"], + ["Подписки", "repeat", "cyan"], ["Образование", "book.fill", "mint"], + ["Другое", "ellipsis.circle.fill", "gray"], + ], + income: [ + ["Зарплата", "banknote.fill", "green"], ["Подработка", "laptopcomputer", "teal"], + ["Инвестиции", "chart.line.uptrend.xyaxis", "blue"], ["Подарок", "gift.fill", "pink"], + ["Другое", "plus.circle.fill", "gray"], + ], +}; + +const ICON_GLYPHS = new Map(ICONS); +const EXTRA_GLYPHS = { + "repeat": "↻", "ellipsis.circle.fill": "•••", "plus.circle.fill": "+", + "laptopcomputer": "▰", "chart.line.uptrend.xyaxis": "↗", "tune": "≡", +}; + +const state = { + user: null, + route: "dashboard", + transactions: new Map(), + categories: new Map(), + budgets: new Map(), + cursor: 0, + syncing: false, + analyticsMonths: 1, + transactionQuery: "", + transactionKind: "all", + authMode: "login", + lastSyncAt: null, +}; + +const preferences = loadPreferences(); +const mediaDark = window.matchMedia("(prefers-color-scheme: dark)"); + +class APIError extends Error { + constructor(message, code, status) { + super(message); + this.name = "APIError"; + this.code = code; + this.status = status; + } +} + +function loadPreferences() { + try { + const value = JSON.parse(localStorage.getItem("balance.web.preferences") || "{}"); + return { + currency: ["RUB", "EUR", "USD", "SEK"].includes(value.currency) ? value.currency : "RUB", + theme: ["system", "light", "dark"].includes(value.theme) ? value.theme : "system", + }; + } catch { + return { currency: "RUB", theme: "system" }; + } +} + +function savePreferences() { + localStorage.setItem("balance.web.preferences", JSON.stringify(preferences)); +} + +function applyTheme() { + const dark = preferences.theme === "dark" || (preferences.theme === "system" && mediaDark.matches); + document.documentElement.dataset.theme = dark ? "dark" : "light"; + $("meta[name='theme-color']")?.setAttribute("content", dark ? "#1b1b21" : "#5b5bd6"); +} + +function escapeHTML(value) { + return String(value ?? "").replace(/[&<>"']/g, character => ({ + "&": "&", "<": "<", ">": ">", "\"": """, "'": "'", + })[character]); +} + +function safeColor(value) { + return COLORS.includes(value) ? value : "indigo"; +} + +function iconGlyph(value) { + return ICON_GLYPHS.get(value) || EXTRA_GLYPHS[value] || "◆"; +} + +function categoryBadge(item, size = "") { + const visual = item.emoji ? escapeHTML(item.emoji) : `${escapeHTML(iconGlyph(item.icon))}`; + return `${visual}`; +} + +function formatMoney(value, options = {}) { + const amount = Number.isFinite(Number(value)) ? Number(value) : 0; + return new Intl.NumberFormat("ru-RU", { + style: "currency", + currency: preferences.currency, + maximumFractionDigits: Math.abs(amount % 1) < 0.0001 ? 0 : 2, + ...options, + }).format(amount); +} + +function formatDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + return new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short", year: "numeric" }).format(date); +} + +function formatDateTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "никогда"; + return new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(date); +} + +function localDateInput(value = new Date()) { + const date = new Date(value); + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 10); +} + +function dateInputISO(value) { + const [year, month, day] = value.split("-").map(Number); + return new Date(year, month - 1, day, 0, 0, 0, 0).toISOString(); +} + +function randomID() { + return crypto.randomUUID ? crypto.randomUUID() : `${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(2)).join("-")}`; +} + +function firstGrapheme(value) { + const trimmed = String(value || "").trim(); + if (!trimmed) return ""; + if (Intl.Segmenter) return [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(trimmed)][0]?.segment || ""; + return Array.from(trimmed)[0] || ""; +} + +function allTransactions() { + return [...state.transactions.values()]; +} + +function allCategories() { + return [...state.categories.values()]; +} + +function allBudgets() { + return [...state.budgets.values()]; +} + +function categoriesFor(kind) { + const builtIn = BUILT_IN[kind].map(([name, icon, colorName]) => ({ name, icon, emoji: "", colorName, kindRawValue: kind, builtIn: true })); + const custom = allCategories() + .filter(category => category.kindRawValue === kind) + .sort((a, b) => a.name.localeCompare(b.name, "ru")); + return [...builtIn, ...custom]; +} + +function monthBounds(months = 1) { + const now = new Date(); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1); + return [start.getTime(), end.getTime()]; +} + +function inPeriod(transaction, start, end) { + const time = new Date(transaction.date).getTime(); + return Number.isFinite(time) && time >= start && time < end; +} + +function totalBalance() { + return allTransactions().reduce((sum, transaction) => sum + (transaction.kindRawValue === "income" ? transaction.amount : -transaction.amount), 0); +} + +function summaryFor(months = 1) { + const [start, end] = monthBounds(months); + const items = allTransactions().filter(item => !item.isBalanceAdjustment && inPeriod(item, start, end)); + return { + income: items.filter(item => item.kindRawValue === "income").reduce((sum, item) => sum + item.amount, 0), + expenses: items.filter(item => item.kindRawValue === "expense").reduce((sum, item) => sum + item.amount, 0), + items, + start, + end, + }; +} + +function spendingBreakdown(months = 1) { + const { items } = summaryFor(months); + const groups = new Map(); + for (const item of items.filter(value => value.kindRawValue === "expense")) { + const group = groups.get(item.categoryName) || { + name: item.categoryName, icon: item.categoryIcon, emoji: item.categoryEmoji, + colorName: item.categoryColorName, amount: 0, + }; + group.amount += Number(item.amount) || 0; + groups.set(item.categoryName, group); + } + return [...groups.values()].sort((a, b) => b.amount - a.amount); +} + +function currentBudget(categoryName) { + const now = new Date(); + return allBudgets() + .filter(item => item.categoryName === categoryName) + .filter(item => { + const date = new Date(item.monthStart); + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth(); + }) + .sort((a, b) => new Date(b._updatedAt) - new Date(a._updatedAt))[0] || null; +} + +function currentMonthStartISO() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); +} + +async function apiFetch(path, options = {}, retry = true) { + const headers = new Headers(options.headers || {}); + if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + const response = await fetch(path, { ...options, headers, credentials: "same-origin" }); + if (response.status === 401 && retry && path !== "/v1/auth/refresh") { + const refreshed = await fetch("/v1/auth/refresh", { + method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken: "" }), + }); + if (refreshed.ok) return apiFetch(path, options, false); + } + let payload = null; + if (response.status !== 204) { + const type = response.headers.get("Content-Type") || ""; + payload = type.includes("application/json") ? await response.json().catch(() => null) : await response.text().catch(() => ""); + } + if (!response.ok) { + const code = payload?.error?.code || "request_failed"; + throw new APIError(localizedError(code, payload?.error?.message), code, response.status); + } + return payload; +} + +function localizedError(code, fallback) { + const messages = { + invalid_credentials: "Неверный email или пароль.", + invalid_email: "Введите корректный email.", + invalid_password: "Пароль должен содержать не менее 8 символов.", + email_exists: "Аккаунт с таким email уже существует.", + registration_disabled: "Регистрация на этом сервере отключена.", + rate_limited: "Слишком много попыток. Повторите через минуту.", + authorization_required: "Необходимо войти в аккаунт.", + invalid_token: "Сессия истекла. Войдите снова.", + invalid_refresh_token: "Сессия истекла. Войдите снова.", + sync_failed: "Не удалось синхронизировать данные.", + }; + return messages[code] || fallback || "Не удалось выполнить запрос."; +} + +function applyChanges(changes) { + for (const change of [...(changes || [])].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))) { + const target = change.entity === "transaction" ? state.transactions : change.entity === "category" ? state.categories : change.entity === "budget" ? state.budgets : null; + if (!target) continue; + if (change.deleted) target.delete(change.id); + else if (change.payload) target.set(change.id, { id: change.id, ...change.payload, _updatedAt: change.updatedAt }); + } +} + +async function fullSync() { + if (!state.user || state.syncing) return; + state.transactions.clear(); + state.categories.clear(); + state.budgets.clear(); + state.cursor = 0; + await pullChanges(true); +} + +async function pullChanges(silent = false) { + if (!state.user || state.syncing) return; + state.syncing = true; + setSyncStatus("syncing", "Синхронизация…"); + try { + let hasMore = true; + let guard = 0; + while (hasMore) { + const previous = state.cursor; + const response = await apiFetch("/v1/sync", { + method: "POST", + body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: [], pull: true }), + }); + applyChanges(response.changes); + state.cursor = response.cursor; + hasMore = Boolean(response.hasMore); + if (hasMore && state.cursor <= previous) throw new Error("Сервер не смог продолжить синхронизацию."); + if (++guard > 10000) throw new Error("Слишком много страниц синхронизации."); + } + state.lastSyncAt = new Date(); + setSyncStatus("ok", "Синхронизировано"); + render(); + if (!silent) toast("Данные синхронизированы"); + } catch (error) { + handleSyncError(error, silent); + throw error; + } finally { + state.syncing = false; + } +} + +async function pushChanges(changes) { + if (!state.user || !changes.length) return; + if (state.syncing) throw new Error("Дождитесь завершения синхронизации."); + state.syncing = true; + setSyncStatus("syncing", "Сохранение…"); + try { + for (let offset = 0; offset < changes.length; offset += 100) { + const batch = changes.slice(offset, offset + 100); + let response = await apiFetch("/v1/sync", { + method: "POST", + body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: batch, pull: true }), + }); + applyChanges(response.changes); + state.cursor = response.cursor; + while (response.hasMore) { + const previous = state.cursor; + response = await apiFetch("/v1/sync", { + method: "POST", + body: JSON.stringify({ cursor: state.cursor, deviceId: webDeviceID(), changes: [], pull: true }), + }); + applyChanges(response.changes); + state.cursor = response.cursor; + if (response.hasMore && state.cursor <= previous) throw new Error("Сервер не смог продолжить синхронизацию."); + } + } + state.lastSyncAt = new Date(); + setSyncStatus("ok", "Синхронизировано"); + render(); + } catch (error) { + handleSyncError(error, false); + throw error; + } finally { + state.syncing = false; + } +} + +function handleSyncError(error, silent) { + setSyncStatus("error", "Ошибка синхронизации"); + if (error instanceof APIError && error.status === 401) showAuth(); + if (!silent) toast(error.message || "Ошибка синхронизации", true); +} + +function webDeviceID() { + let value = localStorage.getItem("balance.web.device"); + if (!value) { + value = `web-${randomID()}`; + localStorage.setItem("balance.web.device", value); + } + return value; +} + +function changeFor(entity, id, payload, deleted = false, updatedAt = new Date().toISOString()) { + return { entity, id, deleted, updatedAt, ...(deleted ? {} : { payload }) }; +} + +function transactionPayload(value) { + return { + amount: Number(value.amount), date: value.date, note: value.note || "", + categoryName: value.categoryName, categoryIcon: value.categoryIcon || "circle", + categoryEmoji: value.categoryEmoji || "", categoryColorName: value.categoryColorName || "indigo", + isBalanceAdjustment: Boolean(value.isBalanceAdjustment), kindRawValue: value.kindRawValue === "income" ? "income" : "expense", + }; +} + +function categoryPayload(value) { + return { + name: value.name, icon: value.icon || "star", emoji: value.emoji || "", colorName: safeColor(value.colorName), + kindRawValue: value.kindRawValue === "income" ? "income" : "expense", createdAt: value.createdAt, + }; +} + +function budgetPayload(value) { + return { + categoryName: value.categoryName, categoryIcon: value.categoryIcon || "circle", + categoryEmoji: value.categoryEmoji || "", limit: Number(value.limit), monthStart: value.monthStart, + }; +} + +function pageHeader(title, subtitle, actions = "") { + return ``; +} + +function emptyState(icon, title, text, action = "") { + return `
${escapeHTML(icon)}

${escapeHTML(title)}

${escapeHTML(text)}

${action}
`; +} + +function render() { + if (!state.user) return; + const renderers = { + dashboard: renderDashboard, + transactions: renderTransactions, + analytics: renderAnalytics, + budgets: renderBudgets, + categories: renderCategories, + settings: renderSettings, + }; + $("#content").innerHTML = (renderers[state.route] || renderDashboard)(); + $$('[data-route]').forEach(button => button.classList.toggle("is-active", button.dataset.route === state.route)); + document.title = `${routeTitle(state.route)} — Balance`; +} + +function routeTitle(route) { + return ({ dashboard: "Обзор", transactions: "Операции", analytics: "Аналитика", budgets: "Бюджеты", categories: "Категории", settings: "Настройки" })[route] || "Balance"; +} + +function renderDashboard() { + const summary = summaryFor(1); + const recent = allTransactions().sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 6); + const savings = summary.income > 0 ? ((summary.income - summary.expenses) / summary.income) * 100 : 0; + return `${pageHeader("Обзор", "Главное о ваших финансах за текущий месяц", ` + + `)} +
+
+

Общий баланс

+
${formatMoney(totalBalance())}
+
+
Доходы за месяц+ ${formatMoney(summary.income)}
+
Расходы за месяц− ${formatMoney(summary.expenses)}
+
+ +
+
+
Доходы${formatMoney(summary.income)}
Текущий месяц
+
Расходы${formatMoney(summary.expenses)}
Без корректировок
+
Сбережения${formatMoney(summary.income - summary.expenses)}
Доходы минус расходы
+
Норма сбережений${Number.isFinite(savings) ? `${Math.round(savings)}%` : "—"}
За текущий месяц
+
+
+

Последние операции

+
${recent.length ? `
${recent.map(transactionRow).join("")}
` : emptyState("↕", "Операций пока нет", "Добавьте первый доход или расход, и он появится на всех ваших устройствах.", ``)}
+
+
`; +} + +function transactionRow(transaction) { + const income = transaction.kindRawValue === "income"; + const item = { + emoji: transaction.categoryEmoji, icon: transaction.categoryIcon, + colorName: transaction.categoryColorName, + }; + const category = transaction.isBalanceAdjustment ? "Корректировка" : transaction.categoryName; + const note = transaction.note ? ` · ${escapeHTML(transaction.note)}` : ""; + return `
+ ${categoryBadge(item)} +
${escapeHTML(category)}${formatDate(transaction.date)}${note}
+
${income ? "+" : "−"} ${formatMoney(transaction.amount)}${income ? "Доход" : "Расход"}
+
+ + +
+
`; +} + +function filteredTransactions() { + const query = state.transactionQuery.trim().toLocaleLowerCase("ru"); + return allTransactions() + .filter(item => state.transactionKind === "all" || item.kindRawValue === state.transactionKind) + .filter(item => !query || `${item.categoryName} ${item.note || ""}`.toLocaleLowerCase("ru").includes(query)) + .sort((a, b) => new Date(b.date) - new Date(a.date)); +} + +function transactionListHTML() { + const items = filteredTransactions(); + return items.length ? `
${items.map(transactionRow).join("")}
` : emptyState("⌕", "Ничего не найдено", "Измените поиск или фильтр, либо добавьте новую операцию."); +} + +function renderTransactions() { + return `${pageHeader("Операции", "Доходы, расходы и ручные корректировки баланса", ``)} +
+
+ +
+
${transactionListHTML()}
`; +} + +function renderAnalytics() { + const months = state.analyticsMonths; + const summary = summaryFor(months); + const breakdown = spendingBreakdown(months); + const savings = summary.income > 0 ? ((summary.income - summary.expenses) / summary.income) * 100 : 0; + const max = Math.max(...breakdown.map(item => item.amount), 1); + const periodName = months === 1 ? "месяц" : `${months} месяца`; + const insight = summary.income === 0 + ? "Добавьте доходы, чтобы увидеть норму сбережений и персональную подсказку." + : savings >= 20 + ? `Вы сохраняете ${Math.round(savings)}% доходов — это сильный результат.` + : savings >= 0 + ? `Вы сохраняете ${Math.round(savings)}% доходов. Попробуйте постепенно приблизиться к 20%.` + : "Расходы выше доходов за выбранный период. Проверьте самые крупные категории."; + return `
+ ${pageHeader("Аналитика", "Структура расходов и динамика сбережений", ``)} +
+ + + +
+
+
Доходы${formatMoney(summary.income)}
За ${periodName}
+
Расходы${formatMoney(summary.expenses)}
За ${periodName}
+
Сбережения${formatMoney(summary.income - summary.expenses)}
Чистый результат
+
Норма${summary.income ? `${Math.round(savings)}%` : "—"}
От доходов
+
+
+
+

Расходы по категориям

${formatMoney(summary.expenses)}
+
${breakdown.length ? `
${breakdown.map(item => ` +
+
${categoryBadge(item, "small")}${escapeHTML(item.name)}
+ +
${formatMoney(item.amount)}
+
`).join("")}
` : emptyState("◫", "Недостаточно данных", "В выбранном периоде ещё нет расходов.")}
+
+ +
+
`; +} + +function renderBudgets() { + const [start, end] = monthBounds(1); + const spending = new Map(); + for (const transaction of allTransactions().filter(item => item.kindRawValue === "expense" && !item.isBalanceAdjustment && inPeriod(item, start, end))) { + spending.set(transaction.categoryName, (spending.get(transaction.categoryName) || 0) + Number(transaction.amount || 0)); + } + const budgets = allBudgets().filter(item => { + const date = new Date(item.monthStart); + const now = new Date(); + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && Number(item.limit) > 0; + }).sort((a, b) => a.categoryName.localeCompare(b.categoryName, "ru")); + const content = budgets.length ? budgets.map(budget => { + const value = spending.get(budget.categoryName) || 0; + const ratio = Math.min(value / Math.max(Number(budget.limit), 1), 1); + const over = value > Number(budget.limit); + const item = { emoji: budget.categoryEmoji, icon: budget.categoryIcon, colorName: "indigo" }; + return `
+
${categoryBadge(item)}
${escapeHTML(budget.categoryName)}${over ? "Лимит превышен" : `Осталось ${formatMoney(Math.max(Number(budget.limit) - value, 0))}`}
+
${formatMoney(value)}из ${formatMoney(Number(budget.limit))}
+ +
`; + }).join("") : emptyState("◎", "Лимиты не настроены", "Задайте месячный лимит для одной или нескольких категорий расходов.", ``); + return `${pageHeader("Бюджеты", "Месячные лимиты по категориям расходов", ``)} +
${content}
`; +} + +function renderCategories() { + const categories = allCategories().sort((a, b) => a.name.localeCompare(b.name, "ru")); + return `${pageHeader("Категории", "Собственные категории синхронизируются со всеми устройствами", ``)} + ${categories.length ? `
${categories.map(category => `
+ ${categoryBadge(category)} +
${escapeHTML(category.name)}${category.kindRawValue === "income" ? "доход" : "расход"}
+
+ + +
+
`).join("")}
` : `
${emptyState("◆", "Своих категорий пока нет", "Создайте категорию с одной из 50 иконок или собственным эмодзи.", ``)}
`}`; +} + +function renderSettings() { + return `${pageHeader("Настройки", "Отображение, синхронизация и аккаунт")} +
+
+
+

Отображение

Настройки сохраняются только в этом браузере.

+
ВалютаИспользуется для форматирования сумм
+
+
ТемаСветлая, тёмная или системная
+
+
+
+

Данные

Все изменения отправляются на ваш Balance Server.

+
Пользовательские категории${state.categories.size} сохранено
+
СинхронизацияПоследняя: ${state.lastSyncAt ? formatDateTime(state.lastSyncAt) : "в этой сессии не выполнялась"}
+
Записи${state.transactions.size} операций · ${state.budgets.size} бюджетов
+
+
+ +
`; +} + +function buildCategoryOptions(kind, selected) { + return categoriesFor(kind).map(category => ``).join(""); +} + +function modalShell(title, body, saveLabel = "Сохранить", form = "editor-form") { + return ``; +} + +function showDialog(content) { + $("#dialog-content").innerHTML = content; + const dialog = $("#editor-dialog"); + if (!dialog.open) dialog.showModal(); +} + +function closeDialog() { + const dialog = $("#editor-dialog"); + if (dialog.open) dialog.close(); + $("#dialog-content").innerHTML = ""; +} + +function openTransactionEditor(id = null) { + const existing = id ? state.transactions.get(id) : null; + const kind = existing?.kindRawValue || "expense"; + const first = categoriesFor(kind)[0]; + const selected = existing?.categoryName || first.name; + const body = ` + + +
+ + +
+ + + + `; + showDialog(modalShell(existing ? "Редактирование операции" : "Новая операция", body, "Сохранить", "transaction-form")); +} + +function iconChoices(selected) { + return ICONS.map(([value, glyph]) => ``).join(""); +} + +function emojiChoices(selected) { + return EMOJIS.map(value => ``).join(""); +} + +function colorChoices(selected) { + return COLORS.map(value => ``).join(""); +} + +function openCategoryEditor(id = null) { + const existing = id ? state.categories.get(id) : null; + const kind = existing?.kindRawValue || "expense"; + const visual = existing?.emoji ? "emoji" : "icon"; + const body = ` + + + + + ${existing ? `` : ""} +
+

Иконка

${iconChoices(existing?.icon || "star.fill")}
+
+ +
${emojiChoices(existing?.emoji || "")}
+
+

Цвет

${colorChoices(existing?.colorName || "indigo")}
+

При изменении категории её название, иконка и цвет обновятся в связанных операциях и бюджетах.

`; + showDialog(modalShell(existing ? "Редактирование категории" : "Новая категория", body, "Сохранить", "category-form")); +} + +function openBudgetEditor() { + const categories = categoriesFor("expense"); + const rows = categories.map((category, index) => { + const budget = currentBudget(category.name); + return `
${categoryBadge(category, "small")}${escapeHTML(category.name)}
`; + }).join(""); + const body = `

Укажите месячный лимит. Пустое поле или ноль удаляет существующий бюджет.

${rows}
`; + showDialog(modalShell("Месячные лимиты", body, "Сохранить", "budget-form")); +} + +function openBalanceAdjustment() { + const current = totalBalance(); + const body = `

Расчётный баланс: ${formatMoney(current)}. Корректировка не влияет на аналитику.

+ + `; + showDialog(modalShell("Корректировка баланса", body, "Скорректировать", "adjustment-form")); +} + +async function submitTransaction(form) { + const data = new FormData(form); + const amount = Number(String(data.get("amount")).replace(",", ".")); + if (!Number.isFinite(amount) || amount <= 0) throw new Error("Введите сумму больше нуля."); + const kind = data.get("kind") === "income" ? "income" : "expense"; + const category = categoriesFor(kind).find(item => item.name === data.get("category")); + if (!category) throw new Error("Выберите категорию."); + const existing = state.transactions.get(String(data.get("id"))) || null; + const id = existing?.id || randomID(); + const value = { + amount, date: dateInputISO(String(data.get("date"))), note: String(data.get("note") || "").trim(), + categoryName: category.name, categoryIcon: category.icon, categoryEmoji: category.emoji || "", + categoryColorName: category.colorName, isBalanceAdjustment: false, kindRawValue: kind, + }; + await pushChanges([changeFor("transaction", id, transactionPayload(value))]); + closeDialog(); + toast(existing ? "Операция обновлена" : "Операция добавлена"); +} + +async function submitCategory(form) { + const data = new FormData(form); + const existing = state.categories.get(String(data.get("id"))) || null; + const name = String(data.get("name") || "").trim(); + const kind = String(existing?.kindRawValue || data.get("kind-fixed") || data.get("kind")) === "income" ? "income" : "expense"; + if (!name) throw new Error("Введите название категории."); + const duplicate = allCategories().some(item => item.id !== existing?.id && item.kindRawValue === kind && item.name.localeCompare(name, "ru", { sensitivity: "accent" }) === 0); + if (duplicate) throw new Error("Категория с таким названием уже существует."); + const visual = data.get("visual") === "emoji" ? "emoji" : "icon"; + const emoji = visual === "emoji" ? firstGrapheme(data.get("emoji") || data.get("emoji-choice")) : ""; + if (visual === "emoji" && !emoji) throw new Error("Введите или выберите эмодзи."); + const now = new Date().toISOString(); + const value = { + name, icon: String(data.get("icon") || existing?.icon || "star.fill"), emoji, + colorName: safeColor(String(data.get("color") || "indigo")), kindRawValue: kind, + createdAt: existing?.createdAt || now, + }; + const changes = [changeFor("category", existing?.id || randomID(), categoryPayload(value), false, now)]; + if (existing) { + for (const transaction of allTransactions().filter(item => item.categoryName === existing.name && item.kindRawValue === kind)) { + changes.push(changeFor("transaction", transaction.id, transactionPayload({ + ...transaction, categoryName: name, categoryIcon: value.icon, categoryEmoji: value.emoji, categoryColorName: value.colorName, + }), false, now)); + } + for (const budget of allBudgets().filter(item => item.categoryName === existing.name)) { + changes.push(changeFor("budget", budget.id, budgetPayload({ + ...budget, categoryName: name, categoryIcon: value.icon, categoryEmoji: value.emoji, + }), false, now)); + } + } + await pushChanges(changes); + closeDialog(); + toast(existing ? "Категория обновлена" : "Категория добавлена"); +} + +async function submitBudgets(form) { + const data = new FormData(form); + const categories = categoriesFor("expense"); + const now = new Date().toISOString(); + const changes = []; + categories.forEach((category, index) => { + const amount = Number(String(data.get(`budget-${index}`) || "0").replace(",", ".")); + const existing = currentBudget(category.name); + if (Number.isFinite(amount) && amount > 0) { + changes.push(changeFor("budget", existing?.id || randomID(), budgetPayload({ + categoryName: category.name, categoryIcon: category.icon, categoryEmoji: category.emoji || "", + limit: amount, monthStart: existing?.monthStart || currentMonthStartISO(), + }), false, now)); + } else if (existing) { + changes.push(changeFor("budget", existing.id, null, true, now)); + } + }); + if (changes.length) await pushChanges(changes); + closeDialog(); + toast("Бюджеты обновлены"); +} + +async function submitAdjustment(form) { + const data = new FormData(form); + const target = Number(String(data.get("target")).replace(",", ".")); + if (!Number.isFinite(target)) throw new Error("Введите фактический баланс."); + const difference = target - totalBalance(); + if (Math.abs(difference) < 0.005) throw new Error("Баланс уже соответствует указанной сумме."); + const value = { + amount: Math.abs(difference), date: new Date().toISOString(), + note: String(data.get("note") || "").trim() || "Ручная корректировка баланса", + categoryName: "Корректировка", categoryIcon: "tune", categoryEmoji: "", categoryColorName: "indigo", + isBalanceAdjustment: true, kindRawValue: difference > 0 ? "income" : "expense", + }; + await pushChanges([changeFor("transaction", randomID(), transactionPayload(value))]); + closeDialog(); + toast("Баланс скорректирован"); +} + +async function deleteTransaction(id) { + const value = state.transactions.get(id); + if (!value || !confirm(`Удалить операцию «${value.categoryName}» на сумму ${formatMoney(value.amount)}?`)) return; + await pushChanges([changeFor("transaction", id, null, true)]); + toast("Операция удалена"); +} + +async function deleteCategory(id) { + const value = state.categories.get(id); + if (!value || !confirm(`Удалить категорию «${value.name}»? Существующие операции сохранятся.`)) return; + await pushChanges([changeFor("category", id, null, true)]); + toast("Категория удалена"); +} + +function setRoute(route) { + if (!["dashboard", "transactions", "analytics", "budgets", "categories", "settings"].includes(route)) return; + state.route = route; + render(); + $("#content")?.focus({ preventScroll: true }); + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +function setSyncStatus(type, text) { + const pill = $("#sync-pill"); + if (!pill) return; + pill.classList.toggle("is-syncing", type === "syncing"); + pill.classList.toggle("is-error", type === "error"); + const label = $("span:last-child", pill); + if (label) label.textContent = text; +} + +function toast(message, error = false) { + const item = document.createElement("div"); + item.className = `toast${error ? " is-error" : ""}`; + item.textContent = message; + $("#toast-region").append(item); + window.setTimeout(() => item.remove(), 4200); +} + +function showApp() { + $("#auth-view").hidden = true; + $("#app-view").hidden = false; + $("#account-email").textContent = state.user.email; + $("#account-avatar").textContent = (state.user.email || "Б")[0].toUpperCase(); + render(); +} + +function showAuth() { + state.user = null; + state.transactions.clear(); + state.categories.clear(); + state.budgets.clear(); + state.cursor = 0; + $("#app-view").hidden = true; + $("#auth-view").hidden = false; + document.title = "Balance — вход"; +} + +async function authenticate(event) { + event.preventDefault(); + const form = event.currentTarget; + const button = $("#auth-submit"); + const error = $("#auth-error"); + error.hidden = true; + button.disabled = true; + button.textContent = state.authMode === "login" ? "Входим…" : "Создаём аккаунт…"; + try { + await apiFetch(`/v1/auth/${state.authMode}`, { + method: "POST", + body: JSON.stringify({ email: form.email.value.trim(), password: form.password.value }), + }, false); + state.user = await apiFetch("/v1/me"); + showApp(); + } catch (failure) { + error.textContent = failure.message || "Не удалось войти."; + error.hidden = false; + return; + } finally { + button.disabled = false; + button.textContent = state.authMode === "login" ? "Войти" : "Создать аккаунт"; + } + try { + await fullSync(); + } catch (failure) { + toast(failure.message || "Вход выполнен, но синхронизация пока недоступна.", true); + } +} + +function setAuthMode(mode) { + state.authMode = mode === "register" ? "register" : "login"; + $$('[data-auth-mode]').forEach(button => button.classList.toggle("is-active", button.dataset.authMode === state.authMode)); + $("#auth-submit").textContent = state.authMode === "login" ? "Войти" : "Создать аккаунт"; + $("#auth-password").autocomplete = state.authMode === "login" ? "current-password" : "new-password"; + $("#auth-error").hidden = true; +} + +async function logout() { + if (!confirm("Выйти из аккаунта в этом браузере?")) return; + try { + await apiFetch("/v1/auth/logout", { method: "POST", body: JSON.stringify({ refreshToken: "" }) }, false); + } catch { + // The local session is cleared even if the server is temporarily unavailable. + } + showAuth(); +} + +async function handleAction(action, id) { + try { + if (action === "transaction-new") openTransactionEditor(); + else if (action === "transaction-edit") openTransactionEditor(id); + else if (action === "transaction-delete") await deleteTransaction(id); + else if (action === "category-new") openCategoryEditor(); + else if (action === "category-edit") openCategoryEditor(id); + else if (action === "category-delete") await deleteCategory(id); + else if (action === "budgets-edit") openBudgetEditor(); + else if (action === "balance-adjust") openBalanceAdjustment(); + else if (action === "sync") await pullChanges(false); + else if (action === "logout") await logout(); + else if (action === "dialog-close") closeDialog(); + } catch (error) { + toast(error.message || "Не удалось выполнить действие", true); + } +} + +document.addEventListener("click", event => { + const route = event.target.closest("[data-route]"); + if (route) { + event.preventDefault(); + setRoute(route.dataset.route); + return; + } + const authMode = event.target.closest("[data-auth-mode]"); + if (authMode) { + setAuthMode(authMode.dataset.authMode); + return; + } + const analytics = event.target.closest("[data-analytics-months]"); + if (analytics) { + state.analyticsMonths = Number(analytics.dataset.analyticsMonths) || 1; + render(); + return; + } + const transactionKind = event.target.closest("[data-transaction-kind]"); + if (transactionKind) { + const kind = transactionKind.dataset.transactionKind; + $("#transaction-kind").value = kind; + $$('[data-transaction-kind]').forEach(button => button.classList.toggle("is-active", button.dataset.transactionKind === kind)); + $("#transaction-category").innerHTML = buildCategoryOptions(kind, categoriesFor(kind)[0]?.name); + return; + } + const categoryVisual = event.target.closest("[data-category-visual]"); + if (categoryVisual) { + const visual = categoryVisual.dataset.categoryVisual; + $("#category-visual").value = visual; + $$('[data-category-visual]').forEach(button => button.classList.toggle("is-active", button.dataset.categoryVisual === visual)); + $("#icon-section").hidden = visual !== "icon"; + $("#emoji-section").hidden = visual !== "emoji"; + return; + } + const emojiChoice = event.target.closest('input[name="emoji-choice"]'); + if (emojiChoice && $("#category-emoji")) $("#category-emoji").value = emojiChoice.value; + const action = event.target.closest("[data-action]"); + if (action) void handleAction(action.dataset.action, action.dataset.id); +}); + +document.addEventListener("input", event => { + if (event.target.id === "transaction-search") { + state.transactionQuery = event.target.value; + const list = $("#transaction-list"); + if (list) list.innerHTML = transactionListHTML(); + } + if (event.target.id === "category-emoji") { + $$('input[name="emoji-choice"]').forEach(input => { input.checked = input.value === event.target.value; }); + } +}); + +document.addEventListener("change", event => { + if (event.target.id === "transaction-filter") { + state.transactionKind = event.target.value; + const list = $("#transaction-list"); + if (list) list.innerHTML = transactionListHTML(); + } else if (event.target.id === "currency-setting") { + preferences.currency = event.target.value; + savePreferences(); + render(); + } else if (event.target.id === "theme-setting") { + preferences.theme = event.target.value; + savePreferences(); + applyTheme(); + render(); + } else if (event.target.name === "emoji-choice" && $("#category-emoji")) { + $("#category-emoji").value = event.target.value; + } +}); + +document.addEventListener("submit", event => { + if (event.target.id === "auth-form") return; + event.preventDefault(); + const form = event.target; + const submit = $('button[type="submit"]', form); + if (submit) submit.disabled = true; + const actions = { + "transaction-form": submitTransaction, + "category-form": submitCategory, + "budget-form": submitBudgets, + "adjustment-form": submitAdjustment, + }; + const action = actions[form.id]; + if (!action) return; + void action(form).catch(error => { + toast(error.message || "Проверьте введённые данные", true); + if (submit) submit.disabled = false; + }); +}); + +$("#auth-form").addEventListener("submit", authenticate); +$("#editor-dialog").addEventListener("click", event => { + if (event.target === event.currentTarget) closeDialog(); +}); +mediaDark.addEventListener("change", () => { if (preferences.theme === "system") applyTheme(); }); +document.addEventListener("visibilitychange", () => { + if (!document.hidden && state.user && !state.syncing) void pullChanges(true).catch(() => {}); +}); +window.setInterval(() => { + if (state.user && !document.hidden && !state.syncing) void pullChanges(true).catch(() => {}); +}, 30_000); + +async function boot() { + applyTheme(); + if ("serviceWorker" in navigator) navigator.serviceWorker.register("/service-worker.js").catch(() => {}); + try { + state.user = await apiFetch("/v1/me"); + } catch { + showAuth(); + return; + } + showApp(); + try { + await fullSync(); + } catch (failure) { + toast(failure.message || "Не удалось загрузить данные с сервера.", true); + } +} + +void boot(); diff --git a/BalanceServer/internal/httpapi/web/icon.svg b/BalanceServer/internal/httpapi/web/icon.svg new file mode 100644 index 0000000..2c85ef5 --- /dev/null +++ b/BalanceServer/internal/httpapi/web/icon.svg @@ -0,0 +1,19 @@ + + Balance + + + + + + + + + + + + + + + + + diff --git a/BalanceServer/internal/httpapi/web/index.html b/BalanceServer/internal/httpapi/web/index.html new file mode 100644 index 0000000..e439725 --- /dev/null +++ b/BalanceServer/internal/httpapi/web/index.html @@ -0,0 +1,106 @@ + + + + + + + + Balance + + + + + +
+ +
+
+
+ +
+ Balance + Ваши финансы. Ваш сервер. +
+
+
+

SELF-HOSTED

+

Финансы под вашим контролем

+

Войдите в аккаунт этого сервера. Операции синхронизируются с iPhone, Apple Watch, Mac и Android.

+
+
+ + +
+
+ + + + +
+

Сессия хранится в защищённых HttpOnly-cookie и недоступна JavaScript.

+
+
+ + + + +
+
+ + + + + diff --git a/BalanceServer/internal/httpapi/web/manifest.webmanifest b/BalanceServer/internal/httpapi/web/manifest.webmanifest new file mode 100644 index 0000000..bf18318 --- /dev/null +++ b/BalanceServer/internal/httpapi/web/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Balance — личные финансы", + "short_name": "Balance", + "description": "Self-hosted приложение для управления личными финансами", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f4f5f8", + "theme_color": "#5b5bd6", + "icons": [ + { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" } + ] +} diff --git a/BalanceServer/internal/httpapi/web/service-worker.js b/BalanceServer/internal/httpapi/web/service-worker.js new file mode 100644 index 0000000..db3033f --- /dev/null +++ b/BalanceServer/internal/httpapi/web/service-worker.js @@ -0,0 +1,22 @@ +const CACHE = "balance-shell-v2"; +const SHELL = ["/", "/styles.css", "/app.js", "/icon.svg", "/manifest.webmanifest"]; + +self.addEventListener("install", event => { + event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL))); + self.skipWaiting(); +}); + +self.addEventListener("activate", event => { + event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key))))); + self.clients.claim(); +}); + +self.addEventListener("fetch", event => { + const url = new URL(event.request.url); + if (event.request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/v1/") || url.pathname === "/health") return; + event.respondWith(fetch(event.request).then(response => { + const copy = response.clone(); + caches.open(CACHE).then(cache => cache.put(event.request, copy)); + return response; + }).catch(() => caches.match(event.request).then(response => response || caches.match("/")))); +}); diff --git a/BalanceServer/internal/httpapi/web/styles.css b/BalanceServer/internal/httpapi/web/styles.css new file mode 100644 index 0000000..54af28d --- /dev/null +++ b/BalanceServer/internal/httpapi/web/styles.css @@ -0,0 +1,487 @@ +:root { + color-scheme: light; + --bg: #f4f5f8; + --surface: #ffffff; + --surface-soft: #f8f8fb; + --surface-strong: #edeef3; + --text: #17171c; + --muted: #70717c; + --border: #e3e4ea; + --primary: #5b5bd6; + --primary-strong: #4747bd; + --primary-soft: #ececfe; + --green: #16a36a; + --green-soft: #e8f7f0; + --red: #e05252; + --red-soft: #fcecec; + --orange: #f28c38; + --shadow: 0 16px 44px rgb(31 35 55 / 8%); + --radius: 20px; + --sidebar: 252px; + font-family: Inter, ui-rounded, "SF Pro Rounded", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #111116; + --surface: #1b1b21; + --surface-soft: #222229; + --surface-strong: #2a2a32; + --text: #f5f5f7; + --muted: #a6a6b0; + --border: #303039; + --primary: #8888f5; + --primary-strong: #9d9dff; + --primary-soft: #29294b; + --green-soft: #15392b; + --red-soft: #422326; + --shadow: 0 18px 50px rgb(0 0 0 / 28%); +} + +* { box-sizing: border-box; } +html { min-height: 100%; background: var(--bg); } +body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); color: var(--text); } +button, input, select, textarea { font: inherit; } +button { color: inherit; } +button, select { cursor: pointer; } +img { display: block; } +[hidden] { display: none !important; } + +:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 42%, transparent); outline-offset: 2px; } + +.auth-layout { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px 18px; + background: + radial-gradient(circle at 12% 10%, color-mix(in srgb, var(--primary) 16%, transparent), transparent 32%), + radial-gradient(circle at 88% 88%, color-mix(in srgb, var(--green) 10%, transparent), transparent 30%), + var(--bg); +} + +.auth-card { + width: min(100%, 480px); + padding: 30px; + border: 1px solid var(--border); + border-radius: 30px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: var(--shadow); +} + +.brand { display: flex; align-items: center; gap: 11px; } +.brand-auth { margin-bottom: 32px; } +.brand-name { display: block; font-size: 20px; line-height: 1.05; font-weight: 800; letter-spacing: -.4px; } +.brand-caption { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; } +.auth-copy h1 { margin: 4px 0 12px; font-size: clamp(30px, 7vw, 42px); line-height: 1.04; letter-spacing: -1.6px; } +.auth-copy > p:last-child { margin: 0 0 24px; color: var(--muted); line-height: 1.55; } +.eyebrow { margin: 0; color: var(--primary); font-size: 11px; font-weight: 800; letter-spacing: 1.6px; } +.security-note { display: flex; gap: 8px; margin: 20px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; } + +.stack { display: grid; gap: 14px; } +.field { display: grid; gap: 7px; } +.field > span, .field-label { color: var(--muted); font-size: 12px; font-weight: 700; } +.field input, .field select, .field textarea, .select-control, .search-control { + width: 100%; + min-height: 46px; + padding: 11px 13px; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--surface-soft); + color: var(--text); + outline: none; + transition: border-color .16s, box-shadow .16s, background .16s; +} +.field textarea { min-height: 88px; resize: vertical; } +.field input:focus, .field select:focus, .field textarea:focus, .select-control:focus, .search-control:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 14%, transparent); + background: var(--surface); +} +.form-error { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--red-soft); color: var(--red); font-size: 13px; } + +.segmented { display: flex; gap: 4px; padding: 4px; border-radius: 13px; background: var(--surface-strong); } +.segmented button { flex: 1; min-height: 38px; border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-size: 13px; font-weight: 700; } +.segmented button.is-active { background: var(--surface); color: var(--text); box-shadow: 0 2px 10px rgb(0 0 0 / 7%); } +.auth-switch { margin-bottom: 17px; } + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + padding: 9px 15px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + font-weight: 700; + font-size: 13px; + text-decoration: none; + transition: transform .14s, background .14s, border-color .14s; +} +.button:hover { background: var(--surface-soft); border-color: color-mix(in srgb, var(--primary) 30%, var(--border)); } +.button:active { transform: scale(.98); } +.button:disabled { cursor: not-allowed; opacity: .55; } +.button-large { min-height: 48px; font-size: 14px; } +.button-primary { border-color: var(--primary); background: var(--primary); color: white; } +.button-primary:hover { border-color: var(--primary-strong); background: var(--primary-strong); } +.button-danger { border-color: color-mix(in srgb, var(--red) 25%, var(--border)); color: var(--red); } +.button-ghost { border-color: transparent; background: transparent; color: var(--muted); } +.button-row { display: flex; flex-wrap: wrap; gap: 9px; } + +.app-shell { min-height: 100vh; } +.sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 20; + display: flex; + width: var(--sidebar); + flex-direction: column; + padding: 24px 16px 18px; + border-right: 1px solid var(--border); + background: var(--surface); +} +.sidebar-brand { padding: 0 8px 24px; } +.side-nav { display: grid; gap: 5px; } +.side-nav button { + display: flex; + align-items: center; + gap: 12px; + min-height: 45px; + padding: 8px 13px; + border: 0; + border-radius: 13px; + background: transparent; + color: var(--muted); + text-align: left; + font-weight: 700; + font-size: 14px; +} +.side-nav button span { display: grid; width: 22px; place-items: center; font-size: 20px; } +.side-nav button:hover { background: var(--surface-soft); color: var(--text); } +.side-nav button.is-active { background: var(--primary-soft); color: var(--primary-strong); } +.sidebar-footer { display: grid; gap: 14px; margin-top: auto; } +.sync-pill { display: flex; align-items: center; gap: 8px; padding: 0 9px; color: var(--muted); font-size: 11px; } +.sync-pill > span:first-child { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--green-soft); } +.sync-pill.is-syncing > span:first-child { background: var(--orange); animation: pulse 1s infinite; } +.sync-pill.is-error > span:first-child { background: var(--red); box-shadow: 0 0 0 4px var(--red-soft); } +@keyframes pulse { 50% { opacity: .35; } } +.account-mini { display: flex; align-items: center; gap: 10px; padding: 12px 9px 0; border-top: 1px solid var(--border); overflow: hidden; } +.account-mini > div:last-child { min-width: 0; } +.account-mini strong, .account-mini span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.account-mini strong { font-size: 12px; } +.account-mini span { margin-top: 3px; color: var(--muted); font-size: 10px; } +.avatar { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 11px; background: var(--primary-soft); color: var(--primary-strong); font-weight: 800; } + +.workspace { min-height: 100vh; margin-left: var(--sidebar); } +.content { width: min(1180px, 100%); margin: 0 auto; padding: 38px 36px 60px; } +.mobile-header, .bottom-nav { display: none; } +.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 25px; } +.page-header h1 { margin: 2px 0 0; font-size: clamp(27px, 4vw, 38px); line-height: 1.08; letter-spacing: -1.25px; } +.page-header p { margin: 7px 0 0; color: var(--muted); font-size: 13px; } +.page-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 9px; } + +.grid { display: grid; gap: 16px; } +.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } +.dashboard-grid { grid-template-columns: minmax(0, 1.45fr) minmax(300px, .9fr); align-items: start; } +.span-2 { grid-column: span 2; } +.card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 8px 30px rgb(31 35 55 / 4%); } +.card-body { padding: 20px; } +.card-header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 19px 20px 0; } +.card-header h2, .card-header h3 { margin: 0; font-size: 16px; letter-spacing: -.25px; } +.card-header + .card-body { padding-top: 15px; } +.hero-balance { + position: relative; + min-height: 230px; + overflow: hidden; + padding: 26px; + border: 0; + background: linear-gradient(135deg, #6868e9, #4747b9); + color: white; + box-shadow: 0 20px 42px rgb(70 70 185 / 23%); +} +.hero-balance::after { content: ""; position: absolute; right: -80px; bottom: -125px; width: 290px; height: 290px; border: 44px solid rgb(255 255 255 / 8%); border-radius: 50%; } +.hero-balance .hero-label { position: relative; z-index: 1; margin: 0; color: rgb(255 255 255 / 72%); font-size: 13px; font-weight: 700; } +.hero-value { position: relative; z-index: 1; margin: 13px 0 23px; font-size: clamp(33px, 5vw, 50px); font-weight: 850; letter-spacing: -2px; } +.hero-meta { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 10px 24px; } +.hero-meta div span, .metric-card span { display: block; font-size: 11px; font-weight: 700; } +.hero-meta div span { color: rgb(255 255 255 / 66%); } +.hero-meta div strong { display: block; margin-top: 5px; font-size: 15px; } +.hero-balance .button { position: relative; z-index: 1; margin-top: 22px; border-color: rgb(255 255 255 / 28%); background: rgb(255 255 255 / 13%); color: white; } +.hero-balance .button:hover { background: rgb(255 255 255 / 20%); } + +.metric-card { min-height: 118px; padding: 17px; } +.metric-card span { color: var(--muted); } +.metric-card strong { display: block; margin-top: 12px; font-size: 23px; letter-spacing: -.8px; } +.metric-card .metric-foot { margin-top: 6px; color: var(--muted); font-size: 11px; } +.metric-card.is-income strong { color: var(--green); } +.metric-card.is-expense strong { color: var(--red); } +.metric-card.is-primary strong { color: var(--primary); } + +.list { display: grid; } +.list-row { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 13px 0; border-bottom: 1px solid var(--border); } +.list-row:first-child { padding-top: 3px; } +.list-row:last-child { padding-bottom: 3px; border-bottom: 0; } +.row-main { min-width: 0; flex: 1; } +.row-main strong, .row-main span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.row-main strong { font-size: 13px; } +.row-main span { margin-top: 4px; color: var(--muted); font-size: 11px; } +.row-value { flex: 0 0 auto; text-align: right; } +.row-value strong, .row-value span { display: block; } +.row-value strong { font-size: 13px; } +.row-value span { margin-top: 4px; color: var(--muted); font-size: 10px; } +.amount-income { color: var(--green); } +.amount-expense { color: var(--text); } +.row-actions { display: flex; gap: 3px; opacity: .25; transition: opacity .15s; } +.list-row:hover .row-actions { opacity: 1; } +.icon-button { display: grid; width: 36px; height: 36px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--muted); } +.icon-button:hover { background: var(--surface-strong); color: var(--text); } +.icon-button.is-danger:hover { background: var(--red-soft); color: var(--red); } + +.category-badge { display: grid; width: 43px; height: 43px; flex: 0 0 auto; place-items: center; border-radius: 14px; font-size: 20px; font-weight: 800; } +.category-badge.small { width: 34px; height: 34px; border-radius: 11px; font-size: 16px; } +.category-badge.large { width: 58px; height: 58px; border-radius: 18px; font-size: 26px; } +.category-badge .symbol { font-size: .88em; } +.color-red { color: #e05252; background: #e0525222; } .color-orange { color: #f28c38; background: #f28c3822; } +.color-yellow { color: #e0ac24; background: #e0ac2422; } .color-green { color: #16a36a; background: #16a36a22; } +.color-mint { color: #31b99a; background: #31b99a22; } .color-teal { color: #159eaf; background: #159eaf22; } +.color-cyan { color: #209bcc; background: #209bcc22; } .color-blue { color: #3979e9; background: #3979e922; } +.color-indigo { color: #5b5bd6; background: #5b5bd622; } .color-purple { color: #8b5cf6; background: #8b5cf622; } +.color-pink { color: #e35c9a; background: #e35c9a22; } .color-brown { color: #9b6a4a; background: #9b6a4a22; } +.color-gray { color: #7a7a85; background: #7a7a8522; } .color-coral { color: #fa6161; background: #fa616122; } +.color-peach { color: #ff9461; background: #ff946122; } .color-gold { color: #e6a314; background: #e6a31422; } +.color-lime { color: #85c729; background: #85c72922; } .color-forest { color: #1a7a4d; background: #1a7a4d22; } +.color-turquoise { color: #0daba1; background: #0daba122; } .color-sky { color: #2e9ee8; background: #2e9ee822; } +.color-navy { color: #365ca8; background: #1f408522; } .color-lavender { color: #8c66d9; background: #8c66d922; } +.color-magenta { color: #d1298c; background: #d1298c22; } .color-slate { color: #66758e; background: #4d5c7322; } + +.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; } +.toolbar .search-wrap { position: relative; min-width: 220px; flex: 1; } +.toolbar .search-wrap::before { content: "⌕"; position: absolute; top: 50%; left: 13px; transform: translateY(-50%); color: var(--muted); } +.toolbar .search-control { padding-left: 38px; } +.toolbar .select-control { width: auto; min-width: 150px; } +.operations-card .card-body { padding-top: 9px; } +.operations-card .list-row { padding: 14px 0; } + +.empty-state { display: grid; min-height: 210px; place-items: center; padding: 35px; text-align: center; } +.empty-state .empty-icon { display: grid; width: 62px; height: 62px; place-items: center; margin: 0 auto 14px; border-radius: 20px; background: var(--primary-soft); color: var(--primary); font-size: 27px; } +.empty-state h3 { margin: 0; font-size: 16px; } +.empty-state p { max-width: 390px; margin: 8px auto 0; color: var(--muted); font-size: 13px; line-height: 1.5; } +.empty-state .button { margin-top: 17px; } + +.analytics-page { display: grid; row-gap: 24px; padding-bottom: 8px; } +.analytics-page > .page-header { margin-bottom: 0; } +.analytics-layout { grid-template-columns: minmax(0, 1.25fr) minmax(310px, .75fr); align-items: start; gap: 20px; } +.analytics-layout > * { min-width: 0; } +.analytics-metrics { gap: 18px; } +.analytics-metrics .metric-card { display: flex; min-height: 132px; flex-direction: column; justify-content: center; padding: 20px; } +.period-switch { width: fit-content; margin-bottom: 0; } +.period-switch button { min-width: 82px; } +.breakdown { display: grid; gap: 15px; } +.breakdown-row { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(150px, 2fr) auto; align-items: center; gap: 12px; } +.breakdown-name { display: flex; min-width: 0; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; } +.breakdown-name span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +progress { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-strong); color: var(--primary); } +progress::-webkit-progress-bar { border-radius: 999px; background: var(--surface-strong); } +progress::-webkit-progress-value { border-radius: 999px; background: currentColor; } +progress::-moz-progress-bar { border-radius: 999px; background: currentColor; } +.progress-red { color: var(--red); } .progress-orange { color: var(--orange); } .progress-yellow { color: #e0ac24; } +.progress-green { color: var(--green); } .progress-mint { color: #31b99a; } .progress-teal { color: #159eaf; } +.progress-cyan { color: #209bcc; } .progress-blue { color: #3979e9; } .progress-indigo { color: var(--primary); } +.progress-purple { color: #8b5cf6; } .progress-pink { color: #e35c9a; } .progress-brown { color: #9b6a4a; } +.progress-gray { color: #7a7a85; } .progress-coral { color: #fa6161; } .progress-peach { color: #ff9461; } +.progress-gold { color: #e6a314; } .progress-lime { color: #85c729; } .progress-forest { color: #1a7a4d; } +.progress-turquoise { color: #0daba1; } .progress-sky { color: #2e9ee8; } .progress-navy { color: #365ca8; } +.progress-lavender { color: #8c66d9; } .progress-magenta { color: #d1298c; } .progress-slate { color: #66758e; } +.breakdown-value { min-width: 85px; text-align: right; font-size: 12px; font-weight: 750; } +.insight-card { padding: 20px; background: linear-gradient(145deg, var(--primary-soft), var(--surface)); } +.insight-card .insight-icon { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 13px; background: var(--primary); color: white; font-size: 20px; } +.insight-card h3 { margin: 16px 0 8px; font-size: 16px; } +.insight-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; } +.analytics-breakdown-card .card-body { padding-top: 18px; padding-bottom: 22px; } + +.budget-list { display: grid; gap: 12px; } +.budget-item { padding: 17px; } +.budget-top { display: flex; align-items: center; gap: 11px; } +.budget-copy { min-width: 0; flex: 1; } +.budget-copy strong, .budget-copy span { display: block; } +.budget-copy strong { font-size: 13px; } +.budget-copy span { margin-top: 4px; color: var(--muted); font-size: 11px; } +.budget-values { text-align: right; } +.budget-values strong, .budget-values span { display: block; } +.budget-values strong { font-size: 13px; } +.budget-values span { margin-top: 4px; color: var(--muted); font-size: 10px; } +.budget-item progress { margin-top: 13px; } +.budget-item.is-over { border-color: color-mix(in srgb, var(--red) 35%, var(--border)); } +.budget-item.is-over progress { color: var(--red); } + +.category-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.category-card { display: flex; align-items: center; gap: 12px; padding: 15px; } +.category-card .row-main span { text-transform: capitalize; } +.category-card .row-actions { margin-left: auto; } + +.settings-layout { grid-template-columns: minmax(0, 1.1fr) minmax(300px, .7fr); align-items: start; } +.settings-section { padding: 20px; } +.settings-section h2 { margin: 0 0 4px; font-size: 16px; } +.settings-section > p { margin: 0 0 18px; color: var(--muted); font-size: 12px; line-height: 1.45; } +.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 0; border-top: 1px solid var(--border); } +.settings-row:first-of-type { border-top: 0; } +.settings-row div:first-child { min-width: 0; } +.settings-row strong, .settings-row span { display: block; } +.settings-row strong { font-size: 13px; } +.settings-row span { margin-top: 4px; color: var(--muted); font-size: 11px; } +.settings-row .select-control { width: 150px; min-height: 40px; } +.account-card { overflow: hidden; } +.account-cover { height: 88px; background: linear-gradient(135deg, #7171eb, #4444b4); } +.account-content { position: relative; padding: 0 20px 20px; } +.account-content .avatar { width: 58px; height: 58px; margin-top: -29px; border: 5px solid var(--surface); border-radius: 19px; background: var(--primary-soft); font-size: 20px; } +.account-content h3 { margin: 13px 0 4px; font-size: 16px; overflow-wrap: anywhere; } +.account-content p { margin: 0 0 17px; color: var(--muted); font-size: 12px; } +.account-content .button { width: 100%; } +.status-line { display: flex; align-items: center; gap: 9px; margin: 14px 0; padding: 11px; border-radius: 12px; background: var(--surface-soft); color: var(--muted); font-size: 11px; } +.status-line span:first-child { width: 8px; height: 8px; border-radius: 50%; background: var(--green); } + +.modal { + width: min(620px, calc(100% - 28px)); + max-height: min(88vh, 820px); + padding: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--surface); + color: var(--text); + box-shadow: 0 26px 90px rgb(0 0 0 / 30%); +} +.modal::backdrop { background: rgb(10 10 18 / 48%); backdrop-filter: blur(5px); } +.modal-shell { display: flex; max-height: min(88vh, 820px); flex-direction: column; } +.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 20px 22px 15px; border-bottom: 1px solid var(--border); } +.modal-header h2 { margin: 0; font-size: 19px; letter-spacing: -.4px; } +.modal-body { display: grid; gap: 15px; padding: 20px 22px; overflow-y: auto; } +.modal-footer { display: flex; justify-content: flex-end; gap: 9px; padding: 14px 22px 19px; border-top: 1px solid var(--border); background: var(--surface); } +.kind-switch { width: 100%; } +.kind-switch button { min-height: 40px; } +.choice-grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); gap: 7px; } +.choice-grid label { position: relative; } +.choice-grid input { position: absolute; opacity: 0; pointer-events: none; } +.choice-option { display: grid; aspect-ratio: 1; place-items: center; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-soft); font-size: 18px; transition: border-color .14s, transform .14s; } +.choice-grid label:hover .choice-option { transform: translateY(-1px); border-color: var(--primary); } +.choice-grid input:checked + .choice-option { border: 2px solid var(--primary); background: var(--primary-soft); } +.color-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 8px; } +.color-grid label { position: relative; } +.color-grid input { position: absolute; opacity: 0; pointer-events: none; } +.color-swatch { display: block; aspect-ratio: 1; padding: 4px; border: 2px solid transparent; border-radius: 50%; background-clip: content-box; } +.color-grid input:checked + .color-swatch { border-color: var(--text); } +.swatch-red { background-color: #e05252; } .swatch-orange { background-color: #f28c38; } .swatch-yellow { background-color: #e0ac24; } +.swatch-green { background-color: #16a36a; } .swatch-mint { background-color: #31b99a; } .swatch-teal { background-color: #159eaf; } +.swatch-cyan { background-color: #209bcc; } .swatch-blue { background-color: #3979e9; } .swatch-indigo { background-color: #5b5bd6; } +.swatch-purple { background-color: #8b5cf6; } .swatch-pink { background-color: #e35c9a; } .swatch-brown { background-color: #9b6a4a; } +.swatch-gray { background-color: #7a7a85; } .swatch-coral { background-color: #fa6161; } .swatch-peach { background-color: #ff9461; } +.swatch-gold { background-color: #e6a314; } .swatch-lime { background-color: #85c729; } .swatch-forest { background-color: #1a7a4d; } +.swatch-turquoise { background-color: #0daba1; } .swatch-sky { background-color: #2e9ee8; } .swatch-navy { background-color: #1f4085; } +.swatch-lavender { background-color: #8c66d9; } .swatch-magenta { background-color: #d1298c; } .swatch-slate { background-color: #4d5c73; } +.helper-text { margin: -7px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; } +.budget-edit-list { display: grid; gap: 9px; } +.budget-edit-row { display: grid; grid-template-columns: auto minmax(0, 1fr) 135px; align-items: center; gap: 10px; } +.budget-edit-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.budget-edit-row input { min-width: 0; } + +.toast-region { position: fixed; top: 18px; right: 18px; z-index: 100; display: grid; width: min(380px, calc(100% - 36px)); gap: 8px; pointer-events: none; } +.toast { padding: 12px 14px; border: 1px solid var(--border); border-radius: 13px; background: var(--surface); color: var(--text); box-shadow: var(--shadow); font-size: 12px; animation: toast-in .2s ease-out; } +.toast.is-error { border-color: color-mix(in srgb, var(--red) 40%, var(--border)); color: var(--red); } +@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } } + +@media (max-width: 1040px) { + .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-grid, .analytics-layout, .settings-layout { grid-template-columns: 1fr; } + .category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + :root { --radius: 17px; } + .sidebar { display: none; } + .workspace { margin-left: 0; } + .mobile-header { + position: sticky; + top: 0; + z-index: 16; + display: flex; + height: 62px; + align-items: center; + justify-content: space-between; + padding: max(10px, env(safe-area-inset-top)) 17px 8px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 91%, transparent); + backdrop-filter: blur(18px); + } + .mobile-header .brand-name { font-size: 18px; } + .content { padding: 23px 15px calc(92px + env(safe-area-inset-bottom)); } + .bottom-nav { + position: fixed; + inset: auto 0 0; + z-index: 18; + display: grid; + grid-template-columns: repeat(5, 1fr); + padding: 7px 5px calc(7px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 92%, transparent); + backdrop-filter: blur(18px); + } + .bottom-nav button { display: grid; place-items: center; gap: 2px; min-height: 43px; border: 0; background: transparent; color: var(--muted); } + .bottom-nav button span { font-size: 20px; line-height: 1; } + .bottom-nav button small { font-size: 9px; font-weight: 700; } + .bottom-nav button.is-active { color: var(--primary); } + .page-header { align-items: flex-start; margin-bottom: 18px; } + .page-header h1 { font-size: 29px; } + .page-header p { font-size: 12px; } + .page-actions .button:not(.button-primary) { display: none; } + .grid-2, .grid-3, .grid-4, .category-grid { grid-template-columns: 1fr; } + .metric-grid-mobile { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .metric-card { min-height: 104px; } + .analytics-page { row-gap: 18px; padding-bottom: 4px; } + .analytics-page > .page-header { margin-bottom: 2px; } + .analytics-metrics { gap: 12px; } + .analytics-metrics .metric-card { min-height: 112px; padding: 17px; } + .analytics-layout { gap: 16px; } + .period-switch { width: 100%; } + .hero-balance { min-height: 220px; padding: 23px; } + .hero-value { font-size: 36px; } + .toolbar { gap: 8px; } + .toolbar .search-wrap { flex-basis: 100%; } + .toolbar .select-control { min-width: 0; flex: 1; } + .row-actions { opacity: 1; } + .breakdown-row { grid-template-columns: minmax(100px, 1fr) minmax(90px, 1.2fr); } + .breakdown-value { grid-column: 2; margin-top: -7px; text-align: left; color: var(--muted); } + .choice-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } + .color-grid { grid-template-columns: repeat(8, 1fr); } + .modal { width: 100%; max-width: none; max-height: calc(100vh - 32px); margin: auto 0 0; border-radius: 24px 24px 0 0; } + .modal-shell { max-height: calc(100vh - 32px); } + .modal-header, .modal-body, .modal-footer { padding-left: 17px; padding-right: 17px; } + .budget-edit-row { grid-template-columns: auto minmax(0, 1fr) 105px; } +} + +@media (max-width: 430px) { + .auth-card { padding: 23px 19px; border-radius: 24px; } + .auth-copy h1 { font-size: 32px; } + .page-actions .button-primary { min-width: 42px; padding-inline: 12px; font-size: 0; } + .page-actions .button-primary::before { content: "+"; font-size: 21px; } + .card-body { padding: 17px; } + .card-header { padding: 17px 17px 0; } + .metric-grid-mobile { gap: 10px; } + .analytics-page { row-gap: 16px; } + .analytics-metrics { gap: 10px; } + .metric-card { padding: 14px; } + .analytics-metrics .metric-card { min-height: 108px; padding: 15px; } + .metric-card strong { font-size: 19px; } + .hero-meta { gap: 10px 18px; } + .choice-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .color-grid { grid-template-columns: repeat(6, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..74684ab --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Balance — все платформы + +В архиве находятся нативные приложения личных финансов и общий self-hosted сервер синхронизации. + +| Папка | Назначение | +| --- | --- | +| `Balance` | iOS 17+, macOS 14+ и watchOS 10+, SwiftUI + SwiftData | +| `BalanceAndroid` | Android 8.0+, Kotlin + Jetpack Compose + Room | +| `BalanceServer` | Go-сервер авторизации и синхронизации со встроенным web-приложением | + +Все пять клиентов — iOS, macOS, watchOS, Android и web — используют одну модель данных и один протокол сервера: операции, пользовательские категории, бюджеты, удаления, регистрация, вход, refresh-токены и постраничная двусторонняя синхронизация. Web-интерфейс открывается по адресу запущенного сервера и не требует отдельной установки. + +Инструкции по запуску находятся в `Balance/README.md`, `BalanceAndroid/README.md` и `BalanceServer/README.md`.