diff --git a/Baemin/Baemin.xcodeproj/project.pbxproj b/Baemin/Baemin.xcodeproj/project.pbxproj index 9bcc197..dbbae8c 100644 --- a/Baemin/Baemin.xcodeproj/project.pbxproj +++ b/Baemin/Baemin.xcodeproj/project.pbxproj @@ -11,7 +11,18 @@ A424C26F2EAE8F72006E0F1E /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = A424C26E2EAE8F72006E0F1E /* Then */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A40142462EC8D45F00D174C4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A4A640ED2EAB492200077FCA /* Project object */; + proxyType = 1; + remoteGlobalIDString = A4A640F42EAB492200077FCA; + remoteInfo = Baemin; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + A40142422EC8D45F00D174C4 /* BaeminTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BaeminTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A4A640F52EAB492200077FCA /* Baemin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Baemin.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -26,6 +37,11 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A40142432EC8D45F00D174C4 /* BaeminTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = BaeminTests; + sourceTree = ""; + }; A4A640F72EAB492200077FCA /* Baemin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -37,6 +53,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + A401423F2EC8D45F00D174C4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A4A640F22EAB492200077FCA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -53,6 +76,7 @@ isa = PBXGroup; children = ( A4A640F72EAB492200077FCA /* Baemin */, + A40142432EC8D45F00D174C4 /* BaeminTests */, A4A640F62EAB492200077FCA /* Products */, ); sourceTree = ""; @@ -61,6 +85,7 @@ isa = PBXGroup; children = ( A4A640F52EAB492200077FCA /* Baemin.app */, + A40142422EC8D45F00D174C4 /* BaeminTests.xctest */, ); name = Products; sourceTree = ""; @@ -68,6 +93,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + A40142412EC8D45F00D174C4 /* BaeminTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A40142482EC8D45F00D174C4 /* Build configuration list for PBXNativeTarget "BaeminTests" */; + buildPhases = ( + A401423E2EC8D45F00D174C4 /* Sources */, + A401423F2EC8D45F00D174C4 /* Frameworks */, + A40142402EC8D45F00D174C4 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A40142472EC8D45F00D174C4 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A40142432EC8D45F00D174C4 /* BaeminTests */, + ); + name = BaeminTests; + packageProductDependencies = ( + ); + productName = BaeminTests; + productReference = A40142422EC8D45F00D174C4 /* BaeminTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; A4A640F42EAB492200077FCA /* Baemin */ = { isa = PBXNativeTarget; buildConfigurationList = A4A641082EAB492300077FCA /* Build configuration list for PBXNativeTarget "Baemin" */; @@ -102,6 +150,10 @@ LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2600; TargetAttributes = { + A40142412EC8D45F00D174C4 = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = A4A640F42EAB492200077FCA; + }; A4A640F42EAB492200077FCA = { CreatedOnToolsVersion = 26.0.1; }; @@ -126,11 +178,19 @@ projectRoot = ""; targets = ( A4A640F42EAB492200077FCA /* Baemin */, + A40142412EC8D45F00D174C4 /* BaeminTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + A40142402EC8D45F00D174C4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A4A640F32EAB492200077FCA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -141,6 +201,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + A401423E2EC8D45F00D174C4 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A4A640F12EAB492200077FCA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -150,7 +217,69 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A40142472EC8D45F00D174C4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A4A640F42EAB492200077FCA /* Baemin */; + targetProxy = A40142462EC8D45F00D174C4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + A40142492EC8D45F00D174C4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6B47X24XTW; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.BaeminTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Baemin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Baemin"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + A401424A2EC8D45F00D174C4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6B47X24XTW; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.BaeminTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Baemin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Baemin"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; A4A641092EAB492300077FCA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -349,6 +478,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A40142482EC8D45F00D174C4 /* Build configuration list for PBXNativeTarget "BaeminTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A40142492EC8D45F00D174C4 /* Debug */, + A401424A2EC8D45F00D174C4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A4A640F02EAB492200077FCA /* Build configuration list for PBXProject "Baemin" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Baemin/Baemin.xcodeproj/xcshareddata/xcschemes/Baemin.xcscheme b/Baemin/Baemin.xcodeproj/xcshareddata/xcschemes/Baemin.xcscheme new file mode 100644 index 0000000..3de8f5a --- /dev/null +++ b/Baemin/Baemin.xcodeproj/xcshareddata/xcschemes/Baemin.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Baemin/Baemin/Presentation/Login/LoginViewController.swift b/Baemin/Baemin/Presentation/Login/LoginViewController.swift index 7fcc502..77ecfa4 100644 --- a/Baemin/Baemin/Presentation/Login/LoginViewController.swift +++ b/Baemin/Baemin/Presentation/Login/LoginViewController.swift @@ -16,19 +16,19 @@ final class LoginViewController: BaseViewController { private let navigationBar = NavigationBar() - private let emailField = LoginTextField( + lazy var emailField = LoginTextField( type: .id, labelText: "이메일 아이디", placeholderText: "이메일을 입력해주세요" ) - private let passwordField = LoginTextField( + lazy var passwordField = LoginTextField( type: .password, labelText: "비밀번호", placeholderText: "비밀번호를 입력해주세요" ) - private let loginButton = CTAButton(title: "로그인", isActive: false, size: .large) + lazy var loginButton = CTAButton(title: "로그인", isActive: false, size: .large) private let findAccountButton = UIButton(type: .system).then { var config = UIButton.Configuration.plain() diff --git a/Baemin/BaeminTests/BaeminTests.swift b/Baemin/BaeminTests/BaeminTests.swift new file mode 100644 index 0000000..0a860b8 --- /dev/null +++ b/Baemin/BaeminTests/BaeminTests.swift @@ -0,0 +1,35 @@ +// +// BaeminTests.swift +// BaeminTests +// +// Created by sun on 11/16/25. +// + +import XCTest + +final class BaeminTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/Baemin/BaeminTests/LoginViewControllerTests.swift b/Baemin/BaeminTests/LoginViewControllerTests.swift new file mode 100644 index 0000000..e7a5d1d --- /dev/null +++ b/Baemin/BaeminTests/LoginViewControllerTests.swift @@ -0,0 +1,69 @@ +// +// LoginViewControllerTests.swift +// BaeminTests +// +// Created by sun on 11/16/25. +// + +import XCTest +@testable import Baemin + +final class LoginViewControllerTests: XCTestCase { + + // MARK: - Properties + + private var sut: LoginViewController! + private var navigationController: UINavigationController! + + // MARK: - Lifecycle + + override func setUp() { + super.setUp() + sut = LoginViewController() + navigationController = UINavigationController(rootViewController: sut) + + sut.loadViewIfNeeded() + } + + override func tearDown() { + navigationController = nil + sut = nil + super.tearDown() + } + + // MARK: - Tests + + func test_login_shouldNotPush_whenEmailIsInvalid() { + // Given + sut.emailField.setText("not-an-email") + sut.passwordField.setText("ValidPass1!") + XCTAssertEqual(navigationController.viewControllers.count, 1) + + // When + sut.loginButton.sendActions(for: .touchUpInside) + + // Then + XCTAssertEqual( + navigationController.viewControllers.count, + 1, + "이메일 형식이 잘못된 경우에는 화면 전환이 일어나면 안 됩니다." + ) + } + + func test_login_shouldNotPush_whenPasswordIsInvalid() { + // Given + sut.emailField.setText("test@example.com") + sut.passwordField.setText("123") + XCTAssertEqual(navigationController.viewControllers.count, 1) + + // When + sut.loginButton.sendActions(for: .touchUpInside) + + // Then + XCTAssertEqual( + navigationController.viewControllers.count, + 1, + "비밀번호 형식이 잘못된 경우에는 화면 전환이 일어나면 안 됩니다." + ) + } +} diff --git a/Calculator/Calculator.xcodeproj/project.pbxproj b/Calculator/Calculator.xcodeproj/project.pbxproj new file mode 100644 index 0000000..cff152d --- /dev/null +++ b/Calculator/Calculator.xcodeproj/project.pbxproj @@ -0,0 +1,529 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A4AAAC0A2EFE638E00D4038A /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = A4AAAC092EFE638E00D4038A /* Then */; }; + A4AAAC0D2EFE639800D4038A /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = A4AAAC0C2EFE639800D4038A /* SnapKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A4AAAC162EFE65CF00D4038A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A4AAAB9F2EFE586800D4038A /* Project object */; + proxyType = 1; + remoteGlobalIDString = A4AAABA62EFE586800D4038A; + remoteInfo = Calculator; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + A4AAABA72EFE586800D4038A /* Calculator.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Calculator.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A4AAAC122EFE65CF00D4038A /* CalculatorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CalculatorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A4AAABB92EFE586900D4038A /* Exceptions for "Calculator" folder in "Calculator" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A4AAABA62EFE586800D4038A /* Calculator */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A4AAABA92EFE586800D4038A /* Calculator */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A4AAABB92EFE586900D4038A /* Exceptions for "Calculator" folder in "Calculator" target */, + ); + path = Calculator; + sourceTree = ""; + }; + A4AAAC132EFE65CF00D4038A /* CalculatorTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CalculatorTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A4AAABA42EFE586800D4038A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A4AAAC0A2EFE638E00D4038A /* Then in Frameworks */, + A4AAAC0D2EFE639800D4038A /* SnapKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A4AAAC0F2EFE65CF00D4038A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A4AAAB9E2EFE586800D4038A = { + isa = PBXGroup; + children = ( + A4AAABA92EFE586800D4038A /* Calculator */, + A4AAAC132EFE65CF00D4038A /* CalculatorTests */, + A4AAABA82EFE586800D4038A /* Products */, + ); + sourceTree = ""; + }; + A4AAABA82EFE586800D4038A /* Products */ = { + isa = PBXGroup; + children = ( + A4AAABA72EFE586800D4038A /* Calculator.app */, + A4AAAC122EFE65CF00D4038A /* CalculatorTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A4AAABA62EFE586800D4038A /* Calculator */ = { + isa = PBXNativeTarget; + buildConfigurationList = A4AAABBA2EFE586900D4038A /* Build configuration list for PBXNativeTarget "Calculator" */; + buildPhases = ( + A4AAABA32EFE586800D4038A /* Sources */, + A4AAABA42EFE586800D4038A /* Frameworks */, + A4AAABA52EFE586800D4038A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A4AAABA92EFE586800D4038A /* Calculator */, + ); + name = Calculator; + packageProductDependencies = ( + A4AAAC092EFE638E00D4038A /* Then */, + A4AAAC0C2EFE639800D4038A /* SnapKit */, + ); + productName = Calculator; + productReference = A4AAABA72EFE586800D4038A /* Calculator.app */; + productType = "com.apple.product-type.application"; + }; + A4AAAC112EFE65CF00D4038A /* CalculatorTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A4AAAC182EFE65CF00D4038A /* Build configuration list for PBXNativeTarget "CalculatorTests" */; + buildPhases = ( + A4AAAC0E2EFE65CF00D4038A /* Sources */, + A4AAAC0F2EFE65CF00D4038A /* Frameworks */, + A4AAAC102EFE65CF00D4038A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A4AAAC172EFE65CF00D4038A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A4AAAC132EFE65CF00D4038A /* CalculatorTests */, + ); + name = CalculatorTests; + packageProductDependencies = ( + ); + productName = CalculatorTests; + productReference = A4AAAC122EFE65CF00D4038A /* CalculatorTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A4AAAB9F2EFE586800D4038A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + A4AAABA62EFE586800D4038A = { + CreatedOnToolsVersion = 26.0.1; + }; + A4AAAC112EFE65CF00D4038A = { + CreatedOnToolsVersion = 26.0.1; + TestTargetID = A4AAABA62EFE586800D4038A; + }; + }; + }; + buildConfigurationList = A4AAABA22EFE586800D4038A /* Build configuration list for PBXProject "Calculator" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A4AAAB9E2EFE586800D4038A; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A4AAAC082EFE638E00D4038A /* XCRemoteSwiftPackageReference "Then" */, + A4AAAC0B2EFE639800D4038A /* XCRemoteSwiftPackageReference "SnapKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A4AAABA82EFE586800D4038A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A4AAABA62EFE586800D4038A /* Calculator */, + A4AAAC112EFE65CF00D4038A /* CalculatorTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A4AAABA52EFE586800D4038A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A4AAAC102EFE65CF00D4038A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A4AAABA32EFE586800D4038A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A4AAAC0E2EFE65CF00D4038A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + A4AAAC172EFE65CF00D4038A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A4AAABA62EFE586800D4038A /* Calculator */; + targetProxy = A4AAAC162EFE65CF00D4038A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + A4AAABBB2EFE586900D4038A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6B47X24XTW; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Calculator/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + 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.Calculator; + 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; + }; + A4AAABBC2EFE586900D4038A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6B47X24XTW; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Calculator/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + 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.Calculator; + 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; + }; + A4AAABBD2EFE586900D4038A /* 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; + DEVELOPMENT_TEAM = 6B47X24XTW; + 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 = 26.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; + }; + A4AAABBE2EFE586900D4038A /* 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"; + DEVELOPMENT_TEAM = 6B47X24XTW; + 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 = 26.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; + }; + A4AAAC192EFE65CF00D4038A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6B47X24XTW; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.CalculatorTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calculator.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calculator"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + A4AAAC1A2EFE65CF00D4038A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 6B47X24XTW; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.CalculatorTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calculator.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calculator"; + XROS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A4AAABA22EFE586800D4038A /* Build configuration list for PBXProject "Calculator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A4AAABBD2EFE586900D4038A /* Debug */, + A4AAABBE2EFE586900D4038A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A4AAABBA2EFE586900D4038A /* Build configuration list for PBXNativeTarget "Calculator" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A4AAABBB2EFE586900D4038A /* Debug */, + A4AAABBC2EFE586900D4038A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A4AAAC182EFE65CF00D4038A /* Build configuration list for PBXNativeTarget "CalculatorTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A4AAAC192EFE65CF00D4038A /* Debug */, + A4AAAC1A2EFE65CF00D4038A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + A4AAAC082EFE638E00D4038A /* XCRemoteSwiftPackageReference "Then" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/devxoul/Then"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; + A4AAAC0B2EFE639800D4038A /* XCRemoteSwiftPackageReference "SnapKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SnapKit/SnapKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.7.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A4AAAC092EFE638E00D4038A /* Then */ = { + isa = XCSwiftPackageProductDependency; + package = A4AAAC082EFE638E00D4038A /* XCRemoteSwiftPackageReference "Then" */; + productName = Then; + }; + A4AAAC0C2EFE639800D4038A /* SnapKit */ = { + isa = XCSwiftPackageProductDependency; + package = A4AAAC0B2EFE639800D4038A /* XCRemoteSwiftPackageReference "SnapKit" */; + productName = SnapKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A4AAAB9F2EFE586800D4038A /* Project object */; +} diff --git a/Calculator/Calculator/App/AppDelegate.swift b/Calculator/Calculator/App/AppDelegate.swift new file mode 100644 index 0000000..aca6aff --- /dev/null +++ b/Calculator/Calculator/App/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/Calculator/Calculator/App/SceneDelegate.swift b/Calculator/Calculator/App/SceneDelegate.swift new file mode 100644 index 0000000..224b3e6 --- /dev/null +++ b/Calculator/Calculator/App/SceneDelegate.swift @@ -0,0 +1,70 @@ +// +// SceneDelegate.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { + window.rootViewController = UIViewController() + self.window = window + window.makeKeyAndVisible() + return + } + + let service: CalculatorServicing = CalculatorService() + let viewModel = CalculatorViewModel(service: service) + let root = CalculatorViewController(viewModel: viewModel) + + let nav = UINavigationController(rootViewController: root) + window.rootViewController = nav + self.window = window + window.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/Calculator/Calculator/CalculatorButton.swift b/Calculator/Calculator/CalculatorButton.swift new file mode 100644 index 0000000..da9ac51 --- /dev/null +++ b/Calculator/Calculator/CalculatorButton.swift @@ -0,0 +1,57 @@ +// +// CalculatorButton.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import UIKit + +enum CalculatorButton: Hashable { + case clear + case sign + case percent + case operation(Operation) + case digit(Int) + case decimal + case equals + + enum Operation: String, CaseIterable { + case add = "+" + case subtract = "−" + case multiply = "×" + case divide = "÷" + } + + var title: String { + switch self { + case .clear: return "AC" + case .sign: return "±" + case .percent: return "%" + case .operation(let op): return op.rawValue + case .digit(let n): return "\(n)" + case .decimal: return "." + case .equals: return "=" + } + } + + var backgroundColor: UIColor { + switch self { + case .operation, .equals: + return .systemOrange + case .clear, .sign, .percent: + return .systemGray2 + case .digit, .decimal: + return .systemGray4 + } + } + + var titleColor: UIColor { + switch self { + case .clear, .sign, .percent: + return .label + default: + return .white + } + } +} diff --git a/Calculator/Calculator/CalculatorService.swift b/Calculator/Calculator/CalculatorService.swift new file mode 100644 index 0000000..1c37234 --- /dev/null +++ b/Calculator/Calculator/CalculatorService.swift @@ -0,0 +1,141 @@ +// +// CalculatorService.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import Foundation + +protocol CalculatorServicing { + var displayText: String { get } + + func inputDigit(_ digit: Int) + func inputDecimal() + func clear() + func setOperation(_ op: CalculatorButton.Operation) + func equals() + + func toggleSign() + func percent() +} + +extension CalculatorServicing { + func toggleSign() {} + func percent() {} +} + +final class CalculatorService: CalculatorServicing { + + private enum State { + case enteringLeft + case enteringRight + case showingResult + } + + private(set) var displayText: String = "0" + + private var state: State = .enteringLeft + + private var leftInput: String = "0" + private var rightInput: String = "0" + private var operation: CalculatorButton.Operation? + + private let formatter: NumberFormatter = { + let f = NumberFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.numberStyle = .decimal + f.maximumFractionDigits = 9 + f.minimumFractionDigits = 0 + f.usesGroupingSeparator = false + return f + }() + + init() { + syncDisplay() + } + + func inputDigit(_ digit: Int) { + guard (0...9).contains(digit) else { return } + guard displayText != "Error" else { return } + + switch state { + case .enteringLeft: + leftInput = appendDigit(to: leftInput, digit: digit) + syncDisplay(from: leftInput) + + case .enteringRight: + rightInput = appendDigit(to: rightInput, digit: digit) + syncDisplay(from: rightInput) + + case .showingResult: + clear() + leftInput = appendDigit(to: leftInput, digit: digit) + syncDisplay(from: leftInput) + } + } + + func inputDecimal() { + return + } + + func clear() { + state = .enteringLeft + leftInput = "0" + rightInput = "0" + operation = nil + syncDisplay() + } + + func setOperation(_ op: CalculatorButton.Operation) { + guard displayText != "Error" else { return } + operation = op + state = .enteringRight + rightInput = "0" + syncDisplay(from: leftInput) + } + + func equals() { + guard displayText != "Error" else { return } + guard let op = operation else { return } + + let lhs = Decimal(string: leftInput) ?? 0 + let rhs = Decimal(string: rightInput) ?? 0 + + let result: Decimal? + switch op { + case .add: result = lhs + rhs + case .subtract: result = lhs - rhs + case .multiply: result = lhs * rhs + case .divide: + result = (rhs == 0) ? nil : (lhs / rhs) + } + + if let result { + let plain = (result as NSDecimalNumber).stringValue + leftInput = plain + rightInput = "0" + operation = nil + state = .showingResult + syncDisplay(from: leftInput) + } else { + displayText = "Error" + } + } + + // MARK: - Helpers + + private func appendDigit(to input: String, digit: Int) -> String { + if input == "0" { return "\(digit)" } + return input + "\(digit)" + } + + private func syncDisplay() { + syncDisplay(from: leftInput) + } + + private func syncDisplay(from input: String) { + let value = Decimal(string: input) ?? 0 + displayText = formatter.string(from: value as NSDecimalNumber) ?? input + } +} diff --git a/Calculator/Calculator/CalculatorViewController.swift b/Calculator/Calculator/CalculatorViewController.swift new file mode 100644 index 0000000..1474c47 --- /dev/null +++ b/Calculator/Calculator/CalculatorViewController.swift @@ -0,0 +1,146 @@ +// +// CalculatorViewController.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import UIKit + +import SnapKit +import Then + +final class CalculatorViewController: UIViewController { + + private let viewModel: CalculatorViewModel + + private let displayLabel = UILabel().then { + $0.text = "0" + $0.font = .systemFont(ofSize: 72, weight: .light) + $0.textAlignment = .right + $0.adjustsFontSizeToFitWidth = true + $0.minimumScaleFactor = 0.4 + $0.textColor = .label + $0.numberOfLines = 1 + } + + private let mainStack = UIStackView().then { + $0.axis = .vertical + $0.spacing = 12 + $0.distribution = .fillEqually + } + + init(viewModel: CalculatorViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + self.title = "Calculator" + } + + required init?(coder: NSCoder) { + let service: CalculatorServicing = CalculatorService() + let vm = CalculatorViewModel(service: service) + self.viewModel = vm + super.init(coder: coder) + self.title = "Calculator" + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + layout() + bind() + render(viewModel.output) + } + + private func bind() { + viewModel.onOutputChanged = { [weak self] output in + self?.render(output) + } + } + + private func render(_ output: CalculatorViewModel.Output) { + displayLabel.text = output.displayText + displayLabel.accessibilityLabel = "Display \(output.displayText)" + } + + private func layout() { + view.addSubview(displayLabel) + view.addSubview(mainStack) + + displayLabel.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(16) + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().inset(16) + make.height.equalTo(100) + } + + mainStack.snp.makeConstraints { make in + make.top.greaterThanOrEqualTo(displayLabel.snp.bottom).offset(16) + make.leading.equalToSuperview().offset(16) + make.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + } + + let rows: [[CalculatorButton]] = [ + [.clear, .sign, .percent, .operation(.divide)], + [.digit(7), .digit(8), .digit(9), .operation(.multiply)], + [.digit(4), .digit(5), .digit(6), .operation(.subtract)], + [.digit(1), .digit(2), .digit(3), .operation(.add)], + [.digit(0), .decimal, .equals] + ] + + for row in rows { + let rowStack = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 12 + $0.distribution = .fillEqually + } + + if row.count == 3, row.contains(.digit(0)) { + let zero = makeButton(.digit(0)) + let dot = makeButton(.decimal) + let eq = makeButton(.equals) + + rowStack.addArrangedSubview(zero) + rowStack.addArrangedSubview(dot) + rowStack.addArrangedSubview(eq) + + zero.snp.makeConstraints { make in + make.width.equalTo(dot.snp.width).multipliedBy(2.0).offset(12) + } + } else { + row.forEach { rowStack.addArrangedSubview(makeButton($0)) } + } + + mainStack.addArrangedSubview(rowStack) + } + } + + private func makeButton(_ button: CalculatorButton) -> UIButton { + let b = UIButton(type: .system).then { + $0.setTitle(button.title, for: .normal) + $0.setTitleColor(button.titleColor, for: .normal) + $0.titleLabel?.font = .systemFont(ofSize: 32, weight: .medium) + $0.backgroundColor = button.backgroundColor + $0.layer.cornerRadius = 36 + $0.clipsToBounds = true + $0.accessibilityLabel = "Button \(button.title)" + } + + b.snp.makeConstraints { make in + make.height.equalTo(72) + } + + if case .decimal = button { + b.isEnabled = false + b.alpha = 0.35 + return b + } + + b.addAction(UIAction { [weak self] _ in + self?.viewModel.onTap(button) + }, for: .touchUpInside) + + return b + } +} diff --git a/Calculator/Calculator/CalculatorViewModel.swift b/Calculator/Calculator/CalculatorViewModel.swift new file mode 100644 index 0000000..0cf4092 --- /dev/null +++ b/Calculator/Calculator/CalculatorViewModel.swift @@ -0,0 +1,49 @@ +// +// CalculatorViewModel.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import Foundation + +final class CalculatorViewModel { + + struct Output: Equatable { + var displayText: String + } + + private let service: CalculatorServicing + + private(set) var output: Output { + didSet { onOutputChanged?(output) } + } + + var onOutputChanged: ((Output) -> Void)? + + init(service: CalculatorServicing) { + self.service = service + self.output = Output(displayText: service.displayText) + } + + func onTap(_ button: CalculatorButton) { + switch button { + case .digit(let n): + service.inputDigit(n) + case .decimal: + service.inputDecimal() + case .clear: + service.clear() + case .sign: + service.toggleSign() + case .percent: + service.percent() + case .operation(let op): + service.setOperation(op) + case .equals: + service.equals() + } + + output = Output(displayText: service.displayText) + } +} diff --git a/Calculator/Calculator/Info.plist b/Calculator/Calculator/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/Calculator/Calculator/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Calculator/CalculatorTests/CalculatorServiceTests.swift b/Calculator/CalculatorTests/CalculatorServiceTests.swift new file mode 100644 index 0000000..0aa9e6d --- /dev/null +++ b/Calculator/CalculatorTests/CalculatorServiceTests.swift @@ -0,0 +1,26 @@ +// +// CalculatorServiceTests.swift +// Calculator +// +// Created by sun on 12/26/25. +// + +import XCTest +@testable import Calculator + +final class CalculatorServiceTests: XCTestCase { + + func test_add() { + // Given + let sut = CalculatorService() + + // When + sut.inputDigit(2) + sut.setOperation(.add) + sut.inputDigit(3) + sut.equals() + + // Then + XCTAssertEqual(sut.displayText, "5") + } +}