Files
Balance/Balance/BalanceWatch/WatchFeatures.swift
T

583 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
#Preview("Аналитика") {
NavigationStack {
WatchAnalyticsView()
}
.modelContainer(BalanceModelContainer.previewContainer)
}
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)
}
}
#Preview("Операции") {
NavigationStack {
WatchTransactionsView()
}
.modelContainer(BalanceModelContainer.previewContainer)
}
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)
}
}
}
#Preview("Бюджеты") {
NavigationStack {
WatchBudgetsView()
}
.modelContainer(BalanceModelContainer.previewContainer)
}
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
Section(category.name) {
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()
}
}
#Preview("Лимиты") {
NavigationStack {
WatchBudgetEditor(
budgets: [
MonthlyBudget(
categoryName: "Продукты",
categoryIcon: "cart.fill",
limit: 30_000,
monthStart: .now
),
MonthlyBudget(
categoryName: "Транспорт",
categoryIcon: "car.fill",
limit: 8_000,
monthStart: .now
)
],
monthStart: FinanceCalculations.monthInterval(containing: .now).start
)
}
.modelContainer(BalanceModelContainer.previewContainer)
}
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) }
}
}
#Preview("Категории") {
NavigationStack {
WatchCategoriesView()
}
.modelContainer(BalanceModelContainer.previewContainer)
}
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()
}
}
#Preview("Новая категория") {
NavigationStack {
WatchCategoryEditor()
}
.modelContainer(BalanceModelContainer.previewContainer)
}
#Preview("Редактирование категории") {
NavigationStack {
WatchCategoryEditor(
category: CustomCategory(
name: "Кофе",
icon: "cup.and.saucer.fill",
emoji: "☕️",
colorName: "brown",
kind: .expense
)
)
}
.modelContainer(BalanceModelContainer.previewContainer)
}
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()
}
}
#Preview("Корректировка баланса") {
NavigationStack {
WatchBalanceAdjustmentView()
}
.modelContainer(BalanceModelContainer.previewContainer)
}
struct WatchSettingsView: View {
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
var body: some View {
Form {
Picker("Валюта", selection: $currencyCode) {
ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) }
}
Picker("Тема", selection: $appTheme) {
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
}
NavigationLink {
ServerAccountView()
} label: {
Label("Сервер", systemImage: "server.rack")
}
Label(
BalanceModelContainer.isCloudSyncEnabled ? "CloudKit" : "Локально",
systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill"
)
LabeledContent("Версия", value: "3.1.1")
}
.navigationTitle("Настройки")
}
}
#Preview("Настройки") {
NavigationStack {
WatchSettingsView()
}
.modelContainer(BalanceModelContainer.previewContainer)
}