4.1.1
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# Xcode
|
||||
DerivedData/
|
||||
build/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
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()
|
||||
}
|
||||
}
|
||||
@@ -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("Настройки")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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`, затем снова откройте проект.
|
||||