MoneyCounterApp/MoneyCounter/ContentView.swift

264 lines
11 KiB
Swift
Raw Normal View History

2026-04-06 01:25:41 -07:00
import SwiftUI
import SwiftData
struct ContentView: View {
@State private var viewModel = MoneyCounterViewModel()
@FocusState private var focusedField: UUID?
@FocusState private var isFloatFocused: Bool
@AppStorage("currentSessionNotes") private var sessionNotes: String = ""
@Environment(\.modelContext) private var modelContext
2026-04-06 02:16:19 -07:00
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
2026-04-06 01:25:41 -07:00
@State private var showingNotes = false
@State private var showingHistory = false
@State private var showingResetAlert = false
var body: some View {
2026-04-06 02:16:19 -07:00
ZStack(alignment: .trailing) {
// 1. MAIN INTERFACE LAYER
VStack(spacing: 0) {
headerView
2026-04-06 01:25:41 -07:00
2026-04-06 02:16:19 -07:00
// Magic Layout Container: Automatically snaps between layouts based on exact window width
ViewThatFits(in: .horizontal) {
2026-04-06 01:25:41 -07:00
2026-04-06 02:16:19 -07:00
// OPTION 1: Wide iPad Layout
HStack(alignment: .top, spacing: 0) {
ScrollView { VStack(spacing: 24) { looseSection }.padding() }
.frame(minWidth: 280, maxWidth: .infinity)
Divider()
ScrollView { VStack(spacing: 24) { rollsSection }.padding() }
.frame(minWidth: 280, maxWidth: .infinity)
Divider()
dashboardCard
.frame(width: 320, maxHeight: .infinity, alignment: .top) // Forces iPad column to stretch
.background(Color(UIColor.secondarySystemBackground))
2026-04-06 01:25:41 -07:00
}
2026-04-06 02:16:19 -07:00
.scrollDismissesKeyboard(.interactively)
2026-04-06 01:25:41 -07:00
2026-04-06 02:16:19 -07:00
// OPTION 2: Fallback Narrow/iPhone Layout
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 24) {
looseSection
VStack(alignment: .leading, spacing: 8) {
Divider().background(Color.gray)
Text("Rolls").font(.title3).bold().foregroundColor(.secondary)
}.padding(.top, 8)
rollsSection
}
.padding()
}
.scrollDismissesKeyboard(.interactively)
dashboardCard
.background(Color(UIColor.secondarySystemBackground)) // Hugs tightly on iPhone
2026-04-06 01:25:41 -07:00
}
}
}
2026-04-06 02:16:19 -07:00
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(UIColor.systemBackground))
2026-04-06 01:25:41 -07:00
.onTapGesture { dismissKeyboard() }
2026-04-06 02:16:19 -07:00
// 2. IPAD SLIDE-OVER PANEL LAYER
if showingNotes && horizontalSizeClass == .regular {
NotesView(notes: $sessionNotes, total: viewModel.total, discrepancy: viewModel.discrepancy, expectedFloat: viewModel.startingFloat, isPresented: $showingNotes)
.frame(width: 375)
.background(Color(UIColor.systemBackground))
.shadow(color: Color.black.opacity(0.3), radius: 25, x: -10, y: 0)
.transition(.move(edge: .trailing))
.zIndex(2)
.ignoresSafeArea(.all, edges: .bottom)
2026-04-06 01:25:41 -07:00
}
}
.toolbar {
2026-04-06 02:16:19 -07:00
if UIDevice.current.userInterfaceIdiom != .pad {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") { dismissKeyboard() }
2026-04-06 01:25:41 -07:00
}
}
}
2026-04-06 02:16:19 -07:00
.keyboardShortcut("s", modifiers: .command)
.onKeyPress(.downArrow) { moveFocus(forward: true); return .handled }
.onKeyPress(.upArrow) { moveFocus(forward: false); return .handled }
2026-04-06 01:25:41 -07:00
.onChange(of: viewModel.looseDenominations) { _, _ in viewModel.saveCache() }
.onChange(of: viewModel.rollDenominations) { _, _ in viewModel.saveCache() }
.onChange(of: viewModel.startingFloat) { _, _ in viewModel.saveCache() }
2026-04-06 02:16:19 -07:00
// IPHONE FALLBACK: Continues to use a standard bottom sheet on mobile
.sheet(isPresented: Binding(
get: { showingNotes && horizontalSizeClass != .regular },
set: { showingNotes = $0 }
)) {
NotesView(notes: $sessionNotes, total: viewModel.total, discrepancy: viewModel.discrepancy, expectedFloat: viewModel.startingFloat, isPresented: $showingNotes)
.presentationDetents([.medium, .large])
2026-04-06 01:25:41 -07:00
}
.sheet(isPresented: $showingHistory) {
HistoryView(viewModel: viewModel)
}
.confirmationDialog("Reset Everything?", isPresented: $showingResetAlert) {
Button("Reset Counts & Notes", role: .destructive) {
withAnimation { viewModel.reset() }
2026-04-06 02:16:19 -07:00
sessionNotes = ""
2026-04-06 01:25:41 -07:00
}
Button("Reset Counts Only", role: .destructive) {
withAnimation { viewModel.reset() }
}
Button("Cancel", role: .cancel) { }
}
}
2026-04-06 02:16:19 -07:00
// MARK: - Subviews
private var headerView: some View {
HStack {
Menu {
Button(action: {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
showingNotes.toggle()
}
}) { Label("Session Notes", systemImage: "note.text") }
Button(action: { showingHistory = true }) { Label("Saved History", systemImage: "clock.arrow.circlepath") }
Divider()
Button(action: saveCurrentSession) { Label("Save This Total", systemImage: "square.and.arrow.down") }
} label: {
Image(systemName: "ellipsis.circle").font(.title).foregroundColor(.white)
}
Spacer()
Text("Money Counter").font(.title2).bold().foregroundColor(.white)
Spacer()
Button(action: { showingResetAlert = true }) {
Image(systemName: "arrow.counterclockwise").font(.title).foregroundColor(.white)
}
}
.padding()
.background(Color(red: 0.4, green: 0.7, blue: 0.4))
}
private var looseSection: some View {
ForEach($viewModel.looseDenominations) { $denom in
DenominationRow(denomination: $denom, focusedField: $focusedField, valueColor: Color(UIColor.systemGreen))
}
}
private var rollsSection: some View {
ForEach($viewModel.rollDenominations) { $denom in
DenominationRow(denomination: $denom, focusedField: $focusedField, valueColor: Color(UIColor.systemRed))
}
}
// Notice: The internal Spacer() and .background() modifiers have been completely removed
private var dashboardCard: some View {
VStack(spacing: 16) {
HStack {
Text("Starting Float").font(.headline).foregroundColor(.secondary)
Spacer()
HStack(spacing: 2) {
Text("$").foregroundColor(.secondary)
TextField("0.00", value: $viewModel.startingFloat, format: .number.precision(.fractionLength(2)))
.keyboardType(.decimalPad)
.focused($isFloatFocused)
.multilineTextAlignment(.trailing)
}
.font(.headline).padding(6).frame(width: 100)
.background(Color(UIColor.tertiarySystemFill), in: RoundedRectangle(cornerRadius: 8))
}
if viewModel.startingFloat > 0 {
HStack {
Text(viewModel.discrepancy >= 0 ? "Deposit Amount" : "Shortage").font(.headline)
Spacer()
Text(String(format: "$%.2f", viewModel.discrepancy))
.font(.headline)
.foregroundColor(viewModel.discrepancy >= 0 ? .primary : .red)
}
}
Divider()
HStack {
Text("Total").font(.largeTitle).bold()
Spacer()
Text(String(format: "$%.2f", viewModel.total)).font(.largeTitle).bold()
}
}
.padding()
}
// MARK: - Logic
2026-04-06 01:25:41 -07:00
private func dismissKeyboard() {
focusedField = nil
isFloatFocused = false
}
2026-04-06 02:16:19 -07:00
private func moveFocus(forward: Bool) {
guard let current = focusedField,
let currentIndex = viewModel.allDenominationIDs.firstIndex(of: current) else { return }
let nextIndex = forward ? currentIndex + 1 : currentIndex - 1
if viewModel.allDenominationIDs.indices.contains(nextIndex) {
focusedField = viewModel.allDenominationIDs[nextIndex]
} else if forward && nextIndex == viewModel.allDenominationIDs.count {
focusedField = nil
isFloatFocused = true
}
}
2026-04-06 01:25:41 -07:00
private func saveCurrentSession() {
2026-04-06 02:16:19 -07:00
let newSave = SavedCount(total: viewModel.total, startingFloat: viewModel.startingFloat, notes: sessionNotes, snapshotData: viewModel.generateSnapshot())
2026-04-06 01:25:41 -07:00
modelContext.insert(newSave)
2026-04-06 02:16:19 -07:00
viewModel.reset()
sessionNotes = ""
viewModel.startingFloat = 0.0
2026-04-06 01:25:41 -07:00
}
}
2026-04-06 02:16:19 -07:00
// MARK: - Denomination Row
2026-04-06 01:25:41 -07:00
struct DenominationRow: View {
@Binding var denomination: MoneyCounterViewModel.Denomination
var focusedField: FocusState<UUID?>.Binding
var valueColor: Color
var body: some View {
HStack(spacing: 16) {
HStack {
2026-04-06 02:16:19 -07:00
Text(formatValue(denomination.value)).font(.title2).bold().foregroundColor(valueColor).frame(width: 70, alignment: .leading)
2026-04-06 01:25:41 -07:00
Spacer()
}
2026-04-06 02:16:19 -07:00
.contentShape(Rectangle())
2026-04-06 01:25:41 -07:00
.onTapGesture { focusedField.wrappedValue = nil }
Button(action: {
denomination.count += 1
focusedField.wrappedValue = nil
2026-04-06 02:16:19 -07:00
}) { Image(systemName: "plus.circle.fill").foregroundColor(Color(UIColor.systemGreen)).font(.title) }
2026-04-06 01:25:41 -07:00
Button(action: {
if denomination.count > 0 { denomination.count -= 1 }
focusedField.wrappedValue = nil
2026-04-06 02:16:19 -07:00
}) { Image(systemName: "minus.circle.fill").foregroundColor(Color(UIColor.systemRed)).font(.title) }
2026-04-06 01:25:41 -07:00
TextField("0", value: $denomination.count, format: .number)
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
2026-04-06 02:16:19 -07:00
.font(.title2).bold().frame(width: 80).padding(.vertical, 4)
2026-04-06 01:25:41 -07:00
.focused(focusedField, equals: denomination.id)
2026-04-06 02:16:19 -07:00
.overlay(Rectangle().frame(height: 1).foregroundColor(Color(UIColor.separator)), alignment: .bottom)
.foregroundColor(.primary)
2026-04-06 01:25:41 -07:00
}
}
private func formatValue(_ value: Double) -> String {
2026-04-06 02:16:19 -07:00
if value >= 1.0 { return String(format: "%.1f", value) }
2026-04-06 01:25:41 -07:00
else { return String(format: "%.2f", value) }
}
2026-04-06 02:16:19 -07:00
}