commit 6c54c18c8e44c6e4459927b03910846d013a5eaf Author: Dallas Groot Date: Mon Apr 6 01:25:41 2026 -0700 Initial Commit diff --git a/Money Counter Helper.xcodeproj/project.pbxproj b/Money Counter Helper.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0103e36 --- /dev/null +++ b/Money Counter Helper.xcodeproj/project.pbxproj @@ -0,0 +1,339 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 03FCE5019D47C07C927BFFDF /* MoneyCounterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E720D12F8A774AB3823CE549 /* MoneyCounterViewModel.swift */; }; + 04D9261E65BF1D1EA57A5A2A /* NotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC99D9CC4D964FF3B95F7F4 /* NotesView.swift */; }; + 35A70D674AD823A371EB9886 /* readme.md in Resources */ = {isa = PBXBuildFile; fileRef = D17275F644BE74149E14AF72 /* readme.md */; }; + 5E78D53B3F80DBBF20F9DE64 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC6BA222F4025FFC6DF5071 /* Models.swift */; }; + 84CBB754319506019217838D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36ABC0BC044FF8C24A5767F3 /* ContentView.swift */; }; + 8F7AFBF398CB8A13B05027E1 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691F130B5684308DE78A0F74 /* HistoryView.swift */; }; + 967B54CB13C131B79D88C3BD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F5B18E647F795687053A63BF /* Assets.xcassets */; }; + B176BF3999BF2BF85D64417B /* MoneyCounterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A61FC1D6DD22486FF51045 /* MoneyCounterApp.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 36ABC0BC044FF8C24A5767F3 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 3F3D64C0ADD174CD4EAB7E4A /* MoneyCounter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MoneyCounter.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 691F130B5684308DE78A0F74 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 8AC6BA222F4025FFC6DF5071 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + A1A61FC1D6DD22486FF51045 /* MoneyCounterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyCounterApp.swift; sourceTree = ""; }; + AEC99D9CC4D964FF3B95F7F4 /* NotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotesView.swift; sourceTree = ""; }; + D17275F644BE74149E14AF72 /* readme.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = readme.md; sourceTree = ""; }; + E720D12F8A774AB3823CE549 /* MoneyCounterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoneyCounterViewModel.swift; sourceTree = ""; }; + F5B18E647F795687053A63BF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 6826BDA41F17D79DAD879CE7 /* Products */ = { + isa = PBXGroup; + children = ( + 3F3D64C0ADD174CD4EAB7E4A /* MoneyCounter.app */, + ); + name = Products; + sourceTree = ""; + }; + 68B272F56BFCCDB9F00C9C56 = { + isa = PBXGroup; + children = ( + A71888571558F3F5F5C79BAC /* MoneyCounter */, + 6826BDA41F17D79DAD879CE7 /* Products */, + ); + sourceTree = ""; + }; + A71888571558F3F5F5C79BAC /* MoneyCounter */ = { + isa = PBXGroup; + children = ( + F5B18E647F795687053A63BF /* Assets.xcassets */, + 36ABC0BC044FF8C24A5767F3 /* ContentView.swift */, + 691F130B5684308DE78A0F74 /* HistoryView.swift */, + 8AC6BA222F4025FFC6DF5071 /* Models.swift */, + A1A61FC1D6DD22486FF51045 /* MoneyCounterApp.swift */, + E720D12F8A774AB3823CE549 /* MoneyCounterViewModel.swift */, + AEC99D9CC4D964FF3B95F7F4 /* NotesView.swift */, + D17275F644BE74149E14AF72 /* readme.md */, + ); + path = MoneyCounter; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 62C96FF8FDA1D259F0B46031 /* MoneyCounter */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3DF320375F54C769755D2132 /* Build configuration list for PBXNativeTarget "MoneyCounter" */; + buildPhases = ( + FCEC40F7A3605B57D3AFE3BE /* Sources */, + 3737AEB231AC5475179CA8A5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MoneyCounter; + packageProductDependencies = ( + ); + productName = MoneyCounter; + productReference = 3F3D64C0ADD174CD4EAB7E4A /* MoneyCounter.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + D79B23C9D01EE01A2D06739C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + }; + buildConfigurationList = 3787E955246998DCB5E51E7C /* Build configuration list for PBXProject "Money Counter Helper" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 68B272F56BFCCDB9F00C9C56; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 6826BDA41F17D79DAD879CE7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 62C96FF8FDA1D259F0B46031 /* MoneyCounter */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 3737AEB231AC5475179CA8A5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 967B54CB13C131B79D88C3BD /* Assets.xcassets in Resources */, + 35A70D674AD823A371EB9886 /* readme.md in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + FCEC40F7A3605B57D3AFE3BE /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84CBB754319506019217838D /* ContentView.swift in Sources */, + 8F7AFBF398CB8A13B05027E1 /* HistoryView.swift in Sources */, + 5E78D53B3F80DBBF20F9DE64 /* Models.swift in Sources */, + B176BF3999BF2BF85D64417B /* MoneyCounterApp.swift in Sources */, + 03FCE5019D47C07C927BFFDF /* MoneyCounterViewModel.swift in Sources */, + 04D9261E65BF1D1EA57A5A2A /* NotesView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 061E6A24D68135CFAA4B2A42 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = E9C9AGS9K6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ca.dallasgroot.MoneyCounter; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 3A6E44877CB1EBF8B0188FC8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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 = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 3C8D9CCE6851BE8125AB26C9 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = E9C9AGS9K6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiresFullScreen = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ca.dallasgroot.MoneyCounter; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 610A1B17D6C0F9D128701229 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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 = gnu11; + 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; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 3787E955246998DCB5E51E7C /* Build configuration list for PBXProject "Money Counter Helper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3A6E44877CB1EBF8B0188FC8 /* Debug */, + 610A1B17D6C0F9D128701229 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 3DF320375F54C769755D2132 /* Build configuration list for PBXNativeTarget "MoneyCounter" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 061E6A24D68135CFAA4B2A42 /* Debug */, + 3C8D9CCE6851BE8125AB26C9 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = D79B23C9D01EE01A2D06739C /* Project object */; +} diff --git a/Money Counter Helper.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Money Counter Helper.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Money Counter Helper.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Money Counter Helper.xcodeproj/xcshareddata/xcschemes/MoneyCounter.xcscheme b/Money Counter Helper.xcodeproj/xcshareddata/xcschemes/MoneyCounter.xcscheme new file mode 100644 index 0000000..bf8abaa --- /dev/null +++ b/Money Counter Helper.xcodeproj/xcshareddata/xcschemes/MoneyCounter.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Money Counter Helper.xcodeproj/xcuserdata/dallasgroot.xcuserdatad/xcschemes/xcschememanagement.plist b/Money Counter Helper.xcodeproj/xcuserdata/dallasgroot.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..24bdfb1 --- /dev/null +++ b/Money Counter Helper.xcodeproj/xcuserdata/dallasgroot.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,22 @@ + + + + + SchemeUserState + + MoneyCounter.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 62C96FF8FDA1D259F0B46031 + + primary + + + + + diff --git a/MoneyCounter/Assets.xcassets/AppIcon.appiconset/1024.png b/MoneyCounter/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000..2d3fa67 Binary files /dev/null and b/MoneyCounter/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/MoneyCounter/Assets.xcassets/AppIcon.appiconset/Contents.json b/MoneyCounter/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cff1680 --- /dev/null +++ b/MoneyCounter/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MoneyCounter/ContentView.swift b/MoneyCounter/ContentView.swift new file mode 100644 index 0000000..1de7236 --- /dev/null +++ b/MoneyCounter/ContentView.swift @@ -0,0 +1,234 @@ +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 + + @State private var showingNotes = false + @State private var showingHistory = false + @State private var showingResetAlert = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Menu { + Button(action: { showingNotes = true }) { + 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)) + + // Scrolling Denominations + ScrollView { + VStack(spacing: 24) { + ForEach($viewModel.looseDenominations) { $denom in + DenominationRow(denomination: $denom, focusedField: $focusedField, valueColor: Color(UIColor.systemGreen)) + } + + VStack(alignment: .leading, spacing: 8) { + Divider().background(Color.gray) + Text("Rolls") + .font(.title3) + .bold() + .foregroundColor(.secondary) + } + .padding(.top, 8) + + ForEach($viewModel.rollDenominations) { $denom in + DenominationRow(denomination: $denom, focusedField: $focusedField, valueColor: Color(UIColor.systemRed)) + } + } + .padding() + } + .contentShape(Rectangle()) + .onTapGesture { dismissKeyboard() } + // 1. Native drag-to-dismiss behavior + .scrollDismissesKeyboard(.interactively) + + // Footer (Math & Totals) + VStack(spacing: 8) { + 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() + .background(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.primary) + } + .background(Color(UIColor.systemBackground)) + // 2. Adds the "Done" button directly above the keyboard + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + dismissKeyboard() + } + .font(.headline) + .foregroundColor(.blue) + } + } + .onChange(of: viewModel.looseDenominations) { _, _ in viewModel.saveCache() } + .onChange(of: viewModel.rollDenominations) { _, _ in viewModel.saveCache() } + .onChange(of: viewModel.startingFloat) { _, _ in viewModel.saveCache() } + .sheet(isPresented: $showingNotes) { + NotesView(notes: $sessionNotes, total: viewModel.total, discrepancy: viewModel.discrepancy, expectedFloat: viewModel.startingFloat) + } + .sheet(isPresented: $showingHistory) { + HistoryView(viewModel: viewModel) + } + .confirmationDialog("Reset Everything?", isPresented: $showingResetAlert) { + Button("Reset Counts & Notes", role: .destructive) { + withAnimation { viewModel.reset() } + sessionNotes = "" + } + Button("Reset Counts Only", role: .destructive) { + withAnimation { viewModel.reset() } + } + Button("Cancel", role: .cancel) { } + } + } + + private func dismissKeyboard() { + focusedField = nil + isFloatFocused = false + } + + private func saveCurrentSession() { + let newSave = SavedCount( + total: viewModel.total, + startingFloat: viewModel.startingFloat, + notes: sessionNotes, + snapshotData: viewModel.generateSnapshot() + ) + modelContext.insert(newSave) + } +} + +// DenominationRow remains exactly the same as the previous iteration +struct DenominationRow: View { + @Binding var denomination: MoneyCounterViewModel.Denomination + var focusedField: FocusState.Binding + var valueColor: Color + + var body: some View { + HStack(spacing: 16) { + HStack { + Text(formatValue(denomination.value)) + .font(.title2).bold() + .foregroundColor(valueColor) + .frame(width: 70, alignment: .leading) + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { focusedField.wrappedValue = nil } + + Button(action: { + denomination.count += 1 + focusedField.wrappedValue = nil + }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(Color(UIColor.systemGreen)) + .font(.title) + } + + Button(action: { + if denomination.count > 0 { denomination.count -= 1 } + focusedField.wrappedValue = nil + }) { + Image(systemName: "minus.circle.fill") + .foregroundColor(Color(UIColor.systemRed)) + .font(.title) + } + + TextField("0", value: $denomination.count, format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.center) + .font(.title2).bold() + .frame(width: 80) + .padding(.vertical, 4) + .focused(focusedField, equals: denomination.id) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(Color(UIColor.separator)), + alignment: .bottom + ) + .foregroundColor(.primary) + } + } + + private func formatValue(_ value: Double) -> String { + if value >= 1.0 { return String(format: "%.1f", value) } + else { return String(format: "%.2f", value) } + } +} diff --git a/MoneyCounter/HistoryView.swift b/MoneyCounter/HistoryView.swift new file mode 100644 index 0000000..8d215b1 --- /dev/null +++ b/MoneyCounter/HistoryView.swift @@ -0,0 +1,87 @@ +// +// HistoryView.swift +// MoneyCounter +// +// Created by Dallas Groot on 2026-04-06. +// + +import SwiftUI +import SwiftData + +struct HistoryView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + // Auto-fetches and sorts by date descending + @Query(sort: \SavedCount.date, order: .reverse) private var savedCounts: [SavedCount] + + var viewModel: MoneyCounterViewModel + + var body: some View { + NavigationStack { + List { + ForEach(savedCounts) { save in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(save.date.formatted(date: .abbreviated, time: .shortened)) + .font(.headline) + Spacer() + Text(String(format: "$%.2f", save.total)) + .font(.title3).bold() + } + + if save.startingFloat > 0 { + HStack { + Text("Float: $\(save.startingFloat, specifier: "%.2f")") + .font(.subheadline).foregroundColor(.secondary) + Spacer() + Text(save.discrepancy >= 0 ? "Dep: +$\(save.discrepancy, specifier: "%.2f")" : "Short: $\(save.discrepancy, specifier: "%.2f")") + .font(.subheadline) + .foregroundColor(save.discrepancy >= 0 ? .green : .red) + } + } + + if !save.notes.isEmpty { + Text(save.notes) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + .swipeActions(edge: .leading) { + Button(action: { + resume(save: save) + }) { + Label("Resume", systemImage: "arrow.counterclockwise") + } + .tint(.blue) + } + } + .onDelete(perform: deleteItems) + } + .navigationTitle("Count History") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Close") { dismiss() } + } + } + .overlay { + if savedCounts.isEmpty { + ContentUnavailableView("No History", systemImage: "tray", description: Text("Saved counts will appear here.")) + } + } + } + } + + private func deleteItems(offsets: IndexSet) { + for index in offsets { + modelContext.delete(savedCounts[index]) + } + } + + private func resume(save: SavedCount) { + viewModel.restore(from: save.snapshotData, float: save.startingFloat) + dismiss() + } +} diff --git a/MoneyCounter/Models.swift b/MoneyCounter/Models.swift new file mode 100644 index 0000000..e518878 --- /dev/null +++ b/MoneyCounter/Models.swift @@ -0,0 +1,35 @@ +import Foundation +import SwiftData + +// Represents a snapshot of the counts at the time of saving +struct CountSnapshot: Codable { + var id: UUID + var value: Double + var count: Int + var isRoll: Bool +} + +@Model +class SavedCount { + var id: UUID + var date: Date + var total: Double + var startingFloat: Double + var notes: String + + // SwiftData automatically handles Codable arrays + var snapshotData: [CountSnapshot] + + var discrepancy: Double { + return total - startingFloat + } + + init(id: UUID = UUID(), date: Date = .now, total: Double, startingFloat: Double, notes: String, snapshotData: [CountSnapshot]) { + self.id = id + self.date = date + self.total = total + self.startingFloat = startingFloat + self.notes = notes + self.snapshotData = snapshotData + } +} diff --git a/MoneyCounter/MoneyCounterApp.swift b/MoneyCounter/MoneyCounterApp.swift new file mode 100644 index 0000000..a30467b --- /dev/null +++ b/MoneyCounter/MoneyCounterApp.swift @@ -0,0 +1,13 @@ +import SwiftUI +import SwiftData + +@main +struct MoneyCounterApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + // Modern iOS 26 data persistence + .modelContainer(for: SavedCount.self) + } +} diff --git a/MoneyCounter/MoneyCounterViewModel.swift b/MoneyCounter/MoneyCounterViewModel.swift new file mode 100644 index 0000000..1b30f98 --- /dev/null +++ b/MoneyCounter/MoneyCounterViewModel.swift @@ -0,0 +1,104 @@ +import SwiftUI +import Observation + +@Observable +class MoneyCounterViewModel { + struct Denomination: Identifiable, Codable, Equatable { + var id: UUID = UUID() + let value: Double + var count: Int = 0 + var isRoll: Bool = false + } + + var looseDenominations: [Denomination] = [ + Denomination(value: 0.05), + Denomination(value: 0.10), + Denomination(value: 0.25), + Denomination(value: 1.00), + Denomination(value: 2.00), + Denomination(value: 5.00), + Denomination(value: 10.00), + Denomination(value: 20.00), + Denomination(value: 50.00), + Denomination(value: 100.00) + ] + + var rollDenominations: [Denomination] = [ + Denomination(value: 2.00, isRoll: true), + Denomination(value: 5.00, isRoll: true), + Denomination(value: 10.00, isRoll: true), + Denomination(value: 25.00, isRoll: true), + Denomination(value: 50.00, isRoll: true) + ] + + var startingFloat: Double = 0.0 + + init() { + loadCache() + } + + var total: Double { + let looseTotal = looseDenominations.reduce(0) { $0 + ($1.value * Double($1.count)) } + let rollsTotal = rollDenominations.reduce(0) { $0 + ($1.value * Double($1.count)) } + return looseTotal + rollsTotal + } + + var discrepancy: Double { + total - startingFloat + } + + func reset() { + for i in looseDenominations.indices { looseDenominations[i].count = 0 } + for i in rollDenominations.indices { rollDenominations[i].count = 0 } + // The float is intentionally NOT reset here anymore so it persists + saveCache() + } + + func generateSnapshot() -> [CountSnapshot] { + let loose = looseDenominations.map { CountSnapshot(id: $0.id, value: $0.value, count: $0.count, isRoll: $0.isRoll) } + let rolls = rollDenominations.map { CountSnapshot(id: $0.id, value: $0.value, count: $0.count, isRoll: $0.isRoll) } + return loose + rolls + } + + func restore(from snapshot: [CountSnapshot], float: Double) { + startingFloat = float + + for snap in snapshot { + if snap.isRoll { + if let index = rollDenominations.firstIndex(where: { $0.value == snap.value }) { + rollDenominations[index].count = snap.count + } + } else { + if let index = looseDenominations.firstIndex(where: { $0.value == snap.value }) { + looseDenominations[index].count = snap.count + } + } + } + saveCache() + } + + // MARK: - Caching Logic + func saveCache() { + if let looseData = try? JSONEncoder().encode(looseDenominations) { + UserDefaults.standard.set(looseData, forKey: "cachedLoose") + } + if let rollData = try? JSONEncoder().encode(rollDenominations) { + UserDefaults.standard.set(rollData, forKey: "cachedRolls") + } + UserDefaults.standard.set(startingFloat, forKey: "cachedFloat") + } + + private func loadCache() { + if let looseData = UserDefaults.standard.data(forKey: "cachedLoose"), + let decodedLoose = try? JSONDecoder().decode([Denomination].self, from: looseData) { + looseDenominations = decodedLoose + } + + if let rollData = UserDefaults.standard.data(forKey: "cachedRolls"), + let decodedRolls = try? JSONDecoder().decode([Denomination].self, from: rollData) { + rollDenominations = decodedRolls + } + + startingFloat = UserDefaults.standard.double(forKey: "cachedFloat") + } +} diff --git a/MoneyCounter/NotesView.swift b/MoneyCounter/NotesView.swift new file mode 100644 index 0000000..d6836b9 --- /dev/null +++ b/MoneyCounter/NotesView.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct NotesView: View { + @Binding var notes: String + let total: Double + let discrepancy: Double + let expectedFloat: Double + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + TextEditor(text: $notes) + .padding() + .navigationTitle("Session Notes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + ShareLink(item: notes) + } + // Keyboard extension tools + ToolbarItemGroup(placement: .keyboard) { + Button(action: injectTotal) { Label("Total", systemImage: "dollarsign.circle") } + Spacer() + Button(action: injectDeposit) { Label("Deposit", systemImage: "arrow.down.circle") } + Spacer() + Button(action: injectTime) { Label("Time", systemImage: "clock") } + } + } + } + } + + private func injectTotal() { + notes += "\nTotal Count: \(String(format: "$%.2f", total))" + } + + private func injectDeposit() { + if expectedFloat > 0 { + notes += "\nFloat: \(String(format: "$%.2f", expectedFloat)) | Deposit: \(String(format: "$%.2f", discrepancy))" + } + } + + private func injectTime() { + notes += "\nChecked at: \(Date.now.formatted(date: .omitted, time: .shortened))" + } +} diff --git a/MoneyCounter/readme.md b/MoneyCounter/readme.md new file mode 100644 index 0000000..e0d911f --- /dev/null +++ b/MoneyCounter/readme.md @@ -0,0 +1,37 @@ + +Money Counter (iOS 26+) +A modern, highly resilient till-counting application built strictly with modern SwiftUI guidelines. It is designed to track loose change, bills, and rolled coins, automatically calculate deposits against a starting float, and persist data across app launches using SwiftData and UserDefaults caching. +✨ Features +• Loose & Roll Tracking: Distinct sections for loose denominations (green) and coin rolls (red). +• Starting Float & Deposit Math: Enter a starting float, and the app automatically calculates your expected deposit or cash shortage. +• Aggressive Auto-Caching: Every single tap or number entry is instantly saved to UserDefaults. If the app crashes or is swiped away, your exact count is restored upon reopening. +• Session Notes: A built-in scratchpad for the current count. Features custom keyboard toolbar buttons to instantly inject the time, total, and float discrepancy into the text. +• Count History: Save snapshots of your counts natively using SwiftData. View past counts, check over/short metrics, or swipe to "Resume" a past count directly back into the main calculator. +• Native Keyboard Handling: Includes interactive swipe-to-dismiss and a dedicated "Done" button on the numeric keyboard. +🏗 Architecture: How the Files Interact +This app uses modern Swift data flow. Instead of passing static variables back and forth, the app relies on Observation (@Observable) for live memory and SwiftData for permanent storage. +Here is how each file reads from and communicates with the others: +1. MoneyCounterApp.swift (The Entry Point) +• What it does: This is where the app launches. +• How it connects: It wraps ContentView and attaches .modelContainer(for: SavedCount.self). This is a crucial step—it tells the entire app environment that SwiftData exists and makes the database available to any file that asks for it. +2. Models.swift (The Database Schema) +• What it does: Defines the blueprint for what a "Saved History" item looks like. +• How it connects: It defines the @Model class SavedCount. This does not hold live UI data; it acts as the strict template that ContentView uses when writing to the database, and that HistoryView uses when reading from the database. +3. MoneyCounterViewModel.swift (The Brain) +• What it does: Handles all the math, stores the arrays of denominations, and manages the UserDefaults background caching. +• How it connects: It is marked with the modern @Observable macro. This means it acts like a live broadcast station. When ContentView creates an instance of it, the view automatically "listens" to it. If the ViewModel calculates a new total, ContentView instantly redraws without needing to be manually told to update. +4. ContentView.swift (The Main UI) +• What it does: Displays the scrolling list of coins/bills and the bottom totals bar. +• How it connects: * Reads from ViewModel: Uses @State private var viewModel to read the live totals and write button taps back to the ViewModel. +• Reads from AppStorage: Uses @AppStorage to hold the session notes string. This acts as an invisible bridge to the device's hard drive so the text survives app closures. +• Writes to SwiftData: It accesses the database provided by MoneyCounterApp using @Environment(\.modelContext). When you save a session, it packages the ViewModel's current numbers into a SavedCount model and pushes it into this context. +• Passes to Sub-Views: It opens NotesView and HistoryView as .sheet overlays, passing down the necessary references so they can see the same data. +5. NotesView.swift (The Sub-View for Text) +• What it does: Displays the text editor for shift notes. +• How it connects: It receives data from ContentView in two ways: +• @Binding var notes: It binds directly to the @AppStorage string in ContentView. Typing here actually edits the variable living inside ContentView. +• Constants (let total, let discrepancy): ContentView passes the current math totals down as read-only constants so NotesView can inject them into the text via its keyboard shortcut buttons. +6. HistoryView.swift (The SwiftData Reader) +• What it does: Displays the list of previously saved counts. +• How it connects: * Reads from SwiftData: Uses the @Query macro to automatically look into the database (set up by MoneyCounterApp) and fetch all SavedCount items, sorting them by date. +• Writes to ViewModel: When you swipe and tap "Resume", HistoryView takes the saved snapshot and pushes it directly into the MoneyCounterViewModel instance passed to it by ContentView. This instantly overwrites the live calculator with the historical data. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..d3fe433 --- /dev/null +++ b/project.yml @@ -0,0 +1,27 @@ +name: Money Counter Helper +options: + bundleIdPrefix: ca.dallasgroot + createIntermediateGroups: true + +targets: + MoneyCounter: + type: application + platform: iOS + deploymentTarget: "26.0" + sources: + - path: MoneyCounter + settings: + base: + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + SWIFT_VERSION: 5.0 + MARKETING_VERSION: 1.0 + CURRENT_PROJECT_VERSION: 1 + + GENERATE_INFOPLIST_FILE: YES + INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait + + # Add this line to silence the orientation/multitasking warning: + INFOPLIST_KEY_UIRequiresFullScreen: YES