4.1.1
No files matched your search
@@ -0,0 +1 @@
|
||||
*.env
|
||||
@@ -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`, затем снова откройте проект.
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
.kotlin/
|
||||
.idea/
|
||||
local.properties
|
||||
**/build/
|
||||
*.iml
|
||||
captures/
|
||||
@@ -0,0 +1,96 @@
|
||||
# Баланс для Android
|
||||
|
||||
Нативное Android-приложение личных финансов в дизайне основного проекта Balance. Написано на Kotlin и Jetpack Compose, использует Room для локального хранения и тот же self-hosted Go-сервер для синхронизации с iOS, macOS и watchOS.
|
||||
|
||||
## Возможности
|
||||
|
||||
- доходы, расходы, поиск, фильтрация, добавление, редактирование и удаление операций;
|
||||
- общий баланс и ручная корректировка, не влияющая на аналитику;
|
||||
- аналитика за месяц, 3 и 12 месяцев, структура расходов и норма сбережений;
|
||||
- месячные бюджеты;
|
||||
- собственные категории, 50 вариантов иконок, ввод эмодзи, 30 готовых эмодзи и палитра из 24 цветов;
|
||||
- светлая, тёмная и системная тема;
|
||||
- RUB, EUR, USD и SEK;
|
||||
- локальная работа без сервера;
|
||||
- указание и смена сервера, регистрация, вход, выход и полная пересинхронизация;
|
||||
- пакетная двусторонняя синхронизация операций, категорий, бюджетов и удалений;
|
||||
- access/refresh-токены шифруются ключом Android Keystore;
|
||||
- фоновая синхронизация через WorkManager раз в 15 минут при наличии сети.
|
||||
|
||||
## Запуск
|
||||
|
||||
1. Откройте папку `BalanceAndroid` в Android Studio Quail 1 (2026.1.1) или новее.
|
||||
2. Дождитесь Gradle Sync и установки Android SDK 36.
|
||||
3. Запустите конфигурацию `app` на устройстве с Android 8.0 (API 26) или новее.
|
||||
|
||||
Для запуска Gradle проекту нужен JDK 17–23; байткод собирается с целевым уровнем Java 17. Используются Gradle 8.13, AGP 8.13.0, Kotlin 2.3.21 и Compose BOM 2026.06.00.
|
||||
|
||||
Kotlin и Compose compiler plugin закреплены на 2.3.21, потому что стабильный Room 2.8.4 поддерживает Kotlin metadata до версии 2.3. Обновлять Kotlin до 2.4 можно после выхода совместимой стабильной версии Room.
|
||||
|
||||
Lifecycle закреплён на ветке 2.10.0, совместимой с compileSdk 36 и AGP 8.13. Lifecycle 2.11 требует API 37 и AGP 9.1, поэтому не обновляйте эти две зависимости отдельно без одновременного перехода всего проекта на новый Android toolchain.
|
||||
|
||||
Перед публикацией в Google Play замените примерный `applicationId` `com.example.balanceandroid` в `app/build.gradle.kts` на собственный уникальный идентификатор.
|
||||
|
||||
## Подключение сервера
|
||||
|
||||
Откройте **Настройки → Сервер и синхронизация**, введите адрес и сохраните его, затем зарегистрируйтесь или войдите. На эмуляторе сервер, запущенный на компьютере, доступен по адресу:
|
||||
|
||||
```text
|
||||
http://10.0.2.2:8080
|
||||
```
|
||||
|
||||
Для физического устройства используйте HTTPS. Незащищённый HTTP разрешён только для `localhost`, `127.0.0.1` и специального адреса Android Emulator `10.0.2.2`.
|
||||
|
||||
Android совместим с протоколом `BalanceServer` 3.1. Сервер из общего архива менять не нужно.
|
||||
|
||||
## Сборка из командной строки
|
||||
|
||||
Скрипт автоматически ищет SDK в переменных `ANDROID_HOME`/`ANDROID_SDK_ROOT` и стандартных каталогах:
|
||||
|
||||
- Linux: `$HOME/Android/Sdk`;
|
||||
- macOS: `$HOME/Library/Android/sdk`;
|
||||
- Windows: `%LOCALAPPDATA%\Android\Sdk`.
|
||||
|
||||
После установки Android SDK 36 выполните:
|
||||
|
||||
```sh
|
||||
./gradlew test assembleDebug
|
||||
```
|
||||
|
||||
Если SDK ещё не установлен, откройте **Tools → SDK Manager** и установите **Android SDK Platform 36**, **Android SDK Build-Tools** и **Android SDK Platform-Tools**. Путь указан в верхней строке `Android SDK Location`.
|
||||
|
||||
Для нестандартного пути на Linux или macOS:
|
||||
|
||||
```sh
|
||||
export ANDROID_HOME="/путь/к/Android/Sdk"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
./gradlew test assembleDebug
|
||||
```
|
||||
|
||||
Вместо переменных можно создать файл `BalanceAndroid/local.properties`:
|
||||
|
||||
```properties
|
||||
sdk.dir=/home/USER/Android/Sdk
|
||||
```
|
||||
|
||||
На macOS скрипт автоматически использует JBR из стандартной установки Android Studio, если `JAVA_HOME` указывает на устаревшую Java 8. Проверить активную версию можно командой `./gradlew --version` — в строке `Launcher JVM` должна быть Java 17–23.
|
||||
|
||||
Если Android Studio установлена в нестандартную папку, задайте её JBR вручную:
|
||||
|
||||
```sh
|
||||
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
||||
./gradlew test assembleDebug
|
||||
```
|
||||
|
||||
В Android Studio выберите **Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK → Embedded JDK**.
|
||||
|
||||
Если ранее Gradle запускался с недостаточной памятью, остановите старый daemon перед повторной сборкой:
|
||||
|
||||
```sh
|
||||
./gradlew --stop
|
||||
./gradlew clean test assembleDebug
|
||||
```
|
||||
|
||||
Проект задаёт для Gradle 2 ГБ heap, 1 ГБ metaspace и не более двух параллельных workers. Эти параметры находятся в `gradle.properties` и применяются также при сборке из Android Studio.
|
||||
|
||||
Debug APK появится в `app/build/outputs/apk/debug/`.
|
||||
@@ -0,0 +1,91 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.kapt")
|
||||
}
|
||||
|
||||
val lifecycleVersion = "2.10.0"
|
||||
|
||||
configurations.configureEach {
|
||||
resolutionStrategy.eachDependency {
|
||||
if (requested.group == "androidx.lifecycle") {
|
||||
useVersion(lifecycleVersion)
|
||||
because("Lifecycle 2.11 requires compileSdk 37 and AGP 9.1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.balanceandroid"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.balanceandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
arguments { arg("room.schemaLocation", "$projectDir/schemas") }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.06.00")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.8")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
implementation("androidx.room:room-runtime:2.8.4")
|
||||
implementation("androidx.room:room-ktx:2.8.4")
|
||||
kapt("androidx.room:room-compiler:2.8.4")
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
-keepattributes *Annotation*
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn org.conscrypt.**
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".BalanceApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/balance_icon"
|
||||
android:label="Баланс"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@drawable/balance_icon"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Balance"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.example.balanceandroid
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import com.example.balanceandroid.data.AppDatabase
|
||||
import com.example.balanceandroid.sync.SecureSessionStore
|
||||
import com.example.balanceandroid.sync.ServerPreferences
|
||||
import com.example.balanceandroid.sync.SyncRepository
|
||||
import com.example.balanceandroid.sync.SyncWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BalanceApplication : Application() {
|
||||
val database by lazy { AppDatabase.get(this) }
|
||||
val preferences by lazy { ServerPreferences(this) }
|
||||
val sessions by lazy { SecureSessionStore(this) }
|
||||
val syncRepository by lazy { SyncRepository(database, preferences, sessions) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
"balance-periodic-sync",
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.balanceandroid
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.balanceandroid.ui.BalanceRoot
|
||||
import com.example.balanceandroid.ui.theme.BalanceTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val model: MainViewModel = viewModel(factory = MainViewModel.Factory(application as BalanceApplication))
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
BalanceTheme(theme = state.theme) {
|
||||
BalanceRoot(model, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.example.balanceandroid
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.balanceandroid.data.BudgetEntity
|
||||
import com.example.balanceandroid.data.BuiltInCategories
|
||||
import com.example.balanceandroid.data.CategoryDesign
|
||||
import com.example.balanceandroid.data.CategoryEntity
|
||||
import com.example.balanceandroid.data.FinanceDao
|
||||
import com.example.balanceandroid.data.TransactionEntity
|
||||
import com.example.balanceandroid.sync.ServerPreferences
|
||||
import com.example.balanceandroid.sync.ServerSession
|
||||
import com.example.balanceandroid.sync.SyncRepository
|
||||
import com.example.balanceandroid.sync.SyncStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
data class FinanceData(
|
||||
val transactions: List<TransactionEntity> = emptyList(),
|
||||
val categories: List<CategoryEntity> = emptyList(),
|
||||
val budgets: List<BudgetEntity> = emptyList(),
|
||||
)
|
||||
|
||||
data class FinanceUiState(
|
||||
val data: FinanceData = FinanceData(),
|
||||
val sync: SyncStatus = SyncStatus(),
|
||||
val session: ServerSession? = null,
|
||||
val currency: String = "RUB",
|
||||
val theme: String = "system",
|
||||
val serverUrl: String = "",
|
||||
val serverEmail: String = "",
|
||||
)
|
||||
|
||||
class MainViewModel(
|
||||
private val dao: FinanceDao,
|
||||
private val preferences: ServerPreferences,
|
||||
private val syncRepository: SyncRepository,
|
||||
) : ViewModel() {
|
||||
private val settingsRevision = MutableStateFlow(0)
|
||||
private val financeData = combine(
|
||||
dao.observeTransactions(),
|
||||
dao.observeCategories(),
|
||||
dao.observeBudgets(),
|
||||
) { transactions, categories, budgets -> FinanceData(transactions, categories, budgets) }
|
||||
|
||||
val state: StateFlow<FinanceUiState> = combine(
|
||||
financeData,
|
||||
syncRepository.status,
|
||||
syncRepository.session,
|
||||
settingsRevision,
|
||||
) { data, sync, session, _ ->
|
||||
FinanceUiState(
|
||||
data = data,
|
||||
sync = sync,
|
||||
session = session,
|
||||
currency = preferences.currency,
|
||||
theme = preferences.theme,
|
||||
serverUrl = preferences.serverUrl,
|
||||
serverEmail = preferences.email,
|
||||
)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), FinanceUiState())
|
||||
|
||||
init { viewModelScope.launch { syncRepository.sync() } }
|
||||
|
||||
fun categories(kind: String, data: FinanceData = state.value.data): List<CategoryDesign> {
|
||||
val builtIn = if (kind == "income") BuiltInCategories.incomes else BuiltInCategories.expenses
|
||||
return builtIn + data.categories.filter { it.kindRawValue == kind }.map {
|
||||
CategoryDesign(it.name, it.icon, it.emoji, it.colorName, it.kindRawValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveTransaction(
|
||||
existing: TransactionEntity?, amount: Double, kind: String, category: CategoryDesign,
|
||||
note: String, date: Long,
|
||||
) = viewModelScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
dao.upsertTransaction((existing ?: TransactionEntity(amount = amount, categoryName = category.name)).copy(
|
||||
amount = amount,
|
||||
date = date,
|
||||
note = note.trim(),
|
||||
categoryName = category.name,
|
||||
categoryIcon = category.icon,
|
||||
categoryEmoji = category.emoji,
|
||||
categoryColorName = category.colorName,
|
||||
kindRawValue = kind,
|
||||
updatedAt = now,
|
||||
))
|
||||
}
|
||||
|
||||
fun deleteTransaction(value: TransactionEntity) = viewModelScope.launch { dao.deleteTransaction(value) }
|
||||
|
||||
fun adjustBalance(target: Double, note: String) = viewModelScope.launch {
|
||||
val current = com.example.balanceandroid.data.FinanceMath.totalBalance(state.value.data.transactions)
|
||||
val difference = target - current
|
||||
if (kotlin.math.abs(difference) < 0.005) return@launch
|
||||
dao.upsertTransaction(TransactionEntity(
|
||||
amount = kotlin.math.abs(difference),
|
||||
note = note.ifBlank { "Ручная корректировка баланса" },
|
||||
categoryName = "Корректировка",
|
||||
categoryIcon = "tune",
|
||||
categoryColorName = "indigo",
|
||||
isBalanceAdjustment = true,
|
||||
kindRawValue = if (difference > 0) "income" else "expense",
|
||||
))
|
||||
}
|
||||
|
||||
fun saveCategory(existing: CategoryEntity?, name: String, kind: String, icon: String, emoji: String, color: String) = viewModelScope.launch {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return@launch
|
||||
val now = System.currentTimeMillis()
|
||||
if (existing != null) {
|
||||
val oldName = existing.name
|
||||
val updated = existing.copy(name = trimmed, icon = icon, emoji = emoji, colorName = color, updatedAt = now)
|
||||
dao.upsertCategory(updated)
|
||||
dao.upsertTransactions(dao.transactionsForCategory(oldName).map {
|
||||
it.copy(categoryName = trimmed, categoryIcon = icon, categoryEmoji = emoji, categoryColorName = color, updatedAt = now)
|
||||
})
|
||||
dao.upsertBudgets(dao.budgetsForCategory(oldName).map {
|
||||
it.copy(categoryName = trimmed, categoryIcon = icon, categoryEmoji = emoji, updatedAt = now)
|
||||
})
|
||||
} else {
|
||||
dao.upsertCategory(CategoryEntity(name = trimmed, icon = icon, emoji = emoji, colorName = color, kindRawValue = kind))
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCategory(value: CategoryEntity) = viewModelScope.launch { dao.deleteCategory(value) }
|
||||
|
||||
fun saveBudget(category: CategoryDesign, amount: Double, monthStart: Long = currentMonthStart()) = viewModelScope.launch {
|
||||
val existing = state.value.data.budgets.firstOrNull { it.categoryName == category.name && it.monthStart == monthStart }
|
||||
if (amount <= 0 && existing != null) dao.deleteBudget(existing)
|
||||
else if (amount > 0) dao.upsertBudget((existing ?: BudgetEntity(
|
||||
categoryName = category.name,
|
||||
categoryIcon = category.icon,
|
||||
categoryEmoji = category.emoji,
|
||||
limitAmount = amount,
|
||||
monthStart = monthStart,
|
||||
)).copy(limitAmount = amount, categoryIcon = category.icon, categoryEmoji = category.emoji, updatedAt = System.currentTimeMillis()))
|
||||
}
|
||||
|
||||
fun setCurrency(value: String) { preferences.currency = value; settingsRevision.value++ }
|
||||
fun setTheme(value: String) { preferences.theme = value; settingsRevision.value++ }
|
||||
|
||||
fun changeServer(value: String) = viewModelScope.launch {
|
||||
runCatching { syncRepository.changeServer(value) }
|
||||
.onSuccess { settingsRevision.value++ }
|
||||
}
|
||||
fun login(email: String, password: String) = viewModelScope.launch { syncRepository.login(email, password) }
|
||||
fun register(email: String, password: String) = viewModelScope.launch { syncRepository.register(email, password) }
|
||||
fun logout() = viewModelScope.launch { syncRepository.logout() }
|
||||
fun sync() = viewModelScope.launch { syncRepository.sync() }
|
||||
fun resetSync() = viewModelScope.launch { syncRepository.resetAndSync() }
|
||||
|
||||
companion object {
|
||||
fun currentMonthStart(now: Long = System.currentTimeMillis()): Long = Instant.ofEpochMilli(now)
|
||||
.atZone(ZoneId.systemDefault()).withDayOfMonth(1).toLocalDate().atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant().toEpochMilli()
|
||||
}
|
||||
|
||||
class Factory(private val application: BalanceApplication) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = MainViewModel(
|
||||
application.database.financeDao(), application.preferences, application.syncRepository,
|
||||
) as T
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.balanceandroid.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [TransactionEntity::class, CategoryEntity::class, BudgetEntity::class, DeletionEntity::class],
|
||||
version = 1,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun financeDao(): FinanceDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var instance: AppDatabase? = null
|
||||
|
||||
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"balance-android.db",
|
||||
).build().also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.example.balanceandroid.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.util.UUID
|
||||
|
||||
@Entity(tableName = "transactions", indices = [Index(value = ["updatedAt"]), Index(value = ["date"])])
|
||||
data class TransactionEntity(
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val amount: Double,
|
||||
val date: Long = System.currentTimeMillis(),
|
||||
val note: String = "",
|
||||
val categoryName: String,
|
||||
val categoryIcon: String = "circle",
|
||||
val categoryEmoji: String = "",
|
||||
val categoryColorName: String = "indigo",
|
||||
val isBalanceAdjustment: Boolean = false,
|
||||
val kindRawValue: String = "expense",
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Entity(tableName = "categories", indices = [Index(value = ["updatedAt"]), Index(value = ["name", "kindRawValue"])])
|
||||
data class CategoryEntity(
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val icon: String = "star",
|
||||
val emoji: String = "",
|
||||
val colorName: String = "indigo",
|
||||
val kindRawValue: String = "expense",
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Entity(tableName = "budgets", indices = [Index(value = ["updatedAt"]), Index(value = ["monthStart"])])
|
||||
data class BudgetEntity(
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val categoryName: String,
|
||||
val categoryIcon: String = "circle",
|
||||
val categoryEmoji: String = "",
|
||||
val limitAmount: Double,
|
||||
val monthStart: Long,
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Entity(tableName = "deletion_journal", indices = [Index(value = ["updatedAt"])])
|
||||
data class DeletionEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val entity: String,
|
||||
val recordId: String,
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
companion object {
|
||||
fun create(entity: String, recordId: String, updatedAt: Long = System.currentTimeMillis()) =
|
||||
DeletionEntity("$entity:$recordId", entity, recordId, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
data class CategoryDesign(
|
||||
val name: String,
|
||||
val icon: String,
|
||||
val emoji: String = "",
|
||||
val colorName: String,
|
||||
val kind: String,
|
||||
)
|
||||
|
||||
object BuiltInCategories {
|
||||
val expenses = listOf(
|
||||
CategoryDesign("Продукты", "cart.fill", "", "orange", "expense"),
|
||||
CategoryDesign("Транспорт", "car.fill", "", "blue", "expense"),
|
||||
CategoryDesign("Дом", "house.fill", "", "indigo", "expense"),
|
||||
CategoryDesign("Развлечения", "gamecontroller.fill", "", "pink", "expense"),
|
||||
CategoryDesign("Здоровье", "cross.case.fill", "", "red", "expense"),
|
||||
CategoryDesign("Покупки", "bag.fill", "", "purple", "expense"),
|
||||
CategoryDesign("Подписки", "repeat", "", "cyan", "expense"),
|
||||
CategoryDesign("Образование", "book.fill", "", "mint", "expense"),
|
||||
CategoryDesign("Другое", "ellipsis.circle.fill", "", "gray", "expense"),
|
||||
)
|
||||
val incomes = listOf(
|
||||
CategoryDesign("Зарплата", "banknote.fill", "", "green", "income"),
|
||||
CategoryDesign("Подработка", "laptopcomputer", "", "teal", "income"),
|
||||
CategoryDesign("Инвестиции", "chart.line.uptrend.xyaxis", "", "blue", "income"),
|
||||
CategoryDesign("Подарок", "gift.fill", "", "pink", "income"),
|
||||
CategoryDesign("Другое", "plus.circle.fill", "", "gray", "income"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.balanceandroid.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface FinanceDao {
|
||||
@Query("SELECT * FROM transactions ORDER BY date DESC")
|
||||
fun observeTransactions(): Flow<List<TransactionEntity>>
|
||||
|
||||
@Query("SELECT * FROM categories ORDER BY createdAt")
|
||||
fun observeCategories(): Flow<List<CategoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM budgets ORDER BY monthStart DESC, categoryName")
|
||||
fun observeBudgets(): Flow<List<BudgetEntity>>
|
||||
|
||||
@Upsert suspend fun upsertTransaction(value: TransactionEntity)
|
||||
@Upsert suspend fun upsertTransactions(values: List<TransactionEntity>)
|
||||
@Upsert suspend fun upsertCategory(value: CategoryEntity)
|
||||
@Upsert suspend fun upsertCategories(values: List<CategoryEntity>)
|
||||
@Upsert suspend fun upsertBudget(value: BudgetEntity)
|
||||
@Upsert suspend fun upsertBudgets(values: List<BudgetEntity>)
|
||||
@Upsert suspend fun upsertDeletion(value: DeletionEntity)
|
||||
|
||||
@Query("SELECT * FROM transactions WHERE id = :id LIMIT 1")
|
||||
suspend fun transaction(id: String): TransactionEntity?
|
||||
@Query("SELECT * FROM categories WHERE id = :id LIMIT 1")
|
||||
suspend fun category(id: String): CategoryEntity?
|
||||
@Query("SELECT * FROM budgets WHERE id = :id LIMIT 1")
|
||||
suspend fun budget(id: String): BudgetEntity?
|
||||
|
||||
@Query("SELECT * FROM transactions WHERE categoryName = :name")
|
||||
suspend fun transactionsForCategory(name: String): List<TransactionEntity>
|
||||
@Query("SELECT * FROM budgets WHERE categoryName = :name")
|
||||
suspend fun budgetsForCategory(name: String): List<BudgetEntity>
|
||||
|
||||
@Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteTransactionDirect(id: String)
|
||||
@Query("DELETE FROM categories WHERE id = :id") suspend fun deleteCategoryDirect(id: String)
|
||||
@Query("DELETE FROM budgets WHERE id = :id") suspend fun deleteBudgetDirect(id: String)
|
||||
@Query("DELETE FROM deletion_journal WHERE entity = :entity AND recordId = :recordId AND updatedAt <= :through")
|
||||
suspend fun clearDeletion(entity: String, recordId: String, through: Long)
|
||||
|
||||
@Query("SELECT * FROM transactions WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
|
||||
suspend fun transactionChanges(after: Long, through: Long, limit: Int, offset: Int): List<TransactionEntity>
|
||||
@Query("SELECT * FROM categories WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
|
||||
suspend fun categoryChanges(after: Long, through: Long, limit: Int, offset: Int): List<CategoryEntity>
|
||||
@Query("SELECT * FROM budgets WHERE updatedAt >= :after AND updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
|
||||
suspend fun budgetChanges(after: Long, through: Long, limit: Int, offset: Int): List<BudgetEntity>
|
||||
@Query("SELECT * FROM deletion_journal WHERE updatedAt <= :through ORDER BY updatedAt LIMIT :limit OFFSET :offset")
|
||||
suspend fun deletionChanges(through: Long, limit: Int, offset: Int): List<DeletionEntity>
|
||||
|
||||
@Transaction
|
||||
suspend fun deleteTransaction(value: TransactionEntity) {
|
||||
upsertDeletion(DeletionEntity.create("transaction", value.id))
|
||||
deleteTransactionDirect(value.id)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
suspend fun deleteBudget(value: BudgetEntity) {
|
||||
upsertDeletion(DeletionEntity.create("budget", value.id))
|
||||
deleteBudgetDirect(value.id)
|
||||
}
|
||||
|
||||
@Transaction
|
||||
suspend fun deleteCategory(value: CategoryEntity) {
|
||||
budgetsForCategory(value.name).forEach { deleteBudget(it) }
|
||||
upsertDeletion(DeletionEntity.create("category", value.id))
|
||||
deleteCategoryDirect(value.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.example.balanceandroid.data
|
||||
|
||||
data class FinanceSummary(val income: Double, val expenses: Double) {
|
||||
val balance: Double get() = income - expenses
|
||||
}
|
||||
|
||||
data class CategoryTotal(
|
||||
val name: String,
|
||||
val icon: String,
|
||||
val emoji: String,
|
||||
val colorName: String,
|
||||
val amount: Double,
|
||||
)
|
||||
|
||||
object FinanceMath {
|
||||
fun totalBalance(items: List<TransactionEntity>): Double = items.sumOf {
|
||||
if (it.kindRawValue == "income") it.amount else -it.amount
|
||||
}
|
||||
|
||||
fun summary(items: List<TransactionEntity>, start: Long, endExclusive: Long): FinanceSummary {
|
||||
val filtered = items.filter { !it.isBalanceAdjustment && it.date >= start && it.date < endExclusive }
|
||||
return FinanceSummary(
|
||||
income = filtered.filter { it.kindRawValue == "income" }.sumOf { it.amount },
|
||||
expenses = filtered.filter { it.kindRawValue == "expense" }.sumOf { it.amount },
|
||||
)
|
||||
}
|
||||
|
||||
fun spending(items: List<TransactionEntity>, start: Long, endExclusive: Long): List<CategoryTotal> =
|
||||
items.asSequence()
|
||||
.filter { it.kindRawValue == "expense" && !it.isBalanceAdjustment && it.date >= start && it.date < endExclusive }
|
||||
.groupBy { it.categoryName }
|
||||
.map { (name, values) ->
|
||||
val first = values.first()
|
||||
CategoryTotal(name, first.categoryIcon, first.categoryEmoji, first.categoryColorName, values.sumOf { it.amount })
|
||||
}
|
||||
.sortedByDescending { it.amount }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.example.balanceandroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class ServerSession(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresAt: Long,
|
||||
val userId: String,
|
||||
val email: String,
|
||||
)
|
||||
|
||||
class SecureSessionStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences("balance_secure_session", Context.MODE_PRIVATE)
|
||||
private val alias = "balance.server.session.key"
|
||||
|
||||
fun save(session: ServerSession) {
|
||||
val source = JSONObject()
|
||||
.put("accessToken", session.accessToken)
|
||||
.put("refreshToken", session.refreshToken)
|
||||
.put("expiresAt", session.expiresAt)
|
||||
.put("userId", session.userId)
|
||||
.put("email", session.email)
|
||||
.toString().toByteArray()
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
|
||||
preferences.edit()
|
||||
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
||||
.putString("data", Base64.encodeToString(cipher.doFinal(source), Base64.NO_WRAP))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun load(): ServerSession? = runCatching {
|
||||
val iv = Base64.decode(preferences.getString("iv", null), Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(preferences.getString("data", null), Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv))
|
||||
val json = JSONObject(String(cipher.doFinal(encrypted)))
|
||||
ServerSession(
|
||||
accessToken = json.getString("accessToken"),
|
||||
refreshToken = json.getString("refreshToken"),
|
||||
expiresAt = json.getLong("expiresAt"),
|
||||
userId = json.getString("userId"),
|
||||
email = json.getString("email"),
|
||||
)
|
||||
}.getOrElse {
|
||||
clear()
|
||||
null
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit().clear().apply()
|
||||
}
|
||||
|
||||
private fun secretKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
)
|
||||
generateKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.example.balanceandroid.sync
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.time.Instant
|
||||
|
||||
class ServerException(message: String, val status: Int = 0) : Exception(message)
|
||||
|
||||
data class SyncPage(
|
||||
val cursor: Long,
|
||||
val changes: JSONArray,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
|
||||
class ServerApi(
|
||||
private val preferences: ServerPreferences,
|
||||
private val sessions: SecureSessionStore,
|
||||
) {
|
||||
suspend fun register(email: String, password: String): ServerSession = authenticate("v1/auth/register", email, password)
|
||||
suspend fun login(email: String, password: String): ServerSession = authenticate("v1/auth/login", email, password)
|
||||
|
||||
suspend fun logout() {
|
||||
sessions.load()?.let { session ->
|
||||
runCatching { post("v1/auth/logout", JSONObject().put("refreshToken", session.refreshToken), null) }
|
||||
}
|
||||
sessions.clear()
|
||||
}
|
||||
|
||||
suspend fun sync(cursor: Long, changes: JSONArray, pull: Boolean): SyncPage {
|
||||
val body = JSONObject()
|
||||
.put("cursor", cursor)
|
||||
.put("deviceId", preferences.deviceId)
|
||||
.put("changes", changes)
|
||||
.put("pull", pull)
|
||||
val response = authorizedPost("v1/sync", body)
|
||||
return SyncPage(
|
||||
cursor = response.optLong("cursor", cursor),
|
||||
changes = response.optJSONArray("changes") ?: JSONArray(),
|
||||
hasMore = response.optBoolean("hasMore", false),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun authenticate(path: String, email: String, password: String): ServerSession {
|
||||
val response = post(path, JSONObject().put("email", email.trim()).put("password", password), null)
|
||||
return parseSession(response).also { sessions.save(it) }
|
||||
}
|
||||
|
||||
private suspend fun authorizedPost(path: String, body: JSONObject, retry: Boolean = true): JSONObject {
|
||||
var session = sessions.load() ?: throw ServerException("Сначала войдите в аккаунт")
|
||||
if (session.expiresAt - System.currentTimeMillis() < 60_000) session = refresh(session)
|
||||
return try {
|
||||
post(path, body, session.accessToken)
|
||||
} catch (error: ServerException) {
|
||||
if (error.status == 401 && retry) {
|
||||
session = refresh(session)
|
||||
post(path, body, session.accessToken)
|
||||
} else throw error
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refresh(session: ServerSession): ServerSession {
|
||||
return try {
|
||||
parseSession(post("v1/auth/refresh", JSONObject().put("refreshToken", session.refreshToken), null))
|
||||
.also { sessions.save(it) }
|
||||
} catch (error: Throwable) {
|
||||
sessions.clear()
|
||||
throw ServerException("Сессия истекла. Войдите снова.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSession(json: JSONObject): ServerSession {
|
||||
val user = json.getJSONObject("user")
|
||||
return ServerSession(
|
||||
accessToken = json.getString("accessToken"),
|
||||
refreshToken = json.getString("refreshToken"),
|
||||
expiresAt = Instant.parse(json.getString("expiresAt")).toEpochMilli(),
|
||||
userId = user.getString("id"),
|
||||
email = user.getString("email"),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun post(path: String, body: JSONObject, token: String?): JSONObject = withContext(Dispatchers.IO) {
|
||||
val base = preferences.normalizeServer(preferences.serverUrl)
|
||||
val connection = (URL("$base/$path").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
if (token != null) setRequestProperty("Authorization", "Bearer $token")
|
||||
}
|
||||
try {
|
||||
connection.outputStream.use { it.write(body.toString().toByteArray()) }
|
||||
val status = connection.responseCode
|
||||
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader()?.use { it.readText() }.orEmpty()
|
||||
if (status !in 200..299) {
|
||||
val message = runCatching { JSONObject(text).getJSONObject("error").getString("message") }
|
||||
.getOrDefault("Ошибка сервера: HTTP $status")
|
||||
throw ServerException(message, status)
|
||||
}
|
||||
if (text.isBlank()) JSONObject() else JSONObject(text)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.example.balanceandroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
||||
class ServerPreferences(context: Context) {
|
||||
private val values = context.getSharedPreferences("balance_settings", Context.MODE_PRIVATE)
|
||||
|
||||
var serverUrl: String
|
||||
get() = values.getString("server_url", "") ?: ""
|
||||
set(value) { values.edit().putString("server_url", value).apply() }
|
||||
var email: String
|
||||
get() = values.getString("server_email", "") ?: ""
|
||||
set(value) { values.edit().putString("server_email", value).apply() }
|
||||
var currency: String
|
||||
get() = values.getString("currency", "RUB") ?: "RUB"
|
||||
set(value) { values.edit().putString("currency", value).apply() }
|
||||
var theme: String
|
||||
get() = values.getString("theme", "system") ?: "system"
|
||||
set(value) { values.edit().putString("theme", value).apply() }
|
||||
|
||||
val deviceId: String
|
||||
get() = values.getString("device_id", null) ?: UUID.randomUUID().toString().also {
|
||||
values.edit().putString("device_id", it).apply()
|
||||
}
|
||||
|
||||
fun normalizeServer(input: String): String {
|
||||
val trimmed = input.trim().trimEnd('/')
|
||||
val uri = runCatching { URI(trimmed) }.getOrNull()
|
||||
?: error("Укажите полный адрес сервера")
|
||||
val host = uri.host?.lowercase() ?: error("Укажите полный адрес сервера")
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
require(scheme == "https" || scheme == "http") { "Адрес должен начинаться с https://" }
|
||||
require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {
|
||||
"Адрес сервера не должен содержать логин, параметры или фрагмент"
|
||||
}
|
||||
if (scheme == "http") {
|
||||
require(host in setOf("localhost", "127.0.0.1", "10.0.2.2")) {
|
||||
"Для удалённого сервера требуется HTTPS"
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
fun cursor(scope: String): Long = values.getLong("cursor_${hash(scope)}", 0).coerceAtLeast(0)
|
||||
fun setCursor(scope: String, cursor: Long) = values.edit().putLong("cursor_${hash(scope)}", cursor).apply()
|
||||
fun lastPush(scope: String): Long = values.getLong("push_${hash(scope)}", 0)
|
||||
fun setLastPush(scope: String, value: Long) = values.edit().putLong("push_${hash(scope)}", value).apply()
|
||||
fun resetSync(scope: String) {
|
||||
values.edit().remove("cursor_${hash(scope)}").remove("push_${hash(scope)}").apply()
|
||||
}
|
||||
|
||||
private fun hash(value: String): String = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.toByteArray()).take(12).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.example.balanceandroid.sync
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import com.example.balanceandroid.data.AppDatabase
|
||||
import com.example.balanceandroid.data.BudgetEntity
|
||||
import com.example.balanceandroid.data.CategoryEntity
|
||||
import com.example.balanceandroid.data.TransactionEntity
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.time.Instant
|
||||
|
||||
data class SyncStatus(
|
||||
val running: Boolean = false,
|
||||
val message: String? = null,
|
||||
val lastSyncAt: Long? = null,
|
||||
)
|
||||
|
||||
class SyncRepository(
|
||||
private val database: AppDatabase,
|
||||
private val preferences: ServerPreferences,
|
||||
private val sessions: SecureSessionStore,
|
||||
) {
|
||||
private val dao = database.financeDao()
|
||||
private val api = ServerApi(preferences, sessions)
|
||||
private val mutex = Mutex()
|
||||
private val mutableStatus = MutableStateFlow(SyncStatus())
|
||||
private val mutableSession = MutableStateFlow(sessions.load())
|
||||
val status: StateFlow<SyncStatus> = mutableStatus
|
||||
val session: StateFlow<ServerSession?> = mutableSession
|
||||
|
||||
fun serverUrl(): String = preferences.serverUrl
|
||||
fun savedEmail(): String = preferences.email
|
||||
|
||||
suspend fun changeServer(value: String): String {
|
||||
return try {
|
||||
val normalized = preferences.normalizeServer(value)
|
||||
val changed = normalized != preferences.serverUrl
|
||||
if (changed) {
|
||||
runCatching { api.logout() }
|
||||
sessions.clear()
|
||||
mutableSession.value = null
|
||||
preferences.serverUrl = normalized
|
||||
}
|
||||
mutableStatus.value = SyncStatus(message = if (changed) "Адрес сохранён. Войдите заново." else "Адрес сохранён")
|
||||
normalized
|
||||
} catch (error: Throwable) {
|
||||
mutableStatus.value = SyncStatus(message = error.message ?: "Некорректный адрес сервера")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(email: String, password: String) = authenticate(email) { api.register(email, password) }
|
||||
suspend fun login(email: String, password: String) = authenticate(email) { api.login(email, password) }
|
||||
|
||||
private suspend fun authenticate(email: String, action: suspend () -> ServerSession) {
|
||||
mutableStatus.value = SyncStatus(running = true)
|
||||
runCatching { action() }
|
||||
.onSuccess {
|
||||
preferences.email = email.trim()
|
||||
mutableSession.value = it
|
||||
mutableStatus.value = SyncStatus(message = "Вход выполнен")
|
||||
sync()
|
||||
}
|
||||
.onFailure { mutableStatus.value = SyncStatus(message = it.message ?: "Ошибка входа") }
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
if (mutableStatus.value.running) return
|
||||
runCatching { api.logout() }
|
||||
sessions.clear()
|
||||
mutableSession.value = null
|
||||
mutableStatus.value = SyncStatus(message = "Вы вышли из аккаунта")
|
||||
}
|
||||
|
||||
suspend fun resetAndSync() {
|
||||
val session = sessions.load() ?: return
|
||||
preferences.resetSync(scope(session))
|
||||
sync()
|
||||
}
|
||||
|
||||
suspend fun sync(): Boolean = mutex.withLock {
|
||||
val session = sessions.load() ?: return@withLock false
|
||||
if (preferences.serverUrl.isBlank()) return@withLock false
|
||||
mutableSession.value = session
|
||||
mutableStatus.value = mutableStatus.value.copy(running = true, message = null)
|
||||
val result = runCatching {
|
||||
val scope = scope(session)
|
||||
val cutoff = System.currentTimeMillis()
|
||||
val lastPush = preferences.lastPush(scope)
|
||||
val threshold = if (lastPush <= cutoff + 5_000) (lastPush - 5_000).coerceAtLeast(0) else 0
|
||||
val cursor = preferences.cursor(scope)
|
||||
|
||||
pushTransactions(threshold, cutoff, cursor)
|
||||
pushCategories(threshold, cutoff, cursor)
|
||||
pushBudgets(threshold, cutoff, cursor)
|
||||
pushDeletions(cutoff, cursor)
|
||||
pull(scope, cursor)
|
||||
preferences.setLastPush(scope, cutoff)
|
||||
}
|
||||
return@withLock result.fold(
|
||||
onSuccess = {
|
||||
val now = System.currentTimeMillis()
|
||||
mutableStatus.value = SyncStatus(message = "Синхронизация завершена", lastSyncAt = now)
|
||||
true
|
||||
},
|
||||
onFailure = {
|
||||
if (it is ServerException && it.status == 401) {
|
||||
sessions.clear()
|
||||
mutableSession.value = null
|
||||
}
|
||||
mutableStatus.value = SyncStatus(message = it.message ?: "Ошибка синхронизации")
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun pushTransactions(after: Long, through: Long, cursor: Long) {
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val items = dao.transactionChanges(after, through, BATCH_SIZE, offset)
|
||||
if (items.isEmpty()) break
|
||||
push(cursor, items.map(::transactionChange))
|
||||
offset += items.size
|
||||
if (items.size < BATCH_SIZE) break
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushCategories(after: Long, through: Long, cursor: Long) {
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val items = dao.categoryChanges(after, through, BATCH_SIZE, offset)
|
||||
if (items.isEmpty()) break
|
||||
push(cursor, items.map(::categoryChange))
|
||||
offset += items.size
|
||||
if (items.size < BATCH_SIZE) break
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushBudgets(after: Long, through: Long, cursor: Long) {
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val items = dao.budgetChanges(after, through, BATCH_SIZE, offset)
|
||||
if (items.isEmpty()) break
|
||||
push(cursor, items.map(::budgetChange))
|
||||
offset += items.size
|
||||
if (items.size < BATCH_SIZE) break
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pushDeletions(through: Long, cursor: Long) {
|
||||
var offset = 0
|
||||
while (true) {
|
||||
val items = dao.deletionChanges(through, BATCH_SIZE, offset)
|
||||
if (items.isEmpty()) break
|
||||
push(cursor, items.map {
|
||||
JSONObject().put("entity", it.entity).put("id", it.recordId)
|
||||
.put("deleted", true).put("updatedAt", iso(it.updatedAt))
|
||||
})
|
||||
offset += items.size
|
||||
if (items.size < BATCH_SIZE) break
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun push(cursor: Long, changes: List<JSONObject>) {
|
||||
if (changes.isEmpty()) return
|
||||
val array = JSONArray().apply { changes.forEach { put(it) } }
|
||||
if (array.toString().toByteArray().size > 1_500_000 && changes.size > 1) {
|
||||
val middle = changes.size / 2
|
||||
push(cursor, changes.subList(0, middle))
|
||||
push(cursor, changes.subList(middle, changes.size))
|
||||
} else {
|
||||
api.sync(cursor, array, pull = false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pull(scope: String, initialCursor: Long) {
|
||||
var cursor = initialCursor
|
||||
do {
|
||||
val page = api.sync(cursor, JSONArray(), pull = true)
|
||||
require(page.cursor >= cursor) { "Сервер вернул некорректный курсор" }
|
||||
database.withTransaction { apply(page.changes) }
|
||||
preferences.setCursor(scope, page.cursor)
|
||||
if (!page.hasMore) return
|
||||
require(page.cursor > cursor) { "Сервер не смог продолжить синхронизацию" }
|
||||
cursor = page.cursor
|
||||
} while (true)
|
||||
}
|
||||
|
||||
private suspend fun apply(changes: JSONArray) {
|
||||
val sorted = (0 until changes.length()).mapNotNull { changes.optJSONObject(it) }
|
||||
.sortedBy { it.optLong("sequence") }
|
||||
for (change in sorted) {
|
||||
val entity = change.optString("entity")
|
||||
val id = change.optString("id")
|
||||
val updatedAt = parseTime(change.optString("updatedAt")) ?: continue
|
||||
if (change.optBoolean("deleted")) {
|
||||
when (entity) {
|
||||
"transaction" -> dao.transaction(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteTransactionDirect(id) }
|
||||
"category" -> dao.category(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteCategoryDirect(id) }
|
||||
"budget" -> dao.budget(id)?.takeIf { it.updatedAt <= updatedAt }?.let { dao.deleteBudgetDirect(id) }
|
||||
}
|
||||
} else {
|
||||
val payload = change.optJSONObject("payload") ?: continue
|
||||
runCatching {
|
||||
when (entity) {
|
||||
"transaction" -> applyTransaction(id, updatedAt, payload)
|
||||
"category" -> applyCategory(id, updatedAt, payload)
|
||||
"budget" -> applyBudget(id, updatedAt, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
dao.clearDeletion(entity, id, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyTransaction(id: String, updatedAt: Long, value: JSONObject) {
|
||||
if ((dao.transaction(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return
|
||||
dao.upsertTransaction(TransactionEntity(
|
||||
id = id,
|
||||
amount = value.getDouble("amount"),
|
||||
date = requireTime(value.getString("date")),
|
||||
note = value.optString("note"),
|
||||
categoryName = value.getString("categoryName"),
|
||||
categoryIcon = value.optString("categoryIcon", "circle"),
|
||||
categoryEmoji = value.optString("categoryEmoji"),
|
||||
categoryColorName = value.optString("categoryColorName", "indigo"),
|
||||
isBalanceAdjustment = value.optBoolean("isBalanceAdjustment"),
|
||||
kindRawValue = value.optString("kindRawValue", "expense"),
|
||||
updatedAt = updatedAt,
|
||||
))
|
||||
}
|
||||
|
||||
private suspend fun applyCategory(id: String, updatedAt: Long, value: JSONObject) {
|
||||
if ((dao.category(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return
|
||||
dao.upsertCategory(CategoryEntity(
|
||||
id = id,
|
||||
name = value.getString("name"),
|
||||
icon = value.optString("icon", "star"),
|
||||
emoji = value.optString("emoji"),
|
||||
colorName = value.optString("colorName", "indigo"),
|
||||
kindRawValue = value.optString("kindRawValue", "expense"),
|
||||
createdAt = requireTime(value.getString("createdAt")),
|
||||
updatedAt = updatedAt,
|
||||
))
|
||||
}
|
||||
|
||||
private suspend fun applyBudget(id: String, updatedAt: Long, value: JSONObject) {
|
||||
if ((dao.budget(id)?.updatedAt ?: Long.MIN_VALUE) >= updatedAt) return
|
||||
dao.upsertBudget(BudgetEntity(
|
||||
id = id,
|
||||
categoryName = value.getString("categoryName"),
|
||||
categoryIcon = value.optString("categoryIcon", "circle"),
|
||||
categoryEmoji = value.optString("categoryEmoji"),
|
||||
limitAmount = value.getDouble("limit"),
|
||||
monthStart = requireTime(value.getString("monthStart")),
|
||||
updatedAt = updatedAt,
|
||||
))
|
||||
}
|
||||
|
||||
private fun transactionChange(value: TransactionEntity) = change("transaction", value.id, value.updatedAt, JSONObject()
|
||||
.put("amount", value.amount).put("date", iso(value.date)).put("note", value.note)
|
||||
.put("categoryName", value.categoryName).put("categoryIcon", value.categoryIcon)
|
||||
.put("categoryEmoji", value.categoryEmoji).put("categoryColorName", value.categoryColorName)
|
||||
.put("isBalanceAdjustment", value.isBalanceAdjustment).put("kindRawValue", value.kindRawValue))
|
||||
|
||||
private fun categoryChange(value: CategoryEntity) = change("category", value.id, value.updatedAt, JSONObject()
|
||||
.put("name", value.name).put("icon", value.icon).put("emoji", value.emoji)
|
||||
.put("colorName", value.colorName).put("kindRawValue", value.kindRawValue)
|
||||
.put("createdAt", iso(value.createdAt)))
|
||||
|
||||
private fun budgetChange(value: BudgetEntity) = change("budget", value.id, value.updatedAt, JSONObject()
|
||||
.put("categoryName", value.categoryName).put("categoryIcon", value.categoryIcon)
|
||||
.put("categoryEmoji", value.categoryEmoji).put("limit", value.limitAmount)
|
||||
.put("monthStart", iso(value.monthStart)))
|
||||
|
||||
private fun change(entity: String, id: String, updatedAt: Long, payload: JSONObject) = JSONObject()
|
||||
.put("entity", entity).put("id", id).put("deleted", false)
|
||||
.put("updatedAt", iso(updatedAt)).put("payload", payload)
|
||||
|
||||
private fun scope(session: ServerSession) = "${preferences.serverUrl}|${session.userId}"
|
||||
private fun iso(value: Long): String = Instant.ofEpochMilli(value).toString()
|
||||
private fun parseTime(value: String): Long? = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull()
|
||||
private fun requireTime(value: String): Long = Instant.parse(value).toEpochMilli()
|
||||
|
||||
companion object { private const val BATCH_SIZE = 100 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.balanceandroid.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.example.balanceandroid.BalanceApplication
|
||||
|
||||
class SyncWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
val app = applicationContext as BalanceApplication
|
||||
if (app.sessions.load() == null || app.preferences.serverUrl.isBlank()) return Result.success()
|
||||
return if (app.syncRepository.sync()) Result.success() else Result.retry()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package com.example.balanceandroid.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Category
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Savings
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.example.balanceandroid.FinanceUiState
|
||||
import com.example.balanceandroid.MainViewModel
|
||||
import com.example.balanceandroid.data.CategoryDesign
|
||||
import com.example.balanceandroid.data.FinanceMath
|
||||
import com.example.balanceandroid.data.TransactionEntity
|
||||
import com.example.balanceandroid.ui.theme.BalanceGreen
|
||||
import com.example.balanceandroid.ui.theme.BalanceIndigo
|
||||
import com.example.balanceandroid.ui.theme.BalancePurple
|
||||
import com.example.balanceandroid.ui.theme.BalanceRed
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private data class Destination(val route: String, val title: String, val icon: ImageVector)
|
||||
private val destinations = listOf(
|
||||
Destination("overview", "Обзор", Icons.Default.Home),
|
||||
Destination("analytics", "Аналитика", Icons.Default.Analytics),
|
||||
Destination("transactions", "Операции", Icons.Default.List),
|
||||
Destination("budgets", "Бюджеты", Icons.Default.Savings),
|
||||
Destination("settings", "Настройки", Icons.Default.Settings),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BalanceRoot(model: MainViewModel, state: FinanceUiState) {
|
||||
val nav = rememberNavController()
|
||||
val route = nav.currentBackStackEntryAsState().value?.destination?.route ?: "overview"
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (destinations.any { it.route == route }) NavigationBar {
|
||||
destinations.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = route == item.route,
|
||||
onClick = { nav.navigate(item.route) { popUpTo("overview"); launchSingleTop = true } },
|
||||
icon = { Icon(item.icon, item.title) },
|
||||
label = { Text(item.title) },
|
||||
alwaysShowLabel = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(nav, "overview", Modifier.padding(padding)) {
|
||||
composable("overview") { OverviewScreen(model, state) }
|
||||
composable("analytics") { AnalyticsScreen(state) }
|
||||
composable("transactions") { TransactionsScreen(model, state) }
|
||||
composable("budgets") { BudgetsScreen(model, state) }
|
||||
composable("settings") { SettingsScreen(model, state, nav) }
|
||||
composable("categories") { CategoriesScreen(model, state, nav) }
|
||||
composable("server") { ServerScreen(model, state, nav) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewScreen(model: MainViewModel, state: FinanceUiState) {
|
||||
var adjustment by remember { mutableStateOf(false) }
|
||||
val (start, end) = periodBounds(1)
|
||||
val summary = FinanceMath.summary(state.data.transactions, start, end)
|
||||
val spending = FinanceMath.spending(state.data.transactions, start, end)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
item { ScreenTitle("Обзор") }
|
||||
item {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().background(
|
||||
Brush.linearGradient(listOf(BalanceIndigo, BalancePurple)), RoundedCornerShape(22.dp),
|
||||
).padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("Общий баланс", color = Color.White.copy(alpha = .82f))
|
||||
Text(formatMoney(FinanceMath.totalBalance(state.data.transactions), state.currency), color = Color.White, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
OutlinedButton(onClick = { adjustment = true }) {
|
||||
Icon(Icons.Default.Tune, null); Spacer(Modifier.size(7.dp)); Text("Скорректировать", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
MetricCard("Доходы", formatMoney(summary.income, state.currency), BalanceGreen, Modifier.weight(1f))
|
||||
MetricCard("Расходы", formatMoney(summary.expenses, state.currency), BalanceRed, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Расходы по категориям", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
SpendingBars(spending, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.data.transactions.isNotEmpty()) {
|
||||
item { Text("Последние операции", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) }
|
||||
items(state.data.transactions.take(5), key = { it.id }) { TransactionRow(it, state.currency) }
|
||||
}
|
||||
}
|
||||
if (adjustment) BalanceAdjustmentDialog(
|
||||
current = FinanceMath.totalBalance(state.data.transactions),
|
||||
currency = state.currency,
|
||||
onDismiss = { adjustment = false },
|
||||
onSave = { target, note -> model.adjustBalance(target, note); adjustment = false },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalyticsScreen(state: FinanceUiState) {
|
||||
var months by remember { mutableIntStateOf(1) }
|
||||
val bounds = periodBounds(months)
|
||||
val summary = FinanceMath.summary(state.data.transactions, bounds.first, bounds.second)
|
||||
val spending = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second)
|
||||
val savings = if (summary.income > 0) summary.balance / summary.income * 100 else 0.0
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
item { ScreenTitle("Аналитика") }
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf(1 to "Месяц", 3 to "3 месяца", 12 to "12 месяцев").forEach { (value, title) ->
|
||||
FilterChip(selected = months == value, onClick = { months = value }, label = { Text(title) })
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
MetricCard("Доходы", formatMoney(summary.income, state.currency), BalanceGreen, Modifier.weight(1f))
|
||||
MetricCard("Расходы", formatMoney(summary.expenses, state.currency), BalanceRed, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
MetricCard("Результат", formatMoney(summary.balance, state.currency), BalanceIndigo, Modifier.weight(1f))
|
||||
MetricCard("Сбережения", "%.0f%%".format(savings), BalanceGreen, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
item {
|
||||
Card { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("Структура расходов", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
SpendingBars(spending, Modifier.fillMaxWidth())
|
||||
} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionsScreen(model: MainViewModel, state: FinanceUiState) {
|
||||
var search by remember { mutableStateOf("") }
|
||||
var kind by remember { mutableStateOf("all") }
|
||||
var editing by remember { mutableStateOf<TransactionEntity?>(null) }
|
||||
var showingEditor by remember { mutableStateOf(false) }
|
||||
val filtered = state.data.transactions.filter {
|
||||
(kind == "all" || it.kindRawValue == kind) &&
|
||||
(search.isBlank() || it.categoryName.contains(search, true) || it.note.contains(search, true))
|
||||
}
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(onClick = { editing = null; showingEditor = true }, icon = { Icon(Icons.Default.Add, null) }, text = { Text("Добавить") })
|
||||
},
|
||||
) { padding ->
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item { ScreenTitle("Операции") }
|
||||
item { OutlinedTextField(search, { search = it }, Modifier.fillMaxWidth(), label = { Text("Поиск") }, singleLine = true) }
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf("all" to "Все", "expense" to "Расходы", "income" to "Доходы").forEach { item ->
|
||||
FilterChip(kind == item.first, { kind = item.first }, label = { Text(item.second) })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filtered.isEmpty()) item { EmptyMessage("Операций пока нет") }
|
||||
items(filtered, key = { it.id }) { transaction ->
|
||||
Card(Modifier.fillMaxWidth().clickable { editing = transaction; showingEditor = true }) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TransactionRow(transaction, state.currency, Modifier.weight(1f))
|
||||
IconButton({ editing = transaction; showingEditor = true }) { Icon(Icons.Default.Edit, "Изменить") }
|
||||
IconButton({ model.deleteTransaction(transaction) }) { Icon(Icons.Default.Delete, "Удалить", tint = BalanceRed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(72.dp)) }
|
||||
}
|
||||
}
|
||||
if (showingEditor) TransactionEditorDialog(model, state, editing, onDismiss = { showingEditor = false })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BudgetsScreen(model: MainViewModel, state: FinanceUiState) {
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
val month = MainViewModel.currentMonthStart()
|
||||
val budgets = state.data.budgets.filter { it.monthStart == month }
|
||||
val bounds = periodBounds(1)
|
||||
val spent = FinanceMath.spending(state.data.transactions, bounds.first, bounds.second).associate { it.name to it.amount }
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ScreenTitle("Бюджеты", Modifier.weight(1f))
|
||||
Button({ editing = true }) { Icon(Icons.Default.Edit, null); Spacer(Modifier.size(6.dp)); Text("Лимиты") }
|
||||
}
|
||||
}
|
||||
if (budgets.isEmpty()) item { EmptyMessage("Лимиты не заданы") }
|
||||
items(budgets, key = { it.id }) { budget ->
|
||||
val value = spent[budget.categoryName] ?: 0.0
|
||||
val progress = (value / budget.limitAmount.coerceAtLeast(1.0)).toFloat().coerceIn(0f, 1f)
|
||||
Card { Column(Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CategoryBadge(CategoryDesign(budget.categoryName, budget.categoryIcon, budget.categoryEmoji, "indigo", "expense"), size = 36)
|
||||
Spacer(Modifier.size(10.dp)); Text(budget.categoryName, Modifier.weight(1f), fontWeight = FontWeight.Bold)
|
||||
Text(formatMoney(value, state.currency))
|
||||
}
|
||||
LinearProgressIndicator({ progress }, Modifier.fillMaxWidth())
|
||||
Text("Лимит: ${formatMoney(budget.limitAmount, state.currency)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} }
|
||||
}
|
||||
}
|
||||
if (editing) BudgetEditorDialog(model, state, onDismiss = { editing = false })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) {
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item { ScreenTitle("Настройки") }
|
||||
item { SettingsCard("Отображение") {
|
||||
ChoiceRow("Валюта", state.currency, listOf("RUB", "EUR", "USD", "SEK"), model::setCurrency)
|
||||
ChoiceRow("Тема", state.theme, listOf("system", "light", "dark"), model::setTheme, label = { when(it) { "light" -> "Светлая"; "dark" -> "Тёмная"; else -> "Системная" } })
|
||||
} }
|
||||
item { SettingsCard("Данные") {
|
||||
SettingsLink("Категории", Icons.Default.Category) { nav.navigate("categories") }
|
||||
HorizontalDivider()
|
||||
SettingsLink("Сервер и синхронизация", Icons.Default.Sync) { nav.navigate("server") }
|
||||
Text("Сохранено операций: ${state.data.transactions.size}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} }
|
||||
item { SettingsCard("Приватность") {
|
||||
Text("Данные хранятся локально. Токены сервера шифруются ключом Android Keystore.")
|
||||
} }
|
||||
item { Text("Версия 1.0.0", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoriesScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) {
|
||||
var editing by remember { mutableStateOf<com.example.balanceandroid.data.CategoryEntity?>(null) }
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
Scaffold(floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { editing = null; showEditor = true },
|
||||
icon = { Icon(Icons.Default.Add, null) },
|
||||
text = { Text("Категория") }
|
||||
)
|
||||
}) { padding ->
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
item { BackTitle("Категории") { nav.popBackStack() } }
|
||||
if (state.data.categories.isEmpty()) item { EmptyMessage("Своих категорий пока нет") }
|
||||
items(state.data.categories, key = { it.id }) { category ->
|
||||
Card(Modifier.fillMaxWidth().clickable { editing = category; showEditor = true }) {
|
||||
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CategoryBadge(CategoryDesign(category.name, category.icon, category.emoji, category.colorName, category.kindRawValue))
|
||||
Spacer(Modifier.size(12.dp)); Column(Modifier.weight(1f)) { Text(category.name, fontWeight = FontWeight.Bold); Text(if(category.kindRawValue == "income") "Доход" else "Расход", style = MaterialTheme.typography.bodySmall) }
|
||||
IconButton({ editing = category; showEditor = true }) { Icon(Icons.Default.Edit, null) }
|
||||
IconButton({ model.deleteCategory(category) }) { Icon(Icons.Default.Delete, null, tint = BalanceRed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(72.dp)) }
|
||||
}
|
||||
}
|
||||
if (showEditor) CategoryEditorDialog(model, state, editing, onDismiss = { showEditor = false })
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerScreen(model: MainViewModel, state: FinanceUiState, nav: NavHostController) {
|
||||
var server by remember(state.serverUrl) { mutableStateOf(state.serverUrl) }
|
||||
var email by remember(state.session?.email, state.serverEmail) { mutableStateOf(state.session?.email ?: state.serverEmail) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val savedServerSelected = state.serverUrl.isNotBlank() && server.trim().trimEnd('/') == state.serverUrl
|
||||
LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item { BackTitle("Сервер") { nav.popBackStack() } }
|
||||
item { SettingsCard("Адрес") {
|
||||
OutlinedTextField(server, { server = it }, Modifier.fillMaxWidth(), label = { Text("https://balance.example.com") }, singleLine = true)
|
||||
Button({ model.changeServer(server) }, enabled = !state.sync.running) { Text("Сохранить адрес") }
|
||||
Text("Для эмулятора локальный сервер доступен как http://10.0.2.2:8080", style = MaterialTheme.typography.bodySmall)
|
||||
} }
|
||||
item { SettingsCard("Аккаунт") {
|
||||
if (state.session == null) {
|
||||
OutlinedTextField(email, { email = it }, Modifier.fillMaxWidth(), label = { Text("Email") }, singleLine = true)
|
||||
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("Пароль") }, singleLine = true, visualTransformation = PasswordVisualTransformation())
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ model.login(email, password) }, enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.isNotBlank()) { Text("Войти") }
|
||||
OutlinedButton({ model.register(email, password) }, enabled = !state.sync.running && savedServerSelected && email.isNotBlank() && password.length >= 8) { Text("Регистрация") }
|
||||
}
|
||||
if (!savedServerSelected) Text("Сначала сохраните адрес сервера", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Text("Выполнен вход: ${state.session.email}", fontWeight = FontWeight.Bold)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ model.sync() }, enabled = !state.sync.running) { Icon(Icons.Default.Sync, null); Spacer(Modifier.size(6.dp)); Text("Синхронизировать") }
|
||||
OutlinedButton({ model.logout() }, enabled = !state.sync.running) { Text("Выйти") }
|
||||
}
|
||||
OutlinedButton({ model.resetSync() }, enabled = !state.sync.running) { Text("Полная пересинхронизация") }
|
||||
}
|
||||
if (state.sync.running) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
state.sync.message?.let { Text(it, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
} }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun ScreenTitle(text: String, modifier: Modifier = Modifier) = Text(text, modifier, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
@Composable private fun BackTitle(text: String, back: () -> Unit) = Row(verticalAlignment = Alignment.CenterVertically) { IconButton(back) { Icon(Icons.Default.ArrowBack, "Назад") }; ScreenTitle(text) }
|
||||
@Composable private fun EmptyMessage(text: String) = Box(Modifier.fillMaxWidth().padding(30.dp), contentAlignment = Alignment.Center) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
|
||||
@Composable
|
||||
private fun TransactionRow(value: TransactionEntity, currency: String, modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
CategoryBadge(CategoryDesign(value.categoryName, value.categoryIcon, value.categoryEmoji, value.categoryColorName, value.kindRawValue), size = 38)
|
||||
Spacer(Modifier.size(10.dp)); Column(Modifier.weight(1f)) {
|
||||
Text(value.categoryName, fontWeight = FontWeight.SemiBold)
|
||||
Text(value.note.ifBlank { Instant.ofEpochMilli(value.date).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("d MMM")) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
Text((if(value.kindRawValue == "income") "+" else "−") + formatMoney(value.amount, currency), color = if(value.kindRawValue == "income") BalanceGreen else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun SettingsCard(title: String, content: @Composable ColumnScope.() -> Unit) = Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(title, fontWeight = FontWeight.Bold); content() } }
|
||||
@Composable private fun SettingsLink(title: String, icon: ImageVector, action: () -> Unit) = Row(Modifier.fillMaxWidth().clickable(onClick = action).padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null); Spacer(Modifier.size(12.dp)); Text(title, Modifier.weight(1f)); Text("›", style = MaterialTheme.typography.titleLarge) }
|
||||
|
||||
@Composable
|
||||
private fun ChoiceRow(title: String, current: String, values: List<String>, onSelect: (String) -> Unit, label: (String) -> String = { it }) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(Modifier.fillMaxWidth().clickable { expanded = true }.padding(vertical = 7.dp)) { Text(title, Modifier.weight(1f)); Text(label(current), color = MaterialTheme.colorScheme.primary) }
|
||||
androidx.compose.material3.DropdownMenu(expanded, { expanded = false }) {
|
||||
values.forEach { value -> androidx.compose.material3.DropdownMenuItem({ Text(label(value)) }, { onSelect(value); expanded = false }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package com.example.balanceandroid.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountBalanceWallet
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material.icons.filled.BarChart
|
||||
import androidx.compose.material.icons.filled.Bed
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CameraAlt
|
||||
import androidx.compose.material.icons.filled.CardGiftcard
|
||||
import androidx.compose.material.icons.filled.Checkroom
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.Construction
|
||||
import androidx.compose.material.icons.filled.CreditCard
|
||||
import androidx.compose.material.icons.filled.DirectionsCar
|
||||
import androidx.compose.material.icons.filled.DirectionsRun
|
||||
import androidx.compose.material.icons.filled.DirectionsWalk
|
||||
import androidx.compose.material.icons.filled.Eco
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Fastfood
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Flight
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Inventory2
|
||||
import androidx.compose.material.icons.filled.Laptop
|
||||
import androidx.compose.material.icons.filled.Lightbulb
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material.icons.filled.LocalGasStation
|
||||
import androidx.compose.material.icons.filled.LocalGroceryStore
|
||||
import androidx.compose.material.icons.filled.MedicalServices
|
||||
import androidx.compose.material.icons.filled.Medication
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.MusicNote
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PedalBike
|
||||
import androidx.compose.material.icons.filled.Pets
|
||||
import androidx.compose.material.icons.filled.Phone
|
||||
import androidx.compose.material.icons.filled.Print
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Restaurant
|
||||
import androidx.compose.material.icons.filled.School
|
||||
import androidx.compose.material.icons.filled.Sell
|
||||
import androidx.compose.material.icons.filled.ShoppingBag
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.filled.Work
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.balanceandroid.data.CategoryDesign
|
||||
import com.example.balanceandroid.data.CategoryTotal
|
||||
import java.text.NumberFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
fun formatMoney(value: Double, currency: String): String = runCatching {
|
||||
NumberFormat.getCurrencyInstance(Locale.getDefault()).apply {
|
||||
this.currency = Currency.getInstance(currency)
|
||||
maximumFractionDigits = if (value % 1.0 == 0.0) 0 else 2
|
||||
}.format(value)
|
||||
}.getOrElse { "%.2f %s".format(value, currency) }
|
||||
|
||||
fun colorFor(name: String): Color = when (name) {
|
||||
"red" -> Color(0xFFE05252)
|
||||
"orange" -> Color(0xFFF28C38)
|
||||
"yellow" -> Color(0xFFE0AC24)
|
||||
"green" -> Color(0xFF16A36A)
|
||||
"mint" -> Color(0xFF31B99A)
|
||||
"teal" -> Color(0xFF159EAF)
|
||||
"cyan" -> Color(0xFF209BCC)
|
||||
"blue" -> Color(0xFF3979E9)
|
||||
"purple" -> Color(0xFF8B5CF6)
|
||||
"pink" -> Color(0xFFE35C9A)
|
||||
"brown" -> Color(0xFF9B6A4A)
|
||||
"gray" -> Color(0xFF7A7A85)
|
||||
"coral" -> Color(0xFFFA6161)
|
||||
"peach" -> Color(0xFFFF9461)
|
||||
"gold" -> Color(0xFFE6A314)
|
||||
"lime" -> Color(0xFF85C729)
|
||||
"forest" -> Color(0xFF1A7A4D)
|
||||
"turquoise" -> Color(0xFF0DABA1)
|
||||
"sky" -> Color(0xFF2E9EE8)
|
||||
"navy" -> Color(0xFF1F4085)
|
||||
"lavender" -> Color(0xFF8C66D9)
|
||||
"magenta" -> Color(0xFFD1298C)
|
||||
"slate" -> Color(0xFF4D5C73)
|
||||
else -> Color(0xFF5B5BD6)
|
||||
}
|
||||
|
||||
fun iconFor(name: String): ImageVector = when (name) {
|
||||
"star", "star.fill" -> Icons.Default.Star
|
||||
"heart.fill", "health" -> Icons.Default.Favorite
|
||||
"sparkles" -> Icons.Default.AutoAwesome
|
||||
"bolt.fill" -> Icons.Default.Bolt
|
||||
"flame.fill" -> Icons.Default.LocalFireDepartment
|
||||
"drop.fill" -> Icons.Default.WaterDrop
|
||||
"cart", "cart.fill" -> Icons.Default.LocalGroceryStore
|
||||
"bag", "bag.fill" -> Icons.Default.ShoppingBag
|
||||
"creditcard.fill" -> Icons.Default.CreditCard
|
||||
"money", "banknote.fill" -> Icons.Default.AccountBalanceWallet
|
||||
"gift", "gift.fill" -> Icons.Default.CardGiftcard
|
||||
"tag.fill" -> Icons.Default.Sell
|
||||
"cup.and.saucer.fill" -> Icons.Default.Coffee
|
||||
"fork.knife" -> Icons.Default.Restaurant
|
||||
"takeoutbag.and.cup.and.straw.fill" -> Icons.Default.Fastfood
|
||||
"birthday.cake.fill" -> Icons.Default.Cake
|
||||
"car", "car.fill", "bus.fill", "tram.fill" -> Icons.Default.DirectionsCar
|
||||
"bicycle" -> Icons.Default.PedalBike
|
||||
"airplane" -> Icons.Default.Flight
|
||||
"fuelpump.fill" -> Icons.Default.LocalGasStation
|
||||
"home", "house.fill" -> Icons.Default.Home
|
||||
"bed.double.fill" -> Icons.Default.Bed
|
||||
"lightbulb.fill" -> Icons.Default.Lightbulb
|
||||
"wifi" -> Icons.Default.Wifi
|
||||
"phone.fill" -> Icons.Default.Phone
|
||||
"printer.fill" -> Icons.Default.Print
|
||||
"pawprint.fill" -> Icons.Default.Pets
|
||||
"leaf.fill" -> Icons.Default.Eco
|
||||
"figure.walk" -> Icons.Default.DirectionsWalk
|
||||
"figure.run" -> Icons.Default.DirectionsRun
|
||||
"dumbbell.fill" -> Icons.Default.FitnessCenter
|
||||
"cross.case.fill", "stethoscope" -> Icons.Default.MedicalServices
|
||||
"pills.fill" -> Icons.Default.Medication
|
||||
"book.fill" -> Icons.Default.MenuBook
|
||||
"school", "graduationcap.fill" -> Icons.Default.School
|
||||
"laptopcomputer" -> Icons.Default.Laptop
|
||||
"briefcase.fill" -> Icons.Default.Work
|
||||
"gamecontroller.fill" -> Icons.Default.SportsEsports
|
||||
"music.note" -> Icons.Default.MusicNote
|
||||
"movie", "film.fill" -> Icons.Default.Movie
|
||||
"camera.fill" -> Icons.Default.CameraAlt
|
||||
"paintpalette.fill" -> Icons.Default.Palette
|
||||
"tshirt.fill" -> Icons.Default.Checkroom
|
||||
"hammer.fill", "wrench.and.screwdriver.fill" -> Icons.Default.Construction
|
||||
"shippingbox.fill" -> Icons.Default.Inventory2
|
||||
"globe.europe.africa.fill" -> Icons.Default.Public
|
||||
"chart", "chart.line.uptrend.xyaxis" -> Icons.Default.BarChart
|
||||
"tune" -> Icons.Default.Tune
|
||||
"plus.circle.fill" -> Icons.Default.Add
|
||||
"repeat", "ellipsis.circle.fill", "more" -> Icons.Default.MoreHoriz
|
||||
else -> Icons.Default.MoreHoriz
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CategoryBadge(category: CategoryDesign, modifier: Modifier = Modifier, size: Int = 42) {
|
||||
Box(
|
||||
modifier.size(size.dp).background(colorFor(category.colorName).copy(alpha = .16f), RoundedCornerShape(13.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (category.emoji.isNotBlank()) Text(category.emoji, fontSize = (size * .46f).sp)
|
||||
else Icon(iconFor(category.icon), null, tint = colorFor(category.colorName), modifier = Modifier.size((size * .48f).dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetricCard(title: String, value: String, tint: Color, modifier: Modifier = Modifier) {
|
||||
Card(modifier, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.labelMedium, color = tint)
|
||||
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpendingBars(items: List<CategoryTotal>, modifier: Modifier = Modifier) {
|
||||
if (items.isEmpty()) {
|
||||
Box(modifier.height(170.dp), contentAlignment = Alignment.Center) { Text("Нет расходов", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
return
|
||||
}
|
||||
val maximum = items.maxOf { it.amount }.coerceAtLeast(1.0)
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items.take(7).forEach { item ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(if (item.emoji.isBlank()) item.name else "${item.emoji} ${item.name}", Modifier.weight(1f), maxLines = 1)
|
||||
Text("%.0f%%".format(item.amount / items.sumOf { it.amount } * 100), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Canvas(Modifier.fillMaxWidth().height(8.dp)) {
|
||||
drawRoundRect(Color.Gray.copy(alpha = .18f), cornerRadius = CornerRadius(size.height / 2))
|
||||
drawRoundRect(
|
||||
colorFor(item.colorName),
|
||||
size = Size(size.width * (item.amount / maximum).toFloat(), size.height),
|
||||
cornerRadius = CornerRadius(size.height / 2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun periodBounds(months: Int, now: Long = System.currentTimeMillis()): Pair<Long, Long> {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val date = Instant.ofEpochMilli(now).atZone(zone)
|
||||
val currentStart = date.withDayOfMonth(1).toLocalDate().atStartOfDay(zone)
|
||||
val start = currentStart.minusMonths((months - 1).toLong())
|
||||
val end = currentStart.plusMonths(1)
|
||||
return start.toInstant().toEpochMilli() to end.toInstant().toEpochMilli()
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.example.balanceandroid.ui
|
||||
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.balanceandroid.FinanceUiState
|
||||
import com.example.balanceandroid.MainViewModel
|
||||
import com.example.balanceandroid.data.CategoryDesign
|
||||
import com.example.balanceandroid.data.CategoryEntity
|
||||
import com.example.balanceandroid.data.TransactionEntity
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun TransactionEditorDialog(
|
||||
model: MainViewModel,
|
||||
state: FinanceUiState,
|
||||
existing: TransactionEntity?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var amount by remember(existing?.id) { mutableStateOf(existing?.amount?.toString().orEmpty()) }
|
||||
var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") }
|
||||
var note by remember(existing?.id) { mutableStateOf(existing?.note.orEmpty()) }
|
||||
var date by remember(existing?.id) { mutableStateOf(existing?.date ?: System.currentTimeMillis()) }
|
||||
val categories = model.categories(kind)
|
||||
var categoryName by remember(existing?.id) { mutableStateOf(existing?.categoryName ?: categories.first().name) }
|
||||
val selected = categories.firstOrNull { it.name == categoryName } ?: categories.first()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if(existing == null) "Новая операция" else "Редактирование") },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(kind == "expense", { kind = "expense"; categoryName = model.categories("expense").first().name }, label = { Text("Расход") })
|
||||
FilterChip(kind == "income", { kind = "income"; categoryName = model.categories("income").first().name }, label = { Text("Доход") })
|
||||
}
|
||||
OutlinedTextField(amount, { amount = it }, Modifier.fillMaxWidth(), label = { Text("Сумма") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true)
|
||||
DialogChoice("Категория", categoryName, categories.map { it.name }) { categoryName = it }
|
||||
DateChoice(date) { date = it }
|
||||
OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), label = { Text("Заметка") })
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button({ model.saveTransaction(existing, amount.replace(',', '.').toDoubleOrNull() ?: 0.0, kind, selected, note, date); onDismiss() }, enabled = (amount.replace(',', '.').toDoubleOrNull() ?: 0.0) > 0) { Text("Сохранить") }
|
||||
},
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CategoryEditorDialog(
|
||||
model: MainViewModel,
|
||||
state: FinanceUiState,
|
||||
existing: CategoryEntity?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var name by remember(existing?.id) { mutableStateOf(existing?.name.orEmpty()) }
|
||||
var kind by remember(existing?.id) { mutableStateOf(existing?.kindRawValue ?: "expense") }
|
||||
var icon by remember(existing?.id) { mutableStateOf(existing?.icon ?: "star") }
|
||||
var emoji by remember(existing?.id) { mutableStateOf(existing?.emoji.orEmpty()) }
|
||||
var color by remember(existing?.id) { mutableStateOf(existing?.colorName ?: "indigo") }
|
||||
var useEmoji by remember(existing?.id) { mutableStateOf(existing?.emoji?.isNotBlank() == true) }
|
||||
val duplicate = state.data.categories.any { it.id != existing?.id && it.kindRawValue == kind && it.name.equals(name.trim(), true) }
|
||||
val icons = listOf(
|
||||
"star.fill", "heart.fill", "sparkles", "bolt.fill", "flame.fill", "drop.fill",
|
||||
"cart.fill", "bag.fill", "creditcard.fill", "banknote.fill", "gift.fill", "tag.fill",
|
||||
"cup.and.saucer.fill", "fork.knife", "takeoutbag.and.cup.and.straw.fill", "birthday.cake.fill",
|
||||
"car.fill", "bus.fill", "tram.fill", "bicycle", "airplane", "fuelpump.fill",
|
||||
"house.fill", "bed.double.fill", "lightbulb.fill", "wifi", "phone.fill", "printer.fill",
|
||||
"pawprint.fill", "leaf.fill", "figure.walk", "figure.run", "dumbbell.fill", "cross.case.fill",
|
||||
"pills.fill", "stethoscope", "book.fill", "graduationcap.fill", "laptopcomputer", "briefcase.fill",
|
||||
"gamecontroller.fill", "music.note", "film.fill", "camera.fill", "paintpalette.fill", "tshirt.fill",
|
||||
"hammer.fill", "wrench.and.screwdriver.fill", "shippingbox.fill", "globe.europe.africa.fill",
|
||||
)
|
||||
val emojis = listOf(
|
||||
"🍔", "☕️", "🛒", "🚕", "✈️", "🏠", "💡", "📱", "💻", "🎮",
|
||||
"🎬", "🎵", "📚", "🎓", "💊", "🏋️", "🐶", "🐱", "👕", "🎁",
|
||||
"💰", "💳", "📈", "🔧", "🌿", "❤️", "⭐️", "🎯", "🧾", "🏖️",
|
||||
)
|
||||
val colors = listOf(
|
||||
"red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue",
|
||||
"indigo", "purple", "pink", "gray", "coral", "peach", "gold", "lime",
|
||||
"forest", "turquoise", "sky", "navy", "lavender", "magenta", "brown", "slate",
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if(existing == null) "Новая категория" else "Редактирование") },
|
||||
text = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Название") }, singleLine = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(kind == "expense", { if(existing == null) kind = "expense" }, label = { Text("Расход") })
|
||||
FilterChip(kind == "income", { if(existing == null) kind = "income" }, label = { Text("Доход") })
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(!useEmoji, { useEmoji = false }, label = { Text("Иконка") })
|
||||
FilterChip(useEmoji, { useEmoji = true }, label = { Text("Эмодзи") })
|
||||
}
|
||||
if (useEmoji) {
|
||||
OutlinedTextField(emoji, { emoji = it }, Modifier.fillMaxWidth(), label = { Text("Эмодзи") }, singleLine = true)
|
||||
EmojiChoice(emoji, emojis) { emoji = it }
|
||||
} else IconChoice(icon, color, kind, icons) { icon = it }
|
||||
ColorChoice(color, colors) { color = it }
|
||||
if (duplicate) Text("Категория с таким названием уже существует", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button({ model.saveCategory(existing, name, kind, icon, if(useEmoji) emoji.trim() else "", color); onDismiss() }, enabled = name.isNotBlank() && !duplicate && (!useEmoji || emoji.isNotBlank())) { Text("Сохранить") }
|
||||
},
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconChoice(current: String, color: String, kind: String, values: List<String>, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text("Иконка", style = MaterialTheme.typography.labelLarge)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(values, key = { it }) { value ->
|
||||
Box(
|
||||
Modifier
|
||||
.border(if (value == current) 2.dp else 0.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(3.dp)
|
||||
.clickable { onSelect(value) }
|
||||
) {
|
||||
CategoryBadge(CategoryDesign("", value, "", color, kind), size = 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiChoice(current: String, values: List<String>, onSelect: (String) -> Unit) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(values, key = { it }) { value ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.border(if (value == current) 2.dp else 1.dp, if (value == current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||
.clickable { onSelect(value) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorChoice(current: String, values: List<String>, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text("Цвет", style = MaterialTheme.typography.labelLarge)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
items(values, key = { it }) { value ->
|
||||
Box(
|
||||
Modifier
|
||||
.size(34.dp)
|
||||
.border(if (value == current) 3.dp else 1.dp, if (value == current) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||
.padding(4.dp)
|
||||
.background(colorFor(value), CircleShape)
|
||||
.clickable { onSelect(value) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BudgetEditorDialog(model: MainViewModel, state: FinanceUiState, onDismiss: () -> Unit) {
|
||||
val categories = model.categories("expense")
|
||||
val month = MainViewModel.currentMonthStart()
|
||||
val limits = remember(state.data.budgets) { mutableStateMapOf<String, String>().apply {
|
||||
categories.forEach { category ->
|
||||
put(category.name, state.data.budgets.firstOrNull { it.categoryName == category.name && it.monthStart == month }?.limitAmount?.toString().orEmpty())
|
||||
}
|
||||
} }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Месячные лимиты") },
|
||||
text = {
|
||||
Column(Modifier.heightIn(max = 520.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
categories.forEach { category ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CategoryBadge(category, size = 34); Spacer(Modifier.size(9.dp)); Text(category.name, Modifier.weight(1f), maxLines = 1)
|
||||
OutlinedTextField(limits[category.name].orEmpty(), { limits[category.name] = it }, Modifier.fillMaxWidth(.38f), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { Button({ categories.forEach { model.saveBudget(it, limits[it.name].orEmpty().replace(',', '.').toDoubleOrNull() ?: 0.0, month) }; onDismiss() }) { Text("Сохранить") } },
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BalanceAdjustmentDialog(current: Double, currency: String, onDismiss: () -> Unit, onSave: (Double, String) -> Unit) {
|
||||
var target by remember { mutableStateOf(current.toString()) }
|
||||
var note by remember { mutableStateOf("") }
|
||||
val value = target.replace(',', '.').toDoubleOrNull()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Корректировка баланса") },
|
||||
text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Расчётный баланс: ${formatMoney(current, currency)}")
|
||||
OutlinedTextField(target, { target = it }, Modifier.fillMaxWidth(), label = { Text("Фактический баланс") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true)
|
||||
OutlinedTextField(note, { note = it }, Modifier.fillMaxWidth(), label = { Text("Комментарий") })
|
||||
Text("Корректировка не влияет на аналитику.", style = MaterialTheme.typography.bodySmall)
|
||||
} },
|
||||
confirmButton = { Button({ value?.let { onSave(it, note) } }, enabled = value != null && kotlin.math.abs(value - current) >= .005) { Text("Сохранить") } },
|
||||
dismissButton = { TextButton(onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialogChoice(title: String, current: String, values: List<String>, onSelect: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
OutlinedButton({ expanded = true }, Modifier.fillMaxWidth()) { Text("$title: ${current.ifBlank { "Выбрать" }}", Modifier.weight(1f)) }
|
||||
DropdownMenu(expanded, { expanded = false }) {
|
||||
values.forEach { value -> DropdownMenuItem({ Text(value) }, { onSelect(value); expanded = false }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateChoice(value: Long, onChange: (Long) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val zone = ZoneId.systemDefault()
|
||||
val date = Instant.ofEpochMilli(value).atZone(zone).toLocalDate()
|
||||
OutlinedButton({
|
||||
DatePickerDialog(context, { _, year, month, day ->
|
||||
onChange(LocalDate.of(year, month + 1, day).atStartOfDay(zone).toInstant().toEpochMilli())
|
||||
}, date.year, date.monthValue - 1, date.dayOfMonth).show()
|
||||
}, Modifier.fillMaxWidth()) {
|
||||
Text("Дата: ${date.format(DateTimeFormatter.ofPattern("d MMMM yyyy"))}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.example.balanceandroid.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
val BalanceIndigo = Color(0xFF5B5BD6)
|
||||
val BalancePurple = Color(0xFF8B5CF6)
|
||||
val BalanceGreen = Color(0xFF16A36A)
|
||||
val BalanceRed = Color(0xFFE05252)
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = BalanceIndigo,
|
||||
secondary = BalancePurple,
|
||||
tertiary = BalanceGreen,
|
||||
background = Color(0xFFF7F7FB),
|
||||
surface = Color.White,
|
||||
surfaceVariant = Color(0xFFECECF5),
|
||||
)
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Color(0xFFB8B5FF),
|
||||
secondary = Color(0xFFD0BCFF),
|
||||
tertiary = Color(0xFF6DD7A8),
|
||||
background = Color(0xFF111116),
|
||||
surface = Color(0xFF1A1A22),
|
||||
surfaceVariant = Color(0xFF292936),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun BalanceTheme(theme: String, content: @Composable () -> Unit) {
|
||||
val dark = when (theme) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
val view = LocalView.current
|
||||
val activity = view.context as? Activity
|
||||
if (!view.isInEditMode && activity != null) {
|
||||
SideEffect {
|
||||
val controller = WindowCompat.getInsetsController(activity.window, view)
|
||||
controller.isAppearanceLightStatusBars = !dark
|
||||
}
|
||||
}
|
||||
MaterialTheme(colorScheme = if (dark) DarkColors else LightColors, content = content)
|
||||
}
|
||||
|
After Width: | Height: | Size: 260 KiB |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Баланс</string>
|
||||
</resources>
|
||||