From ce09f987c9aa6b4254a1b3a431e0284e76665efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSamoilenkoVadym=E2=80=9D?= <“samoylenko.vadym@gmail.com”> Date: Sun, 2 Mar 2025 20:30:44 +0000 Subject: [PATCH] Update UI components with modern iOS design and improve transaction management UI --- Coinly/Core/Style/AppStyle.swift | 110 ++++++++++ .../Transactions/AddTransactionView.swift | 201 ++++++++++++++---- .../Transactions/TransactionFilterView.swift | 126 +++++++++-- .../Transactions/TransactionsView.swift | 108 +++++++--- Coinly/UI/Components/AccountCardView.swift | 85 +++++--- Coinly/UI/Components/TransactionRowView.swift | 120 ++++++++--- 6 files changed, 604 insertions(+), 146 deletions(-) create mode 100644 Coinly/Core/Style/AppStyle.swift diff --git a/Coinly/Core/Style/AppStyle.swift b/Coinly/Core/Style/AppStyle.swift new file mode 100644 index 0000000..c2c3bce --- /dev/null +++ b/Coinly/Core/Style/AppStyle.swift @@ -0,0 +1,110 @@ +// +// AppStyle.swift +// Coinly +// +// Created by Vadym Samoilenko on 02/03/2025. +// + + +// AppStyle.swift +import SwiftUI + +enum AppStyle { + // Colors + static let backgroundPrimary = Color(uiColor: .systemBackground) + static let backgroundSecondary = Color(uiColor: .secondarySystemBackground) + static let backgroundTertiary = Color(uiColor: .tertiarySystemBackground) + + static let labelPrimary = Color(uiColor: .label) + static let labelSecondary = Color(uiColor: .secondaryLabel) + static let labelTertiary = Color(uiColor: .tertiaryLabel) + + // Corner Radius + static let cornerRadiusSmall: CGFloat = 8 + static let cornerRadiusMedium: CGFloat = 12 + static let cornerRadiusLarge: CGFloat = 16 + + // Padding + static let paddingSmall: CGFloat = 8 + static let paddingMedium: CGFloat = 16 + static let paddingLarge: CGFloat = 24 + + // Font Sizes + static let fontTitle = Font.system(size: 34, weight: .bold) + static let fontTitle2 = Font.system(size: 28, weight: .semibold) + static let fontTitle3 = Font.system(size: 22, weight: .semibold) + static let fontHeadline = Font.system(size: 17, weight: .semibold) + static let fontBody = Font.system(size: 17, weight: .regular) + static let fontCallout = Font.system(size: 16, weight: .regular) + static let fontSubheadline = Font.system(size: 15, weight: .regular) + static let fontFootnote = Font.system(size: 13, weight: .regular) + static let fontCaption = Font.system(size: 12, weight: .regular) + + // Shadows + struct ShadowModifier: ViewModifier { + let radius: CGFloat + let y: CGFloat + + func body(content: Content) -> some View { + content.shadow( + color: Color.black.opacity(0.1), + radius: radius, + x: 0, + y: y + ) + } + } + + // Custom ViewModifiers + struct CardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .background(backgroundSecondary) + .cornerRadius(cornerRadiusLarge) + .modifier(ShadowModifier(radius: 10, y: 2)) + } + } + + struct PrimaryButtonStyle: ViewModifier { + func body(content: Content) -> some View { + content + .font(fontHeadline) + .foregroundColor(.white) + .padding(.vertical, paddingMedium) + .frame(maxWidth: .infinity) + .background(Color.accentColor) + .cornerRadius(cornerRadiusMedium) + } + } + + struct SecondaryButtonStyle: ViewModifier { + func body(content: Content) -> some View { + content + .font(fontHeadline) + .foregroundColor(.accentColor) + .padding(.vertical, paddingMedium) + .frame(maxWidth: .infinity) + .background(backgroundSecondary) + .cornerRadius(cornerRadiusMedium) + } + } +} + +// Extension для удобного использования стилей +extension View { + func cardStyle() -> some View { + modifier(AppStyle.CardStyle()) + } + + func primaryButtonStyle() -> some View { + modifier(AppStyle.PrimaryButtonStyle()) + } + + func secondaryButtonStyle() -> some View { + modifier(AppStyle.SecondaryButtonStyle()) + } + + func appShadow(radius: CGFloat = 10, y: CGFloat = 2) -> some View { + modifier(AppStyle.ShadowModifier(radius: radius, y: y)) + } +} diff --git a/Coinly/Features/Transactions/AddTransactionView.swift b/Coinly/Features/Transactions/AddTransactionView.swift index b006d7a..0a064c1 100644 --- a/Coinly/Features/Transactions/AddTransactionView.swift +++ b/Coinly/Features/Transactions/AddTransactionView.swift @@ -3,49 +3,108 @@ import SwiftUI struct AddTransactionView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var settings: AppSettings - @State private var amount: String = "" - @State private var note: String = "" - @State private var category: String = CategoryModel.categories[0].name + + @State private var amount = "" + @State private var note = "" + @State private var category = CategoryModel.categories[0].name @State private var date = Date() @State private var type: TransactionType = .expense + @State private var showingCategories = false let addTransaction: (TransactionModel) -> Void + private var isValidAmount: Bool { + guard let amountDouble = Double(amount) else { return false } + return amountDouble > 0 + } + var body: some View { NavigationView { - Form { - Section("Amount") { - HStack { - Text(settings.currency.symbol) - .foregroundColor(.gray) - TextField("Amount", text: $amount) - .keyboardType(.decimalPad) - } - } + ZStack { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() - Section("Type") { - Picker("Type", selection: $type) { - Text("Expense").tag(TransactionType.expense) - Text("Income").tag(TransactionType.income) - } - .pickerStyle(.segmented) - } - - Section("Details") { - Picker("Category", selection: $category) { - ForEach(CategoryModel.categories) { category in + ScrollView { + VStack(spacing: 24) { + // Amount Input + VStack(spacing: 8) { HStack { - Image(systemName: category.icon) - .foregroundColor(Color(category.color)) - Text(category.name) + Spacer() + TextField("0", text: $amount) + .font(.system(size: 48, weight: .medium, design: .rounded)) + .foregroundColor(type == .expense ? .red : .green) + .multilineTextAlignment(.center) + .keyboardType(.decimalPad) + Spacer() } - .tag(category.name) + + Text(settings.currency.rawValue) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(Color(uiColor: .secondaryLabel)) } + .padding(.vertical) + + // Transaction Type + VStack { + Picker("Type", selection: $type) { + Text("Expense").tag(TransactionType.expense) + Text("Income").tag(TransactionType.income) + } + .pickerStyle(.segmented) + } + .padding(.horizontal) + + // Details + VStack(spacing: 0) { + Button(action: { + showingCategories = true + }) { + HStack { + let selectedCategory = CategoryModel.category(for: category) + Image(systemName: selectedCategory.icon) + .foregroundColor(Color(selectedCategory.color)) + Text("Category") + .foregroundColor(AppStyle.labelPrimary) + Spacer() + Text(category) + .foregroundColor(AppStyle.labelSecondary) + Image(systemName: "chevron.right") + .font(.system(size: 13)) + .foregroundColor(Color(uiColor: .systemGray3)) + } + .padding() + } + + Divider() + .padding(.leading) + + HStack { + Image(systemName: "calendar") + .foregroundColor(.blue) + DatePicker( + "Date", + selection: $date, + displayedComponents: [.date, .hourAndMinute] + ) + .labelsHidden() + } + .padding() + + Divider() + .padding(.leading) + + HStack { + Image(systemName: "text.alignleft") + .foregroundColor(.purple) + TextField("Note", text: $note) + } + .padding() + } + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .cornerRadius(12) + .padding(.horizontal) } - - DatePicker("Date", selection: $date, displayedComponents: .date) - - TextField("Note", text: $note) + .padding(.top, 20) } } .navigationTitle("New Transaction") @@ -59,20 +118,66 @@ struct AddTransactionView: View { ToolbarItem(placement: .confirmationAction) { Button("Add") { - if let amountDouble = Double(amount) { - let transaction = TransactionModel( - amount: amountDouble, - date: date, - type: type, - category: category, - note: note.isEmpty ? nil : note, - originalCurrency: settings.currency // Используем текущую валюту из настроек - ) - addTransaction(transaction) - dismiss() + guard let amountDouble = Double(amount) else { return } + let transaction = TransactionModel( + amount: amountDouble, + date: date, + type: type, + category: category, + note: note.isEmpty ? nil : note, + originalCurrency: settings.currency + ) + addTransaction(transaction) + dismiss() + } + .disabled(!isValidAmount) + } + } + } + .sheet(isPresented: $showingCategories) { + CategoryPickerView(selectedCategory: $category) + } + } +} + +// Вспомогательное view для выбора категории +struct CategoryPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedCategory: String + + var body: some View { + NavigationView { + List { + ForEach(CategoryModel.categories) { category in + Button { + selectedCategory = category.name + dismiss() + } label: { + HStack { + Image(systemName: category.icon) + .foregroundColor(Color(category.color)) + .frame(width: 30) + + Text(category.name) + .foregroundColor(AppStyle.labelPrimary) + + Spacer() + + if selectedCategory == category.name { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } } } - .disabled(amount.isEmpty) + } + } + .navigationTitle("Select Category") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } } } } @@ -81,7 +186,13 @@ struct AddTransactionView: View { struct AddTransactionView_Previews: PreviewProvider { static var previews: some View { - AddTransactionView(addTransaction: { _ in }) - .environmentObject(AppSettings.shared) + Group { + AddTransactionView(addTransaction: { _ in }) + .preferredColorScheme(.light) + + AddTransactionView(addTransaction: { _ in }) + .preferredColorScheme(.dark) + } + .environmentObject(AppSettings.shared) } } diff --git a/Coinly/Features/Transactions/TransactionFilterView.swift b/Coinly/Features/Transactions/TransactionFilterView.swift index e10e26e..924204a 100644 --- a/Coinly/Features/Transactions/TransactionFilterView.swift +++ b/Coinly/Features/Transactions/TransactionFilterView.swift @@ -3,34 +3,81 @@ import SwiftUI struct TransactionFilterView: View { @Environment(\.dismiss) private var dismiss @Binding var filter: TransactionFilter + @State private var selectedPeriodIndex = 0 + + private let periods: [(name: String, period: TransactionFilter.Period)] = [ + ("All Time", .all), + ("Today", .today), + ("Last 7 Days", .week), + ("Last 30 Days", .month) + ] var body: some View { NavigationView { - Form { - // Period Filter - Section("Time Period") { - Picker("Period", selection: $filter.period) { - Text("All Time").tag(TransactionFilter.Period.all) - Text("Today").tag(TransactionFilter.Period.today) - Text("This Week").tag(TransactionFilter.Period.week) - Text("This Month").tag(TransactionFilter.Period.month) + List { + // Period Section + Section { + Picker("Time Period", selection: $selectedPeriodIndex) { + ForEach(0.. $1.key } } var body: some View { NavigationView { - List { - ForEach(filteredTransactions) { transaction in - TransactionRowView(transaction: transaction) - } - .onDelete { indexSet in - // Преобразуем индексы отфильтрованного списка в индексы полного списка - let transactionsToDelete = indexSet.map { filteredTransactions[$0] } - for transaction in transactionsToDelete { - if let index = store.transactions.firstIndex(where: { $0.id == transaction.id }) { - store.deleteTransaction(at: IndexSet([index])) + ZStack { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + + ScrollView { + LazyVStack(spacing: 24) { + ForEach(groupedTransactions, id: \.0) { month, transactions in + VStack(spacing: 8) { + // Month Header + HStack { + Text(month) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color(uiColor: .secondaryLabel)) + .textCase(.uppercase) + Spacer() + } + .padding(.horizontal) + + // Transactions + VStack(spacing: 1) { + ForEach(transactions) { transaction in + TransactionRowView(transaction: transaction) + } + } + } } } + .padding(.top) } } .navigationTitle("Transactions") + .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { showingFilters = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") - .foregroundColor(hasActiveFilters ? .blue : .gray) + .foregroundColor(.accentColor) } } @@ -42,21 +66,55 @@ struct TransactionsView: View { showingAddTransaction = true } label: { Image(systemName: "plus") + .foregroundColor(.accentColor) } } } - .sheet(isPresented: $showingAddTransaction) { - AddTransactionView(addTransaction: store.addTransaction) - } - .sheet(isPresented: $showingFilters) { - TransactionFilterView(filter: $filter) - } + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search transactions" + ) + } + .sheet(isPresented: $showingAddTransaction) { + AddTransactionView(addTransaction: store.addTransaction) + } + .sheet(isPresented: $showingFilters) { + TransactionFilterView(filter: .constant(TransactionFilter())) } } - - private var hasActiveFilters: Bool { - filter.period != .all || - !filter.selectedCategories.isEmpty || - filter.transactionType != nil +} + +// Вспомогательное view для пустого состояния +struct EmptyTransactionsView: View { + var body: some View { + VStack(spacing: 16) { + Image(systemName: "creditcard") + .font(.system(size: 48)) + .foregroundColor(Color(uiColor: .systemGray)) + + Text("No Transactions") + .font(.title3) + .fontWeight(.semibold) + + Text("Start adding your transactions to track your expenses and income") + .font(.subheadline) + .foregroundColor(Color(uiColor: .secondaryLabel)) + .multilineTextAlignment(.center) + } + .padding() + } +} + +struct TransactionsView_Previews: PreviewProvider { + static var previews: some View { + Group { + TransactionsView(store: TransactionsStore()) + .preferredColorScheme(.light) + + TransactionsView(store: TransactionsStore()) + .preferredColorScheme(.dark) + } + .environmentObject(AppSettings.shared) } } diff --git a/Coinly/UI/Components/AccountCardView.swift b/Coinly/UI/Components/AccountCardView.swift index e22b5c1..fd45e51 100644 --- a/Coinly/UI/Components/AccountCardView.swift +++ b/Coinly/UI/Components/AccountCardView.swift @@ -10,65 +10,98 @@ struct AccountCardView: View { private var accountColor: Color { switch account.type { - case .wallet: return .blue - case .bankAccount: return .green - case .creditCard: return .purple - case .deposit: return .orange - case .debt: return .red + case .wallet: return Color(uiColor: .systemBlue) + case .bankAccount: return Color(uiColor: .systemGreen) + case .creditCard: return Color(uiColor: .systemPurple) + case .deposit: return Color(uiColor: .systemOrange) + case .debt: return Color(uiColor: .systemRed) } } var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(spacing: AppStyle.paddingMedium) { // Header - HStack { + HStack(alignment: .center) { + // Icon Image(systemName: accountIcon) - .font(.title2) + .font(.system(size: 24, weight: .medium)) .foregroundColor(accountColor) + .frame(width: 32) - Text(account.name) - .font(.headline) + VStack(alignment: .leading, spacing: 4) { + Text(account.name) + .font(AppStyle.fontHeadline) + .foregroundColor(AppStyle.labelPrimary) + + Text(account.type.rawValue) + .font(AppStyle.fontSubheadline) + .foregroundColor(AppStyle.labelSecondary) + } Spacer() Text(account.currency.symbol) - .font(.subheadline) - .foregroundColor(Color(uiColor: .secondaryLabel)) + .font(AppStyle.fontSubheadline) + .foregroundColor(AppStyle.labelSecondary) } + Divider() + .padding(.horizontal, -AppStyle.paddingMedium) + // Balance - Text(account.balance.formatAsCurrency()) - .font(.title2) - .fontWeight(.bold) + VStack(alignment: .leading, spacing: 4) { + Text("Balance") + .font(AppStyle.fontSubheadline) + .foregroundColor(AppStyle.labelSecondary) + + Text(account.balance.formatAsCurrency()) + .font(AppStyle.fontTitle2) + .foregroundColor(AppStyle.labelPrimary) + } + .frame(maxWidth: .infinity, alignment: .leading) // Additional Info if let availableCredit = account.availableCredit { - Text("Available: \(availableCredit.formatAsCurrency())") - .font(.caption) - .foregroundColor(Color(uiColor: .secondaryLabel)) + HStack { + Text("Available Credit") + .font(AppStyle.fontFootnote) + .foregroundColor(AppStyle.labelSecondary) + Spacer() + Text(availableCredit.formatAsCurrency()) + .font(AppStyle.fontCallout) + .foregroundColor(AppStyle.labelPrimary) + } + .padding(.top, 4) } if account.isOverdue { - Label("Overdue", systemImage: "exclamationmark.circle.fill") - .font(.caption) - .foregroundColor(.red) + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color(uiColor: .systemRed)) + Text("Payment Overdue") + .font(AppStyle.fontFootnote) + .foregroundColor(Color(uiColor: .systemRed)) + } + .padding(.top, 4) } } - .padding() - .background(Color(uiColor: .systemBackground)) - .cornerRadius(12) - .shadow(radius: 2) + .padding(AppStyle.paddingMedium) + .cardStyle() } } +// Preview struct AccountCardView_Previews: PreviewProvider { static var previews: some View { Group { AccountCardView(account: AccountModel.sampleData[0]) + .preferredColorScheme(.light) + AccountCardView(account: AccountModel.sampleData[2]) + .preferredColorScheme(.dark) } .padding() - .background(Color(uiColor: .systemGroupedBackground)) + .background(AppStyle.backgroundPrimary) .environmentObject(AppSettings.shared) .previewLayout(.sizeThatFits) } diff --git a/Coinly/UI/Components/TransactionRowView.swift b/Coinly/UI/Components/TransactionRowView.swift index 9a945f0..52be251 100644 --- a/Coinly/UI/Components/TransactionRowView.swift +++ b/Coinly/UI/Components/TransactionRowView.swift @@ -10,58 +10,116 @@ struct TransactionRowView: View { private var categoryColor: Color { switch category.color { - case "red": return .red - case "blue": return .blue - case "purple": return .purple - case "orange": return .orange - case "green": return .green - case "gray": return .gray - default: return .gray + case "red": return Color(uiColor: .systemRed) + case "blue": return Color(uiColor: .systemBlue) + case "purple": return Color(uiColor: .systemPurple) + case "orange": return Color(uiColor: .systemOrange) + case "green": return Color(uiColor: .systemGreen) + case "gray": return Color(uiColor: .systemGray) + default: return Color(uiColor: .systemGray) } } + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "d MMM yyyy" + return formatter + }() + var body: some View { - HStack(spacing: 12) { + HStack(spacing: AppStyle.paddingMedium) { // Category Icon - Image(systemName: category.icon) - .font(.title2) - .foregroundColor(.white) - .frame(width: 40, height: 40) - .background(categoryColor) - .clipShape(RoundedRectangle(cornerRadius: 10)) + ZStack { + Circle() + .fill(categoryColor) + .frame(width: 36, height: 36) + + Image(systemName: category.icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.white) + } // Transaction Details VStack(alignment: .leading, spacing: 4) { - Text(transaction.category) - .font(.headline) - if let note = transaction.note { - Text(note) - .font(.subheadline) - .foregroundColor(.gray) + HStack { + Text(transaction.category) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(AppStyle.labelPrimary) + + if transaction.isExpense { + Text("Expense") + .font(.system(size: 13)) + .foregroundColor(Color(uiColor: .systemRed)) + } } + + HStack(spacing: 4) { + Text(dateFormatter.string(from: transaction.date)) + + if let note = transaction.note { + Text("•") + Text(note) + } + } + .font(.system(size: 15)) + .foregroundColor(Color(uiColor: .systemGray)) } Spacer() - // Amount and Date + // Amount VStack(alignment: .trailing, spacing: 4) { Text(transaction.amountInCurrentCurrency().formatAsCurrency()) - .font(.headline) - .foregroundColor(transaction.isExpense ? .red : .green) - Text(transaction.date, style: .date) - .font(.caption) - .foregroundColor(.gray) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(transaction.isExpense ? + Color(uiColor: .systemRed) : + Color(uiColor: .systemGreen)) + + Text(transaction.originalCurrency.rawValue) + .font(.system(size: 13)) + .foregroundColor(Color(uiColor: .systemGray)) } } - .padding(.vertical, 8) + .padding(.horizontal, AppStyle.paddingMedium) + .padding(.vertical, 12) + .background(Color(uiColor: .secondarySystemBackground)) + .cornerRadius(12) } } struct TransactionRowView_Previews: PreviewProvider { static var previews: some View { - TransactionRowView(transaction: TransactionModel.sampleData[0]) - .previewLayout(.sizeThatFits) - .padding() - .environmentObject(AppSettings.shared) + VStack(spacing: 8) { + TransactionRowView(transaction: TransactionModel( + amount: 100.00, + date: Date(), + type: .income, + category: "Salary", + note: "Monthly salary", + originalCurrency: .usd + )) + + TransactionRowView(transaction: TransactionModel( + amount: 25.99, + date: Date(), + type: .expense, + category: "Food", + note: "Lunch", + originalCurrency: .usd + )) + + TransactionRowView(transaction: TransactionModel( + amount: 50.00, + date: Date(), + type: .expense, + category: "Transport", + note: "Fuel", + originalCurrency: .usd + )) + } + .padding() + .background(Color(uiColor: .systemBackground)) + .environmentObject(AppSettings.shared) + .preferredColorScheme(.dark) } }