initial commit
No files matched your search
@@ -0,0 +1,60 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.name }})
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: watchOS Simulator
|
||||
destination: generic/platform=watchOS Simulator
|
||||
- name: watchOS Device
|
||||
destination: generic/platform=watchOS
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep -i watch
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -o pipefail
|
||||
mkdir -p build
|
||||
xcodebuild \
|
||||
-project BalanceWatch.xcodeproj \
|
||||
-scheme BalanceWatch \
|
||||
-configuration Debug \
|
||||
-destination '${{ matrix.destination }}' \
|
||||
-derivedDataPath build/DerivedData \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
build | tee build/xcodebuild.log
|
||||
|
||||
- name: Upload build log
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xcodebuild-log-${{ strategy.job-index }}
|
||||
path: build/xcodebuild.log
|
||||
@@ -0,0 +1,12 @@
|
||||
# Xcode
|
||||
DerivedData/
|
||||
build/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
# Swift Package Manager
|
||||
.build/
|
||||
.swiftpm/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,313 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
F01C0000000000000000001C /* BalanceWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BalanceWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
G00200000000000000000002 /* BalanceWatch */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = BalanceWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
S00B0000000000000000000B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
G00100000000000000000001 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
G00200000000000000000002 /* BalanceWatch */,
|
||||
G00B0000000000000000000B /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
G00B0000000000000000000B /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F01C0000000000000000001C /* BalanceWatch.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
T00400000000000000000004 /* BalanceWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = L00500000000000000000005 /* Build configuration list for PBXNativeTarget "BalanceWatch" */;
|
||||
buildPhases = (
|
||||
S00A0000000000000000000A /* Sources */,
|
||||
S00B0000000000000000000B /* Frameworks */,
|
||||
S00C0000000000000000000C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
G00200000000000000000002 /* BalanceWatch */,
|
||||
);
|
||||
name = BalanceWatch;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
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 = {
|
||||
T00400000000000000000004 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = L00100000000000000000001 /* Build configuration list for PBXProject "BalanceWatch" */;
|
||||
developmentRegion = ru;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
ru,
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = G00100000000000000000001;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = G00B0000000000000000000B /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
T00400000000000000000004 /* BalanceWatch */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
S00C0000000000000000000C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
S00A0000000000000000000A /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase 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;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = watchos;
|
||||
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;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = watchos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
C00900000000000000000009 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
DEVELOPMENT_TEAM = K3AS449A74;
|
||||
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.tolamironcenko.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;
|
||||
DEVELOPMENT_TEAM = K3AS449A74;
|
||||
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.tolamironcenko.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 "BalanceWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C00100000000000000000001 /* Debug */,
|
||||
C00200000000000000000002 /* 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,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<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:BalanceWatch.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</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 = "T00400000000000000000004"
|
||||
BuildableName = "BalanceWatch.app"
|
||||
BlueprintName = "BalanceWatch"
|
||||
ReferencedContainer = "container:BalanceWatch.xcodeproj">
|
||||
</BuildableReference>
|
||||
</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:BalanceWatch.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -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(
|
||||
"Локально; синхронизация через сервер",
|
||||
systemImage: "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)
|
||||
}
|
||||
}
|
||||
|
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,139 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "33x33",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"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,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)
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum BalanceModelContainer {
|
||||
static let schema = Schema([
|
||||
FinanceTransaction.self,
|
||||
MonthlyBudget.self,
|
||||
CustomCategory.self,
|
||||
SyncTombstone.self
|
||||
])
|
||||
|
||||
static func make() -> ModelContainer {
|
||||
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,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,53 @@
|
||||
# BalanceWatch
|
||||
|
||||
Самостоятельное (watch-only) приложение Balance для watchOS 10+, выделенное
|
||||
из монорепозитория Balance (`Balance/BalanceWatch` + общий код).
|
||||
|
||||
Стек: SwiftUI + SwiftData, синхронизация через self-hosted BalanceServer.
|
||||
|
||||
## Структура
|
||||
|
||||
```text
|
||||
BalanceWatch/
|
||||
├── BalanceWatch.xcodeproj/
|
||||
└── BalanceWatch/
|
||||
├── App/ # точка входа и экраны часов
|
||||
├── Models/ # SwiftData-модели (копия из монорепы)
|
||||
├── Shared/ # контейнер SwiftData, тема, ServerSync
|
||||
├── Utilities/ # FinanceCalculations, форматирование валют
|
||||
├── Views/Components/ # CategoryIconView
|
||||
└── Assets.xcassets # иконка приложения
|
||||
```
|
||||
|
||||
Проект использует папки, синхронизируемые с файловой системой (Xcode 16+):
|
||||
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
|
||||
таргет — править `project.pbxproj` вручную не нужно.
|
||||
|
||||
## Сборка
|
||||
|
||||
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
|
||||
2. Выбрать схему `BalanceWatch` и симулятор Apple Watch.
|
||||
3. В Signing & Capabilities при необходимости поменять Team и
|
||||
bundle id (`com.tolamironcenko.Balance.watchkitapp`).
|
||||
|
||||
Из командной строки:
|
||||
|
||||
```bash
|
||||
xcodebuild -project BalanceWatch.xcodeproj -scheme BalanceWatch \
|
||||
-destination 'generic/platform=watchOS Simulator' build
|
||||
```
|
||||
|
||||
## CI
|
||||
|
||||
GitHub Actions (`.github/workflows/build.yml`) собирает проект на
|
||||
`macos-15` с последним стабильным Xcode для двух назначений — watchOS
|
||||
Simulator и watchOS device (без подписи). Запускается на push в
|
||||
`main`/`develop`, на pull request и вручную (Run workflow). При падении
|
||||
лог `xcodebuild` прикладывается к запуску как артефакт.
|
||||
|
||||
## Важно
|
||||
|
||||
Модели, `FinanceCalculations.swift` и `ServerSync.swift` — копии общего
|
||||
кода из монорепозитория. Протокол синхронизации и правила расчётов должны
|
||||
совпадать с остальными клиентами Balance: при изменении контракта на
|
||||
сервере переносите правки и сюда.
|
||||