start Expiranner project and first demo app build

This commit is contained in:
songyc macbook 2026-07-09 19:23:40 +09:00
parent 2d9e7ad20b
commit 3a46505451
16 changed files with 1024 additions and 1 deletions

@ -1 +0,0 @@
Subproject commit 74a95d489240619326e8abc1725d15b6e62031ab

View File

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

View File

@ -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 = "<absolute>"; };
725A9F862FFFAB2400846FFB /* CLAUDE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CLAUDE.md; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
725A9F722FFFA9D400846FFB /* IOS */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = IOS;
sourceTree = "<group>";
};
/* 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 = "<group>";
};
/* 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 */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Expiranner.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

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

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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<ProductItem> { $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)
}

View File

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

View File

@ -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<ProductItem> { $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..<firstWeekdayOffset, id: \.self) { _ in
Text("")
}
ForEach(1...daysInMonth, id: \.self) { day in
let count = items.filter { self.day(of: $0) == day }.count
Button {
selectedDay = day
} label: {
VStack(spacing: 2) {
Text("\(day)")
.fontWeight(selectedDay == day ? .bold : .regular)
Circle()
.fill(count > 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)
}

View File

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

View File

@ -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<Date> {
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("스캔 중지")
}
}
}

BIN
myApp/Expiranner/icon.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB