diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 00000000..818e19b0 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,47 @@ +# TimesheetApp iOS client + +## Prerequisites + +- macOS with Xcode 26.6 or later +- XcodeGen (`/opt/homebrew/bin/xcodegen`) +- An iOS 17 or later simulator + +The app uses only Foundation, SwiftUI, and system frameworks. XcodeGen is the +source of truth for the Xcode project: + +```sh +cd ios +xcodegen generate +``` + +## Run the backend + +From the repository root: + +```sh +cd backend +npm install +npm run dev +``` + +The development backend listens on port 3001. The simulator can reach a +backend running on the Mac at `http://localhost:3001`. + +## Point the app at another host + +On the login screen, expand **Advanced** and edit **Server URL**. The value is +stored in the app's settings for the next launch. For a physical device, use +the Mac's LAN IP and ensure the backend is bound to an address reachable by the +device. + +## Build and test + +```sh +xcodebuild -project TimesheetApp.xcodeproj \ + -scheme TimesheetApp \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build + +xcodebuild -project TimesheetApp.xcodeproj \ + -scheme TimesheetApp \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test +``` diff --git a/ios/TimesheetApp.xcodeproj/project.pbxproj b/ios/TimesheetApp.xcodeproj/project.pbxproj new file mode 100644 index 00000000..1f98f9b0 --- /dev/null +++ b/ios/TimesheetApp.xcodeproj/project.pbxproj @@ -0,0 +1,500 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 05DA68A5EFD23752DCDA4EDF /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 189EE073D2F49ADD8B106DA6 /* RootTabView.swift */; }; + 299175B97E192A428B4661D9 /* ViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77DB13969CBAA453155CFF19 /* ViewModelTests.swift */; }; + 2EC78E93E2FA9808A18AFF15 /* SessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7635BE183E81FFCBDF8C15 /* SessionStore.swift */; }; + 4065EAD6BAB9EEF17688AB5A /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C937FD580BE6B7D50099A9E2 /* LoginView.swift */; }; + 43310EA0F6ACE4FDDCC21582 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2092D58DF78CCEDD11107D3F /* Models.swift */; }; + 5377AEDDF6B497CB1ECE7B03 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CA231AF96973D13BC4CD8D0F /* Assets.xcassets */; }; + 6152E942FF056D397E3A5C20 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016620FD167B2408F5258FA2 /* SettingsView.swift */; }; + 6D90910BF719A260B7CE72D4 /* SharedViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB891FEBA22DA18175BEF1CA /* SharedViews.swift */; }; + 6EE89B0E579AA02D04A77A1C /* ViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F4C0B924E45FAFD1637DAE /* ViewModels.swift */; }; + 84D9FC2A155010D8D3AFD844 /* ClientsViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5661E1975CE2C83CFC59F54D /* ClientsViews.swift */; }; + A0E69DFE120FF1BF6E90C8C2 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49562B5AD22E614327D966A8 /* APIClient.swift */; }; + AC86ED2F268A00413FD1554B /* EntriesViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5496C85F399D742D8864EE3 /* EntriesViews.swift */; }; + BB123F0CFC93D01DFFEFF35F /* APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F0B6D5FA98C8765A572699A /* APIError.swift */; }; + CC9F564BDDEEB74A2D1B966C /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26DE12922D84736686313A83 /* DashboardView.swift */; }; + CF30FDE5968654531FD076C6 /* LiveAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9CDED1581AE15DEC60C3B7 /* LiveAPIClient.swift */; }; + D4531955C31ADF73E4A50307 /* APIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78BD3DFC1D723F111D51B19D /* APIClientTests.swift */; }; + F42904F48DEE968FD6C62142 /* TimesheetApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA52A70BEFFFB078A936945 /* TimesheetApp.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 42182A1D268CDE631917231E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7A52131F5D9A595DFB89D5DA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 431A7BD4ABBF7F1DC4A8251B; + remoteInfo = TimesheetApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 016620FD167B2408F5258FA2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 0E9CDED1581AE15DEC60C3B7 /* LiveAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveAPIClient.swift; sourceTree = ""; }; + 189EE073D2F49ADD8B106DA6 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + 2092D58DF78CCEDD11107D3F /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 26DE12922D84736686313A83 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 27F4C0B924E45FAFD1637DAE /* ViewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModels.swift; sourceTree = ""; }; + 2BFD152FA191DC1B5E93B272 /* TimesheetApp.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = TimesheetApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 49562B5AD22E614327D966A8 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; + 500A41A684646054F8138EB6 /* TimesheetAppTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TimesheetAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5661E1975CE2C83CFC59F54D /* ClientsViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientsViews.swift; sourceTree = ""; }; + 5D7635BE183E81FFCBDF8C15 /* SessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionStore.swift; sourceTree = ""; }; + 6BA52A70BEFFFB078A936945 /* TimesheetApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimesheetApp.swift; sourceTree = ""; }; + 77DB13969CBAA453155CFF19 /* ViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTests.swift; sourceTree = ""; }; + 78BD3DFC1D723F111D51B19D /* APIClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientTests.swift; sourceTree = ""; }; + 7A335EB5518DE92BC3C2D907 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 7F0B6D5FA98C8765A572699A /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = ""; }; + AB891FEBA22DA18175BEF1CA /* SharedViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedViews.swift; sourceTree = ""; }; + C5496C85F399D742D8864EE3 /* EntriesViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntriesViews.swift; sourceTree = ""; }; + C937FD580BE6B7D50099A9E2 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + CA231AF96973D13BC4CD8D0F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 1F992CD2385FF13CF664FF74 /* TimesheetApp */ = { + isa = PBXGroup; + children = ( + CA231AF96973D13BC4CD8D0F /* Assets.xcassets */, + 7A335EB5518DE92BC3C2D907 /* Info.plist */, + 6BA52A70BEFFFB078A936945 /* TimesheetApp.swift */, + 42273BCB78EBB935CAD0A688 /* Models */, + 7D71787E6A14B72869ACEC66 /* Networking */, + 66B2F8F83CE2A7541AAB1D62 /* State */, + 48A2BC5C1D442B8355AE24C1 /* Views */, + ); + path = TimesheetApp; + sourceTree = ""; + }; + 3B0DE168CA47AB97A3126E67 /* TimesheetAppTests */ = { + isa = PBXGroup; + children = ( + 78BD3DFC1D723F111D51B19D /* APIClientTests.swift */, + 77DB13969CBAA453155CFF19 /* ViewModelTests.swift */, + ); + path = TimesheetAppTests; + sourceTree = ""; + }; + 40F153C405CA7A4F203A9693 = { + isa = PBXGroup; + children = ( + 1F992CD2385FF13CF664FF74 /* TimesheetApp */, + 3B0DE168CA47AB97A3126E67 /* TimesheetAppTests */, + 477C3BC2EF355C13E5652931 /* Products */, + ); + sourceTree = ""; + }; + 42273BCB78EBB935CAD0A688 /* Models */ = { + isa = PBXGroup; + children = ( + 2092D58DF78CCEDD11107D3F /* Models.swift */, + ); + path = Models; + sourceTree = ""; + }; + 477C3BC2EF355C13E5652931 /* Products */ = { + isa = PBXGroup; + children = ( + 2BFD152FA191DC1B5E93B272 /* TimesheetApp.app */, + 500A41A684646054F8138EB6 /* TimesheetAppTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 48A2BC5C1D442B8355AE24C1 /* Views */ = { + isa = PBXGroup; + children = ( + 5661E1975CE2C83CFC59F54D /* ClientsViews.swift */, + 26DE12922D84736686313A83 /* DashboardView.swift */, + C5496C85F399D742D8864EE3 /* EntriesViews.swift */, + C937FD580BE6B7D50099A9E2 /* LoginView.swift */, + 189EE073D2F49ADD8B106DA6 /* RootTabView.swift */, + 016620FD167B2408F5258FA2 /* SettingsView.swift */, + AB891FEBA22DA18175BEF1CA /* SharedViews.swift */, + ); + path = Views; + sourceTree = ""; + }; + 66B2F8F83CE2A7541AAB1D62 /* State */ = { + isa = PBXGroup; + children = ( + 5D7635BE183E81FFCBDF8C15 /* SessionStore.swift */, + 27F4C0B924E45FAFD1637DAE /* ViewModels.swift */, + ); + path = State; + sourceTree = ""; + }; + 7D71787E6A14B72869ACEC66 /* Networking */ = { + isa = PBXGroup; + children = ( + 49562B5AD22E614327D966A8 /* APIClient.swift */, + 7F0B6D5FA98C8765A572699A /* APIError.swift */, + 0E9CDED1581AE15DEC60C3B7 /* LiveAPIClient.swift */, + ); + path = Networking; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 431A7BD4ABBF7F1DC4A8251B /* TimesheetApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = D4A8F2D1D4CD077F2F35BF8E /* Build configuration list for PBXNativeTarget "TimesheetApp" */; + buildPhases = ( + 5BEA7446DDC8F0397A0BECA0 /* Sources */, + E1FA6E69F7460ECDDCA3AEC7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TimesheetApp; + packageProductDependencies = ( + ); + productName = TimesheetApp; + productReference = 2BFD152FA191DC1B5E93B272 /* TimesheetApp.app */; + productType = "com.apple.product-type.application"; + }; + 69C2FB1DFE7D6351B1C346F1 /* TimesheetAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C05C00B4B239D52A1CA9B2BA /* Build configuration list for PBXNativeTarget "TimesheetAppTests" */; + buildPhases = ( + B4F70152D41E57BA97688791 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + E1EA733F076DA8384CB51189 /* PBXTargetDependency */, + ); + name = TimesheetAppTests; + packageProductDependencies = ( + ); + productName = TimesheetAppTests; + productReference = 500A41A684646054F8138EB6 /* TimesheetAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7A52131F5D9A595DFB89D5DA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 431A7BD4ABBF7F1DC4A8251B = { + DevelopmentTeam = ""; + }; + 69C2FB1DFE7D6351B1C346F1 = { + DevelopmentTeam = ""; + }; + }; + }; + buildConfigurationList = 68D101E099F7519EB91D982E /* Build configuration list for PBXProject "TimesheetApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 40F153C405CA7A4F203A9693; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 477C3BC2EF355C13E5652931 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 431A7BD4ABBF7F1DC4A8251B /* TimesheetApp */, + 69C2FB1DFE7D6351B1C346F1 /* TimesheetAppTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E1FA6E69F7460ECDDCA3AEC7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5377AEDDF6B497CB1ECE7B03 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5BEA7446DDC8F0397A0BECA0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A0E69DFE120FF1BF6E90C8C2 /* APIClient.swift in Sources */, + BB123F0CFC93D01DFFEFF35F /* APIError.swift in Sources */, + 84D9FC2A155010D8D3AFD844 /* ClientsViews.swift in Sources */, + CC9F564BDDEEB74A2D1B966C /* DashboardView.swift in Sources */, + AC86ED2F268A00413FD1554B /* EntriesViews.swift in Sources */, + CF30FDE5968654531FD076C6 /* LiveAPIClient.swift in Sources */, + 4065EAD6BAB9EEF17688AB5A /* LoginView.swift in Sources */, + 43310EA0F6ACE4FDDCC21582 /* Models.swift in Sources */, + 05DA68A5EFD23752DCDA4EDF /* RootTabView.swift in Sources */, + 2EC78E93E2FA9808A18AFF15 /* SessionStore.swift in Sources */, + 6152E942FF056D397E3A5C20 /* SettingsView.swift in Sources */, + 6D90910BF719A260B7CE72D4 /* SharedViews.swift in Sources */, + F42904F48DEE968FD6C62142 /* TimesheetApp.swift in Sources */, + 6EE89B0E579AA02D04A77A1C /* ViewModels.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B4F70152D41E57BA97688791 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D4531955C31ADF73E4A50307 /* APIClientTests.swift in Sources */, + 299175B97E192A428B4661D9 /* ViewModelTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + E1EA733F076DA8384CB51189 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 431A7BD4ABBF7F1DC4A8251B /* TimesheetApp */; + targetProxy = 42182A1D268CDE631917231E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 024CE5BD33F24464438B64D0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 331FA4BE00C494849FD18440 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = TimesheetApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.cognition.workshops.timesheet; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 55363275594638CDFE072619 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + 58068C46203919C36B737B31 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.cognition.workshops.timesheet.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TimesheetApp.app/TimesheetApp"; + }; + name = Release; + }; + 8B17B406FB8D9CE8F4286D3F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.cognition.workshops.timesheet.tests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TimesheetApp.app/TimesheetApp"; + }; + name = Debug; + }; + 8B4A45121BC5CC0A3DBAED3B /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = TimesheetApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = ai.cognition.workshops.timesheet; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 68D101E099F7519EB91D982E /* Build configuration list for PBXProject "TimesheetApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 024CE5BD33F24464438B64D0 /* Debug */, + 55363275594638CDFE072619 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + C05C00B4B239D52A1CA9B2BA /* Build configuration list for PBXNativeTarget "TimesheetAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B17B406FB8D9CE8F4286D3F /* Debug */, + 58068C46203919C36B737B31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D4A8F2D1D4CD077F2F35BF8E /* Build configuration list for PBXNativeTarget "TimesheetApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331FA4BE00C494849FD18440 /* Debug */, + 8B4A45121BC5CC0A3DBAED3B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7A52131F5D9A595DFB89D5DA /* Project object */; +} diff --git a/ios/TimesheetApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/TimesheetApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/ios/TimesheetApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/TimesheetApp.xcodeproj/xcshareddata/xcschemes/TimesheetApp.xcscheme b/ios/TimesheetApp.xcodeproj/xcshareddata/xcschemes/TimesheetApp.xcscheme new file mode 100644 index 00000000..0098d285 --- /dev/null +++ b/ios/TimesheetApp.xcodeproj/xcshareddata/xcschemes/TimesheetApp.xcscheme @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/TimesheetApp/Assets.xcassets/AppAccent.colorset/Contents.json b/ios/TimesheetApp/Assets.xcassets/AppAccent.colorset/Contents.json new file mode 100644 index 00000000..74ddd4b8 --- /dev/null +++ b/ios/TimesheetApp/Assets.xcassets/AppAccent.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.850", + "green" : "0.350", + "red" : "0.180" + } + } + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimesheetApp/Assets.xcassets/Contents.json b/ios/TimesheetApp/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/TimesheetApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimesheetApp/Info.plist b/ios/TimesheetApp/Info.plist new file mode 100644 index 00000000..4329f68c --- /dev/null +++ b/ios/TimesheetApp/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleDisplayName + Timesheet + CFBundleIdentifier + ai.cognition.workshops.timesheet + CFBundleName + TimesheetApp + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSExceptionDomains + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + + + + + diff --git a/ios/TimesheetApp/Models/Models.swift b/ios/TimesheetApp/Models/Models.swift new file mode 100644 index 00000000..cacd7c43 --- /dev/null +++ b/ios/TimesheetApp/Models/Models.swift @@ -0,0 +1,223 @@ +import Foundation + +enum DateCoding { + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .iso8601) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current + return calendar + }() + + static let dateOnlyFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .iso8601) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static func localMidnight(for date: Date) -> Date { + let components = utcCalendar.dateComponents([.year, .month, .day], from: date) + return Calendar.current.date(from: components) ?? date + } + + static func decode(_ value: String) throws -> Date { + if let date = dateOnlyFormatter.date(from: value) { + return date + } + if let date = isoFormatter.date(from: value) { + return localMidnight(for: date) + } + let fallback = ISO8601DateFormatter() + if let date = fallback.date(from: value) { + return localMidnight(for: date) + } + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid date: \(value)")) + } + + static func decode(epochMilliseconds: Double) -> Date { + localMidnight(for: Date(timeIntervalSince1970: epochMilliseconds / 1000)) + } + + static func decodeDate( + from container: KeyedDecodingContainer, + forKey key: K + ) throws -> Date { + if let string = try? container.decode(String.self, forKey: key) { + return try decode(string) + } + if let epochMilliseconds = try? container.decode(Double.self, forKey: key) { + return decode(epochMilliseconds: epochMilliseconds) + } + throw DecodingError.dataCorrupted(.init( + codingPath: container.codingPath + [key], + debugDescription: "Invalid date value." + )) + } + + static func encode(_ date: Date) -> String { + dateOnlyFormatter.string(from: date) + } +} + +struct User: Codable, Identifiable, Hashable, Sendable { + var id: String { email } + let email: String + let createdAt: String? + + enum CodingKeys: String, CodingKey { + case email + case createdAt = "created_at" + } +} + +struct Client: Codable, Identifiable, Hashable, Sendable { + let id: Int + var name: String + var description: String? + var department: String? + var email: String? + let createdAt: String? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id, name, description, department, email + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +struct ClientPayload: Codable, Hashable, Sendable { + var name: String? + var description: String? + var department: String? + var email: String? +} + +struct WorkEntry: Codable, Identifiable, Hashable, Sendable { + let id: Int + var clientID: Int? + var hours: Double + var description: String? + var date: Date + let createdAt: String? + let updatedAt: String? + var clientName: String? + + enum CodingKeys: String, CodingKey { + case id + case clientID = "client_id" + case hours, description, date + case createdAt = "created_at" + case updatedAt = "updated_at" + case clientName = "client_name" + } + + init(id: Int, clientID: Int?, hours: Double, description: String?, date: Date, + createdAt: String? = nil, updatedAt: String? = nil, clientName: String? = nil) { + self.id = id + self.clientID = clientID + self.hours = hours + self.description = description + self.date = date + self.createdAt = createdAt + self.updatedAt = updatedAt + self.clientName = clientName + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + clientID = try container.decodeIfPresent(Int.self, forKey: .clientID) + hours = try container.decode(Double.self, forKey: .hours) + description = try container.decodeIfPresent(String.self, forKey: .description) + date = try DateCoding.decodeDate(from: container, forKey: .date) + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + clientName = try container.decodeIfPresent(String.self, forKey: .clientName) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(clientID, forKey: .clientID) + try container.encode(hours, forKey: .hours) + try container.encodeIfPresent(description, forKey: .description) + try container.encode(DateCoding.encode(date), forKey: .date) + try container.encodeIfPresent(createdAt, forKey: .createdAt) + try container.encodeIfPresent(updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(clientName, forKey: .clientName) + } +} + +struct WorkEntryPayload: Codable, Hashable, Sendable { + var clientID: Int? + var hours: Double? + var description: String? + var date: Date? + + enum CodingKeys: String, CodingKey { + case clientID = "clientId" + case hours, description, date + } + + init(clientID: Int? = nil, hours: Double? = nil, description: String? = nil, date: Date? = nil) { + self.clientID = clientID + self.hours = hours + self.description = description + self.date = date + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(clientID, forKey: .clientID) + try container.encodeIfPresent(hours, forKey: .hours) + try container.encodeIfPresent(description, forKey: .description) + if let date { + try container.encode(DateCoding.encode(date), forKey: .date) + } + } +} + +struct ClientReport: Codable, Hashable, Sendable { + let client: ReportClient + let workEntries: [ReportEntry] + let totalHours: Double + let entryCount: Int + + struct ReportClient: Codable, Hashable, Sendable { + let id: Int + let name: String + } + + struct ReportEntry: Codable, Identifiable, Hashable, Sendable { + let id: Int + let hours: Double + let description: String? + let date: Date + let createdAt: String? + let updatedAt: String? + + enum CodingKeys: String, CodingKey { + case id, hours, description, date + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + hours = try container.decode(Double.self, forKey: .hours) + description = try container.decodeIfPresent(String.self, forKey: .description) + date = try DateCoding.decodeDate(from: container, forKey: .date) + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + } + } +} diff --git a/ios/TimesheetApp/Networking/APIClient.swift b/ios/TimesheetApp/Networking/APIClient.swift new file mode 100644 index 00000000..86082370 --- /dev/null +++ b/ios/TimesheetApp/Networking/APIClient.swift @@ -0,0 +1,21 @@ +import Foundation + +enum ExportFormat: String, Sendable { + case csv + case pdf +} + +protocol APIClient: Sendable { + func login(email: String) async throws -> User + func currentUser() async throws -> User + func clients() async throws -> [Client] + func createClient(_ payload: ClientPayload) async throws -> Client + func updateClient(id: Int, payload: ClientPayload) async throws -> Client + func deleteClient(id: Int) async throws + func workEntries(clientId: Int?) async throws -> [WorkEntry] + func createWorkEntry(_ payload: WorkEntryPayload) async throws -> WorkEntry + func updateWorkEntry(id: Int, payload: WorkEntryPayload) async throws -> WorkEntry + func deleteWorkEntry(id: Int) async throws + func report(clientId: Int) async throws -> ClientReport + func exportFile(clientId: Int, format: ExportFormat) async throws -> URL +} diff --git a/ios/TimesheetApp/Networking/APIError.swift b/ios/TimesheetApp/Networking/APIError.swift new file mode 100644 index 00000000..7ba863c6 --- /dev/null +++ b/ios/TimesheetApp/Networking/APIError.swift @@ -0,0 +1,24 @@ +import Foundation + +enum APIError: LocalizedError, Sendable { + case invalidURL + case unauthorized + case rateLimited + case notFound + case server(String) + case decoding(Error) + case transport(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: return "The server URL is invalid." + case .unauthorized: return "Your session is not authorized. Please sign in again." + case .rateLimited: return "Too many requests. Please try again later." + case .notFound: return "The requested item could not be found." + case .server(let message): return message + case .decoding(let error): return "The server returned an unexpected response: \(error.localizedDescription)" + case .transport(let error): return "Network error: \(error.localizedDescription)" + } + } + +} diff --git a/ios/TimesheetApp/Networking/LiveAPIClient.swift b/ios/TimesheetApp/Networking/LiveAPIClient.swift new file mode 100644 index 00000000..8c88d2a4 --- /dev/null +++ b/ios/TimesheetApp/Networking/LiveAPIClient.swift @@ -0,0 +1,165 @@ +import Foundation + +actor LiveAPIClient: APIClient { + let baseURL: URL + private let emailProvider: @Sendable () -> String? + private let session: URLSession + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(baseURL: URL, emailProvider: @escaping @Sendable () -> String?, session: URLSession = .shared) { + self.baseURL = baseURL + self.emailProvider = emailProvider + self.session = session + } + + func login(email: String) async throws -> User { + try await send(path: "/api/auth/login", method: "POST", body: LoginPayload(email: email), response: LoginResponse.self).user + } + + func currentUser() async throws -> User { + try await send(path: "/api/auth/me", response: UserResponse.self).user + } + + func clients() async throws -> [Client] { + try await send(path: "/api/clients", response: ClientsResponse.self).clients + } + + func createClient(_ payload: ClientPayload) async throws -> Client { + try await send(path: "/api/clients", method: "POST", body: payload, response: ClientResponse.self).client + } + + func updateClient(id: Int, payload: ClientPayload) async throws -> Client { + try await send(path: "/api/clients/\(id)", method: "PUT", body: payload, response: ClientResponse.self).client + } + + func deleteClient(id: Int) async throws { + _ = try await send(path: "/api/clients/\(id)", method: "DELETE", response: EmptyResponse.self) + } + + func workEntries(clientId: Int?) async throws -> [WorkEntry] { + var path = "/api/work-entries" + if let clientId { + path += "?clientId=\(clientId)" + } + return try await send(path: path, response: WorkEntriesResponse.self).workEntries + } + + func createWorkEntry(_ payload: WorkEntryPayload) async throws -> WorkEntry { + try await send(path: "/api/work-entries", method: "POST", body: payload, response: WorkEntryResponse.self).workEntry + } + + func updateWorkEntry(id: Int, payload: WorkEntryPayload) async throws -> WorkEntry { + try await send(path: "/api/work-entries/\(id)", method: "PUT", body: payload, response: WorkEntryResponse.self).workEntry + } + + func deleteWorkEntry(id: Int) async throws { + _ = try await send(path: "/api/work-entries/\(id)", method: "DELETE", response: EmptyResponse.self) + } + + func report(clientId: Int) async throws -> ClientReport { + try await send(path: "/api/reports/client/\(clientId)", response: ClientReport.self) + } + + func exportFile(clientId: Int, format: ExportFormat) async throws -> URL { + let request = try makeRequest(path: "/api/reports/export/\(format.rawValue)/\(clientId)", method: "GET", body: nil) + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw APIError.transport(error) + } + try validate(response: response, data: data) + let extensionName = format.rawValue + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("timesheet-\(clientId)-\(UUID().uuidString).\(extensionName)") + do { + try data.write(to: fileURL, options: .atomic) + } catch { + throw APIError.transport(error) + } + return fileURL + } + + private func send( + path: String, method: String = "GET", body: Body? = nil, response: T.Type + ) async throws -> T { + let bodyData = try body.map { try encoder.encode($0) } + let request = try makeRequest(path: path, method: method, body: bodyData) + let data: Data + let urlResponse: URLResponse + do { + (data, urlResponse) = try await session.data(for: request) + } catch { + throw APIError.transport(error) + } + try validate(response: urlResponse, data: data) + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decoding(error) + } + } + + private func send( + path: String, method: String = "GET", response: T.Type + ) async throws -> T { + let request = try makeRequest(path: path, method: method, body: nil) + let data: Data + let urlResponse: URLResponse + do { + (data, urlResponse) = try await session.data(for: request) + } catch { + throw APIError.transport(error) + } + try validate(response: urlResponse, data: data) + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decoding(error) + } + } + + private func makeRequest(path: String, method: String, body: Data?) throws -> URLRequest { + guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { + throw APIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let email = emailProvider(), !email.isEmpty { + request.setValue(email, forHTTPHeaderField: "x-user-email") + } + request.httpBody = body + return request + } + + private func validate(response: URLResponse, data: Data) throws { + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.transport(NSError(domain: "TimesheetApp", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid server response." + ])) + } + guard (200..<300).contains(httpResponse.statusCode) else { + switch httpResponse.statusCode { + case 401: throw APIError.unauthorized + case 404: throw APIError.notFound + case 429: throw APIError.rateLimited + default: + let message = (try? JSONDecoder().decode(ErrorResponse.self, from: data).error) ?? "The server returned HTTP \(httpResponse.statusCode)." + throw APIError.server(message) + } + } + } + + private struct LoginPayload: Encodable { let email: String } + private struct UserResponse: Decodable { let user: User } + private struct LoginResponse: Decodable { let user: User } + private struct ClientsResponse: Decodable { let clients: [Client] } + private struct ClientResponse: Decodable { let client: Client } + private struct WorkEntriesResponse: Decodable { let workEntries: [WorkEntry] } + private struct WorkEntryResponse: Decodable { let workEntry: WorkEntry } + private struct ErrorResponse: Decodable { let error: String } + private struct EmptyResponse: Decodable {} +} diff --git a/ios/TimesheetApp/State/SessionStore.swift b/ios/TimesheetApp/State/SessionStore.swift new file mode 100644 index 00000000..4a9b15c2 --- /dev/null +++ b/ios/TimesheetApp/State/SessionStore.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftUI + +@MainActor +final class SessionStore: ObservableObject { + @Published private(set) var email: String + @Published private(set) var baseURLString: String + @Published private(set) var isSigningIn = false + @Published var errorMessage: String? + + @Published private(set) var apiClient: any APIClient + + init(apiClient: (any APIClient)? = nil) { + let defaults = UserDefaults.standard + let storedBaseURL = defaults.string(forKey: "timesheet.baseURL") ?? "http://localhost:3001" + email = defaults.string(forKey: "timesheet.userEmail") ?? "" + baseURLString = storedBaseURL + self.apiClient = apiClient ?? Self.makeClient(baseURLString: storedBaseURL) + } + + var isSignedIn: Bool { !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + func signIn(email newEmail: String) async { + let normalized = newEmail.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard Self.isValidEmail(normalized) else { + errorMessage = "Enter a valid email address." + return + } + guard let baseURL = URL(string: baseURLString.trimmingCharacters(in: .whitespacesAndNewlines)), + baseURL.scheme != nil else { + errorMessage = APIError.invalidURL.localizedDescription + return + } + isSigningIn = true + errorMessage = nil + apiClient = Self.makeClient(baseURLString: baseURL.absoluteString) + do { + let user = try await apiClient.login(email: normalized) + email = user.email + UserDefaults.standard.set(email, forKey: "timesheet.userEmail") + } catch { + errorMessage = error.localizedDescription + } + isSigningIn = false + } + + func updateBaseURL(_ value: String) { + baseURLString = value.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(baseURLString, forKey: "timesheet.baseURL") + if let _ = URL(string: baseURLString) { + apiClient = Self.makeClient(baseURLString: baseURLString) + } + } + + func signOut() { + email = "" + UserDefaults.standard.set(email, forKey: "timesheet.userEmail") + errorMessage = nil + } + + private static func makeClient(baseURLString: String) -> any APIClient { + let url = URL(string: baseURLString) ?? URL(fileURLWithPath: "/") + return LiveAPIClient(baseURL: url, emailProvider: { + UserDefaults.standard.string(forKey: "timesheet.userEmail") + }) + } + + static func isValidEmail(_ value: String) -> Bool { + value.contains("@") && value.contains(".") && !value.contains(" ") + } +} diff --git a/ios/TimesheetApp/State/ViewModels.swift b/ios/TimesheetApp/State/ViewModels.swift new file mode 100644 index 00000000..bc17d4a5 --- /dev/null +++ b/ios/TimesheetApp/State/ViewModels.swift @@ -0,0 +1,176 @@ +import Foundation + +enum LoadState: Equatable, Sendable { + case idle + case loading + case loaded + case failed(String) +} + +@MainActor +final class ClientsViewModel: ObservableObject { + @Published private(set) var clients: [Client] = [] + @Published private(set) var state: LoadState = .idle + let api: any APIClient + + init(api: any APIClient) { self.api = api } + + func load() async { + state = .loading + do { + clients = try await api.clients() + state = .loaded + } catch { + state = .failed(error.localizedDescription) + } + } + + func save(id: Int?, payload: ClientPayload) async { + do { + if let id { + _ = try await api.updateClient(id: id, payload: payload) + } else { + _ = try await api.createClient(payload) + } + await load() + } catch { + state = .failed(error.localizedDescription) + } + } + + func delete(at offsets: IndexSet) async { + let ids = offsets.compactMap { clients.indices.contains($0) ? clients[$0].id : nil } + do { + for id in ids { try await api.deleteClient(id: id) } + clients.remove(atOffsets: offsets) + } catch { + state = .failed(error.localizedDescription) + } + } +} + +@MainActor +final class EntriesViewModel: ObservableObject { + @Published private(set) var entries: [WorkEntry] = [] + @Published private(set) var state: LoadState = .idle + @Published var clientFilter: Int? + private let api: any APIClient + + init(api: any APIClient, clientFilter: Int? = nil) { + self.api = api + self.clientFilter = clientFilter + } + + func load() async { + state = .loading + do { + entries = try await api.workEntries(clientId: clientFilter).sorted { $0.date > $1.date } + state = .loaded + } catch { + state = .failed(error.localizedDescription) + } + } + + func save(id: Int?, payload: WorkEntryPayload) async { + do { + if let id { + _ = try await api.updateWorkEntry(id: id, payload: payload) + } else { + _ = try await api.createWorkEntry(payload) + } + await load() + } catch { + state = .failed(error.localizedDescription) + } + } + + func delete(at offsets: IndexSet) async { + let ids = offsets.compactMap { entries.indices.contains($0) ? entries[$0].id : nil } + do { + for id in ids { try await api.deleteWorkEntry(id: id) } + entries.remove(atOffsets: offsets) + } catch { + state = .failed(error.localizedDescription) + } + } + + var groupedEntries: [(Date, [WorkEntry])] { + Dictionary(grouping: entries) { Calendar.current.startOfDay(for: $0.date) } + .sorted { $0.key > $1.key } + } +} + +@MainActor +final class DashboardViewModel: ObservableObject { + @Published private(set) var entries: [WorkEntry] = [] + @Published private(set) var state: LoadState = .idle + private let api: any APIClient + + init(api: any APIClient) { self.api = api } + + func load() async { + state = .loading + do { + entries = try await api.workEntries(clientId: nil).sorted { $0.date > $1.date } + state = .loaded + } catch { + state = .failed(error.localizedDescription) + } + } + + var totalEntries: Int { entries.count } + + var hoursThisWeek: Double { + let start = Calendar.current.dateInterval(of: .weekOfYear, for: .now)?.start ?? .now + return entries.filter { $0.date >= start }.reduce(0) { $0 + $1.hours } + } + + var hoursThisMonth: Double { + let start = Calendar.current.dateInterval(of: .month, for: .now)?.start ?? .now + return entries.filter { $0.date >= start }.reduce(0) { $0 + $1.hours } + } + + var lastThirtyDaysByClient: [(String, Double)] { + let start = Calendar.current.date(byAdding: .day, value: -30, to: .now) ?? .now + let groups = Dictionary(grouping: entries.filter { $0.date >= start }) { $0.clientName ?? "Unknown client" } + return groups.map { ($0.key, $0.value.reduce(0) { $0 + $1.hours }) }.sorted { $0.1 > $1.1 } + } + + var recentEntries: [WorkEntry] { Array(entries.prefix(5)) } + + var lastSevenDaysByDay: [(Date, Double)] { + let calendar = Calendar.current + return (0..<7).compactMap { offset in + guard let date = calendar.date(byAdding: .day, value: -offset, to: calendar.startOfDay(for: .now)) else { return nil } + let total = entries.filter { calendar.isDate($0.date, inSameDayAs: date) }.reduce(0) { $0 + $1.hours } + return (date, total) + }.reversed() + } +} + +@MainActor +final class ReportViewModel: ObservableObject { + @Published private(set) var report: ClientReport? + @Published private(set) var state: LoadState = .idle + private let api: any APIClient + let clientID: Int + + init(api: any APIClient, clientID: Int) { + self.api = api + self.clientID = clientID + } + + func load() async { + state = .loading + do { + report = try await api.report(clientId: clientID) + state = .loaded + } catch { + state = .failed(error.localizedDescription) + } + } + + func export(format: ExportFormat) async throws -> URL { + try await api.exportFile(clientId: clientID, format: format) + } +} diff --git a/ios/TimesheetApp/TimesheetApp.swift b/ios/TimesheetApp/TimesheetApp.swift new file mode 100644 index 00000000..b01a78a2 --- /dev/null +++ b/ios/TimesheetApp/TimesheetApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct TimesheetApp: App { + @StateObject private var session = SessionStore() + + var body: some Scene { + WindowGroup { + Group { + if session.isSignedIn { + RootTabView(session: session) + .id(session.baseURLString) + } else { + LoginView(session: session) + } + } + .tint(Color("AppAccent")) + } + } +} diff --git a/ios/TimesheetApp/Views/ClientsViews.swift b/ios/TimesheetApp/Views/ClientsViews.swift new file mode 100644 index 00000000..615132b1 --- /dev/null +++ b/ios/TimesheetApp/Views/ClientsViews.swift @@ -0,0 +1,254 @@ +import SwiftUI + +struct ClientsListView: View { + @ObservedObject var model: ClientsViewModel + @State private var searchText = "" + @State private var showingForm = false + @State private var editingClient: Client? + @State private var pendingDelete: Client? + + private var filteredClients: [Client] { + guard !searchText.isEmpty else { return model.clients } + return model.clients.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + ($0.department ?? "").localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + Group { + if case .loading = model.state, model.clients.isEmpty { + LoadingView() + } else if filteredClients.isEmpty { + EmptyStateView(title: "No clients", description: "Add a client to start organizing your work.", systemImage: "person.2") + } else { + List { + ForEach(filteredClients) { client in + NavigationLink { + ClientReportView(api: model.api, client: client) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(client.name).font(.headline) + if let department = client.department, !department.isEmpty { + Text(department).font(.subheadline).foregroundStyle(.secondary) + } + } + } + .swipeActions { + Button(role: .destructive) { + pendingDelete = client + } label: { + Label("Delete", systemImage: "trash") + } + Button { + editingClient = client + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.orange) + } + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Clients") + .searchable(text: $searchText, prompt: "Search clients") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { showingForm = true } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add client") + } + } + .overlay(alignment: .top) { + if case .failed(let message) = model.state { + ErrorBanner(message: message) { Task { await model.load() } } + } + } + .refreshable { await model.load() } + .task { await model.load() } + .sheet(isPresented: $showingForm) { + ClientFormView { payload in await model.save(id: nil, payload: payload) } + } + .sheet(item: $editingClient) { client in + ClientFormView(client: client) { payload in await model.save(id: client.id, payload: payload) } + } + .confirmationDialog("Delete \(pendingDelete?.name ?? "client")?", isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + )) { + Button("Delete", role: .destructive) { + if let pendingDelete, let index = model.clients.firstIndex(of: pendingDelete) { + Task { await model.delete(at: IndexSet(integer: index)) } + } + pendingDelete = nil + } + } + } +} + +struct ClientFormView: View { + @Environment(\.dismiss) private var dismiss + let client: Client? + let onSave: (ClientPayload) async -> Void + @State private var name: String + @State private var description: String + @State private var department: String + @State private var email: String + @State private var isSaving = false + + init(client: Client? = nil, onSave: @escaping (ClientPayload) async -> Void) { + self.client = client + self.onSave = onSave + _name = State(initialValue: client?.name ?? "") + _description = State(initialValue: client?.description ?? "") + _department = State(initialValue: client?.department ?? "") + _email = State(initialValue: client?.email ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section("Client details") { + TextField("Name", text: $name) + TextField("Description", text: $description, axis: .vertical) + TextField("Department", text: $department) + TextField("Email", text: $email) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + } + } + .navigationTitle(client == nil ? "New client" : "Edit client") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + isSaving = true + Task { + await onSave(ClientPayload( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + description: description.isEmpty ? nil : description, + department: department.isEmpty ? nil : department, + email: email.isEmpty ? nil : email + )) + isSaving = false + dismiss() + } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + } + } + } + } +} + +struct ClientReportView: View { + let api: any APIClient + let client: Client + @StateObject private var model: ReportViewModel + @State private var shareURL: URL? + @State private var exportError: String? + + init(api: any APIClient, client: Client) { + self.api = api + self.client = client + _model = StateObject(wrappedValue: ReportViewModel(api: api, clientID: client.id)) + } + + var body: some View { + Group { + if case .loading = model.state, model.report == nil { + LoadingView() + } else if let report = model.report { + List { + Section { + HStack { + VStack(alignment: .leading) { + Text("\(report.totalHours, specifier: "%.2f") h").font(.title.bold()) + Text("\(report.entryCount) entries").foregroundStyle(.secondary) + } + Spacer() + } + } + Section("Entries") { + ForEach(report.workEntries) { entry in + VStack(alignment: .leading) { + HStack { + Text(entry.date, format: .dateTime.year().month().day()) + Spacer() + Text("\(entry.hours, specifier: "%.2f") h").monospacedDigit() + } + if let description = entry.description, !description.isEmpty { + Text(description).foregroundStyle(.secondary) + } + } + } + } + } + .listStyle(.insetGrouped) + } else { + EmptyStateView(title: "No report", description: "There is no report data for this client.", systemImage: "doc.text") + } + } + .navigationTitle(client.name) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("Export CSV") { Task { await export(.csv) } } + Button("Export PDF") { Task { await export(.pdf) } } + } label: { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Export report") + } + } + .overlay(alignment: .top) { + if let exportError { + ErrorBanner(message: exportError) + } + if case .failed(let message) = model.state { + ErrorBanner(message: message) { Task { await model.load() } } + } + } + .refreshable { await model.load() } + .task { await model.load() } + .sheet(isPresented: Binding( + get: { shareURL != nil }, + set: { if !$0 { shareURL = nil } } + )) { + if let shareURL { + ShareSheet(url: shareURL) + } + } + } + + private func export(_ format: ExportFormat) async { + do { + shareURL = try await model.export(format: format) + } catch { + exportError = error.localizedDescription + } + } +} + +struct ShareSheet: View { + let url: URL + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ShareLink(item: url) { + Label("Share file", systemImage: "square.and.arrow.up") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding() + .navigationTitle("Share report") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + } + } + } +} diff --git a/ios/TimesheetApp/Views/DashboardView.swift b/ios/TimesheetApp/Views/DashboardView.swift new file mode 100644 index 00000000..021074fb --- /dev/null +++ b/ios/TimesheetApp/Views/DashboardView.swift @@ -0,0 +1,127 @@ +import Charts +import SwiftUI + +struct DashboardView: View { + @ObservedObject var model: DashboardViewModel + @ObservedObject var clientsModel: ClientsViewModel + @ObservedObject var entriesModel: EntriesViewModel + @State private var showingEntryForm = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if case .failed(let message) = model.state { + ErrorBanner(message: message) { Task { await model.load() } } + } + HStack(spacing: 12) { + MetricCard(title: "This week", value: model.hoursThisWeek, suffix: "h", systemImage: "calendar") + MetricCard(title: "This month", value: model.hoursThisMonth, suffix: "h", systemImage: "calendar.badge.clock") + } + HStack(spacing: 12) { + MetricCard(title: "Entries", value: Double(model.totalEntries), suffix: "", systemImage: "list.bullet") + Spacer() + } + Button { showingEntryForm = true } label: { + Label("Log hours", systemImage: "plus.circle.fill") + .font(.headline) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityLabel("Log hours") + if !model.lastSevenDaysByDay.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Last 7 days").font(.headline) + Chart(model.lastSevenDaysByDay, id: \.0) { day, hours in + BarMark(x: .value("Day", day, unit: .day), y: .value("Hours", hours)) + .foregroundStyle(.tint) + } + .frame(height: 180) + } + } + if !model.lastThirtyDaysByClient.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Last 30 days by client").font(.headline) + ForEach(model.lastThirtyDaysByClient, id: \.0) { client, hours in + HStack { + Text(client).lineLimit(1) + Spacer() + Text("\(hours, specifier: "%.2f") h").monospacedDigit() + } + ProgressView(value: hours, total: max(model.lastThirtyDaysByClient.map(\.1).max() ?? 1, 1)) + } + } + } + VStack(alignment: .leading, spacing: 8) { + Text("Recent entries").font(.headline) + if model.recentEntries.isEmpty { + EmptyStateView(title: "No entries yet", description: "Log your first work entry to see it here.", systemImage: "clock") + } else { + ForEach(model.recentEntries) { entry in + EntryRow(entry: entry) + } + } + } + } + .padding() + } + .navigationTitle("Dashboard") + .refreshable { await model.load() } + .task { + if clientsModel.clients.isEmpty { + await clientsModel.load() + } + await model.load() + } + .sheet(isPresented: $showingEntryForm) { + WorkEntryFormView(clients: clientsModel.clients) { payload in + await entriesModel.save(id: nil, payload: payload) + await model.load() + } + } + } +} + +private struct MetricCard: View { + let title: String + let value: Double + let suffix: String + let systemImage: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: systemImage) + .font(.subheadline) + .foregroundStyle(.secondary) + Text("\(value, specifier: "%.2f")\(suffix)") + .font(.title2.bold()) + .monospacedDigit() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } +} + +struct EntryRow: View { + let entry: WorkEntry + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(entry.clientName ?? "Client") + .font(.headline) + if let description = entry.description, !description.isEmpty { + Text(description).foregroundStyle(.secondary).lineLimit(1) + } + Text(entry.date, format: .dateTime.year().month().day()) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(entry.hours, specifier: "%.2f") h") + .font(.headline) + .monospacedDigit() + } + .padding(.vertical, 4) + } +} diff --git a/ios/TimesheetApp/Views/EntriesViews.swift b/ios/TimesheetApp/Views/EntriesViews.swift new file mode 100644 index 00000000..7e13fd07 --- /dev/null +++ b/ios/TimesheetApp/Views/EntriesViews.swift @@ -0,0 +1,178 @@ +import SwiftUI + +struct EntriesListView: View { + @ObservedObject var model: EntriesViewModel + @ObservedObject var clientsModel: ClientsViewModel + @State private var showingForm = false + @State private var editingEntry: WorkEntry? + + var body: some View { + Group { + if case .loading = model.state, model.entries.isEmpty { + LoadingView() + } else if model.entries.isEmpty { + EmptyStateView(title: "No work entries", description: "Log time to keep your timesheet up to date.", systemImage: "clock") + } else { + List { + ForEach(model.groupedEntries, id: \.0) { date, entries in + Section { + ForEach(entries) { entry in + EntryListRow(entry: entry, + onEdit: { editingEntry = entry }, + onDelete: { delete(entry) }) + } + } header: { + Text(date, format: .dateTime.year().month().day()) + } + } + } + .listStyle(.insetGrouped) + } + } + .navigationTitle("Entries") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Menu { + Button("All clients") { model.clientFilter = nil; Task { await model.load() } } + ForEach(clientsModel.clients) { client in + Button(client.name) { model.clientFilter = client.id; Task { await model.load() } } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + .accessibilityLabel("Filter entries by client") + } + ToolbarItem(placement: .topBarTrailing) { + Button { showingForm = true } label: { Image(systemName: "plus") } + .accessibilityLabel("Add work entry") + } + } + .overlay(alignment: .top) { + if case .failed(let message) = model.state { + ErrorBanner(message: message) { Task { await model.load() } } + } + } + .refreshable { await model.load() } + .task { + if clientsModel.clients.isEmpty { + await clientsModel.load() + } + await model.load() + } + .sheet(isPresented: $showingForm) { + WorkEntryFormView(clients: clientsModel.clients) { payload in await model.save(id: nil, payload: payload) } + } + .sheet(item: $editingEntry) { entry in + WorkEntryFormView(clients: clientsModel.clients, entry: entry) { payload in await model.save(id: entry.id, payload: payload) } + } + } + + private func delete(_ entry: WorkEntry) { + guard let index = model.entries.firstIndex(where: { $0.id == entry.id }) else { return } + Task { await model.delete(at: IndexSet(integer: index)) } + } +} + +private struct EntryListRow: View { + let entry: WorkEntry + let onEdit: () -> Void + let onDelete: () -> Void + + var body: some View { + Button(action: onEdit) { + EntryRow(entry: entry) + .foregroundStyle(.primary) + } + .swipeActions { + Button(role: .destructive, action: onDelete) { + Label("Delete", systemImage: "trash") + } + } + } +} + +struct WorkEntryFormView: View { + @Environment(\.dismiss) private var dismiss + let clients: [Client] + let entry: WorkEntry? + let onSave: (WorkEntryPayload) async -> Void + @State private var clientID: Int? + @State private var hours = "" + @State private var description = "" + @State private var date: Date + @State private var isSaving = false + @State private var validationMessage: String? + + init(clients: [Client], entry: WorkEntry? = nil, onSave: @escaping (WorkEntryPayload) async -> Void) { + self.clients = clients + self.entry = entry + self.onSave = onSave + _clientID = State(initialValue: entry?.clientID ?? clients.first?.id) + _hours = State(initialValue: entry.map { String(format: "%.2f", $0.hours) } ?? "") + _description = State(initialValue: entry?.description ?? "") + _date = State(initialValue: entry?.date ?? .now) + } + + private var parsedHours: Double? { Double(hours.replacingOccurrences(of: ",", with: ".")) } + + var body: some View { + NavigationStack { + Form { + Section("Work entry") { + Picker("Client", selection: $clientID) { + Text("Select a client").tag(nil as Int?) + ForEach(clients) { client in + Text(client.name).tag(client.id as Int?) + } + } + TextField("Hours", text: $hours) + .keyboardType(.decimalPad) + DatePicker("Date", selection: $date, displayedComponents: .date) + TextField("Description", text: $description, axis: .vertical) + } + if let validationMessage { + Text(validationMessage).foregroundStyle(.red) + } + } + .navigationTitle(entry == nil ? "Log hours" : "Edit entry") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(isSaving) + } + } + } + } + + private func save() { + guard let clientID, let parsedHours else { + validationMessage = WorkEntryValidator.validate(clientID: clientID, hours: parsedHours) + return + } + if let validationMessage = WorkEntryValidator.validate(clientID: clientID, hours: parsedHours) { + self.validationMessage = validationMessage + return + } + validationMessage = nil + isSaving = true + Task { + await onSave(WorkEntryPayload(clientID: clientID, hours: parsedHours, description: description.isEmpty ? nil : description, date: date)) + isSaving = false + dismiss() + } + } +} + +enum WorkEntryValidator { + static func validate(clientID: Int?, hours: Double?) -> String? { + guard let clientID, clientID > 0, let hours, hours > 0, hours <= 24 else { + return "Choose a client and enter hours greater than 0 and no more than 24." + } + let scaledHours = hours * 100 + guard abs(scaledHours.rounded() - scaledHours) < 0.0001 else { + return "Hours can have at most two decimal places." + } + return nil + } +} diff --git a/ios/TimesheetApp/Views/LoginView.swift b/ios/TimesheetApp/Views/LoginView.swift new file mode 100644 index 00000000..c5abc233 --- /dev/null +++ b/ios/TimesheetApp/Views/LoginView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct LoginView: View { + @ObservedObject var session: SessionStore + @State private var email: String + @State private var serverURL: String + @State private var showAdvanced = false + + init(session: SessionStore) { + self.session = session + _email = State(initialValue: session.email) + _serverURL = State(initialValue: session.baseURLString) + } + + private var trimmedEmail: String { + email.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var emailIsValid: Bool { + SessionStore.isValidEmail(trimmedEmail) + } + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Spacer() + Image(systemName: "clock.badge.checkmark") + .font(.system(size: 64)) + .foregroundStyle(.tint) + .accessibilityHidden(true) + VStack(spacing: 8) { + Text("Timesheet") + .font(.largeTitle.bold()) + Text("Track your work, wherever you are.") + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 8) { + TextField("Email", text: $email) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textContentType(.emailAddress) + .textFieldStyle(.roundedBorder) + if !trimmedEmail.isEmpty && !emailIsValid { + Text("Enter a valid email address.") + .font(.caption) + .foregroundStyle(.red) + } + } + DisclosureGroup("Advanced", isExpanded: $showAdvanced) { + TextField("Server URL", text: $serverURL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + .padding(.top, 8) + } + .frame(maxWidth: .infinity, alignment: .leading) + if let error = session.errorMessage { + ErrorBanner(message: error) + .padding(.horizontal, -16) + } + Button { + session.updateBaseURL(serverURL) + Task { await session.signIn(email: email) } + } label: { + Group { + if session.isSigningIn { + ProgressView().tint(.white) + } else { + Text("Sign in") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(trimmedEmail.isEmpty || !emailIsValid || session.isSigningIn) + Spacer() + } + .padding(24) + .navigationBarHidden(true) + } + } +} diff --git a/ios/TimesheetApp/Views/RootTabView.swift b/ios/TimesheetApp/Views/RootTabView.swift new file mode 100644 index 00000000..054c6ea2 --- /dev/null +++ b/ios/TimesheetApp/Views/RootTabView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct RootTabView: View { + @ObservedObject var session: SessionStore + @StateObject private var clientsModel: ClientsViewModel + @StateObject private var entriesModel: EntriesViewModel + @StateObject private var dashboardModel: DashboardViewModel + + init(session: SessionStore) { + self.session = session + let api = session.apiClient + _clientsModel = StateObject(wrappedValue: ClientsViewModel(api: api)) + _entriesModel = StateObject(wrappedValue: EntriesViewModel(api: api)) + _dashboardModel = StateObject(wrappedValue: DashboardViewModel(api: api)) + } + + var body: some View { + TabView { + NavigationStack { + DashboardView(model: dashboardModel, clientsModel: clientsModel, entriesModel: entriesModel) + } + .tabItem { Label("Dashboard", systemImage: "chart.bar.xaxis") } + NavigationStack { + ClientsListView(model: clientsModel) + } + .tabItem { Label("Clients", systemImage: "person.2") } + NavigationStack { + EntriesListView(model: entriesModel, clientsModel: clientsModel) + } + .tabItem { Label("Entries", systemImage: "clock") } + NavigationStack { + SettingsView(session: session) + } + .tabItem { Label("Settings", systemImage: "gearshape") } + } + } +} diff --git a/ios/TimesheetApp/Views/SettingsView.swift b/ios/TimesheetApp/Views/SettingsView.swift new file mode 100644 index 00000000..229ff05e --- /dev/null +++ b/ios/TimesheetApp/Views/SettingsView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var session: SessionStore + @State private var serverURL: String + @State private var showingSignOutConfirmation = false + + init(session: SessionStore) { + self.session = session + _serverURL = State(initialValue: session.baseURLString) + } + + var body: some View { + Form { + Section("Account") { + LabeledContent("Signed-in email", value: session.email) + } + Section("Server") { + TextField("Server URL", text: $serverURL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button("Save server URL") { + session.updateBaseURL(serverURL) + } + } + Section { + Button("Sign out", role: .destructive) { + showingSignOutConfirmation = true + } + } + } + .navigationTitle("Settings") + .confirmationDialog("Sign out of Timesheet?", isPresented: $showingSignOutConfirmation) { + Button("Sign out", role: .destructive) { session.signOut() } + } + } +} diff --git a/ios/TimesheetApp/Views/SharedViews.swift b/ios/TimesheetApp/Views/SharedViews.swift new file mode 100644 index 00000000..7db1e1f5 --- /dev/null +++ b/ios/TimesheetApp/Views/SharedViews.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct LoadingView: View { + var body: some View { + ProgressView("Loading…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ErrorBanner: View { + let message: String + var retry: (() -> Void)? + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .accessibilityHidden(true) + Text(message) + .frame(maxWidth: .infinity, alignment: .leading) + if let retry { + Button("Retry", action: retry) + .buttonStyle(.bordered) + } + } + .padding() + .background(.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + } +} + +struct EmptyStateView: View { + let title: String + let description: String + let systemImage: String + + var body: some View { + ContentUnavailableView(title, systemImage: systemImage, description: Text(description)) + } +} diff --git a/ios/TimesheetAppTests/APIClientTests.swift b/ios/TimesheetAppTests/APIClientTests.swift new file mode 100644 index 00000000..a5f47144 --- /dev/null +++ b/ios/TimesheetAppTests/APIClientTests.swift @@ -0,0 +1,230 @@ +import Foundation +import XCTest +@testable import TimesheetApp + +final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var lastRequest: URLRequest? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lastRequest = request + guard let handler = Self.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +final class APIClientTests: XCTestCase { + private var client: LiveAPIClient! + + override func setUp() { + super.setUp() + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + let session = URLSession(configuration: configuration) + client = LiveAPIClient( + baseURL: URL(string: "http://localhost:3001")!, + emailProvider: { "person@example.com" }, + session: session + ) + MockURLProtocol.lastRequest = nil + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + client = nil + super.tearDown() + } + + func testAuthenticatedHeadersAreSent() async throws { + MockURLProtocol.requestHandler = { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "x-user-email"), "person@example.com") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + return Self.response(status: 200, body: #"{"clients":[]}"#) + } + _ = try await client.clients() + } + + func testDecodesClientsEntriesAndReport() async throws { + MockURLProtocol.requestHandler = { request in + switch request.url?.path { + case "/api/clients": + return Self.response(status: 200, body: #"{"clients":[{"id":1,"name":"Acme","description":"A client","department":"Design","email":"a@acme.test","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}]}"#) + case "/api/work-entries": + return Self.response(status: 200, body: #"{"workEntries":[{"id":2,"client_id":1,"hours":2.5,"description":"Design","date":"2026-09-01","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z","client_name":"Acme"}]}"#) + case "/api/reports/client/1": + return Self.response(status: 200, body: #"{"client":{"id":1,"name":"Acme"},"workEntries":[{"id":2,"hours":2.5,"description":"Design","date":"2026-09-01T00:00:00Z","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}],"totalHours":2.5,"entryCount":1}"#) + default: + return Self.response(status: 404, body: #"{"error":"missing"}"#) + } + } + let clients = try await client.clients() + XCTAssertEqual(clients.first?.name, "Acme") + let entries = try await client.workEntries(clientId: nil) + XCTAssertEqual(entries.first?.clientID, 1) + XCTAssertEqual(entries.first?.clientName, "Acme") + XCTAssertEqual(entries.first?.hours, 2.5) + let report = try await client.report(clientId: 1) + XCTAssertEqual(report.totalHours, 2.5) + XCTAssertEqual(report.entryCount, 1) + XCTAssertEqual(report.workEntries.first?.id, 2) + } + + func testCreateWorkEntryUsesCamelCaseAndDateOnly() async throws { + MockURLProtocol.requestHandler = { request in + let bodyData = try XCTUnwrap(Self.requestBodyData(request)) + let body = try XCTUnwrap( + JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + ) + XCTAssertEqual(body["clientId"] as? Int, 3) + XCTAssertEqual(body["date"] as? String, "2026-09-01") + return Self.response(status: 201, body: #"{"workEntry":{"id":4,"client_id":3,"hours":1.25,"description":"Review","date":"2026-09-01"}}"#) + } + _ = try await client.createWorkEntry( + WorkEntryPayload(clientID: 3, hours: 1.25, description: "Review", date: DateCoding.dateOnlyFormatter.date(from: "2026-09-01")) + ) + } + + func testLenientDateDecoding() async throws { + MockURLProtocol.requestHandler = { request in + let date = request.url?.query == "full" ? "2026-09-01T12:30:00Z" : "2026-09-01" + return Self.response(status: 200, body: #"{"workEntries":[{"id":1,"client_id":1,"hours":1,"date":""# + date + #""}]}"#) + } + let dateOnly = try await client.workEntries(clientId: nil) + XCTAssertEqual(DateCoding.encode(dateOnly[0].date), "2026-09-01") + let fullURL = URL(string: "http://localhost:3001")! + let fullClient = LiveAPIClient(baseURL: fullURL, emailProvider: { "person@example.com" }, session: configuredSession()) + MockURLProtocol.requestHandler = { _ in + Self.response(status: 200, body: #"{"workEntries":[{"id":1,"client_id":1,"hours":1,"date":"2026-09-01T12:30:00Z"}]}"#) + } + let full = try await fullClient.workEntries(clientId: nil) + XCTAssertEqual(DateCoding.encode(full[0].date), "2026-09-01") + } + + func testDecodesEpochMillisecondWorkEntryDate() async throws { + MockURLProtocol.requestHandler = { _ in + Self.response(status: 200, body: #"{"workEntries":[{"id":1,"client_id":1,"hours":1,"date":1788220800000}]}"#) + } + let entry = try await client.workEntries(clientId: nil)[0] + XCTAssertEqual(DateCoding.encode(entry.date), "2026-09-01") + XCTAssertEqual(Calendar.current.component(.year, from: entry.date), 2026) + XCTAssertEqual(Calendar.current.component(.month, from: entry.date), 9) + XCTAssertEqual(Calendar.current.component(.day, from: entry.date), 1) + } + + func testDecodesEpochMillisecondReportDate() async throws { + MockURLProtocol.requestHandler = { _ in + Self.response(status: 200, body: #"{"client":{"id":1,"name":"Acme"},"workEntries":[{"id":2,"hours":2.5,"date":1788220800000}],"totalHours":2.5,"entryCount":1}"#) + } + let entry = try await client.report(clientId: 1).workEntries[0] + XCTAssertEqual(DateCoding.encode(entry.date), "2026-09-01") + XCTAssertEqual(Calendar.current.component(.year, from: entry.date), 2026) + XCTAssertEqual(Calendar.current.component(.month, from: entry.date), 9) + XCTAssertEqual(Calendar.current.component(.day, from: entry.date), 1) + } + + func testDecodesISODateToSameLocalCalendarDay() async throws { + MockURLProtocol.requestHandler = { _ in + Self.response(status: 200, body: #"{"workEntries":[{"id":1,"client_id":1,"hours":1,"date":"2026-09-01T00:00:00.000Z"}]}"#) + } + let entry = try await client.workEntries(clientId: nil)[0] + XCTAssertEqual(DateCoding.encode(entry.date), "2026-09-01") + XCTAssertEqual(Calendar.current.component(.day, from: entry.date), 1) + } + + func testMalformedDateSurfacesDecodingError() async { + MockURLProtocol.requestHandler = { _ in + Self.response(status: 200, body: #"{"workEntries":[{"id":1,"client_id":1,"hours":1,"date":true}]}"#) + } + do { + _ = try await client.workEntries(clientId: nil) + XCTFail("Expected a decoding error") + } catch APIError.decoding { + return + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testDateOnlyRoundTripsInCurrentCalendar() throws { + let decoded = try DateCoding.decode("2026-09-01") + XCTAssertEqual(DateCoding.encode(decoded), "2026-09-01") + XCTAssertEqual(Calendar.current.component(.day, from: decoded), 1) + } + + func testErrorMapping() async { + let statuses: [(Int, APIError)] = [(401, .unauthorized), (404, .notFound), (429, .rateLimited), (500, .server("boom"))] + for (status, expected) in statuses { + MockURLProtocol.requestHandler = { _ in + Self.response(status: status, body: status == 500 ? #"{"error":"boom"}"# : #"{"error":"failure"}"#) + } + do { + _ = try await client.clients() + XCTFail("Expected an error for \(status)") + } catch let error as APIError { + switch (error, expected) { + case (.unauthorized, .unauthorized), (.notFound, .notFound), (.rateLimited, .rateLimited): + break + case (.server(let actual), .server(let wanted)): + XCTAssertEqual(actual, wanted) + default: + XCTFail("Unexpected mapping for \(status): \(error)") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } + } + + private static func response(status: Int, body: String) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: URL(string: "http://localhost:3001")!, + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data(body.utf8)) + } + + private func configuredSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + private static func requestBodyData(_ request: URLRequest) -> Data? { + if let httpBody = request.httpBody { + return httpBody + } + guard let stream = request.httpBodyStream else { + return nil + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: bufferSize) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } +} diff --git a/ios/TimesheetAppTests/ViewModelTests.swift b/ios/TimesheetAppTests/ViewModelTests.swift new file mode 100644 index 00000000..879306a9 --- /dev/null +++ b/ios/TimesheetAppTests/ViewModelTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Combine +import XCTest +@testable import TimesheetApp + +actor StubAPIClient: APIClient { + var storedClients: [Client] = [ + Client(id: 1, name: "Acme", description: nil, department: nil, email: nil, createdAt: nil, updatedAt: nil) + ] + var storedEntries: [WorkEntry] = [ + WorkEntry(id: 1, clientID: 1, hours: 2, description: "Build", date: .now, clientName: "Acme") + ] + var shouldFail = false + + func login(email: String) async throws -> User { User(email: email, createdAt: nil) } + func currentUser() async throws -> User { User(email: "person@example.com", createdAt: nil) } + func clients() async throws -> [Client] { + if shouldFail { throw APIError.server("load failed") } + return storedClients + } + func createClient(_ payload: ClientPayload) async throws -> Client { + let client = Client(id: (storedClients.map(\.id).max() ?? 0) + 1, name: payload.name ?? "New", description: payload.description, department: payload.department, email: payload.email, createdAt: nil, updatedAt: nil) + storedClients.append(client) + return client + } + func updateClient(id: Int, payload: ClientPayload) async throws -> Client { storedClients[0] } + func deleteClient(id: Int) async throws { storedClients.removeAll { $0.id == id } } + func workEntries(clientId: Int?) async throws -> [WorkEntry] { + if shouldFail { throw APIError.server("load failed") } + return storedEntries.filter { clientId == nil || $0.clientID == clientId } + } + func createWorkEntry(_ payload: WorkEntryPayload) async throws -> WorkEntry { storedEntries[0] } + func updateWorkEntry(id: Int, payload: WorkEntryPayload) async throws -> WorkEntry { storedEntries[0] } + func deleteWorkEntry(id: Int) async throws { storedEntries.removeAll { $0.id == id } } + func report(clientId: Int) async throws -> ClientReport { + ClientReport(client: .init(id: clientId, name: "Acme"), workEntries: [], totalHours: 0, entryCount: 0) + } + func exportFile(clientId: Int, format: ExportFormat) async throws -> URL { URL(fileURLWithPath: "/tmp/report.\(format.rawValue)") } +} + +@MainActor +final class ViewModelTests: XCTestCase { + func testClientsLoadFailureAndDelete() async { + let stub = StubAPIClient() + let model = ClientsViewModel(api: stub) + await model.load() + XCTAssertEqual(model.state, .loaded) + XCTAssertEqual(model.clients.count, 1) + await model.delete(at: IndexSet(integer: 0)) + XCTAssertTrue(model.clients.isEmpty) + + await stub.setShouldFail() + await model.load() + XCTAssertEqual(model.state, .failed("load failed")) + } + + func testEntriesLoadTransitionsToLoadedAndFailed() async { + let stub = StubAPIClient() + let model = EntriesViewModel(api: stub) + await model.load() + XCTAssertEqual(model.state, .loaded) + XCTAssertEqual(model.entries.count, 1) + await stub.setShouldFail() + await model.load() + XCTAssertEqual(model.state, .failed("load failed")) + } + + func testWorkEntryValidation() { + XCTAssertNil(WorkEntryValidator.validate(clientID: 1, hours: 7.5)) + XCTAssertNil(WorkEntryValidator.validate(clientID: 1, hours: 1.25)) + XCTAssertNotNil(WorkEntryValidator.validate(clientID: 1, hours: 0)) + XCTAssertNotNil(WorkEntryValidator.validate(clientID: 1, hours: 24.5)) + XCTAssertNotNil(WorkEntryValidator.validate(clientID: 1, hours: 1.234)) + } + + func testSessionStorePublishesBaseURLUpdates() async { + let defaults = UserDefaults.standard + let previousBaseURL = defaults.string(forKey: "timesheet.baseURL") + defer { + if let previousBaseURL { + defaults.set(previousBaseURL, forKey: "timesheet.baseURL") + } else { + defaults.removeObject(forKey: "timesheet.baseURL") + } + } + + let session = SessionStore(apiClient: StubAPIClient()) + let expectation = expectation(description: "SessionStore publishes base URL changes") + var didPublish = false + let cancellable = session.objectWillChange.sink { + guard !didPublish else { return } + didPublish = true + expectation.fulfill() + } + + session.updateBaseURL("http://example.test:3001") + + await fulfillment(of: [expectation], timeout: 1) + XCTAssertTrue(didPublish) + XCTAssertEqual(session.baseURLString, "http://example.test:3001") + XCTAssertEqual(defaults.string(forKey: "timesheet.baseURL"), "http://example.test:3001") + XCTAssertTrue(session.apiClient is LiveAPIClient) + if let client = session.apiClient as? LiveAPIClient { + let updatedURL = await client.baseURL.absoluteString + XCTAssertEqual(updatedURL, "http://example.test:3001") + } + + session.updateBaseURL("http://localhost:3001") + + XCTAssertTrue(session.apiClient is LiveAPIClient) + if let client = session.apiClient as? LiveAPIClient { + let restoredURL = await client.baseURL.absoluteString + XCTAssertEqual(restoredURL, "http://localhost:3001") + } + _ = cancellable + } +} + +private extension StubAPIClient { + func setShouldFail() { shouldFail = true } +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 00000000..23d6d6f1 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,39 @@ +name: TimesheetApp +options: + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +settings: + base: + SWIFT_VERSION: "5.9" + DEVELOPMENT_TEAM: "" +targets: + TimesheetApp: + type: application + platform: iOS + deploymentTarget: "17.0" + bundleId: ai.cognition.workshops.timesheet + sources: + - path: TimesheetApp + settings: + base: + INFOPLIST_FILE: TimesheetApp/Info.plist + PRODUCT_BUNDLE_IDENTIFIER: ai.cognition.workshops.timesheet + ASSETCATALOG_COMPILER_APPICON_NAME: "" + SWIFT_EMIT_LOC_STRINGS: YES + scheme: + testTargets: + - TimesheetAppTests + TimesheetAppTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + bundleId: ai.cognition.workshops.timesheet.tests + sources: + - path: TimesheetAppTests + dependencies: + - target: TimesheetApp + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.cognition.workshops.timesheet.tests + GENERATE_INFOPLIST_FILE: YES