diff --git a/myApp/Expiranner b/myApp/Expiranner deleted file mode 160000 index 74a95d4..0000000 --- a/myApp/Expiranner +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 74a95d489240619326e8abc1725d15b6e62031ab diff --git a/myApp/Expiranner/CLAUDE.md b/myApp/Expiranner/CLAUDE.md new file mode 100644 index 0000000..9a52e98 --- /dev/null +++ b/myApp/Expiranner/CLAUDE.md @@ -0,0 +1,41 @@ +# Expiranner Project Rules + +## 1. Project Environment +- Project Name: Expiranner +- Root Folder: Expiranner (All operations must run from this root) +- Subfolder: `IOS` (The main iOS project folder; do NOT recreate `Expiranner` subfolder) +- Framework: SwiftUI +- Local Storage: SwiftData (Do NOT use Core Data) +- Language: Korean (All UI and logs must be in Korean) +- Target: iOS 17+ (Required for SwiftData and VisionKit Revision 3) +- Purpose: Personal use (Do NOT implement complex UI/UX designs, animations, or styling unless explicitly requested. Focus ONLY on functional logic) + +## 2. Core Features & UX Flow +- Main View (Calendar): + - Must include a Year Picker at the top. + - Must show months (1-12) as a grid or list. + - Tapping a month opens a calendar for that month. + - Tapping a day shows a list of saved product items. + - Saved items must support swipe-to-delete. + - Saved items must support date modification (moving to another Year/Month/Day). +- Floating Action Button (FAB): + - Must exist on the calendar view to start recording. + - Opens full-screen Camera Scanner (`DataScannerViewController` or `VNDocumentCameraViewController`). +- OCR & Recording Flow: + 1. Capture product label via camera. + 2. Extract Korean text using `Vision` framework (`VNRecognizeTextRequest`). + 3. Show a popup with thumbnail and extracted text. + 4. Popup must have [재촬영] (Recapture - returns to camera) and [적용] (Apply - opens date picker). + 5. Extracted text must be editable in the popup. + 6. Date picker must restrict selection to the currently viewed month only. + 7. Provide Haptic Feedback (vibration) upon saving data. + 8. Automatically return to the Camera Scanner immediately after saving. + 9. Provide an [X] button to exit scanner and return to Main View. + +## 3. Data Model (SwiftData) +- Always use `@Model` for data structure. +- Never add unnecessary metadata fields unless specified. + +```swift +import Foundation +import SwiftData diff --git a/myApp/Expiranner/Expiranner.xcodeproj/project.pbxproj b/myApp/Expiranner/Expiranner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a1dbecc --- /dev/null +++ b/myApp/Expiranner/Expiranner.xcodeproj/project.pbxproj @@ -0,0 +1,333 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 725A9F872FFFAB2400846FFB /* CLAUDE.md in Resources */ = {isa = PBXBuildFile; fileRef = 725A9F862FFFAB2400846FFB /* CLAUDE.md */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 725A9F702FFFA9D400846FFB /* Expiranner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = Expiranner.app; path = "/Users/ceuak/Documents/ceuak/mycode/myApp/Expiranner/build/Debug-iphoneos/Expiranner.app"; sourceTree = ""; }; + 725A9F862FFFAB2400846FFB /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 725A9F722FFFA9D400846FFB /* IOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = IOS; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 725A9F6D2FFFA9D400846FFB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 725A9F672FFFA9D400846FFB = { + isa = PBXGroup; + children = ( + 725A9F722FFFA9D400846FFB /* IOS */, + 725A9F862FFFAB2400846FFB /* CLAUDE.md */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 725A9F6F2FFFA9D400846FFB /* Expiranner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 725A9F7B2FFFA9D500846FFB /* Build configuration list for PBXNativeTarget "Expiranner" */; + buildPhases = ( + 725A9F6C2FFFA9D400846FFB /* Sources */, + 725A9F6D2FFFA9D400846FFB /* Frameworks */, + 725A9F6E2FFFA9D400846FFB /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 725A9F722FFFA9D400846FFB /* IOS */, + ); + name = Expiranner; + packageProductDependencies = ( + ); + productName = Expiranner; + productReference = 725A9F702FFFA9D400846FFB /* Expiranner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 725A9F682FFFA9D400846FFB /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + 725A9F6F2FFFA9D400846FFB = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = 725A9F6B2FFFA9D400846FFB /* Build configuration list for PBXProject "Expiranner" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 725A9F672FFFA9D400846FFB; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 725A9F672FFFA9D400846FFB; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 725A9F6F2FFFA9D400846FFB /* Expiranner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 725A9F6E2FFFA9D400846FFB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 725A9F872FFFAB2400846FFB /* CLAUDE.md in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 725A9F6C2FFFA9D400846FFB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 725A9F792FFFA9D500846FFB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 725A9F7A2FFFA9D500846FFB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 725A9F7C2FFFA9D500846FFB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "제품 라벨을 촬영하여 유통기한을 기록하기 위해 카메라 접근 권한이 필요합니다."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.Expiranner; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 725A9F7D2FFFA9D500846FFB /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "제품 라벨을 촬영하여 유통기한을 기록하기 위해 카메라 접근 권한이 필요합니다."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.Expiranner; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 725A9F6B2FFFA9D400846FFB /* Build configuration list for PBXProject "Expiranner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 725A9F792FFFA9D500846FFB /* Debug */, + 725A9F7A2FFFA9D500846FFB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 725A9F7B2FFFA9D500846FFB /* Build configuration list for PBXNativeTarget "Expiranner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 725A9F7C2FFFA9D500846FFB /* Debug */, + 725A9F7D2FFFA9D500846FFB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 725A9F682FFFA9D400846FFB /* Project object */; +} diff --git a/myApp/Expiranner/Expiranner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/myApp/Expiranner/Expiranner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/myApp/Expiranner/Expiranner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/myApp/Expiranner/Expiranner.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate b/myApp/Expiranner/Expiranner.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..d2e7e9e Binary files /dev/null and b/myApp/Expiranner/Expiranner.xcodeproj/project.xcworkspace/xcuserdata/ceuak.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/myApp/Expiranner/Expiranner.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist b/myApp/Expiranner/Expiranner.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..f1b2f98 --- /dev/null +++ b/myApp/Expiranner/Expiranner.xcodeproj/xcuserdata/ceuak.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Expiranner.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/myApp/Expiranner/IOS/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/Expiranner/IOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/Expiranner/IOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/Expiranner/IOS/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/myApp/Expiranner/IOS/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..9bdd4e7 Binary files /dev/null and b/myApp/Expiranner/IOS/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/myApp/Expiranner/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/Expiranner/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ce8e776 --- /dev/null +++ b/myApp/Expiranner/IOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/Expiranner/IOS/Assets.xcassets/Contents.json b/myApp/Expiranner/IOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/Expiranner/IOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/Expiranner/IOS/ContentView.swift b/myApp/Expiranner/IOS/ContentView.swift new file mode 100644 index 0000000..7b144c9 --- /dev/null +++ b/myApp/Expiranner/IOS/ContentView.swift @@ -0,0 +1,115 @@ +// +// ContentView.swift +// Expiranner +// +// 메인 뷰: 연도 선택 + 월(1~12) 그리드 + +import SwiftUI +import SwiftData + +struct ContentView: View { + @State private var selectedYear = Calendar.current.component(.year, from: Date()) + @State private var showScanner = false + + private let years = Array(2020...2040) + private let columns = Array(repeating: GridItem(.flexible()), count: 3) + + var body: some View { + NavigationStack { + ZStack(alignment: .bottomTrailing) { + VStack { + Picker("연도", selection: $selectedYear) { + ForEach(years, id: \.self) { year in + Text(String(format: "%d년", year)).tag(year) + } + } + .pickerStyle(.menu) + .font(.title2) + + LazyVGrid(columns: columns, spacing: 16) { + ForEach(1...12, id: \.self) { month in + NavigationLink { + MonthCalendarView(year: selectedYear, month: month) + } label: { + MonthCell(year: selectedYear, month: month) + } + .buttonStyle(.plain) + } + } + .padding() + + Spacer() + } + + FloatingScanButton { + showScanner = true + } + } + .navigationTitle("Expiranner") + .fullScreenCover(isPresented: $showScanner) { + ScannerView( + year: Calendar.current.component(.year, from: Date()), + month: Calendar.current.component(.month, from: Date()) + ) + } + } + } +} + +/// 월 셀: 해당 월에 저장된 아이템 개수 표시 +struct MonthCell: View { + let year: Int + let month: Int + + @Query private var items: [ProductItem] + + init(year: Int, month: Int) { + self.year = year + self.month = month + let (start, end) = Self.monthBounds(year: year, month: month) + _items = Query(filter: #Predicate { $0.date >= start && $0.date < end }) + } + + static func monthBounds(year: Int, month: Int) -> (Date, Date) { + let calendar = Calendar.current + let start = calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? Date() + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start + return (start, end) + } + + var body: some View { + VStack(spacing: 4) { + Text("\(month)월") + .font(.title3) + Text(items.isEmpty ? " " : "\(items.count)개") + .font(.caption) + .foregroundStyle(.red) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} + +/// 플로팅 액션 버튼 (카메라 스캐너 실행) +struct FloatingScanButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "camera.fill") + .font(.title2) + .foregroundStyle(.white) + .padding(18) + .background(Circle().fill(.blue)) + .shadow(radius: 4) + } + .padding(24) + } +} + +#Preview { + ContentView() + .modelContainer(for: ProductItem.self, inMemory: true) +} diff --git a/myApp/Expiranner/IOS/ExpirannerApp.swift b/myApp/Expiranner/IOS/ExpirannerApp.swift new file mode 100644 index 0000000..0ae4471 --- /dev/null +++ b/myApp/Expiranner/IOS/ExpirannerApp.swift @@ -0,0 +1,17 @@ +// +// ExpirannerApp.swift +// Expiranner +// + +import SwiftUI +import SwiftData + +@main +struct ExpirannerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: ProductItem.self) + } +} diff --git a/myApp/Expiranner/IOS/MonthCalendarView.swift b/myApp/Expiranner/IOS/MonthCalendarView.swift new file mode 100644 index 0000000..56f791b --- /dev/null +++ b/myApp/Expiranner/IOS/MonthCalendarView.swift @@ -0,0 +1,187 @@ +// +// MonthCalendarView.swift +// Expiranner +// +// 월별 캘린더: 날짜 그리드 + 선택한 날의 아이템 목록 + +import SwiftUI +import SwiftData + +struct MonthCalendarView: View { + let year: Int + let month: Int + + @Environment(\.modelContext) private var modelContext + @Query private var items: [ProductItem] + + @State private var selectedDay: Int? + @State private var showScanner = false + @State private var editingItem: ProductItem? + + private let columns = Array(repeating: GridItem(.flexible()), count: 7) + private let weekdaySymbols = ["일", "월", "화", "수", "목", "금", "토"] + + init(year: Int, month: Int) { + self.year = year + self.month = month + let (start, end) = MonthCell.monthBounds(year: year, month: month) + _items = Query( + filter: #Predicate { $0.date >= start && $0.date < end }, + sort: \ProductItem.date + ) + } + + private var daysInMonth: Int { + let calendar = Calendar.current + let start = calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? Date() + return calendar.range(of: .day, in: .month, for: start)?.count ?? 30 + } + + /// 1일의 요일 오프셋 (일요일 시작 기준) + private var firstWeekdayOffset: Int { + let calendar = Calendar.current + let start = calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? Date() + return calendar.component(.weekday, from: start) - 1 + } + + private func day(of item: ProductItem) -> Int { + Calendar.current.component(.day, from: item.date) + } + + private var selectedDayItems: [ProductItem] { + guard let selectedDay else { return [] } + return items.filter { day(of: $0) == selectedDay } + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + VStack { + LazyVGrid(columns: columns, spacing: 8) { + ForEach(weekdaySymbols, id: \.self) { symbol in + Text(symbol) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(0.. 0 ? .red : .clear) + .frame(width: 6, height: 6) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(selectedDay == day ? Color(.systemGray4) : .clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + + if let selectedDay { + List { + Section("\(month)월 \(selectedDay)일 저장된 항목") { + if selectedDayItems.isEmpty { + Text("저장된 항목이 없습니다") + .foregroundStyle(.secondary) + } + ForEach(selectedDayItems) { item in + Button { + editingItem = item + } label: { + HStack { + if let data = item.imageData, let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 44, height: 44) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + Text(item.name) + Spacer() + } + } + .buttonStyle(.plain) + } + .onDelete(perform: deleteItems) + } + } + .listStyle(.insetGrouped) + } else { + Spacer() + Text("날짜를 선택하면 저장된 항목이 표시됩니다") + .foregroundStyle(.secondary) + Spacer() + } + } + + FloatingScanButton { + showScanner = true + } + } + .navigationTitle("\(String(year))년 \(month)월") + .navigationBarTitleDisplayMode(.inline) + .fullScreenCover(isPresented: $showScanner) { + ScannerView(year: year, month: month) + } + .sheet(item: $editingItem) { item in + ItemEditView(item: item) + } + } + + private func deleteItems(at offsets: IndexSet) { + let targets = offsets.map { selectedDayItems[$0] } + for item in targets { + print("항목 삭제: \(item.name)") + modelContext.delete(item) + } + } +} + +/// 저장된 항목의 날짜(연/월/일) 수정 뷰 +struct ItemEditView: View { + @Bindable var item: ProductItem + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("제품명") { + TextField("제품명", text: $item.name) + } + Section("날짜 이동") { + DatePicker("날짜", selection: $item.date, displayedComponents: .date) + .datePickerStyle(.graphical) + } + } + .navigationTitle("항목 수정") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("완료") { + print("항목 수정 완료: \(item.name) → \(item.date)") + dismiss() + } + } + } + } + } +} + +#Preview { + NavigationStack { + MonthCalendarView(year: 2026, month: 7) + } + .modelContainer(for: ProductItem.self, inMemory: true) +} diff --git a/myApp/Expiranner/IOS/ProductItem.swift b/myApp/Expiranner/IOS/ProductItem.swift new file mode 100644 index 0000000..3c8c5de --- /dev/null +++ b/myApp/Expiranner/IOS/ProductItem.swift @@ -0,0 +1,15 @@ +import Foundation +import SwiftData + +@Model +final class ProductItem { + var name: String + var date: Date + var imageData: Data? + + init(name: String, date: Date, imageData: Data? = nil) { + self.name = name + self.date = date + self.imageData = imageData + } +} diff --git a/myApp/Expiranner/IOS/ScannerView.swift b/myApp/Expiranner/IOS/ScannerView.swift new file mode 100644 index 0000000..b7a4e22 --- /dev/null +++ b/myApp/Expiranner/IOS/ScannerView.swift @@ -0,0 +1,242 @@ +// +// ScannerView.swift +// Expiranner +// +// 전체 화면 카메라 스캐너: 촬영 → OCR → 팝업 → 날짜 선택 → 저장 + +import SwiftUI +import SwiftData +import VisionKit +import Vision +import UIKit + +struct ScannerView: View { + /// 현재 보고 있는 연/월 (날짜 선택 범위 제한용) + let year: Int + let month: Int + + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var scannerHolder = ScannerHolder() + @State private var isScanning = true + @State private var capturedImage: UIImage? + @State private var recognizedText = "" + @State private var showPopup = false + @State private var showDatePicker = false + @State private var selectedDate = Date() + + /// 선택 가능한 날짜 범위: 현재 보고 있는 월로 제한 + private var monthRange: ClosedRange { + let calendar = Calendar.current + let start = calendar.date(from: DateComponents(year: year, month: month, day: 1)) ?? Date() + let last = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: start) ?? start + return start...last + } + + var body: some View { + ZStack { + if DataScannerViewController.isSupported && DataScannerViewController.isAvailable { + DataScannerRepresentable(holder: scannerHolder, isScanning: $isScanning) + .ignoresSafeArea() + } else { + Color.black.ignoresSafeArea() + Text("이 기기에서는 카메라 스캐너를 사용할 수 없습니다") + .foregroundStyle(.white) + } + + VStack { + HStack { + Spacer() + Button { + print("스캐너 종료") + dismiss() + } label: { + Image(systemName: "xmark") + .font(.title2) + .foregroundStyle(.white) + .padding(12) + .background(Circle().fill(.black.opacity(0.5))) + } + .padding() + } + Spacer() + Button { + capturePhoto() + } label: { + Circle() + .stroke(.white, lineWidth: 4) + .frame(width: 72, height: 72) + .overlay(Circle().fill(.white).frame(width: 58, height: 58)) + } + .padding(.bottom, 32) + } + + if showPopup { + ocrPopup + } + } + .sheet(isPresented: $showDatePicker) { + datePickerSheet + } + } + + /// OCR 결과 팝업: 썸네일 + 편집 가능한 텍스트 + [재촬영]/[적용] + private var ocrPopup: some View { + VStack(spacing: 16) { + if let capturedImage { + Image(uiImage: capturedImage) + .resizable() + .scaledToFit() + .frame(maxHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + TextField("인식된 텍스트", text: $recognizedText, axis: .vertical) + .lineLimit(1...5) + .textFieldStyle(.roundedBorder) + HStack(spacing: 12) { + Button("재촬영") { + print("재촬영 선택") + resumeScanning() + } + .frame(maxWidth: .infinity) + .buttonStyle(.bordered) + + Button("적용") { + print("적용 선택: \(recognizedText)") + selectedDate = defaultDate() + showDatePicker = true + } + .frame(maxWidth: .infinity) + .buttonStyle(.borderedProminent) + } + } + .padding() + .background(RoundedRectangle(cornerRadius: 14).fill(Color(.systemBackground))) + .padding(24) + } + + /// 날짜 선택 시트: 현재 보고 있는 월로 제한 + private var datePickerSheet: some View { + NavigationStack { + VStack { + DatePicker("날짜", selection: $selectedDate, in: monthRange, displayedComponents: .date) + .datePickerStyle(.graphical) + .padding() + Spacer() + } + .navigationTitle("\(String(year))년 \(month)월 날짜 선택") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { showDatePicker = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("저장") { saveItem() } + } + } + } + } + + /// 기본 선택 날짜: 오늘이 해당 월에 포함되면 오늘, 아니면 월의 첫날 + private func defaultDate() -> Date { + let today = Date() + return monthRange.contains(today) ? today : monthRange.lowerBound + } + + private func capturePhoto() { + guard let scanner = scannerHolder.scanner else { + print("스캐너를 사용할 수 없습니다") + return + } + Task { + do { + let photo = try await scanner.capturePhoto() + print("사진 촬영 완료") + isScanning = false + capturedImage = photo + recognizedText = recognizeText(in: photo) + showPopup = true + } catch { + print("사진 촬영 실패: \(error.localizedDescription)") + } + } + } + + /// Vision 프레임워크로 한국어 텍스트 추출 + private func recognizeText(in image: UIImage) -> String { + guard let cgImage = image.cgImage else { return "" } + let request = VNRecognizeTextRequest() + request.recognitionLanguages = ["ko-KR", "en-US"] + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + let handler = VNImageRequestHandler(cgImage: cgImage) + do { + try handler.perform([request]) + } catch { + print("텍스트 인식 실패: \(error.localizedDescription)") + return "" + } + let text = request.results? + .compactMap { $0.topCandidates(1).first?.string } + .joined(separator: "\n") ?? "" + print("인식된 텍스트: \(text)") + return text + } + + private func saveItem() { + let imageData = capturedImage?.jpegData(compressionQuality: 0.5) + let item = ProductItem(name: recognizedText, date: selectedDate, imageData: imageData) + modelContext.insert(item) + print("저장 완료: \(item.name) → \(selectedDate)") + + // 저장 시 햅틱 피드백 + UINotificationFeedbackGenerator().notificationOccurred(.success) + + showDatePicker = false + resumeScanning() + } + + /// 팝업을 닫고 카메라 스캐너로 복귀 + private func resumeScanning() { + showPopup = false + capturedImage = nil + recognizedText = "" + isScanning = true + } +} + +/// DataScannerViewController 참조 보관용 +@Observable +final class ScannerHolder { + var scanner: DataScannerViewController? +} + +struct DataScannerRepresentable: UIViewControllerRepresentable { + let holder: ScannerHolder + @Binding var isScanning: Bool + + func makeUIViewController(context: Context) -> DataScannerViewController { + let scanner = DataScannerViewController( + recognizedDataTypes: [.text(languages: ["ko-KR"])], + qualityLevel: .balanced, + isHighlightingEnabled: false + ) + holder.scanner = scanner + return scanner + } + + func updateUIViewController(_ scanner: DataScannerViewController, context: Context) { + if isScanning, !scanner.isScanning { + do { + try scanner.startScanning() + print("스캔 시작") + } catch { + print("스캔 시작 실패: \(error.localizedDescription)") + } + } else if !isScanning, scanner.isScanning { + scanner.stopScanning() + print("스캔 중지") + } + } +} diff --git a/myApp/Expiranner/icon.jpeg b/myApp/Expiranner/icon.jpeg new file mode 100644 index 0000000..821d342 Binary files /dev/null and b/myApp/Expiranner/icon.jpeg differ