initial commit
Build / Build (watchOS Simulator) (push) Canceled after 0s
Build / Build (watchOS Device) (push) Canceled after 0s

This commit is contained in:
wt committed 2026-10-30 12:24:11 +07:00
commit ea6abfc139
40 files changed
+2539

No files matched your search

+60
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
# Xcode
DerivedData/
build/
*.xcuserstate
xcuserdata/
# Swift Package Manager
.build/
.swiftpm/
# macOS
.DS_Store
+313
View File
@@ -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>
+17
View File
@@ -0,0 +1,17 @@
import SwiftData
import SwiftUI
@main
struct BalanceWatchApp: App {
private let modelContainer = BalanceModelContainer.make()
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
var body: some Scene {
WindowGroup {
WatchRootView()
.tint(.indigo)
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
}
.modelContainer(modelContainer)
}
}
@@ -0,0 +1,110 @@
import SwiftData
import SwiftUI
struct WatchAddTransactionView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Query(sort: \CustomCategory.createdAt)
private var customCategories: [CustomCategory]
private let transaction: FinanceTransaction?
@State private var kind: TransactionKind
@State private var amount: Double
@State private var categoryName: String
@State private var date: Date
@State private var note: String
init(transaction: FinanceTransaction? = nil) {
self.transaction = transaction
let initialKind = transaction?.kind ?? .expense
_kind = State(initialValue: initialKind)
_amount = State(initialValue: transaction?.amount ?? 0)
_categoryName = State(initialValue: transaction?.categoryName
?? FinanceCategory.categories(for: initialKind)[0].name)
_date = State(initialValue: transaction?.date ?? .now)
_note = State(initialValue: transaction?.note ?? "")
}
private var categories: [FinanceCategory] {
var values = FinanceCategory.categories(for: kind)
+ customCategories.filter { $0.kind == kind }.map(\.financeCategory)
if let transaction,
!values.contains(where: { $0.name == transaction.categoryName }) {
values.append(FinanceCategory(
id: "archived-\(transaction.id)",
name: transaction.categoryName,
icon: transaction.categoryIcon,
emoji: transaction.categoryEmoji,
colorName: transaction.categoryColorName
))
}
return values
}
var body: some View {
NavigationStack {
Form {
Picker("Тип", selection: $kind) {
ForEach(TransactionKind.allCases) { Text($0.title).tag($0) }
}
.disabled(transaction?.isBalanceAdjustment == true)
.onChange(of: kind) { _, newKind in
categoryName = FinanceCategory.categories(for: newKind)[0].name
}
TextField("Сумма", value: $amount, format: .number)
Picker("Категория", selection: $categoryName) {
ForEach(categories) { category in
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
.tag(category.name)
}
}
.disabled(transaction?.isBalanceAdjustment == true)
DatePicker("Дата", selection: $date, displayedComponents: .date)
TextField("Заметка", text: $note)
Button("Сохранить") {
save()
}
.disabled(amount <= 0)
}
.navigationTitle(transaction == nil ? "Операция" : "Изменить")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
}
}
}
private func save() {
guard amount > 0 else { return }
let category = categories.first { $0.name == categoryName }
?? FinanceCategory.categories(for: kind)[0]
if let transaction {
transaction.amount = amount
transaction.kind = kind
transaction.categoryName = category.name
transaction.categoryIcon = category.icon
transaction.categoryEmoji = category.emoji
transaction.categoryColorName = category.colorName
transaction.date = date
transaction.note = note
transaction.syncUpdatedAt = .now
} else {
modelContext.insert(FinanceTransaction(
amount: amount,
date: date,
note: note,
categoryName: category.name,
categoryIcon: category.icon,
categoryEmoji: category.emoji,
categoryColorName: category.colorName,
kind: kind
))
}
dismiss()
}
}
+493
View File
@@ -0,0 +1,493 @@
import Foundation
import SwiftData
import SwiftUI
private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable {
case month
case threeMonths
case year
var id: String { rawValue }
var title: String {
switch self {
case .month: "Месяц"
case .threeMonths: "3 мес."
case .year: "12 мес."
}
}
func interval() -> DateInterval {
let calendar = Calendar.current
let current = calendar.dateInterval(of: .month, for: .now)
?? DateInterval(start: .now, duration: 30 * 86_400)
let offset = self == .month ? 0 : (self == .threeMonths ? -2 : -11)
let start = calendar.date(byAdding: .month, value: offset, to: current.start)
?? current.start
return DateInterval(start: start, end: current.end)
}
}
struct WatchAnalyticsView: View {
@Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@State private var period = WatchAnalyticsPeriod.month
private var interval: DateInterval { period.interval() }
private var summary: FinancePeriodSummary {
FinanceCalculations.summary(transactions: transactions, in: interval)
}
private var spending: [CategorySpending] {
Array(FinanceCalculations.spendingByCategory(
transactions: transactions,
in: interval
).prefix(6))
}
private var savingsRate: Double {
summary.income > 0 ? summary.balance / summary.income * 100 : 0
}
var body: some View {
List {
Picker("Период", selection: $period) {
ForEach(WatchAnalyticsPeriod.allCases) { Text($0.title).tag($0) }
}
Section("Итоги") {
metric("Доходы", summary.income, .green)
metric("Расходы", summary.expenses, .red)
LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode))
LabeledContent(
"Сбережения",
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
)
}
Section("Категории") {
if spending.isEmpty {
Text("Нет расходов").foregroundStyle(.secondary)
} else {
ForEach(spending) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text((item.emoji.isEmpty ? "" : item.emoji + " ") + item.name)
.lineLimit(1)
Spacer()
Text(item.amount.formattedCurrency(code: currencyCode))
.font(.caption2)
}
ProgressView(
value: spending.first.map { item.amount / max($0.amount, 1) } ?? 0
)
.tint(tint(for: item))
}
}
}
}
}
.navigationTitle("Аналитика")
}
private func metric(_ title: String, _ amount: Double, _ tint: Color) -> some View {
LabeledContent(title, value: amount.formattedCurrency(code: currencyCode))
.foregroundStyle(tint)
}
private func tint(for item: CategorySpending) -> Color {
item.colorName == "auto"
? FinanceCategory.find(named: item.name)?.tint ?? .indigo
: FinanceCategory.color(named: item.colorName)
}
}
struct WatchTransactionsView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \FinanceTransaction.date, order: .reverse)
private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@State private var selectedKind: TransactionKind?
@State private var searchText = ""
@State private var showingAdd = false
@State private var editing: FinanceTransaction?
private var filtered: [FinanceTransaction] {
transactions.filter {
(selectedKind == nil || $0.kind == selectedKind)
&& (searchText.isEmpty
|| $0.categoryName.localizedCaseInsensitiveContains(searchText)
|| $0.note.localizedCaseInsensitiveContains(searchText))
}
}
var body: some View {
List {
Picker("Фильтр", selection: $selectedKind) {
Text("Все").tag(TransactionKind?.none)
Text("Расходы").tag(TransactionKind?.some(.expense))
Text("Доходы").tag(TransactionKind?.some(.income))
}
ForEach(filtered) { transaction in
Button {
editing = transaction
} label: {
HStack {
CategoryIconView(
systemName: transaction.categoryIcon,
emoji: transaction.categoryEmoji,
tint: tint(transaction),
size: transaction.categoryEmoji.isEmpty ? 13 : 17,
containerSize: 30
)
VStack(alignment: .leading) {
Text(transaction.categoryName).lineLimit(1)
Text((transaction.kind == .income ? "+" : "−")
+ transaction.amount.formattedCurrency(code: currencyCode))
.font(.caption2)
}
}
}
.swipeActions {
Button(role: .destructive) { modelContext.deleteForSync(transaction) } label: {
Label("Удалить", systemImage: "trash")
}
}
}
}
.navigationTitle("Операции")
.searchable(text: $searchText, prompt: "Поиск")
.toolbar {
Button { showingAdd = true } label: { Image(systemName: "plus") }
}
.sheet(isPresented: $showingAdd) { WatchAddTransactionView() }
.sheet(item: $editing) { WatchAddTransactionView(transaction: $0) }
}
private func tint(_ transaction: FinanceTransaction) -> Color {
transaction.categoryColorName == "auto"
? FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo
: FinanceCategory.color(named: transaction.categoryColorName)
}
}
struct WatchBudgetsView: View {
@Query private var budgets: [MonthlyBudget]
@Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@State private var showingEditor = false
private var interval: DateInterval {
FinanceCalculations.monthInterval(containing: .now)
}
private var current: [MonthlyBudget] {
budgets.filter { interval.contains($0.monthStart) }
}
private var spending: [String: Double] {
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
transactions: transactions,
in: interval
).map { ($0.name, $0.amount) })
}
var body: some View {
List {
if current.isEmpty {
Text("Лимиты не заданы").foregroundStyle(.secondary)
}
ForEach(current) { budget in
let spent = spending[budget.categoryName, default: 0]
VStack(alignment: .leading, spacing: 5) {
HStack {
Text((budget.categoryEmoji.isEmpty ? "" : budget.categoryEmoji + " ") + budget.categoryName)
.lineLimit(1)
Spacer()
Text(spent.formattedCurrency(code: currencyCode)).font(.caption2)
}
ProgressView(value: min(budget.limit > 0 ? spent / budget.limit : 0, 1))
Text("Лимит: \(budget.limit.formattedCurrency(code: currencyCode))")
.font(.caption2).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Бюджеты")
.toolbar {
Button { showingEditor = true } label: { Image(systemName: "pencil") }
}
.sheet(isPresented: $showingEditor) {
WatchBudgetEditor(budgets: current, monthStart: interval.start)
}
}
}
private struct WatchBudgetEditor: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Query(sort: \CustomCategory.createdAt) private var customCategories: [CustomCategory]
let budgets: [MonthlyBudget]
let monthStart: Date
@State private var limits: [String: Double]
init(budgets: [MonthlyBudget], monthStart: Date) {
self.budgets = budgets
self.monthStart = monthStart
_limits = State(initialValue: Dictionary(uniqueKeysWithValues: budgets.map {
($0.categoryName, $0.limit)
}))
}
private var categories: [FinanceCategory] {
FinanceCategory.expenseCategories
+ customCategories.filter { $0.kind == .expense }.map(\.financeCategory)
}
var body: some View {
Form {
ForEach(categories) { category in
TextField(category.name, value: binding(category.name), format: .number)
}
Button("Сохранить", action: save)
}
.navigationTitle("Лимиты")
}
private func binding(_ name: String) -> Binding<Double> {
Binding(get: { limits[name, default: 0] }, set: { limits[name] = max(0, $0) })
}
private func save() {
for category in categories {
let value = limits[category.name, default: 0]
let existing = budgets.first { $0.categoryName == category.name }
if value > 0 {
if let existing {
existing.limit = value
existing.syncUpdatedAt = .now
} else {
modelContext.insert(MonthlyBudget(
categoryName: category.name,
categoryIcon: category.icon,
categoryEmoji: category.emoji,
limit: value,
monthStart: monthStart
))
}
} else if let existing {
modelContext.deleteForSync(existing)
}
}
dismiss()
}
}
struct WatchCategoriesView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \CustomCategory.createdAt) private var categories: [CustomCategory]
@Query private var budgets: [MonthlyBudget]
@State private var showingAdd = false
@State private var editing: CustomCategory?
var body: some View {
List {
if categories.isEmpty {
Text("Нет своих категорий").foregroundStyle(.secondary)
}
ForEach(categories) { category in
Button {
editing = category
} label: {
HStack {
CategoryIconView(
systemName: category.icon,
emoji: category.emoji,
tint: FinanceCategory.color(named: category.colorName),
size: category.emoji.isEmpty ? 13 : 17,
containerSize: 30
)
Text(category.name)
}
}
.swipeActions {
Button(role: .destructive) {
budgets.filter { $0.categoryName == category.name }
.forEach { modelContext.deleteForSync($0) }
modelContext.deleteForSync(category)
} label: {
Label("Удалить", systemImage: "trash")
}
}
}
}
.navigationTitle("Категории")
.toolbar { Button { showingAdd = true } label: { Image(systemName: "plus") } }
.sheet(isPresented: $showingAdd) { WatchCategoryEditor() }
.sheet(item: $editing) { WatchCategoryEditor(category: $0) }
}
}
private struct WatchCategoryEditor: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Query private var transactions: [FinanceTransaction]
@Query private var budgets: [MonthlyBudget]
@Query private var customCategories: [CustomCategory]
let category: CustomCategory?
@State private var name: String
@State private var kind: TransactionKind
@State private var usesEmoji: Bool
@State private var emoji: String
@State private var icon: String
@State private var colorName: String
init(category: CustomCategory? = nil) {
self.category = category
_name = State(initialValue: category?.name ?? "")
_kind = State(initialValue: category?.kind ?? .expense)
_usesEmoji = State(initialValue: category?.emoji.isEmpty == false)
_emoji = State(initialValue: category?.emoji ?? "⭐️")
_icon = State(initialValue: category?.icon ?? "star.fill")
_colorName = State(initialValue: category?.colorName ?? "indigo")
}
private var trimmedName: String {
name.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var isDuplicate: Bool {
let names = (FinanceCategory.expenseCategories + FinanceCategory.incomeCategories).map(\.name)
+ customCategories.filter { $0.id != category?.id }.map(\.name)
return names.contains {
$0.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
}
}
var body: some View {
Form {
TextField("Название", text: $name)
if isDuplicate && !trimmedName.isEmpty {
Text("Название уже занято").foregroundStyle(.red)
}
Picker("Тип", selection: $kind) {
ForEach(TransactionKind.allCases) { Text($0.title).tag($0) }
}
.disabled(category != nil)
Toggle("Эмодзи", isOn: $usesEmoji)
if usesEmoji {
TextField("Эмодзи", text: $emoji)
Picker("Готовые", selection: $emoji) {
ForEach(CategoryDesignOptions.emojis, id: \.self) { Text($0).tag($0) }
}
} else {
Picker("Иконка", selection: $icon) {
ForEach(CategoryDesignOptions.icons, id: \.self) { Text($0).tag($0) }
}
}
Picker("Цвет", selection: $colorName) {
ForEach(CategoryDesignOptions.colors, id: \.self) { Text($0).tag($0) }
}
Button("Сохранить", action: save)
.disabled(trimmedName.isEmpty || isDuplicate)
}
.navigationTitle(category == nil ? "Новая" : "Изменить")
}
private func save() {
guard !trimmedName.isEmpty && !isDuplicate else { return }
let finalEmoji = usesEmoji ? emoji.first.map(String.init) ?? "" : ""
if let category {
let oldName = category.name
category.name = trimmedName
category.icon = icon
category.emoji = finalEmoji
category.colorName = colorName
category.syncUpdatedAt = .now
transactions.filter { $0.categoryName == oldName }.forEach {
$0.categoryName = trimmedName
$0.categoryIcon = icon
$0.categoryEmoji = finalEmoji
$0.categoryColorName = colorName
$0.syncUpdatedAt = .now
}
budgets.filter { $0.categoryName == oldName }.forEach {
$0.categoryName = trimmedName
$0.categoryIcon = icon
$0.categoryEmoji = finalEmoji
$0.syncUpdatedAt = .now
}
} else {
modelContext.insert(CustomCategory(
name: trimmedName,
icon: icon,
emoji: finalEmoji,
colorName: colorName,
kind: kind
))
}
dismiss()
}
}
struct WatchBalanceAdjustmentView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Query private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@State private var desired = 0.0
@State private var note = ""
private var current: Double { FinanceCalculations.totalBalance(transactions: transactions) }
private var difference: Double { desired - current }
var body: some View {
Form {
LabeledContent("Сейчас", value: current.formattedCurrency(code: currencyCode))
TextField("Фактически", value: $desired, format: .number)
if abs(difference) >= 0.005 {
LabeledContent("Разница", value: difference.formattedCurrency(code: currencyCode))
}
TextField("Комментарий", text: $note)
Button("Сохранить", action: save).disabled(abs(difference) < 0.005)
}
.navigationTitle("Баланс")
.onAppear { desired = current }
}
private func save() {
guard abs(difference) >= 0.005 else { return }
modelContext.insert(FinanceTransaction(
amount: abs(difference),
note: note.isEmpty ? "Ручная корректировка баланса" : note,
categoryName: "Корректировка",
categoryIcon: "slider.horizontal.3",
categoryColorName: "indigo",
isBalanceAdjustment: true,
kind: difference > 0 ? .income : .expense
))
dismiss()
}
}
struct WatchSettingsView: View {
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
var body: some View {
Form {
Picker("Валюта", selection: $currencyCode) {
ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) }
}
Picker("Тема", selection: $appTheme) {
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
}
NavigationLink {
ServerAccountView()
} label: {
Label("Сервер", systemImage: "server.rack")
}
Label(
"Локально; синхронизация через сервер",
systemImage: "internaldrive.fill"
)
LabeledContent("Версия", value: "3.1.1")
}
.navigationTitle("Настройки")
}
}
+158
View File
@@ -0,0 +1,158 @@
import SwiftData
import SwiftUI
struct WatchRootView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \FinanceTransaction.date, order: .reverse)
private var transactions: [FinanceTransaction]
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@State private var showingAddTransaction = false
private var monthSummary: FinancePeriodSummary {
FinanceCalculations.summary(
transactions: transactions,
in: FinanceCalculations.monthInterval(containing: .now)
)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text("Общий баланс")
.font(.caption)
.foregroundStyle(.secondary)
Text(FinanceCalculations.totalBalance(transactions: transactions)
.formattedCurrency(code: currencyCode))
.font(.title3.bold())
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(
LinearGradient(
colors: [.indigo, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 16)
)
.foregroundStyle(.white)
HStack(spacing: 8) {
watchMetric(
title: "Доходы",
amount: monthSummary.income,
tint: .green
)
watchMetric(
title: "Расходы",
amount: monthSummary.expenses,
tint: .red
)
}
Button {
showingAddTransaction = true
} label: {
Label("Добавить операцию", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
VStack(spacing: 4) {
NavigationLink {
WatchAnalyticsView()
} label: {
Label("Аналитика", systemImage: "chart.xyaxis.line")
}
NavigationLink {
WatchTransactionsView()
} label: {
Label("Все операции", systemImage: "list.bullet.rectangle")
}
NavigationLink {
WatchBudgetsView()
} label: {
Label("Бюджеты", systemImage: "target")
}
NavigationLink {
WatchCategoriesView()
} label: {
Label("Категории", systemImage: "square.grid.2x2")
}
NavigationLink {
WatchBalanceAdjustmentView()
} label: {
Label("Корректировка", systemImage: "slider.horizontal.3")
}
NavigationLink {
WatchSettingsView()
} label: {
Label("Настройки", systemImage: "gearshape")
}
}
if !transactions.isEmpty {
Text("Последние")
.font(.headline)
ForEach(transactions.prefix(4)) { transaction in
HStack(spacing: 8) {
CategoryIconView(
systemName: transaction.categoryIcon,
emoji: transaction.categoryEmoji,
tint: transactionTint(transaction),
size: transaction.categoryEmoji.isEmpty ? 13 : 17,
containerSize: 30
)
VStack(alignment: .leading, spacing: 1) {
Text(transaction.categoryName)
.font(.caption)
.lineLimit(1)
Text((transaction.kind == .income ? "+" : "−")
+ transaction.amount.formattedCurrency(code: currencyCode))
.font(.caption2.weight(.semibold))
.foregroundStyle(transaction.kind == .income ? .green : .secondary)
}
Spacer()
}
}
}
}
}
.navigationTitle("Баланс")
.sheet(isPresented: $showingAddTransaction) {
WatchAddTransactionView()
}
}
.task {
while !Task.isCancelled {
await ServerAccountStore.shared.sync(context: modelContext)
try? await Task.sleep(nanoseconds: 120_000_000_000)
}
}
}
private func watchMetric(title: String, amount: Double, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(amount.formattedCurrency(code: currencyCode))
.font(.caption.bold())
.lineLimit(1)
.minimumScaleFactor(0.65)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(9)
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
}
private func transactionTint(_ transaction: FinanceTransaction) -> Color {
if transaction.categoryColorName == "auto" {
return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo
}
return FinanceCategory.color(named: transaction.categoryColorName)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,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
}
}
+49
View File
@@ -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
)
}
}
+82
View File
@@ -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 }
}
}
+31
View File
@@ -0,0 +1,31 @@
import Foundation
import SwiftData
@Model
final class MonthlyBudget {
var id: UUID = UUID()
var categoryName: String = ""
var categoryIcon: String = "circle.fill"
var categoryEmoji: String = ""
var limit: Double = 0
var monthStart: Date = Date.now
var syncUpdatedAt: Date = Date.now
init(
id: UUID = UUID(),
categoryName: String,
categoryIcon: String,
categoryEmoji: String = "",
limit: Double,
monthStart: Date,
syncUpdatedAt: Date = .now
) {
self.id = id
self.categoryName = categoryName
self.categoryIcon = categoryIcon
self.categoryEmoji = categoryEmoji
self.limit = limit
self.monthStart = monthStart
self.syncUpdatedAt = syncUpdatedAt
}
}
+109
View File
@@ -0,0 +1,109 @@
import Foundation
import SwiftData
@Model
final class SyncTombstone {
var id: UUID = UUID()
var entity: String = ""
var recordID: String = ""
var updatedAt: Date = Date.now
init(entity: String, recordID: String, updatedAt: Date = .now) {
self.entity = entity
self.recordID = recordID
self.updatedAt = updatedAt
}
}
struct SyncDeletionRecord: Codable, Identifiable {
let id: UUID
let entity: String
let recordID: String
let updatedAt: Date
init(entity: String, recordID: String, updatedAt: Date = .now) {
self.id = UUID()
self.entity = entity
self.recordID = recordID
self.updatedAt = updatedAt
}
}
enum SyncDeletionStore {
private static let storageKey = "balance.server.deletionJournal.v1"
private static let quarantineKey = "balance.server.deletionJournal.corrupt"
private static let lock = NSLock()
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
guard supportedEntities.contains(entity), UUID(uuidString:recordID) != nil else { return }
lock.lock()
defer { lock.unlock() }
var records = loadUnlocked()
records.removeAll { $0.entity == entity && $0.recordID == recordID }
records.append(SyncDeletionRecord(entity:entity, recordID:recordID, updatedAt:updatedAt))
saveUnlocked(records)
}
static func page(through cutoff: Date, offset: Int, limit: Int) -> [SyncDeletionRecord] {
guard offset >= 0, limit > 0 else { return [] }
lock.lock()
defer { lock.unlock() }
let records = loadUnlocked()
.filter { $0.updatedAt <= cutoff && supportedEntities.contains($0.entity) && UUID(uuidString:$0.recordID) != nil }
.sorted { $0.updatedAt < $1.updatedAt }
guard offset < records.count else { return [] }
return Array(records[offset..<min(offset + limit, records.count)])
}
static func remove(entity: String, recordID: String, through updatedAt: Date) {
lock.lock()
defer { lock.unlock() }
var records = loadUnlocked()
records.removeAll {
$0.entity == entity && $0.recordID == recordID && $0.updatedAt <= updatedAt
}
saveUnlocked(records)
}
private static let supportedEntities = Set(["transaction", "category", "budget"])
private static func loadUnlocked() -> [SyncDeletionRecord] {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey:storageKey) else { return [] }
do {
return try JSONDecoder().decode([SyncDeletionRecord].self, from:data)
} catch {
defaults.set(data, forKey:quarantineKey)
defaults.removeObject(forKey:storageKey)
return []
}
}
private static func saveUnlocked(_ records: [SyncDeletionRecord]) {
let defaults = UserDefaults.standard
guard !records.isEmpty else {
defaults.removeObject(forKey:storageKey)
return
}
if let data = try? JSONEncoder().encode(records) {
defaults.set(data, forKey:storageKey)
}
}
}
extension ModelContext {
func deleteForSync(_ transaction: FinanceTransaction) {
SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString)
delete(transaction)
}
func deleteForSync(_ category: CustomCategory) {
SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString)
delete(category)
}
func deleteForSync(_ budget: MonthlyBudget) {
SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString)
delete(budget)
}
}
+25
View File
@@ -0,0 +1,25 @@
import SwiftUI
enum AppTheme: String, CaseIterable, Identifiable {
case system
case light
case dark
var id: String { rawValue }
var title: String {
switch self {
case .system: "Системная"
case .light: "Светлая"
case .dark: "Тёмная"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
@@ -0,0 +1,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"
]
}
+535
View File
@@ -0,0 +1,535 @@
import Foundation
import Security
import SwiftData
import SwiftUI
private struct ServerUser: Codable { let id: String; let email: String }
private struct ServerSession: Codable {
let accessToken: String
let refreshToken: String
let expiresAt: Date
let user: ServerUser
}
private struct ServerErrorEnvelope: Decodable {
struct Detail: Decodable { let code: String; let message: String }
let error: Detail
}
private struct SyncChange: Codable {
let entity: String
let id: String
let deleted: Bool
let updatedAt: Date
let payload: Data?
let version: Int64?
let sequence: Int64?
enum CodingKeys: String, CodingKey { case entity, id, deleted, updatedAt, payload, version, sequence }
init(entity: String, id: String, deleted: Bool, updatedAt: Date, payload: Data? = nil, version: Int64? = nil, sequence: Int64? = nil) {
self.entity = entity; self.id = id; self.deleted = deleted; self.updatedAt = updatedAt
self.payload = payload; self.version = version; self.sequence = sequence
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
entity = try values.decode(String.self, forKey: .entity)
id = try values.decode(String.self, forKey: .id)
deleted = try values.decode(Bool.self, forKey: .deleted)
updatedAt = try values.decode(Date.self, forKey: .updatedAt)
version = try values.decodeIfPresent(Int64.self, forKey: .version)
sequence = try values.decodeIfPresent(Int64.self, forKey: .sequence)
if values.contains(.payload) {
let object = try values.decode(JSONValue.self, forKey: .payload)
payload = try ServerCoding.encoder.encode(object)
} else { payload = nil }
}
func encode(to encoder: Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(entity, forKey: .entity); try values.encode(id, forKey: .id)
try values.encode(deleted, forKey: .deleted); try values.encode(updatedAt, forKey: .updatedAt)
try values.encodeIfPresent(version, forKey: .version); try values.encodeIfPresent(sequence, forKey: .sequence)
if let payload { try values.encode(ServerCoding.decoder.decode(JSONValue.self, from: payload), forKey: .payload) }
}
}
private enum JSONValue: Codable {
case string(String), number(Double), bool(Bool), object([String: JSONValue]), array([JSONValue]), null
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer()
if value.decodeNil() { self = .null }
else if let item = try? value.decode(Bool.self) { self = .bool(item) }
else if let item = try? value.decode(Double.self) { self = .number(item) }
else if let item = try? value.decode(String.self) { self = .string(item) }
else if let item = try? value.decode([String: JSONValue].self) { self = .object(item) }
else { self = .array(try value.decode([JSONValue].self)) }
}
func encode(to encoder: Encoder) throws {
var value = encoder.singleValueContainer()
switch self {
case .string(let item): try value.encode(item)
case .number(let item): try value.encode(item)
case .bool(let item): try value.encode(item)
case .object(let item): try value.encode(item)
case .array(let item): try value.encode(item)
case .null: try value.encodeNil()
}
}
}
private struct SyncRequest: Encodable {
let cursor: Int64
let deviceId: String
let changes: [SyncChange]
let pull: Bool
}
private struct SyncResponse: Decodable {
let cursor: Int64
let changes: [SyncChange]
let serverTime: Date
let hasMore: Bool?
}
private struct TransactionPayload: Codable {
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String
let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String
}
private struct CategoryPayload: Codable {
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date
}
private struct BudgetPayload: Codable {
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date
}
private enum ServerCoding {
static let encoder: JSONEncoder = {
let value = JSONEncoder()
value.dateEncodingStrategy = .custom { date, encoder in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var container = encoder.singleValueContainer()
try container.encode(formatter.string(from:date))
}
return value
}()
static let decoder: JSONDecoder = {
let value = JSONDecoder()
value.dateDecodingStrategy = .custom { decoder in
let text = try decoder.singleValueContainer().decode(String.self)
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let regular = ISO8601DateFormatter()
guard let date = fractional.date(from: text) ?? regular.date(from: text) else {
throw DecodingError.dataCorruptedError(
in: try decoder.singleValueContainer(),
debugDescription: "Invalid ISO-8601 date"
)
}
return date
}
return value
}()
}
enum ServerSyncError: LocalizedError {
case invalidURL, insecureURL, response(String), notSignedIn
var errorDescription: String? {
switch self {
case .invalidURL: "Укажите полный адрес сервера, например https://balance.example.com"
case .insecureURL: "Для удалённого сервера требуется HTTPS. HTTP разрешён только для localhost."
case .response(let message): message
case .notSignedIn: "Сначала войдите в аккаунт."
}
}
}
@MainActor
final class ServerAccountStore: ObservableObject {
static let shared = ServerAccountStore()
@Published private(set) var signedInEmail: String?
@Published private(set) var isBusy = false
@Published private(set) var lastSyncAt: Date?
@Published var message: String?
private let defaults = UserDefaults.standard
private var session: ServerSession?
private let sessionKey = "balance.server.session"
private let serverKey = "balance.server.url"
private let syncBatchSize = 100
var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
var isSignedIn: Bool { session != nil }
private init() {
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
session = value; signedInEmail = value.user.email
}
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date
}
func saveServer(_ value: String) throws {
guard !isBusy else {
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
}
let normalized = try normalizedServer(value)
if normalized != serverURL { clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState() }
}
func register(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/register", email: email, password: password, context: context) }
func login(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/login", email: email, password: password, context: context) }
func logout() async {
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse }
clearSession(); message = "Вы вышли из аккаунта."
}
func sync(context: ModelContext) async {
guard session != nil, !isBusy else { return }
isBusy = true; message = nil
defer { isBusy = false }
do {
try context.save()
let cutoff = Date.now
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
let threshold = lastPush <= cutoff.addingTimeInterval(5)
? lastPush.addingTimeInterval(-5)
: .distantPast
let initialCursor = max(0, Int64(defaults.integer(forKey: cursorKey())))
try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor)
try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor)
try await pushTombstones(through: cutoff, cursor: initialCursor)
try await pullChanges(context: context, cursor: initialCursor)
defaults.set(cutoff, forKey: pushKey())
lastSyncAt = .now; defaults.set(lastSyncAt, forKey: "balance.server.lastSync")
message = "Синхронизация завершена."
} catch { message = error.localizedDescription }
}
func resetSynchronization(context: ModelContext) async {
guard !isBusy else { return }
defaults.removeObject(forKey:cursorKey())
defaults.removeObject(forKey:pushKey())
message = "Запущена полная пересинхронизация…"
await sync(context:context)
}
private func authenticate(path: String, email: String, password: String, context: ModelContext) async {
guard !isBusy else { return }; isBusy = true; message = nil; defer { isBusy = false }
do {
let response: ServerSession = try await request(path: path, body: ["email":email, "password":password], authorized: false)
session = response; signedInEmail = response.user.email
try KeychainStore.write(ServerCoding.encoder.encode(response), key: sessionKey)
message = "Вход выполнен. Запускаю синхронизацию…"
isBusy = false; await sync(context: context)
} catch { message = error.localizedDescription }
}
private func request<Response: Decodable, Body: Encodable>(path: String, body: Body, authorized: Bool, retry: Bool = true) async throws -> Response {
if authorized { try await refreshIfNeeded() }
let base = try normalizedServer(serverURL)
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
var request = URLRequest(url: url); request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
request.httpBody = try ServerCoding.encoder.encode(body)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") }
if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, body:body, authorized:true, retry:false) }
guard (200..<300).contains(http.statusCode) else {
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
if path == "v1/sync", envelope?.error.code == "invalid_json" {
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.")
}
throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
}
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }
return try ServerCoding.decoder.decode(Response.self, from: data)
}
private func refreshIfNeeded() async throws { if let session, session.expiresAt.timeIntervalSinceNow < 60 { try await refreshSession() } }
private func refreshSession() async throws {
guard let refresh = session?.refreshToken else { throw ServerSyncError.notSignedIn }
let updated: ServerSession = try await request(path:"v1/auth/refresh", body:["refreshToken":refresh], authorized:false, retry:false)
session = updated; signedInEmail = updated.user.email
try KeychainStore.write(ServerCoding.encoder.encode(updated), key: sessionKey)
}
private func normalizedServer(_ input: String) throws -> String {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let url = URL(string: trimmed), let scheme = url.scheme?.lowercased(), let host = url.host, scheme == "https" || scheme == "http" else { throw ServerSyncError.invalidURL }
if scheme == "http" && host != "localhost" && host != "127.0.0.1" && host != "::1" { throw ServerSyncError.insecureURL }
return trimmed
}
private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) }
private func clearSyncState() {
for key in defaults.dictionaryRepresentation().keys
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
defaults.removeObject(forKey: key)
}
}
private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" }
private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" }
private func deviceID() -> String {
if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value
}
private func pushTransactions(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws {
var offset = 0
while true {
let lowerBound = threshold
let upperBound = cutoff
var descriptor = FetchDescriptor<FinanceTransaction>(
predicate: #Predicate { item in
item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
},
sortBy: [SortDescriptor(\FinanceTransaction.syncUpdatedAt)]
)
descriptor.fetchLimit = syncBatchSize
descriptor.fetchOffset = offset
let items = try context.fetch(descriptor)
guard !items.isEmpty else { break }
let changes = try items.map { item in
let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue)
return SyncChange(entity:"transaction", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
}
try await sendPush(changes, cursor: cursor)
offset += items.count
if items.count < syncBatchSize { break }
await Task.yield()
}
}
private func pushCategories(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws {
var offset = 0
while true {
let lowerBound = threshold
let upperBound = cutoff
var descriptor = FetchDescriptor<CustomCategory>(
predicate: #Predicate { item in
item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
},
sortBy: [SortDescriptor(\CustomCategory.syncUpdatedAt)]
)
descriptor.fetchLimit = syncBatchSize
descriptor.fetchOffset = offset
let items = try context.fetch(descriptor)
guard !items.isEmpty else { break }
let changes = try items.map { item in
let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt)
return SyncChange(entity:"category", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
}
try await sendPush(changes, cursor: cursor)
offset += items.count
if items.count < syncBatchSize { break }
await Task.yield()
}
}
private func pushBudgets(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws {
var offset = 0
while true {
let lowerBound = threshold
let upperBound = cutoff
var descriptor = FetchDescriptor<MonthlyBudget>(
predicate: #Predicate { item in
item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
},
sortBy: [SortDescriptor(\MonthlyBudget.syncUpdatedAt)]
)
descriptor.fetchLimit = syncBatchSize
descriptor.fetchOffset = offset
let items = try context.fetch(descriptor)
guard !items.isEmpty else { break }
let changes = try items.map { item in
let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart)
return SyncChange(entity:"budget", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
}
try await sendPush(changes, cursor: cursor)
offset += items.count
if items.count < syncBatchSize { break }
await Task.yield()
}
}
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
var offset = 0
while true {
let items = SyncDeletionStore.page(
through:cutoff,
offset:offset,
limit:syncBatchSize
)
guard !items.isEmpty else { break }
let changes = items.map { SyncChange(entity:$0.entity, id:$0.recordID, deleted:true, updatedAt:$0.updatedAt) }
try await sendPush(changes, cursor: cursor)
offset += items.count
if items.count < syncBatchSize { break }
await Task.yield()
}
}
private func sendPush(_ changes: [SyncChange], cursor: Int64) async throws {
guard !changes.isEmpty else { return }
if changes.contains(where: { ($0.payload?.count ?? 0) > 256 * 1024 }) {
throw ServerSyncError.response("Одна из операций содержит слишком большую заметку для синхронизации.")
}
let body = SyncRequest(cursor:cursor, deviceId:deviceID(), changes:changes, pull:false)
if try ServerCoding.encoder.encode(body).count > 1_500_000 {
guard changes.count > 1 else {
throw ServerSyncError.response("Одна запись слишком велика для отправки на сервер.")
}
let middle = changes.count / 2
try await sendPush(Array(changes[..<middle]), cursor:cursor)
try await sendPush(Array(changes[middle...]), cursor:cursor)
return
}
let _: SyncResponse = try await request(path:"v1/sync", body:body, authorized:true)
}
private func pullChanges(context: ModelContext, cursor initialCursor: Int64) async throws {
var cursor = initialCursor
while true {
let body = SyncRequest(cursor:cursor, deviceId:deviceID(), changes:[], pull:true)
let response: SyncResponse = try await request(path:"v1/sync", body:body, authorized:true)
guard response.cursor >= cursor else {
throw ServerSyncError.response("Сервер вернул некорректный курсор синхронизации.")
}
try apply(response.changes, context:context)
try context.save()
defaults.set(response.cursor, forKey:cursorKey())
guard response.hasMore == true else { break }
guard response.cursor > cursor else {
throw ServerSyncError.response("Сервер не смог продолжить постраничную синхронизацию.")
}
cursor = response.cursor
await Task.yield()
}
}
private func apply(_ changes: [SyncChange], context: ModelContext) throws {
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
if let id = UUID(uuidString:change.id) {
if change.deleted {
switch change.entity {
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
default: break
}
} else if let data = change.payload {
switch change.entity {
case "transaction":
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { continue }
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
else { context.insert(FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, syncUpdatedAt:change.updatedAt)) }
case "category":
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { continue }
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
else { context.insert(CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)) }
case "budget":
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { continue }
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
else { context.insert(MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)) }
default: break
}
}
}
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
}
}
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
let recordID = id
var descriptor = FetchDescriptor<FinanceTransaction>(predicate:#Predicate { item in item.id == recordID })
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func category(id: UUID, context: ModelContext) throws -> CustomCategory? {
let recordID = id
var descriptor = FetchDescriptor<CustomCategory>(predicate:#Predicate { item in item.id == recordID })
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func budget(id: UUID, context: ModelContext) throws -> MonthlyBudget? {
let recordID = id
var descriptor = FetchDescriptor<MonthlyBudget>(predicate:#Predicate { item in item.id == recordID })
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func update(_ item: FinanceTransaction, _ value: TransactionPayload, _ date: Date) { item.amount=value.amount; item.date=value.date; item.note=value.note; item.categoryName=value.categoryName; item.categoryIcon=value.categoryIcon; item.categoryEmoji=value.categoryEmoji; item.categoryColorName=value.categoryColorName; item.isBalanceAdjustment=value.isBalanceAdjustment; item.kindRawValue=value.kindRawValue; item.syncUpdatedAt=date }
private func update(_ item: CustomCategory, _ value: CategoryPayload, _ date: Date) { item.name=value.name; item.icon=value.icon; item.emoji=value.emoji; item.colorName=value.colorName; item.kindRawValue=value.kindRawValue; item.createdAt=value.createdAt; item.syncUpdatedAt=date }
private func update(_ item: MonthlyBudget, _ value: BudgetPayload, _ date: Date) { item.categoryName=value.categoryName; item.categoryIcon=value.categoryIcon; item.categoryEmoji=value.categoryEmoji; item.limit=value.limit; item.monthStart=value.monthStart; item.syncUpdatedAt=date }
}
private struct EmptyResponse: Codable {}
private enum KeychainStore {
static func read(_ key: String) -> Data? {
let query: [String:Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:"BalanceServer", kSecAttrAccount as String:key, kSecReturnData as String:true, kSecMatchLimit as String:kSecMatchLimitOne]
var result: AnyObject?; guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }; return result as? Data
}
static func write(_ data: Data, key: String) throws {
delete(key)
let query: [String:Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:"BalanceServer", kSecAttrAccount as String:key, kSecValueData as String:data, kSecAttrAccessible as String:kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly]
guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { throw ServerSyncError.response("Не удалось сохранить сессию в Keychain.") }
}
static func delete(_ key: String) { let query:[String:Any] = [kSecClass as String:kSecClassGenericPassword, kSecAttrService as String:"BalanceServer", kSecAttrAccount as String:key]; SecItemDelete(query as CFDictionary) }
}
@MainActor
struct ServerAccountView: View {
@Environment(\.modelContext) private var modelContext
@StateObject private var account = ServerAccountStore.shared
@State private var server = ServerAccountStore.shared.serverURL
@State private var email = ServerAccountStore.shared.signedInEmail ?? ""
@State private var password = ""
var body: some View {
Form {
Section("Сервер") {
TextField("https://balance.example.com", text:$server)
.serverTextEntryConfiguration()
Button("Сохранить адрес") { do { try account.saveServer(server); server = account.serverURL; account.message = "Адрес сохранён. Войдите заново." } catch { account.message = error.localizedDescription } }
.disabled(account.isBusy)
}
Section(account.isSignedIn ? "Аккаунт" : "Вход или регистрация") {
if let value = account.signedInEmail { LabeledContent("Выполнен вход", value:value) }
else {
TextField("Email", text:$email)
.serverTextEntryConfiguration()
SecureField("Пароль (минимум 8 символов)", text:$password)
Button("Войти") { Task { await account.login(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.isEmpty || account.isBusy)
Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy)
}
}
if account.isSignedIn {
Section("Синхронизация") {
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
Button("Полная пересинхронизация") { Task { await account.resetSynchronization(context:modelContext) } }
.disabled(account.isBusy)
if let date = account.lastSyncAt { LabeledContent("Последняя", value:date.formatted(date:.abbreviated, time:.shortened)) }
Button("Выйти", role:.destructive) { Task { await account.logout() } }
.disabled(account.isBusy)
}
}
if account.isBusy { ProgressView() }
if let message = account.message { Section { Text(message).font(.footnote).foregroundStyle(.secondary) } }
}
.navigationTitle("Сервер")
}
}
private extension View {
@ViewBuilder
func serverTextEntryConfiguration() -> some View {
#if os(macOS)
self
#else
self
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
}
}
@@ -0,0 +1,26 @@
import Foundation
enum SupportedCurrency: String, CaseIterable, Identifiable {
case RUB
case EUR
case USD
case SEK
var id: String { rawValue }
}
extension Double {
func formattedCurrency(code: String) -> String {
formatted(
.currency(code: code)
.precision(.fractionLength(0 ... 2))
.presentation(.narrow)
)
}
}
extension Date {
var monthTitle: String {
formatted(.dateTime.month(.wide).year())
}
}
@@ -0,0 +1,71 @@
import Foundation
struct FinancePeriodSummary {
let income: Double
let expenses: Double
var balance: Double { income - expenses }
}
struct CategorySpending: Identifiable {
let name: String
let icon: String
let emoji: String
let colorName: String
let amount: Double
var id: String { name }
}
enum FinanceCalculations {
static func totalBalance(transactions: [FinanceTransaction]) -> Double {
transactions.reduce(0) { balance, transaction in
balance + (transaction.kind == .income ? transaction.amount : -transaction.amount)
}
}
static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval {
calendar.dateInterval(of: .month, for: date)
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
}
static func summary(
transactions: [FinanceTransaction],
in interval: DateInterval
) -> FinancePeriodSummary {
let periodTransactions = transactions.filter {
interval.contains($0.date) && !$0.isBalanceAdjustment
}
let income = periodTransactions
.filter { $0.kind == .income }
.reduce(0) { $0 + $1.amount }
let expenses = periodTransactions
.filter { $0.kind == .expense }
.reduce(0) { $0 + $1.amount }
return FinancePeriodSummary(income: income, expenses: expenses)
}
static func spendingByCategory(
transactions: [FinanceTransaction],
in interval: DateInterval
) -> [CategorySpending] {
let expenses = transactions.filter {
$0.kind == .expense
&& !$0.isBalanceAdjustment
&& interval.contains($0.date)
}
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
return grouped.map { name, items in
CategorySpending(
name: name,
icon: items.first?.categoryIcon ?? "circle.fill",
emoji: items.first?.categoryEmoji ?? "",
colorName: items.first?.categoryColorName ?? "auto",
amount: items.reduce(0) { $0 + $1.amount }
)
}
.sorted { $0.amount > $1.amount }
}
}
@@ -0,0 +1,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))
}
}
}
+53
View File
@@ -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: при изменении контракта на
сервере переносите правки и сюда.