Update UI components with modern iOS design and improve transaction management UI

This commit is contained in:
“SamoilenkoVadym” 2025-03-02 20:30:44 +00:00
parent 78c47819d0
commit ce09f987c9
6 changed files with 604 additions and 146 deletions

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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..<periods.count, id: \.self) { index in
Text(periods[index].name).tag(index)
}
}
.pickerStyle(.segmented)
.onChange(of: selectedPeriodIndex) { newValue in
filter.period = periods[newValue].period
}
} header: {
Text("Time Period")
}
// Type Filter
Section("Transaction Type") {
Picker("Type", selection: Binding(
get: { filter.transactionType ?? .expense },
set: { filter.transactionType = $0 }
)) {
Text("All").tag(TransactionType.expense)
Text("Income").tag(TransactionType.income)
Text("Expense").tag(TransactionType.expense)
// Type Section
Section {
HStack {
Text("All")
Spacer()
if filter.transactionType == nil {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = nil
}
HStack {
Label("Income", systemImage: "arrow.down.circle.fill")
.foregroundColor(.green)
Spacer()
if filter.transactionType == .income {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = .income
}
HStack {
Label("Expense", systemImage: "arrow.up.circle.fill")
.foregroundColor(.red)
Spacer()
if filter.transactionType == .expense {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.contentShape(Rectangle())
.onTapGesture {
filter.transactionType = .expense
}
} header: {
Text("Transaction Type")
}
// Categories Filter
Section("Categories") {
// Categories Section
Section {
ForEach(CategoryModel.categories) { category in
Toggle(isOn: Binding(
get: { filter.selectedCategories.contains(category.name) },
@ -45,18 +92,30 @@ struct TransactionFilterView: View {
HStack {
Image(systemName: category.icon)
.foregroundColor(Color(category.color))
.frame(width: 24)
Text(category.name)
}
}
}
} header: {
Text("Categories")
} footer: {
Text("Select categories to filter transactions")
}
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Reset") {
filter = TransactionFilter()
selectedPeriodIndex = 0
}
}
@ -69,3 +128,32 @@ struct TransactionFilterView: View {
}
}
}
// Preview
struct TransactionFilterView_Previews: PreviewProvider {
static var previews: some View {
Group {
TransactionFilterView(
filter: .constant(TransactionFilter())
)
.preferredColorScheme(.light)
TransactionFilterView(
filter: .constant(TransactionFilter())
)
.preferredColorScheme(.dark)
}
}
}
// Helper extension для красивого отображения периодов
extension TransactionFilter.Period {
var displayName: String {
switch self {
case .all: return "All Time"
case .today: return "Today"
case .week: return "Last 7 Days"
case .month: return "Last 30 Days"
}
}
}

View file

@ -4,36 +4,60 @@ struct TransactionsView: View {
@ObservedObject var store: TransactionsStore
@State private var showingAddTransaction = false
@State private var showingFilters = false
@State private var filter = TransactionFilter()
@State private var searchText = ""
var filteredTransactions: [TransactionModel] {
store.transactions.filter { filter.applies(to: $0) }
private var groupedTransactions: [(String, [TransactionModel])] {
let formatter = DateFormatter()
formatter.dateFormat = "MMMM yyyy"
let grouped = Dictionary(grouping: store.transactions) { transaction in
formatter.string(from: transaction.date)
}
return grouped.sorted { $0.key > $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)
}
}

View file

@ -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)
}

View file

@ -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)
}
}