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

+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`, затем снова откройте проект.