This commit is contained in:
wt committed 2026-07-14 16:20:39 +07:00
commit bd90fe1d65
137 files changed
+12107

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.env
+12
View File
@@ -0,0 +1,12 @@
# Xcode
DerivedData/
build/
*.xcuserstate
xcuserdata/
# Swift Package Manager
.build/
.swiftpm/
# macOS
.DS_Store
+957
View File
@@ -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 = "<group>"; };
F00200000000000000000002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
F00300000000000000000003 /* FinanceCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceCategory.swift; sourceTree = "<group>"; };
F00400000000000000000004 /* FinanceTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceTransaction.swift; sourceTree = "<group>"; };
F00500000000000000000005 /* MonthlyBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonthlyBudget.swift; sourceTree = "<group>"; };
F00600000000000000000006 /* CurrencyFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyFormatting.swift; sourceTree = "<group>"; };
F00700000000000000000007 /* FinanceCalculations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceCalculations.swift; sourceTree = "<group>"; };
F00800000000000000000008 /* BudgetEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetEditorView.swift; sourceTree = "<group>"; };
F00900000000000000000009 /* BudgetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsView.swift; sourceTree = "<group>"; };
F00A0000000000000000000A /* EmptyStateCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateCard.swift; sourceTree = "<group>"; };
F00B0000000000000000000B /* SummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryCard.swift; sourceTree = "<group>"; };
F00C0000000000000000000C /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
F00D0000000000000000000D /* CSVDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVDocument.swift; sourceTree = "<group>"; };
F00E0000000000000000000E /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
F00F0000000000000000000F /* NewTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTransactionView.swift; sourceTree = "<group>"; };
F01000000000000000000010 /* TransactionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRow.swift; sourceTree = "<group>"; };
F01100000000000000000011 /* TransactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsView.swift; sourceTree = "<group>"; };
F01200000000000000000012 /* FinanceCalculationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FinanceCalculationsTests.swift; sourceTree = "<group>"; };
F01300000000000000000013 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
F01400000000000000000014 /* CustomCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCategory.swift; sourceTree = "<group>"; };
F01500000000000000000015 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = "<group>"; };
F01600000000000000000016 /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = "<group>"; };
F01700000000000000000017 /* BalanceAdjustmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceAdjustmentView.swift; sourceTree = "<group>"; };
F01800000000000000000018 /* CategoryIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryIconView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
F02100000000000000000021 /* BalanceMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceMacApp.swift; sourceTree = "<group>"; };
F02200000000000000000022 /* MacContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacContentView.swift; sourceTree = "<group>"; };
F02300000000000000000023 /* MacEditors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacEditors.swift; sourceTree = "<group>"; };
F02400000000000000000024 /* BalanceWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceWatchApp.swift; sourceTree = "<group>"; };
F02500000000000000000025 /* WatchRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = "<group>"; };
F02600000000000000000026 /* WatchAddTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchAddTransactionView.swift; sourceTree = "<group>"; };
F02700000000000000000027 /* Balance.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Balance.entitlements; sourceTree = "<group>"; };
F02800000000000000000028 /* BalanceMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BalanceMac.entitlements; sourceTree = "<group>"; };
F02900000000000000000029 /* BalanceWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BalanceWatch.entitlements; sourceTree = "<group>"; };
F02A0000000000000000002A /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
F02B0000000000000000002B /* CategoryDesignOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDesignOptions.swift; sourceTree = "<group>"; };
F02C0000000000000000002C /* MacFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacFeatures.swift; sourceTree = "<group>"; };
F02D0000000000000000002D /* WatchFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFeatures.swift; sourceTree = "<group>"; };
F02E0000000000000000002E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F02F0000000000000000002F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F03000000000000000000030 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F03100000000000000000031 /* SyncTombstone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncTombstone.swift; sourceTree = "<group>"; };
F03200000000000000000032 /* ServerSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSync.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
G00200000000000000000002 /* Balance */ = {
isa = PBXGroup;
children = (
G00300000000000000000003 /* App */,
G00400000000000000000004 /* Models */,
G00500000000000000000005 /* Utilities */,
G00F0000000000000000000F /* Shared */,
G01300000000000000000013 /* Resources */,
G00600000000000000000006 /* Views */,
);
path = Balance;
sourceTree = "<group>";
};
G00300000000000000000003 /* App */ = {
isa = PBXGroup;
children = (
F00100000000000000000001 /* BalanceApp.swift */,
F00200000000000000000002 /* ContentView.swift */,
);
path = App;
sourceTree = "<group>";
};
G00400000000000000000004 /* Models */ = {
isa = PBXGroup;
children = (
F00300000000000000000003 /* FinanceCategory.swift */,
F00400000000000000000004 /* FinanceTransaction.swift */,
F00500000000000000000005 /* MonthlyBudget.swift */,
F01400000000000000000014 /* CustomCategory.swift */,
F03100000000000000000031 /* SyncTombstone.swift */,
);
path = Models;
sourceTree = "<group>";
};
G00500000000000000000005 /* Utilities */ = {
isa = PBXGroup;
children = (
F00600000000000000000006 /* CurrencyFormatting.swift */,
F00700000000000000000007 /* FinanceCalculations.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
G00600000000000000000006 /* Views */ = {
isa = PBXGroup;
children = (
G00700000000000000000007 /* Components */,
G00800000000000000000008 /* Dashboard */,
G00E0000000000000000000E /* Analytics */,
G00900000000000000000009 /* Transactions */,
G00C0000000000000000000C /* Budgets */,
G00D0000000000000000000D /* Settings */,
);
path = Views;
sourceTree = "<group>";
};
G00700000000000000000007 /* Components */ = {
isa = PBXGroup;
children = (
F00A0000000000000000000A /* EmptyStateCard.swift */,
F00B0000000000000000000B /* SummaryCard.swift */,
F01800000000000000000018 /* CategoryIconView.swift */,
);
path = Components;
sourceTree = "<group>";
};
G00800000000000000000008 /* Dashboard */ = {
isa = PBXGroup;
children = (
F00C0000000000000000000C /* DashboardView.swift */,
F01700000000000000000017 /* BalanceAdjustmentView.swift */,
);
path = Dashboard;
sourceTree = "<group>";
};
G00900000000000000000009 /* Transactions */ = {
isa = PBXGroup;
children = (
F00F0000000000000000000F /* NewTransactionView.swift */,
F01000000000000000000010 /* TransactionRow.swift */,
F01100000000000000000011 /* TransactionsView.swift */,
);
path = Transactions;
sourceTree = "<group>";
};
G00A0000000000000000000A /* BalanceTests */ = {
isa = PBXGroup;
children = (
F01200000000000000000012 /* FinanceCalculationsTests.swift */,
);
path = BalanceTests;
sourceTree = "<group>";
};
G00B0000000000000000000B /* Products */ = {
isa = PBXGroup;
children = (
F01900000000000000000019 /* Balance.app */,
F01A0000000000000000001A /* BalanceTests.xctest */,
F01B0000000000000000001B /* BalanceMac.app */,
F01C0000000000000000001C /* BalanceWatch.app */,
);
name = Products;
sourceTree = "<group>";
};
G00C0000000000000000000C /* Budgets */ = {
isa = PBXGroup;
children = (
F00800000000000000000008 /* BudgetEditorView.swift */,
F00900000000000000000009 /* BudgetsView.swift */,
);
path = Budgets;
sourceTree = "<group>";
};
G00D0000000000000000000D /* Settings */ = {
isa = PBXGroup;
children = (
F00D0000000000000000000D /* CSVDocument.swift */,
F00E0000000000000000000E /* SettingsView.swift */,
F01600000000000000000016 /* CategoriesView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
G00E0000000000000000000E /* Analytics */ = {
isa = PBXGroup;
children = (
F01500000000000000000015 /* AnalyticsView.swift */,
);
path = Analytics;
sourceTree = "<group>";
};
G00F0000000000000000000F /* Shared */ = {
isa = PBXGroup;
children = (
F02000000000000000000020 /* BalanceModelContainer.swift */,
F02A0000000000000000002A /* AppTheme.swift */,
F02B0000000000000000002B /* CategoryDesignOptions.swift */,
F03200000000000000000032 /* ServerSync.swift */,
);
path = Shared;
sourceTree = "<group>";
};
G01000000000000000000010 /* BalanceMac */ = {
isa = PBXGroup;
children = (
F02100000000000000000021 /* BalanceMacApp.swift */,
F02200000000000000000022 /* MacContentView.swift */,
F02300000000000000000023 /* MacEditors.swift */,
F02C0000000000000000002C /* MacFeatures.swift */,
F02F0000000000000000002F /* Assets.xcassets */,
);
path = BalanceMac;
sourceTree = "<group>";
};
G01100000000000000000011 /* BalanceWatch */ = {
isa = PBXGroup;
children = (
F02400000000000000000024 /* BalanceWatchApp.swift */,
F02500000000000000000025 /* WatchRootView.swift */,
F02600000000000000000026 /* WatchAddTransactionView.swift */,
F02D0000000000000000002D /* WatchFeatures.swift */,
F03000000000000000000030 /* Assets.xcassets */,
);
path = BalanceWatch;
sourceTree = "<group>";
};
G01200000000000000000012 /* Config */ = {
isa = PBXGroup;
children = (
F02700000000000000000027 /* Balance.entitlements */,
F02800000000000000000028 /* BalanceMac.entitlements */,
F02900000000000000000029 /* BalanceWatch.entitlements */,
);
path = Config;
sourceTree = "<group>";
};
G01300000000000000000013 /* Resources */ = {
isa = PBXGroup;
children = (
F02E0000000000000000002E /* Assets.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
/* 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 */;
}
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "T00100000000000000000001"
BuildableName = "Balance.app"
BlueprintName = "Balance"
ReferencedContainer = "container:Balance.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "T00200000000000000000002"
BuildableName = "BalanceTests.xctest"
BlueprintName = "BalanceTests"
ReferencedContainer = "container:Balance.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "T00100000000000000000001"
BuildableName = "Balance.app"
BlueprintName = "Balance"
ReferencedContainer = "container:Balance.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "T00100000000000000000001"
BuildableName = "Balance.app"
BlueprintName = "Balance"
ReferencedContainer = "container:Balance.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00300000000000000000003" BuildableName="BalanceMac.app" BlueprintName="BalanceMac" ReferencedContainer="container:Balance.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"/>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00300000000000000000003" BuildableName="BalanceMac.app" BlueprintName="BalanceMac" ReferencedContainer="container:Balance.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00300000000000000000003" BuildableName="BalanceMac.app" BlueprintName="BalanceMac" ReferencedContainer="container:Balance.xcodeproj"/>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
</Scheme>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00400000000000000000004" BuildableName="BalanceWatch.app" BlueprintName="BalanceWatch" ReferencedContainer="container:Balance.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"/>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00400000000000000000004" BuildableName="BalanceWatch.app" BlueprintName="BalanceWatch" ReferencedContainer="container:Balance.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00400000000000000000004" BuildableName="BalanceWatch.app" BlueprintName="BalanceWatch" ReferencedContainer="container:Balance.xcodeproj"/>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
</Scheme>
+19
View File
@@ -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)
}
}
+40
View File
@@ -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)
}
}
}
}
@@ -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
)
}
}
@@ -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
}
}
}
@@ -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 }
}
}
@@ -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
}
}
+109
View File
@@ -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..<min(offset + limit, records.count)])
}
static func remove(entity: String, recordID: String, through updatedAt: Date) {
lock.lock()
defer { lock.unlock() }
var records = loadUnlocked()
records.removeAll {
$0.entity == entity && $0.recordID == recordID && $0.updatedAt <= updatedAt
}
saveUnlocked(records)
}
private static let supportedEntities = Set(["transaction", "category", "budget"])
private static func loadUnlocked() -> [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)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "BalanceIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+25
View File
@@ -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
}
}
}
@@ -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)")
}
}
}
}
@@ -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"
]
}
+535
View File
@@ -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<Response: Decodable, Body: Encodable>(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<FinanceTransaction>(
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<CustomCategory>(
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<MonthlyBudget>(
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[..<middle]), cursor:cursor)
try await sendPush(Array(changes[middle...]), cursor:cursor)
return
}
let _: SyncResponse = try await request(path:"v1/sync", body:body, authorized:true)
}
private func pullChanges(context: ModelContext, cursor initialCursor: Int64) async throws {
var cursor = initialCursor
while true {
let body = SyncRequest(cursor:cursor, deviceId:deviceID(), changes:[], pull:true)
let response: SyncResponse = try await request(path:"v1/sync", body:body, authorized:true)
guard response.cursor >= 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<FinanceTransaction>(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<CustomCategory>(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<MonthlyBudget>(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
}
}
@@ -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())
}
}
@@ -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 }
}
}
@@ -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))
}
}
@@ -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<String> {
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()
}
}
@@ -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
}
}
@@ -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))
}
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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)\""
}
}
@@ -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()
}
}
@@ -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."
}
}
@@ -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 ? "Новый расход" : "Новый доход"
}
}
@@ -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)
}
}
@@ -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])
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+19
View File
@@ -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)
}
}
+436
View File
@@ -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)
}
}
}
+233
View File
@@ -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()
}
}
+354
View File
@@ -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<Double> {
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)
}
}
@@ -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
})
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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)
}
}
@@ -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()
}
}
+493
View File
@@ -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<Double> {
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("Настройки")
}
}
+158
View File
@@ -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)
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.example.Balance</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.Balance</string>
</dict>
</plist>
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.example.Balance</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.Balance</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.example.Balance</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.example.Balance</string>
</dict>
</plist>
+96
View File
@@ -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`, затем снова откройте проект.
+7
View File
@@ -0,0 +1,7 @@
.gradle/
.kotlin/
.idea/
local.properties
**/build/
*.iml
captures/
+96
View File
@@ -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/`.
+91
View File
@@ -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")
}
+3
View File
@@ -0,0 +1,3 @@
-keepattributes *Annotation*
-keep class * extends androidx.room.RoomDatabase { *; }
-dontwarn org.conscrypt.**
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".BalanceApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/balance_icon"
android:label="Баланс"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@drawable/balance_icon"
android:supportsRtl="true"
android:theme="@style/Theme.Balance"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"balance-periodic-sync",
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
}
@@ -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)
}
}
}
}
@@ -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<TransactionEntity> = emptyList(),
val categories: List<CategoryEntity> = emptyList(),
val budgets: List<BudgetEntity> = 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<FinanceUiState> = 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<CategoryDesign> {
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 <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(
application.database.financeDao(), application.preferences, application.syncRepository,
) as T
}
}
@@ -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 }
}
}
}
@@ -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"),
)
}
@@ -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<List<TransactionEntity>>
@Query("SELECT * FROM categories ORDER BY createdAt")
fun observeCategories(): Flow<List<CategoryEntity>>
@Query("SELECT * FROM budgets ORDER BY monthStart DESC, categoryName")
fun observeBudgets(): Flow<List<BudgetEntity>>
@Upsert suspend fun upsertTransaction(value: TransactionEntity)
@Upsert suspend fun upsertTransactions(values: List<TransactionEntity>)
@Upsert suspend fun upsertCategory(value: CategoryEntity)
@Upsert suspend fun upsertCategories(values: List<CategoryEntity>)
@Upsert suspend fun upsertBudget(value: BudgetEntity)
@Upsert suspend fun upsertBudgets(values: List<BudgetEntity>)
@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<TransactionEntity>
@Query("SELECT * FROM budgets WHERE categoryName = :name")
suspend fun budgetsForCategory(name: String): List<BudgetEntity>
@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<TransactionEntity>
@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<CategoryEntity>
@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<BudgetEntity>
@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<DeletionEntity>
@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)
}
}
@@ -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<TransactionEntity>): Double = items.sumOf {
if (it.kindRawValue == "income") it.amount else -it.amount
}
fun summary(items: List<TransactionEntity>, 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<TransactionEntity>, start: Long, endExclusive: Long): List<CategoryTotal> =
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 }
}
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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) }
}
@@ -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<SyncStatus> = mutableStatus
val session: StateFlow<ServerSession?> = 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<JSONObject>) {
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 }
}
@@ -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()
}
}
@@ -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<TransactionEntity?>(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<com.example.balanceandroid.data.CategoryEntity?>(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<String>, 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 }) }
}
}
}
@@ -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<CategoryTotal>, 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<Long, Long> {
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()
}
@@ -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<String>, 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<String>, 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<String>, 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<String, String>().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<String>, 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"))}")
}
}
@@ -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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Баланс</string>
</resources>
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more