├── .gitignore ├── Booklog.xcodeproj ├── project.pbxproj └── project.xcworkspace │ └── contents.xcworkspacedata ├── Booklog ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── BooklogAppIcon 1.png │ │ ├── BooklogAppIcon 2.png │ │ ├── BooklogAppIcon.png │ │ └── Contents.json │ ├── Contents.json │ └── background.colorset │ │ └── Contents.json ├── Booklog.entitlements ├── BooklogApp.swift ├── BooklogConst.swift ├── BooklogError.swift ├── Client │ ├── BookClient.swift │ ├── GoogleBooksClient.swift │ └── StatusClient.swift ├── ContentView.swift ├── EnvironmentValues.swift ├── Extension │ ├── Calendar+numberOfDays.swift │ ├── Color+hex.swift │ ├── Color+random.swift │ ├── UIColor+hex.swift │ ├── UIImage+resize.swift │ ├── UTType+.swift │ └── View+centered.swift ├── Info.plist ├── InfoPlist.xcstrings ├── Localizable.xcstrings ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── SwiftDataModel │ ├── Board.swift │ ├── Book.swift │ ├── Comment.swift │ ├── EntityConvertibleType.swift │ ├── Status.swift │ └── Tag.swift └── View │ ├── AddBookView.swift │ ├── BarcodeScannerView.swift │ ├── BoardView.swift │ ├── BookSearchView.swift │ ├── BookView.swift │ ├── ColorPickerView.swift │ ├── FlowLayout.swift │ ├── OpacityLevel.swift │ ├── SelectTagView.swift │ ├── StatusView.swift │ └── TagListView.swift ├── BooklogTests └── BooklogTests.swift ├── README.md └── docs └── privacy-policy └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | Booklog.xcodeproj/xcuserdata/ 2 | Booklog.xcodeproj/project.xcworkspace/xcuserdata/ 3 | 4 | -------------------------------------------------------------------------------- /Booklog.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXContainerItemProxy section */ 10 | 422CF57C2CECEE1D0026C848 /* PBXContainerItemProxy */ = { 11 | isa = PBXContainerItemProxy; 12 | containerPortal = 422CF55F2CECEE1B0026C848 /* Project object */; 13 | proxyType = 1; 14 | remoteGlobalIDString = 422CF5662CECEE1B0026C848; 15 | remoteInfo = Booklog; 16 | }; 17 | /* End PBXContainerItemProxy section */ 18 | 19 | /* Begin PBXFileReference section */ 20 | 422CF5672CECEE1B0026C848 /* Booklog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Booklog.app; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | 422CF57B2CECEE1D0026C848 /* BooklogTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BooklogTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | /* End PBXFileReference section */ 23 | 24 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 25 | 422CF58D2CECEE1D0026C848 /* Exceptions for "Booklog" folder in "Booklog" target */ = { 26 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 27 | membershipExceptions = ( 28 | Info.plist, 29 | ); 30 | target = 422CF5662CECEE1B0026C848 /* Booklog */; 31 | }; 32 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 33 | 34 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 35 | 422CF5692CECEE1B0026C848 /* Booklog */ = { 36 | isa = PBXFileSystemSynchronizedRootGroup; 37 | exceptions = ( 38 | 422CF58D2CECEE1D0026C848 /* Exceptions for "Booklog" folder in "Booklog" target */, 39 | ); 40 | path = Booklog; 41 | sourceTree = ""; 42 | }; 43 | 422CF57E2CECEE1D0026C848 /* BooklogTests */ = { 44 | isa = PBXFileSystemSynchronizedRootGroup; 45 | path = BooklogTests; 46 | sourceTree = ""; 47 | }; 48 | /* End PBXFileSystemSynchronizedRootGroup section */ 49 | 50 | /* Begin PBXFrameworksBuildPhase section */ 51 | 422CF5642CECEE1B0026C848 /* Frameworks */ = { 52 | isa = PBXFrameworksBuildPhase; 53 | buildActionMask = 2147483647; 54 | files = ( 55 | ); 56 | runOnlyForDeploymentPostprocessing = 0; 57 | }; 58 | 422CF5782CECEE1D0026C848 /* Frameworks */ = { 59 | isa = PBXFrameworksBuildPhase; 60 | buildActionMask = 2147483647; 61 | files = ( 62 | ); 63 | runOnlyForDeploymentPostprocessing = 0; 64 | }; 65 | /* End PBXFrameworksBuildPhase section */ 66 | 67 | /* Begin PBXGroup section */ 68 | 422CF55E2CECEE1B0026C848 = { 69 | isa = PBXGroup; 70 | children = ( 71 | 422CF5692CECEE1B0026C848 /* Booklog */, 72 | 422CF57E2CECEE1D0026C848 /* BooklogTests */, 73 | 422CF5682CECEE1B0026C848 /* Products */, 74 | ); 75 | sourceTree = ""; 76 | }; 77 | 422CF5682CECEE1B0026C848 /* Products */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | 422CF5672CECEE1B0026C848 /* Booklog.app */, 81 | 422CF57B2CECEE1D0026C848 /* BooklogTests.xctest */, 82 | ); 83 | name = Products; 84 | sourceTree = ""; 85 | }; 86 | /* End PBXGroup section */ 87 | 88 | /* Begin PBXNativeTarget section */ 89 | 422CF5662CECEE1B0026C848 /* Booklog */ = { 90 | isa = PBXNativeTarget; 91 | buildConfigurationList = 422CF58E2CECEE1D0026C848 /* Build configuration list for PBXNativeTarget "Booklog" */; 92 | buildPhases = ( 93 | 422CF5632CECEE1B0026C848 /* Sources */, 94 | 422CF5642CECEE1B0026C848 /* Frameworks */, 95 | 422CF5652CECEE1B0026C848 /* Resources */, 96 | ); 97 | buildRules = ( 98 | ); 99 | dependencies = ( 100 | ); 101 | fileSystemSynchronizedGroups = ( 102 | 422CF5692CECEE1B0026C848 /* Booklog */, 103 | ); 104 | name = Booklog; 105 | packageProductDependencies = ( 106 | ); 107 | productName = Booklog; 108 | productReference = 422CF5672CECEE1B0026C848 /* Booklog.app */; 109 | productType = "com.apple.product-type.application"; 110 | }; 111 | 422CF57A2CECEE1D0026C848 /* BooklogTests */ = { 112 | isa = PBXNativeTarget; 113 | buildConfigurationList = 422CF5932CECEE1D0026C848 /* Build configuration list for PBXNativeTarget "BooklogTests" */; 114 | buildPhases = ( 115 | 422CF5772CECEE1D0026C848 /* Sources */, 116 | 422CF5782CECEE1D0026C848 /* Frameworks */, 117 | 422CF5792CECEE1D0026C848 /* Resources */, 118 | ); 119 | buildRules = ( 120 | ); 121 | dependencies = ( 122 | 422CF57D2CECEE1D0026C848 /* PBXTargetDependency */, 123 | ); 124 | fileSystemSynchronizedGroups = ( 125 | 422CF57E2CECEE1D0026C848 /* BooklogTests */, 126 | ); 127 | name = BooklogTests; 128 | packageProductDependencies = ( 129 | ); 130 | productName = BooklogTests; 131 | productReference = 422CF57B2CECEE1D0026C848 /* BooklogTests.xctest */; 132 | productType = "com.apple.product-type.bundle.unit-test"; 133 | }; 134 | /* End PBXNativeTarget section */ 135 | 136 | /* Begin PBXProject section */ 137 | 422CF55F2CECEE1B0026C848 /* Project object */ = { 138 | isa = PBXProject; 139 | attributes = { 140 | BuildIndependentTargetsInParallel = 1; 141 | LastSwiftUpdateCheck = 1600; 142 | LastUpgradeCheck = 1610; 143 | TargetAttributes = { 144 | 422CF5662CECEE1B0026C848 = { 145 | CreatedOnToolsVersion = 16.0; 146 | }; 147 | 422CF57A2CECEE1D0026C848 = { 148 | CreatedOnToolsVersion = 16.0; 149 | TestTargetID = 422CF5662CECEE1B0026C848; 150 | }; 151 | }; 152 | }; 153 | buildConfigurationList = 422CF5622CECEE1B0026C848 /* Build configuration list for PBXProject "Booklog" */; 154 | developmentRegion = en; 155 | hasScannedForEncodings = 0; 156 | knownRegions = ( 157 | en, 158 | Base, 159 | ja, 160 | ); 161 | mainGroup = 422CF55E2CECEE1B0026C848; 162 | minimizedProjectReferenceProxies = 1; 163 | preferredProjectObjectVersion = 77; 164 | productRefGroup = 422CF5682CECEE1B0026C848 /* Products */; 165 | projectDirPath = ""; 166 | projectRoot = ""; 167 | targets = ( 168 | 422CF5662CECEE1B0026C848 /* Booklog */, 169 | 422CF57A2CECEE1D0026C848 /* BooklogTests */, 170 | ); 171 | }; 172 | /* End PBXProject section */ 173 | 174 | /* Begin PBXResourcesBuildPhase section */ 175 | 422CF5652CECEE1B0026C848 /* Resources */ = { 176 | isa = PBXResourcesBuildPhase; 177 | buildActionMask = 2147483647; 178 | files = ( 179 | ); 180 | runOnlyForDeploymentPostprocessing = 0; 181 | }; 182 | 422CF5792CECEE1D0026C848 /* Resources */ = { 183 | isa = PBXResourcesBuildPhase; 184 | buildActionMask = 2147483647; 185 | files = ( 186 | ); 187 | runOnlyForDeploymentPostprocessing = 0; 188 | }; 189 | /* End PBXResourcesBuildPhase section */ 190 | 191 | /* Begin PBXSourcesBuildPhase section */ 192 | 422CF5632CECEE1B0026C848 /* Sources */ = { 193 | isa = PBXSourcesBuildPhase; 194 | buildActionMask = 2147483647; 195 | files = ( 196 | ); 197 | runOnlyForDeploymentPostprocessing = 0; 198 | }; 199 | 422CF5772CECEE1D0026C848 /* Sources */ = { 200 | isa = PBXSourcesBuildPhase; 201 | buildActionMask = 2147483647; 202 | files = ( 203 | ); 204 | runOnlyForDeploymentPostprocessing = 0; 205 | }; 206 | /* End PBXSourcesBuildPhase section */ 207 | 208 | /* Begin PBXTargetDependency section */ 209 | 422CF57D2CECEE1D0026C848 /* PBXTargetDependency */ = { 210 | isa = PBXTargetDependency; 211 | target = 422CF5662CECEE1B0026C848 /* Booklog */; 212 | targetProxy = 422CF57C2CECEE1D0026C848 /* PBXContainerItemProxy */; 213 | }; 214 | /* End PBXTargetDependency section */ 215 | 216 | /* Begin XCBuildConfiguration section */ 217 | 422CF58F2CECEE1D0026C848 /* Debug */ = { 218 | isa = XCBuildConfiguration; 219 | buildSettings = { 220 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 221 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 222 | CODE_SIGN_ENTITLEMENTS = Booklog/Booklog.entitlements; 223 | CODE_SIGN_STYLE = Automatic; 224 | CURRENT_PROJECT_VERSION = 1; 225 | DEVELOPMENT_ASSET_PATHS = "\"Booklog/Preview Content\""; 226 | DEVELOPMENT_TEAM = G8RH83B4LT; 227 | ENABLE_PREVIEWS = YES; 228 | GENERATE_INFOPLIST_FILE = YES; 229 | INFOPLIST_FILE = Booklog/Info.plist; 230 | INFOPLIST_KEY_NSCameraUsageDescription = "Permission is required to read barcodes and add books"; 231 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 232 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 233 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 234 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 235 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 236 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 237 | LD_RUNPATH_SEARCH_PATHS = ( 238 | "$(inherited)", 239 | "@executable_path/Frameworks", 240 | ); 241 | MARKETING_VERSION = 1.0; 242 | PRODUCT_BUNDLE_IDENTIFIER = com.ryu.Booklog; 243 | PRODUCT_NAME = "$(TARGET_NAME)"; 244 | SWIFT_EMIT_LOC_STRINGS = YES; 245 | SWIFT_VERSION = 6.0; 246 | TARGETED_DEVICE_FAMILY = "1,2"; 247 | }; 248 | name = Debug; 249 | }; 250 | 422CF5902CECEE1D0026C848 /* Release */ = { 251 | isa = XCBuildConfiguration; 252 | buildSettings = { 253 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 254 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 255 | CODE_SIGN_ENTITLEMENTS = Booklog/Booklog.entitlements; 256 | CODE_SIGN_STYLE = Automatic; 257 | CURRENT_PROJECT_VERSION = 1; 258 | DEVELOPMENT_ASSET_PATHS = "\"Booklog/Preview Content\""; 259 | DEVELOPMENT_TEAM = G8RH83B4LT; 260 | ENABLE_PREVIEWS = YES; 261 | GENERATE_INFOPLIST_FILE = YES; 262 | INFOPLIST_FILE = Booklog/Info.plist; 263 | INFOPLIST_KEY_NSCameraUsageDescription = "Permission is required to read barcodes and add books"; 264 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 265 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 266 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 267 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 268 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 269 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 270 | LD_RUNPATH_SEARCH_PATHS = ( 271 | "$(inherited)", 272 | "@executable_path/Frameworks", 273 | ); 274 | MARKETING_VERSION = 1.0; 275 | PRODUCT_BUNDLE_IDENTIFIER = com.ryu.Booklog; 276 | PRODUCT_NAME = "$(TARGET_NAME)"; 277 | SWIFT_EMIT_LOC_STRINGS = YES; 278 | SWIFT_VERSION = 6.0; 279 | TARGETED_DEVICE_FAMILY = "1,2"; 280 | }; 281 | name = Release; 282 | }; 283 | 422CF5912CECEE1D0026C848 /* Debug */ = { 284 | isa = XCBuildConfiguration; 285 | buildSettings = { 286 | ALWAYS_SEARCH_USER_PATHS = NO; 287 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 288 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 289 | CLANG_ANALYZER_NONNULL = YES; 290 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 291 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 292 | CLANG_ENABLE_MODULES = YES; 293 | CLANG_ENABLE_OBJC_ARC = YES; 294 | CLANG_ENABLE_OBJC_WEAK = YES; 295 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 296 | CLANG_WARN_BOOL_CONVERSION = YES; 297 | CLANG_WARN_COMMA = YES; 298 | CLANG_WARN_CONSTANT_CONVERSION = YES; 299 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 300 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 301 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 302 | CLANG_WARN_EMPTY_BODY = YES; 303 | CLANG_WARN_ENUM_CONVERSION = YES; 304 | CLANG_WARN_INFINITE_RECURSION = YES; 305 | CLANG_WARN_INT_CONVERSION = YES; 306 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 307 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 308 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 309 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 310 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 311 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 312 | CLANG_WARN_STRICT_PROTOTYPES = YES; 313 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 314 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 315 | CLANG_WARN_UNREACHABLE_CODE = YES; 316 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 317 | COPY_PHASE_STRIP = NO; 318 | DEBUG_INFORMATION_FORMAT = dwarf; 319 | ENABLE_STRICT_OBJC_MSGSEND = YES; 320 | ENABLE_TESTABILITY = YES; 321 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 322 | GCC_C_LANGUAGE_STANDARD = gnu17; 323 | GCC_DYNAMIC_NO_PIC = NO; 324 | GCC_NO_COMMON_BLOCKS = YES; 325 | GCC_OPTIMIZATION_LEVEL = 0; 326 | GCC_PREPROCESSOR_DEFINITIONS = ( 327 | "DEBUG=1", 328 | "$(inherited)", 329 | ); 330 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 331 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 332 | GCC_WARN_UNDECLARED_SELECTOR = YES; 333 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 334 | GCC_WARN_UNUSED_FUNCTION = YES; 335 | GCC_WARN_UNUSED_VARIABLE = YES; 336 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 337 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 338 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 339 | MTL_FAST_MATH = YES; 340 | ONLY_ACTIVE_ARCH = YES; 341 | SDKROOT = iphoneos; 342 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 343 | SWIFT_EMIT_LOC_STRINGS = YES; 344 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 345 | }; 346 | name = Debug; 347 | }; 348 | 422CF5922CECEE1D0026C848 /* Release */ = { 349 | isa = XCBuildConfiguration; 350 | buildSettings = { 351 | ALWAYS_SEARCH_USER_PATHS = NO; 352 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 353 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 354 | CLANG_ANALYZER_NONNULL = YES; 355 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 356 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 357 | CLANG_ENABLE_MODULES = YES; 358 | CLANG_ENABLE_OBJC_ARC = YES; 359 | CLANG_ENABLE_OBJC_WEAK = YES; 360 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 361 | CLANG_WARN_BOOL_CONVERSION = YES; 362 | CLANG_WARN_COMMA = YES; 363 | CLANG_WARN_CONSTANT_CONVERSION = YES; 364 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 365 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 366 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 367 | CLANG_WARN_EMPTY_BODY = YES; 368 | CLANG_WARN_ENUM_CONVERSION = YES; 369 | CLANG_WARN_INFINITE_RECURSION = YES; 370 | CLANG_WARN_INT_CONVERSION = YES; 371 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 372 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 373 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 374 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 375 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 376 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 377 | CLANG_WARN_STRICT_PROTOTYPES = YES; 378 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 379 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 380 | CLANG_WARN_UNREACHABLE_CODE = YES; 381 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 382 | COPY_PHASE_STRIP = NO; 383 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 384 | ENABLE_NS_ASSERTIONS = NO; 385 | ENABLE_STRICT_OBJC_MSGSEND = YES; 386 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 387 | GCC_C_LANGUAGE_STANDARD = gnu17; 388 | GCC_NO_COMMON_BLOCKS = YES; 389 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 390 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 391 | GCC_WARN_UNDECLARED_SELECTOR = YES; 392 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 393 | GCC_WARN_UNUSED_FUNCTION = YES; 394 | GCC_WARN_UNUSED_VARIABLE = YES; 395 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 396 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 397 | MTL_ENABLE_DEBUG_INFO = NO; 398 | MTL_FAST_MATH = YES; 399 | SDKROOT = iphoneos; 400 | SWIFT_COMPILATION_MODE = wholemodule; 401 | SWIFT_EMIT_LOC_STRINGS = YES; 402 | VALIDATE_PRODUCT = YES; 403 | }; 404 | name = Release; 405 | }; 406 | 422CF5942CECEE1D0026C848 /* Debug */ = { 407 | isa = XCBuildConfiguration; 408 | buildSettings = { 409 | BUNDLE_LOADER = "$(TEST_HOST)"; 410 | CODE_SIGN_STYLE = Automatic; 411 | CURRENT_PROJECT_VERSION = 1; 412 | DEVELOPMENT_TEAM = G8RH83B4LT; 413 | GENERATE_INFOPLIST_FILE = YES; 414 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 415 | MARKETING_VERSION = 1.0; 416 | PRODUCT_BUNDLE_IDENTIFIER = com.ryu.BooklogTests; 417 | PRODUCT_NAME = "$(TARGET_NAME)"; 418 | SWIFT_EMIT_LOC_STRINGS = NO; 419 | SWIFT_VERSION = 5.0; 420 | TARGETED_DEVICE_FAMILY = "1,2"; 421 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Booklog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Booklog"; 422 | }; 423 | name = Debug; 424 | }; 425 | 422CF5952CECEE1D0026C848 /* Release */ = { 426 | isa = XCBuildConfiguration; 427 | buildSettings = { 428 | BUNDLE_LOADER = "$(TEST_HOST)"; 429 | CODE_SIGN_STYLE = Automatic; 430 | CURRENT_PROJECT_VERSION = 1; 431 | DEVELOPMENT_TEAM = G8RH83B4LT; 432 | GENERATE_INFOPLIST_FILE = YES; 433 | IPHONEOS_DEPLOYMENT_TARGET = 18.1; 434 | MARKETING_VERSION = 1.0; 435 | PRODUCT_BUNDLE_IDENTIFIER = com.ryu.BooklogTests; 436 | PRODUCT_NAME = "$(TARGET_NAME)"; 437 | SWIFT_EMIT_LOC_STRINGS = NO; 438 | SWIFT_VERSION = 5.0; 439 | TARGETED_DEVICE_FAMILY = "1,2"; 440 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Booklog.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Booklog"; 441 | }; 442 | name = Release; 443 | }; 444 | /* End XCBuildConfiguration section */ 445 | 446 | /* Begin XCConfigurationList section */ 447 | 422CF5622CECEE1B0026C848 /* Build configuration list for PBXProject "Booklog" */ = { 448 | isa = XCConfigurationList; 449 | buildConfigurations = ( 450 | 422CF5912CECEE1D0026C848 /* Debug */, 451 | 422CF5922CECEE1D0026C848 /* Release */, 452 | ); 453 | defaultConfigurationIsVisible = 0; 454 | defaultConfigurationName = Release; 455 | }; 456 | 422CF58E2CECEE1D0026C848 /* Build configuration list for PBXNativeTarget "Booklog" */ = { 457 | isa = XCConfigurationList; 458 | buildConfigurations = ( 459 | 422CF58F2CECEE1D0026C848 /* Debug */, 460 | 422CF5902CECEE1D0026C848 /* Release */, 461 | ); 462 | defaultConfigurationIsVisible = 0; 463 | defaultConfigurationName = Release; 464 | }; 465 | 422CF5932CECEE1D0026C848 /* Build configuration list for PBXNativeTarget "BooklogTests" */ = { 466 | isa = XCConfigurationList; 467 | buildConfigurations = ( 468 | 422CF5942CECEE1D0026C848 /* Debug */, 469 | 422CF5952CECEE1D0026C848 /* Release */, 470 | ); 471 | defaultConfigurationIsVisible = 0; 472 | defaultConfigurationName = Release; 473 | }; 474 | /* End XCConfigurationList section */ 475 | }; 476 | rootObject = 422CF55F2CECEE1B0026C848 /* Project object */; 477 | } 478 | -------------------------------------------------------------------------------- /Booklog.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Booklog/Assets.xcassets/AppIcon.appiconset/BooklogAppIcon 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ryu0118/Booklog/b91f80871d2781b81f45dba90268af46ceb68dd2/Booklog/Assets.xcassets/AppIcon.appiconset/BooklogAppIcon 1.png -------------------------------------------------------------------------------- /Booklog/Assets.xcassets/AppIcon.appiconset/BooklogAppIcon 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ryu0118/Booklog/b91f80871d2781b81f45dba90268af46ceb68dd2/Booklog/Assets.xcassets/AppIcon.appiconset/BooklogAppIcon 2.png -------------------------------------------------------------------------------- /Booklog/Assets.xcassets/AppIcon.appiconset/BooklogAppIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ryu0118/Booklog/b91f80871d2781b81f45dba90268af46ceb68dd2/Booklog/Assets.xcassets/AppIcon.appiconset/BooklogAppIcon.png -------------------------------------------------------------------------------- /Booklog/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "BooklogAppIcon.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "BooklogAppIcon 2.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "BooklogAppIcon 1.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Booklog/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Booklog/Assets.xcassets/background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "255", 9 | "green" : "255", 10 | "red" : "255" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "light" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "255", 27 | "green" : "255", 28 | "red" : "255" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | }, 33 | { 34 | "appearances" : [ 35 | { 36 | "appearance" : "luminosity", 37 | "value" : "dark" 38 | } 39 | ], 40 | "color" : { 41 | "color-space" : "srgb", 42 | "components" : { 43 | "alpha" : "1.000", 44 | "blue" : "0", 45 | "green" : "0", 46 | "red" : "0" 47 | } 48 | }, 49 | "idiom" : "universal" 50 | } 51 | ], 52 | "info" : { 53 | "author" : "xcode", 54 | "version" : 1 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Booklog/Booklog.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.files.user-selected.read-only 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Booklog/BooklogApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftData 3 | 4 | @main 5 | struct BooklogApp: App { 6 | var sharedModelContainer: ModelContainer = { 7 | let modelConfiguration = ModelConfiguration(schema: BooklogConst.schema(), isStoredInMemoryOnly: false) 8 | 9 | do { 10 | return try ModelContainer(for: BooklogConst.schema(), configurations: [modelConfiguration]) 11 | } catch { 12 | fatalError("Could not create ModelContainer: \(error)") 13 | } 14 | }() 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | GeometryReader { proxy in 19 | ContentView() 20 | .environment(\.mainWindowSize, proxy.size) 21 | } 22 | } 23 | .modelContainer(sharedModelContainer) 24 | .modelContainer(for: Book.self, isUndoEnabled: true) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Booklog/BooklogConst.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | import Foundation 3 | 4 | enum BooklogConst { 5 | static let modelTypes: [any PersistentModel.Type] = [ 6 | Book.self, 7 | Comment.self, 8 | Status.self, 9 | Tag.self, 10 | Board.self 11 | ] 12 | 13 | static func schema() -> Schema { 14 | Schema(modelTypes) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Booklog/BooklogError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum BooklogError: LocalizedError { 4 | case requestError 5 | case unknownError 6 | 7 | var errorDescription: String? { 8 | switch self { 9 | case .requestError: 10 | String(localized: "An error occurred during the network request") 11 | case .unknownError: 12 | String(localized: "Unknown error") 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Booklog/Client/BookClient.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | import Foundation 3 | 4 | struct BookClient: Sendable { 5 | func fetchBooks(for statusID: Status.ID, modelContext: ModelContext) throws -> [Book] { 6 | try modelContext.fetch( 7 | FetchDescriptor( 8 | predicate: #Predicate { 9 | $0.status.id == statusID 10 | }, 11 | sortBy: [ 12 | SortDescriptor(\.priority) 13 | ] 14 | ) 15 | ) 16 | } 17 | 18 | func fetchBook(id: Book.ID, modelContext: ModelContext) throws -> Book { 19 | guard let book = try modelContext.fetch( 20 | FetchDescriptor( 21 | predicate: #Predicate { 22 | $0.id == id 23 | } 24 | ) 25 | ).first else { 26 | throw Error.bookNotFound 27 | } 28 | return book 29 | } 30 | 31 | enum Error: LocalizedError { 32 | case bookNotFound 33 | 34 | var errorDescription: String? { 35 | switch self { 36 | case .bookNotFound: 37 | String(localized: "Book cannot be found") 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Booklog/Client/GoogleBooksClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GoogleBooksClient: Sendable { 4 | private let session = URLSession.shared 5 | private let jsonDecoder = JSONDecoder() 6 | 7 | func getBooks(keyword: String) async throws -> [FormattedResponse] { 8 | var url = URL(string: "https://www.googleapis.com/books/v1/volumes")! 9 | url.append(queryItems: [ 10 | URLQueryItem( 11 | name: "q", 12 | value: keyword 13 | ) 14 | ]) 15 | let (data, _) = try await session.data(from: url) 16 | let rawResponse = try jsonDecoder.decode(RawResponse.self, from: data) 17 | let books = rawResponse.items.map { $0.format() } 18 | 19 | guard !books.isEmpty else { 20 | throw Error.notFound 21 | } 22 | 23 | return books 24 | } 25 | 26 | func getBook(isbn: String) async throws -> FormattedResponse { 27 | var url = URL(string: "https://www.googleapis.com/books/v1/volumes")! 28 | url.append(queryItems: [ 29 | URLQueryItem( 30 | name: "q", 31 | value: "isbn:" + isbn 32 | ) 33 | ]) 34 | let (data, _) = try await session.data(from: url) 35 | let rawResponse = try jsonDecoder.decode(RawResponse.self, from: data) 36 | 37 | guard let book = rawResponse.items.map({ $0.format() }).first else { 38 | throw Error.notFound 39 | } 40 | 41 | return book 42 | } 43 | 44 | enum Error: LocalizedError { 45 | case notFound 46 | 47 | var errorDescription: String? { 48 | switch self { 49 | case .notFound: 50 | String(localized: "No books were found") 51 | } 52 | } 53 | } 54 | 55 | struct RawResponse: Decodable, Sendable { 56 | let items: [Item] 57 | 58 | struct Item: Codable, Sendable { 59 | let volumeInfo: VolumeInfo 60 | 61 | struct VolumeInfo: Codable, Sendable { 62 | let title: String 63 | let authors: [String]? 64 | let publisher: String? 65 | let publishedDate: String? 66 | let description: String? 67 | let imageLinks: ImageLinks? 68 | 69 | struct ImageLinks: Codable, Sendable { 70 | let smallThumbnail: String? 71 | let thumbnail: String? 72 | } 73 | } 74 | } 75 | } 76 | 77 | struct FormattedResponse: Decodable, Identifiable, Hashable { 78 | var id: String { title } 79 | 80 | let title: String 81 | let authors: [String] 82 | let publisher: String? 83 | let publishedDate: String? 84 | let description: String? 85 | let smallThumbnail: String? 86 | let thumbnail: String? 87 | } 88 | } 89 | 90 | extension GoogleBooksClient.RawResponse.Item { 91 | func format() -> GoogleBooksClient.FormattedResponse { 92 | GoogleBooksClient.FormattedResponse( 93 | title: volumeInfo.title, 94 | authors: volumeInfo.authors ?? [], 95 | publisher: volumeInfo.publisher, 96 | publishedDate: volumeInfo.publishedDate, 97 | description: volumeInfo.description, 98 | smallThumbnail: volumeInfo.imageLinks?.smallThumbnail, 99 | thumbnail: volumeInfo.imageLinks?.thumbnail 100 | ) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Booklog/Client/StatusClient.swift: -------------------------------------------------------------------------------- 1 | import SwiftData 2 | import Foundation 3 | 4 | struct StatusClient { 5 | func fetchStatus(id: Status.ID, modelContext: ModelContext) throws -> Status { 6 | guard let status = try modelContext.fetch( 7 | FetchDescriptor( 8 | predicate: #Predicate { 9 | $0.id == id 10 | } 11 | ) 12 | ).first else { 13 | throw Error.statusNotFound 14 | } 15 | return status 16 | } 17 | 18 | enum Error: LocalizedError { 19 | case statusNotFound 20 | 21 | var errorDescription: String? { 22 | switch self { 23 | case .statusNotFound: 24 | String(localized: "Status cannot be found") 25 | } 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Booklog/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftData 3 | 4 | struct ContentView: View { 5 | @Environment(\.modelContext) private var modelContext 6 | @AppStorage("selectedBoardID") private var selectedBoardID: String? 7 | @Query(sort: \Board.priority, animation: .smooth) private var boards: [Board] 8 | @State private var selectedBoard: Board? 9 | @State private var isTextFieldAlertPresented = false 10 | @State private var newBoardName = "" 11 | @State private var isTargeted: Bool = false 12 | @State private var isDeleteConfirmationAlertPresented = false 13 | @State private var boardToDelete: Board? 14 | @State private var isErrorAlertPresented = false 15 | @State private var localizedError: (any LocalizedError)? 16 | 17 | var newBoardButtonDisabled: Bool { 18 | newBoardName.isEmpty || boards.lazy.map(\.name).contains(newBoardName) 19 | } 20 | 21 | var allBoardTitles: [String] { 22 | boards.map(\.name) 23 | } 24 | 25 | var body: some View { 26 | NavigationSplitView { 27 | List( 28 | boards, 29 | id: \.id, 30 | selection: Binding( 31 | get: { selectedBoard }, 32 | set: { board in 33 | selectBoard(board) 34 | } 35 | ) 36 | ) { board in 37 | NavigationLink(value: board) { 38 | Label(board.name, systemImage: "square.on.square") 39 | } 40 | .swipeActions(edge: .trailing) { 41 | if boards.count > 1 { 42 | Button("Delete", systemImage: "trash") { 43 | boardDeleteButtonTapped(board: board) 44 | } 45 | .tint(.red) // roleをdestructiveにするとalertが出る前にcellが削除されてしまう 46 | } 47 | } 48 | if boards.last == board { 49 | Section("Other") { 50 | Label("[Source Code](https://github.com/Ryu0118/Booklog)", systemImage: "text.word.spacing") 51 | Label("[Other Apps](https://apps.apple.com/jp/developer/ryunosuke-shibuya/id1588660637)", systemImage: "app.gift") 52 | } 53 | } 54 | } 55 | .navigationTitle("Booklog") 56 | .toolbar { 57 | ToolbarItem(placement: .topBarTrailing) { 58 | Button { 59 | isTextFieldAlertPresented = true 60 | } label: { 61 | Image(systemName: "plus.square.on.square") 62 | } 63 | } 64 | } 65 | .alert("Create a new board", isPresented: $isTextFieldAlertPresented) { 66 | TextField("Enter a new board name", text: $newBoardName) 67 | Button("Cancel", role: .cancel) {} 68 | Button("OK") { 69 | createNewBoardOKButtonTapped() 70 | } 71 | .disabled(newBoardButtonDisabled) 72 | } 73 | .alert("Are you sure you want to delete ‘\(boardToDelete?.name ?? "Board")’ completely?", isPresented: $isDeleteConfirmationAlertPresented) { 74 | Button("Cancel", role: .cancel) {} 75 | Button("Delete", role: .destructive) { 76 | if let boardToDelete { 77 | confirmDeleteBoard(board: boardToDelete) 78 | } 79 | } 80 | } message: { 81 | Text("This action cannot be undone.") 82 | } 83 | } detail: { 84 | if let selectedBoard { 85 | NavigationStack { 86 | BoardView(board: selectedBoard, allBoardTitles: allBoardTitles) 87 | } 88 | } 89 | } 90 | .alert("An error has occurred", isPresented: $isErrorAlertPresented, presenting: localizedError) { error in 91 | Button("OK", role: .cancel) { 92 | } 93 | } message: { error in 94 | Text(error.localizedDescription) 95 | } 96 | .onAppear { 97 | if boards.isEmpty { 98 | initializeBoard() 99 | } 100 | if selectedBoard == nil { 101 | selectedBoard = boards.first(where: { $0.id.uuidString == selectedBoardID }) 102 | } 103 | } 104 | } 105 | 106 | private func confirmDeleteBoard(board: Board) { 107 | defer { 108 | isDeleteConfirmationAlertPresented = false 109 | boardToDelete = nil 110 | } 111 | 112 | let boardsAfterDelete = boards.lazy.filter({ $0.id != board.id }) 113 | 114 | if selectedBoardID == board.id.uuidString { 115 | selectBoard(boardsAfterDelete.first) 116 | } 117 | 118 | do { 119 | try modelContext.transaction { 120 | modelContext.delete(board) 121 | for (index, board) in boardsAfterDelete.enumerated() { 122 | board.priority = index 123 | } 124 | } 125 | } catch { 126 | showError(error: BooklogError.unknownError) 127 | } 128 | } 129 | 130 | private func boardDeleteButtonTapped(board: Board) { 131 | boardToDelete = board 132 | isDeleteConfirmationAlertPresented = true 133 | } 134 | 135 | private func createNewBoardOKButtonTapped() { 136 | let board = createNewBoard(name: newBoardName) 137 | selectBoard(board) 138 | newBoardName = "" 139 | } 140 | 141 | @discardableResult 142 | private func createNewBoard(name: String) -> Board { 143 | let now = Date() 144 | let statuses = Status.createDefaultStatuses(now: now) 145 | let board = Board( 146 | status: statuses, 147 | id: UUID(), 148 | name: name, 149 | priority: boards.count, 150 | createdAt: now, 151 | updatedAt: now 152 | ) 153 | 154 | do { 155 | modelContext.insert(board) 156 | try modelContext.save() 157 | } catch { 158 | showError(error: BooklogError.unknownError) 159 | } 160 | 161 | return board 162 | } 163 | 164 | private func showError(error: any LocalizedError) { 165 | isErrorAlertPresented = true 166 | localizedError = error 167 | } 168 | 169 | private func initializeBoard() { 170 | let board = createNewBoard(name: String(localized: "Default")) 171 | selectBoard(board) 172 | } 173 | 174 | private func selectBoard(_ board: Board?) { 175 | selectedBoard = board 176 | selectedBoardID = board?.id.uuidString 177 | } 178 | } 179 | 180 | #Preview { 181 | ContentView() 182 | .modelContainer(for: BooklogConst.modelTypes, inMemory: true) 183 | } 184 | -------------------------------------------------------------------------------- /Booklog/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension EnvironmentValues { 4 | @Entry var mainWindowSize: CGSize = .zero 5 | } 6 | -------------------------------------------------------------------------------- /Booklog/Extension/Calendar+numberOfDays.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Calendar { 4 | func numberOfDaysBetween(_ from: Date, and to: Date) -> Int { 5 | let fromDate = startOfDay(for: from) 6 | let toDate = startOfDay(for: to) 7 | let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) 8 | 9 | return numberOfDays.day! 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Booklog/Extension/Color+hex.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Color { 4 | init(hexString: String, opacity: Double = 1.0) { 5 | let scanner = Scanner(string: hexString) 6 | 7 | var color: UInt64 = 0 8 | if scanner.scanHexInt64(&color) { 9 | let red = CGFloat((color & 0xFF0000) >> 16) / 255.0 10 | let green = CGFloat((color & 0x00FF00) >> 8) / 255.0 11 | let blue = CGFloat(color & 0x0000FF) / 255.0 12 | self.init(red: red, green: green, blue: blue, opacity: opacity) 13 | } else { 14 | self.init(red: 0, green: 0, blue: 0, opacity: opacity) 15 | } 16 | } 17 | 18 | init(hexString: String, opacity: OpacityLevel) { 19 | self.init(hexString: hexString, opacity: opacity.rawValue) 20 | } 21 | 22 | func hexString(alpha: Bool = false) -> String { 23 | guard let components = cgColor?.components, components.count >= 3 else { 24 | return "FFFFFF" 25 | } 26 | 27 | let r = Float(components[0]) 28 | let g = Float(components[1]) 29 | let b = Float(components[2]) 30 | var a = Float(1.0) 31 | 32 | if components.count >= 4 { 33 | a = Float(components[3]) 34 | } 35 | 36 | return if alpha { 37 | String( 38 | format: "%02lX%02lX%02lX%02lX", 39 | lroundf(r * 255), 40 | lroundf(g * 255), 41 | lroundf(b * 255), 42 | lroundf(a * 255) 43 | ) 44 | } else { 45 | String( 46 | format: "%02lX%02lX%02lX", 47 | lroundf(r * 255), 48 | lroundf(g * 255), 49 | lroundf(b * 255) 50 | ) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Booklog/Extension/Color+random.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension CGFloat { 4 | static func random() -> CGFloat { 5 | CGFloat(arc4random()) / CGFloat(UInt32.max) 6 | } 7 | } 8 | 9 | extension UIColor { 10 | static func random() -> UIColor { 11 | UIColor( 12 | red: .random(), 13 | green: .random(), 14 | blue: .random(), 15 | alpha: 1.0 16 | ) 17 | } 18 | } 19 | 20 | extension Color { 21 | static func random() -> Color { 22 | Color(uiColor: .random()) 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Booklog/Extension/UIColor+hex.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | extension UIColor { 5 | func hexString(alpha: Bool = false) -> String { 6 | Color(uiColor: self).hexString(alpha: alpha) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Booklog/Extension/UIImage+resize.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIImage { 4 | func resize(size specifiedSize: CGSize) -> UIImage? { 5 | let widthRatio = specifiedSize.width / size.width 6 | let heightRatio = specifiedSize.height / size.height 7 | let ratio = widthRatio < heightRatio ? widthRatio : heightRatio 8 | 9 | let resizedSize = CGSize(width: size.width * ratio, height: size.height * ratio) 10 | 11 | UIGraphicsBeginImageContextWithOptions(resizedSize, false, 0.0) // 変更 12 | draw(in: CGRect(origin: .zero, size: resizedSize)) 13 | let resizedImage = UIGraphicsGetImageFromCurrentImageContext() 14 | UIGraphicsEndImageContext() 15 | 16 | return resizedImage 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Booklog/Extension/UTType+.swift: -------------------------------------------------------------------------------- 1 | import UniformTypeIdentifiers 2 | 3 | extension UTType { 4 | static let status = UTType(exportedAs: "com.ryu.Booklog.status") 5 | static let book = UTType(exportedAs: "com.ryu.Booklog.book") 6 | } 7 | -------------------------------------------------------------------------------- /Booklog/Extension/View+centered.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension View { 4 | nonisolated func centered(_ stackType: CenteredModifier.StackType) -> some View { 5 | modifier(CenteredModifier(stackType: stackType)) 6 | } 7 | } 8 | 9 | struct CenteredModifier: ViewModifier { 10 | enum StackType { 11 | case vertical, horizontal 12 | } 13 | 14 | let stackType: StackType 15 | 16 | func body(content: Content) -> some View { 17 | switch stackType { 18 | case .vertical: 19 | VStack { 20 | Spacer() 21 | content 22 | Spacer() 23 | } 24 | case .horizontal: 25 | HStack { 26 | Spacer() 27 | content 28 | Spacer() 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Booklog/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | UIBackgroundModes 11 | 12 | remote-notification 13 | 14 | UTExportedTypeDeclarations 15 | 16 | 17 | UTTypeConformsTo 18 | 19 | public.data 20 | 21 | UTTypeDescription 22 | Booklog Status 23 | UTTypeIconFiles 24 | 25 | UTTypeIdentifier 26 | com.ryu.Booklog.status 27 | UTTypeTagSpecification 28 | 29 | public.filename-extension 30 | 31 | status 32 | 33 | 34 | 35 | 36 | UTTypeConformsTo 37 | 38 | public.data 39 | 40 | UTTypeDescription 41 | Booklog Book 42 | UTTypeIconFiles 43 | 44 | UTTypeIdentifier 45 | com.ryu.Booklog.book 46 | UTTypeTagSpecification 47 | 48 | public.filename-extension 49 | 50 | book 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /Booklog/InfoPlist.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "Booklog Book" : { 5 | 6 | }, 7 | "Booklog Status" : { 8 | 9 | }, 10 | "CFBundleName" : { 11 | "comment" : "Bundle name", 12 | "extractionState" : "extracted_with_value", 13 | "localizations" : { 14 | "en" : { 15 | "stringUnit" : { 16 | "state" : "new", 17 | "value" : "Booklog" 18 | } 19 | }, 20 | "ja" : { 21 | "stringUnit" : { 22 | "state" : "translated", 23 | "value" : "Booklog" 24 | } 25 | } 26 | } 27 | }, 28 | "NSCameraUsageDescription" : { 29 | "comment" : "Privacy - Camera Usage Description", 30 | "extractionState" : "extracted_with_value", 31 | "localizations" : { 32 | "en" : { 33 | "stringUnit" : { 34 | "state" : "new", 35 | "value" : "Permission is required to read barcodes and add books" 36 | } 37 | }, 38 | "ja" : { 39 | "stringUnit" : { 40 | "state" : "translated", 41 | "value" : "バーコードを読み込んで本を追加するには許可が必要です" 42 | } 43 | } 44 | } 45 | } 46 | }, 47 | "version" : "1.0" 48 | } -------------------------------------------------------------------------------- /Booklog/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "" : { 5 | "localizations" : { 6 | "ja" : { 7 | "stringUnit" : { 8 | "state" : "translated", 9 | "value" : "“”" 10 | } 11 | } 12 | } 13 | }, 14 | "[Other Apps](https://apps.apple.com/jp/developer/ryunosuke-shibuya/id1588660637)" : { 15 | "localizations" : { 16 | "ja" : { 17 | "stringUnit" : { 18 | "state" : "translated", 19 | "value" : "[他の開発者のアプリ](https://apps.apple.com/jp/developer/ryunosuke-shibuya/id1588660637)" 20 | } 21 | } 22 | } 23 | }, 24 | "[Source Code](https://github.com/Ryu0118/Booklog)" : { 25 | "localizations" : { 26 | "ja" : { 27 | "stringUnit" : { 28 | "state" : "translated", 29 | "value" : "[ソースコード](https://github.com/Ryu0118/Booklog)" 30 | } 31 | } 32 | } 33 | }, 34 | "%lld days remaining" : { 35 | "localizations" : { 36 | "ja" : { 37 | "stringUnit" : { 38 | "state" : "translated", 39 | "value" : "残り%lld日" 40 | } 41 | } 42 | } 43 | }, 44 | "Add a custom book" : { 45 | "localizations" : { 46 | "ja" : { 47 | "stringUnit" : { 48 | "state" : "translated", 49 | "value" : "独自の本を追加" 50 | } 51 | } 52 | } 53 | }, 54 | "Add book" : { 55 | "localizations" : { 56 | "ja" : { 57 | "stringUnit" : { 58 | "state" : "translated", 59 | "value" : "本を追加" 60 | } 61 | } 62 | } 63 | }, 64 | "All books in \"%@\" will be deleted." : { 65 | "localizations" : { 66 | "ja" : { 67 | "stringUnit" : { 68 | "state" : "translated", 69 | "value" : "“%@”にある本は全て削除されます" 70 | } 71 | } 72 | } 73 | }, 74 | "An error has occurred" : { 75 | "localizations" : { 76 | "ja" : { 77 | "stringUnit" : { 78 | "state" : "translated", 79 | "value" : "エラーが発生しました" 80 | } 81 | } 82 | } 83 | }, 84 | "An error occurred during the network request" : { 85 | "localizations" : { 86 | "ja" : { 87 | "stringUnit" : { 88 | "state" : "translated", 89 | "value" : "ネットワークリクエスト中にエラーが発生しました" 90 | } 91 | } 92 | } 93 | }, 94 | "Are you sure you want to delete ‘%@’ completely?" : { 95 | "localizations" : { 96 | "ja" : { 97 | "stringUnit" : { 98 | "state" : "translated", 99 | "value" : "‘%@’を完全に削除してもよろしいですか?" 100 | } 101 | } 102 | } 103 | }, 104 | "Backlog" : { 105 | "extractionState" : "manual", 106 | "localizations" : { 107 | "ja" : { 108 | "stringUnit" : { 109 | "state" : "translated", 110 | "value" : "バックログ" 111 | } 112 | } 113 | } 114 | }, 115 | "Book cannot be found" : { 116 | "localizations" : { 117 | "ja" : { 118 | "stringUnit" : { 119 | "state" : "translated", 120 | "value" : "本が見つかりませんでした" 121 | } 122 | } 123 | } 124 | }, 125 | "Booklog" : { 126 | "localizations" : { 127 | "ja" : { 128 | "stringUnit" : { 129 | "state" : "translated", 130 | "value" : "Booklog" 131 | } 132 | } 133 | } 134 | }, 135 | "Cancel" : { 136 | "localizations" : { 137 | "ja" : { 138 | "stringUnit" : { 139 | "state" : "translated", 140 | "value" : "キャンセル" 141 | } 142 | } 143 | } 144 | }, 145 | "Change color theme" : { 146 | "localizations" : { 147 | "ja" : { 148 | "stringUnit" : { 149 | "state" : "translated", 150 | "value" : "カラーテーマを変更する" 151 | } 152 | } 153 | } 154 | }, 155 | "Color" : { 156 | "localizations" : { 157 | "ja" : { 158 | "stringUnit" : { 159 | "state" : "translated", 160 | "value" : "色" 161 | } 162 | } 163 | } 164 | }, 165 | "Completed" : { 166 | "extractionState" : "manual", 167 | "localizations" : { 168 | "ja" : { 169 | "stringUnit" : { 170 | "state" : "translated", 171 | "value" : "完了" 172 | } 173 | } 174 | } 175 | }, 176 | "Create a new board" : { 177 | "localizations" : { 178 | "ja" : { 179 | "stringUnit" : { 180 | "state" : "translated", 181 | "value" : "新規ボードを作成" 182 | } 183 | } 184 | } 185 | }, 186 | "Create a new status" : { 187 | "localizations" : { 188 | "ja" : { 189 | "stringUnit" : { 190 | "state" : "translated", 191 | "value" : "新規ステータスを作成" 192 | } 193 | } 194 | } 195 | }, 196 | "Deadline" : { 197 | "localizations" : { 198 | "ja" : { 199 | "stringUnit" : { 200 | "state" : "translated", 201 | "value" : "期限" 202 | } 203 | } 204 | } 205 | }, 206 | "Default" : { 207 | "extractionState" : "manual", 208 | "localizations" : { 209 | "ja" : { 210 | "stringUnit" : { 211 | "state" : "translated", 212 | "value" : "デフォルト" 213 | } 214 | } 215 | } 216 | }, 217 | "Delete" : { 218 | "localizations" : { 219 | "ja" : { 220 | "stringUnit" : { 221 | "state" : "translated", 222 | "value" : "削除" 223 | } 224 | } 225 | } 226 | }, 227 | "Delete \"%@\"" : { 228 | "localizations" : { 229 | "ja" : { 230 | "stringUnit" : { 231 | "state" : "translated", 232 | "value" : "“%@”を削除" 233 | } 234 | } 235 | } 236 | }, 237 | "Delete all books" : { 238 | "localizations" : { 239 | "ja" : { 240 | "stringUnit" : { 241 | "state" : "translated", 242 | "value" : "本を全て削除" 243 | } 244 | } 245 | } 246 | }, 247 | "Description" : { 248 | "localizations" : { 249 | "ja" : { 250 | "stringUnit" : { 251 | "state" : "translated", 252 | "value" : "概要" 253 | } 254 | } 255 | } 256 | }, 257 | "Do you really want to delete \"%@\"?" : { 258 | "localizations" : { 259 | "ja" : { 260 | "stringUnit" : { 261 | "state" : "translated", 262 | "value" : "本当に”%@”を削除しますか?" 263 | } 264 | } 265 | } 266 | }, 267 | "Do you really want to delete all of them?" : { 268 | "localizations" : { 269 | "ja" : { 270 | "stringUnit" : { 271 | "state" : "translated", 272 | "value" : "本当に全て削除しますか?" 273 | } 274 | } 275 | } 276 | }, 277 | "Do you really want to delete this book?" : { 278 | "localizations" : { 279 | "ja" : { 280 | "stringUnit" : { 281 | "state" : "translated", 282 | "value" : "本当にこの本を削除しますか?" 283 | } 284 | } 285 | } 286 | }, 287 | "Edit" : { 288 | "localizations" : { 289 | "ja" : { 290 | "stringUnit" : { 291 | "state" : "translated", 292 | "value" : "編集" 293 | } 294 | } 295 | } 296 | }, 297 | "Enable" : { 298 | "localizations" : { 299 | "ja" : { 300 | "stringUnit" : { 301 | "state" : "translated", 302 | "value" : "有効にする" 303 | } 304 | } 305 | } 306 | }, 307 | "Enter a book title" : { 308 | "localizations" : { 309 | "ja" : { 310 | "stringUnit" : { 311 | "state" : "translated", 312 | "value" : "本のタイトルを入力してください" 313 | } 314 | } 315 | } 316 | }, 317 | "Enter a description" : { 318 | "localizations" : { 319 | "ja" : { 320 | "stringUnit" : { 321 | "state" : "translated", 322 | "value" : "概要を入力してください" 323 | } 324 | } 325 | } 326 | }, 327 | "Enter a new board name" : { 328 | "localizations" : { 329 | "ja" : { 330 | "stringUnit" : { 331 | "state" : "translated", 332 | "value" : "名前を入力してください" 333 | } 334 | } 335 | } 336 | }, 337 | "Enter a new status name" : { 338 | "localizations" : { 339 | "ja" : { 340 | "stringUnit" : { 341 | "state" : "translated", 342 | "value" : "新しいステータス名を入力してください" 343 | } 344 | } 345 | } 346 | }, 347 | "Enter a page count" : { 348 | "localizations" : { 349 | "ja" : { 350 | "stringUnit" : { 351 | "state" : "translated", 352 | "value" : "ページ数を入力してください" 353 | } 354 | } 355 | } 356 | }, 357 | "Enter a tag title" : { 358 | "localizations" : { 359 | "ja" : { 360 | "stringUnit" : { 361 | "state" : "translated", 362 | "value" : "タグのタイトルを入力してください" 363 | } 364 | } 365 | } 366 | }, 367 | "Enter the current number of pages" : { 368 | "localizations" : { 369 | "ja" : { 370 | "stringUnit" : { 371 | "state" : "translated", 372 | "value" : "現在のページ数を入力" 373 | } 374 | } 375 | } 376 | }, 377 | "Enter the name of the book you want to search" : { 378 | "localizations" : { 379 | "ja" : { 380 | "stringUnit" : { 381 | "state" : "translated", 382 | "value" : "検索したい本の名前を入力" 383 | } 384 | } 385 | } 386 | }, 387 | "In progress" : { 388 | "extractionState" : "manual", 389 | "localizations" : { 390 | "ja" : { 391 | "stringUnit" : { 392 | "state" : "translated", 393 | "value" : "進行中" 394 | } 395 | } 396 | } 397 | }, 398 | "No books have been added to \"%@\"" : { 399 | "localizations" : { 400 | "ja" : { 401 | "stringUnit" : { 402 | "state" : "translated", 403 | "value" : "\"%@\"に本が追加されていません" 404 | } 405 | } 406 | } 407 | }, 408 | "No books were found" : { 409 | "localizations" : { 410 | "ja" : { 411 | "stringUnit" : { 412 | "state" : "translated", 413 | "value" : "本が見つかりませんでした" 414 | } 415 | } 416 | } 417 | }, 418 | "No tags have been added" : { 419 | "localizations" : { 420 | "ja" : { 421 | "stringUnit" : { 422 | "state" : "translated", 423 | "value" : "タグが追加されていません" 424 | } 425 | } 426 | } 427 | }, 428 | "OK" : { 429 | "localizations" : { 430 | "ja" : { 431 | "stringUnit" : { 432 | "state" : "translated", 433 | "value" : "OK" 434 | } 435 | } 436 | } 437 | }, 438 | "Other" : { 439 | "localizations" : { 440 | "ja" : { 441 | "stringUnit" : { 442 | "state" : "translated", 443 | "value" : "その他" 444 | } 445 | } 446 | } 447 | }, 448 | "Page %lld / %lld (%@)" : { 449 | "localizations" : { 450 | "en" : { 451 | "stringUnit" : { 452 | "state" : "new", 453 | "value" : "Page %1$lld / %2$lld (%3$@)" 454 | } 455 | }, 456 | "ja" : { 457 | "stringUnit" : { 458 | "state" : "translated", 459 | "value" : "ページ %1$lld / %2$lld (%3$@)" 460 | } 461 | } 462 | } 463 | }, 464 | "Page Count" : { 465 | "localizations" : { 466 | "ja" : { 467 | "stringUnit" : { 468 | "state" : "translated", 469 | "value" : "ページ数" 470 | } 471 | } 472 | } 473 | }, 474 | "Read barcode" : { 475 | "localizations" : { 476 | "ja" : { 477 | "stringUnit" : { 478 | "state" : "translated", 479 | "value" : "本のバーコードを読み取る" 480 | } 481 | } 482 | } 483 | }, 484 | "Rename" : { 485 | "localizations" : { 486 | "ja" : { 487 | "stringUnit" : { 488 | "state" : "translated", 489 | "value" : "名前を変更" 490 | } 491 | } 492 | } 493 | }, 494 | "Save" : { 495 | "localizations" : { 496 | "ja" : { 497 | "stringUnit" : { 498 | "state" : "translated", 499 | "value" : "保存" 500 | } 501 | } 502 | } 503 | }, 504 | "Search books" : { 505 | "localizations" : { 506 | "ja" : { 507 | "stringUnit" : { 508 | "state" : "translated", 509 | "value" : "本を探す" 510 | } 511 | } 512 | } 513 | }, 514 | "Search for books" : { 515 | "localizations" : { 516 | "ja" : { 517 | "stringUnit" : { 518 | "state" : "translated", 519 | "value" : "本を探す" 520 | } 521 | } 522 | } 523 | }, 524 | "Status cannot be found" : { 525 | "localizations" : { 526 | "ja" : { 527 | "stringUnit" : { 528 | "state" : "translated", 529 | "value" : "ステータスが見つかりませんでした" 530 | } 531 | } 532 | } 533 | }, 534 | "Tag" : { 535 | "localizations" : { 536 | "ja" : { 537 | "stringUnit" : { 538 | "state" : "translated", 539 | "value" : "タグ" 540 | } 541 | } 542 | } 543 | }, 544 | "This action cannot be undone." : { 545 | "localizations" : { 546 | "ja" : { 547 | "stringUnit" : { 548 | "state" : "translated", 549 | "value" : "この操作は取り消せません" 550 | } 551 | } 552 | } 553 | }, 554 | "Title" : { 555 | "localizations" : { 556 | "ja" : { 557 | "stringUnit" : { 558 | "state" : "translated", 559 | "value" : "タイトル" 560 | } 561 | } 562 | } 563 | }, 564 | "Todo" : { 565 | "extractionState" : "manual", 566 | "localizations" : { 567 | "ja" : { 568 | "stringUnit" : { 569 | "state" : "translated", 570 | "value" : "Todo" 571 | } 572 | } 573 | } 574 | }, 575 | "Unknown error" : { 576 | "localizations" : { 577 | "ja" : { 578 | "stringUnit" : { 579 | "state" : "translated", 580 | "value" : "不明なエラー" 581 | } 582 | } 583 | } 584 | }, 585 | "Yes" : { 586 | "localizations" : { 587 | "ja" : { 588 | "stringUnit" : { 589 | "state" : "translated", 590 | "value" : "はい" 591 | } 592 | } 593 | } 594 | } 595 | }, 596 | "version" : "1.0" 597 | } -------------------------------------------------------------------------------- /Booklog/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Booklog/SwiftDataModel/Board.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | final class Board: Identifiable { 6 | #Unique([\.id], [\.name], [\.priority]) 7 | @Relationship(deleteRule: .cascade, inverse: \Status.parentBoard) var status: [Status] 8 | 9 | var id: UUID 10 | var name: String 11 | var priority: Int 12 | var createdAt: Date 13 | var updatedAt: Date 14 | 15 | init(status: [Status], id: UUID, name: String, priority: Int, createdAt: Date, updatedAt: Date) { 16 | self.status = status 17 | self.id = id 18 | self.name = name 19 | self.priority = priority 20 | self.createdAt = createdAt 21 | self.updatedAt = updatedAt 22 | } 23 | 24 | struct Entity: EntityConvertibleType { 25 | var status: [Status.Entity] 26 | var id: UUID 27 | var name: String 28 | var priority: Int 29 | var createdAt: Date 30 | var updatedAt: Date 31 | } 32 | } 33 | 34 | extension Board: EntityConvertible { 35 | func toEntity() -> Entity { 36 | Entity(status: status.map { $0.toEntity() }, id: id, name: name, priority: priority, createdAt: createdAt, updatedAt: updatedAt) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Booklog/SwiftDataModel/Book.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | final class Book: Identifiable { 6 | #Unique([\.id], [\.title, \.status], [\.priority, \.status]) 7 | @Attribute(.externalStorage) var thumbnailData: Data? 8 | 9 | @Relationship(inverse: \Tag.books) var tags: [Tag] 10 | @Relationship(deleteRule: .cascade, inverse: \Comment.parentBook) var comments: [Comment] 11 | 12 | var id: UUID 13 | var status: Status 14 | var title: String 15 | var priority: Int 16 | var readData: ReadData? 17 | var authors: [String] 18 | var publisher: String? 19 | var publishedDate: String? 20 | var bookDescription: String? 21 | var smallThumbnail: String? 22 | var thumbnail: String? 23 | 24 | var deadline: Date? 25 | var createdAt: Date 26 | var updatedAt: Date 27 | 28 | struct ReadData: Codable, Equatable, Hashable { 29 | var totalPage: Int 30 | var currentPage: Int 31 | 32 | var progress: Double { 33 | Double(currentPage) / Double(totalPage) 34 | } 35 | } 36 | 37 | init( 38 | id: UUID, 39 | thumbnailData: Data? = nil, 40 | tags: [Tag], 41 | comments: [Comment] = [], 42 | status: Status, 43 | readData: ReadData? = nil, 44 | title: String, 45 | priority: Int, 46 | authors: [String] = [], 47 | publisher: String? = nil, 48 | publishedDate: String? = nil, 49 | bookDescription: String? = nil, 50 | smallThumbnail: String? = nil, 51 | thumbnail: String? = nil, 52 | deadline: Date? = nil, 53 | createdAt: Date, 54 | updatedAt: Date 55 | ) { 56 | self.id = id 57 | self.thumbnailData = thumbnailData 58 | self.tags = tags 59 | self.comments = comments 60 | self.status = status 61 | self.priority = priority 62 | self.readData = readData 63 | self.title = title 64 | self.authors = authors 65 | self.publisher = publisher 66 | self.publishedDate = publishedDate 67 | self.bookDescription = bookDescription 68 | self.smallThumbnail = smallThumbnail 69 | self.thumbnail = thumbnail 70 | self.deadline = deadline 71 | self.createdAt = createdAt 72 | self.updatedAt = updatedAt 73 | } 74 | 75 | struct Entity: EntityConvertibleType { 76 | var id: UUID 77 | var thumbnailData: Data? 78 | var tags: [Tag.Entity] = [] 79 | var comments: [Comment.Entity] = [] 80 | var readData: ReadData? 81 | var title: String 82 | var priority: Int 83 | var authors: [String] = [] 84 | var publisher: String? 85 | var publishedDate: String? 86 | var bookDescription: String? 87 | var smallThumbnail: String? 88 | var thumbnail: String? 89 | var deadline: Date? 90 | var createdAt: Date 91 | var updatedAt: Date 92 | 93 | var thumbnailURL: URL? { 94 | if let urlString = thumbnail ?? smallThumbnail { 95 | URL(string: urlString) 96 | } else { 97 | nil 98 | } 99 | } 100 | } 101 | } 102 | 103 | extension Book: EntityConvertible { 104 | func toEntity() -> Entity { 105 | Entity( 106 | id: id, 107 | thumbnailData: thumbnailData, 108 | tags: tags.map { $0.toEntity() }, 109 | comments: comments.map { $0.toEntity() }, 110 | readData: readData, 111 | title: title, 112 | priority: priority, 113 | authors: authors, 114 | publisher: publisher, 115 | publishedDate: publishedDate, 116 | bookDescription: bookDescription, 117 | smallThumbnail: smallThumbnail, 118 | thumbnail: thumbnail, 119 | deadline: deadline, 120 | createdAt: createdAt, 121 | updatedAt: updatedAt 122 | ) 123 | } 124 | } 125 | 126 | extension Book.Entity { 127 | func toOriginalModel(status: Status, tags: [Tag], comments: [Comment]) -> Book { 128 | Book( 129 | id: id, 130 | thumbnailData: thumbnailData, 131 | tags: tags, 132 | comments: comments, 133 | status: status, 134 | readData: readData, 135 | title: title, 136 | priority: priority, 137 | authors: authors, 138 | publisher: publisher, 139 | publishedDate: publishedDate, 140 | bookDescription: bookDescription, 141 | smallThumbnail: smallThumbnail, 142 | thumbnail: thumbnail, 143 | deadline: deadline, 144 | createdAt: createdAt, 145 | updatedAt: updatedAt 146 | ) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Booklog/SwiftDataModel/Comment.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | final class Comment: Identifiable { 6 | @Attribute(.unique) var id: UUID 7 | var parentBook: Book 8 | var text: String 9 | var createdAt: Date 10 | var updatedAt: Date 11 | 12 | init(id: UUID, parentBook: Book, text: String, createdAt: Date, updatedAt: Date) { 13 | self.id = id 14 | self.parentBook = parentBook 15 | self.text = text 16 | self.createdAt = createdAt 17 | self.updatedAt = updatedAt 18 | } 19 | 20 | struct Entity: EntityConvertibleType { 21 | var id: UUID 22 | var text: String 23 | var createdAt: Date 24 | var updatedAt: Date 25 | } 26 | } 27 | 28 | extension Comment: EntityConvertible { 29 | func toEntity() -> Entity { 30 | Entity( 31 | id: id, 32 | text: text, 33 | createdAt: createdAt, 34 | updatedAt: updatedAt 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Booklog/SwiftDataModel/EntityConvertibleType.swift: -------------------------------------------------------------------------------- 1 | import CoreTransferable 2 | 3 | typealias EntityConvertibleType = Codable & Equatable & Hashable & Identifiable & Sendable 4 | protocol EntityConvertible { 5 | associatedtype Entity: EntityConvertibleType 6 | 7 | func toEntity() -> Entity 8 | } 9 | -------------------------------------------------------------------------------- /Booklog/SwiftDataModel/Status.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | final class Status: Identifiable { 6 | #Unique([\.id], [\.parentBoard, \.priority], [\.title, \.parentBoard]) 7 | 8 | @Relationship(deleteRule: .cascade, inverse: \Book.status) 9 | var books: [Book] 10 | var parentBoard: Board? 11 | var id: UUID 12 | var title: String 13 | var priority: Int 14 | var hexColorString: String 15 | var createdAt: Date 16 | var updatedAt: Date 17 | 18 | init( 19 | id: UUID, 20 | books: [Book], 21 | parentBoard: Board? = nil, 22 | title: String, 23 | priority: Int, 24 | hexColorString: String, 25 | createdAt: Date, 26 | updatedAt: Date 27 | ) { 28 | self.id = id 29 | self.books = books 30 | self.parentBoard = parentBoard 31 | self.title = title 32 | self.priority = priority 33 | self.hexColorString = hexColorString 34 | self.createdAt = createdAt 35 | self.updatedAt = updatedAt 36 | } 37 | 38 | struct Entity: EntityConvertibleType { 39 | var books: [Book.Entity] 40 | var id: UUID 41 | var title: String 42 | var priority: Int 43 | var hexColorString: String 44 | var createdAt: Date 45 | var updatedAt: Date 46 | } 47 | 48 | static func createDefaultStatuses(now: Date = .now) -> [Status] { 49 | [ 50 | .init( 51 | id: UUID(), 52 | books: [], 53 | title: String(localized: "Backlog"), 54 | priority: 0, 55 | hexColorString: "91918E", 56 | createdAt: now, 57 | updatedAt: now 58 | ), 59 | .init( 60 | id: UUID(), 61 | books: [], 62 | title: String(localized: "Todo"), 63 | priority: 1, 64 | hexColorString: "6B94B7", 65 | createdAt: now, 66 | updatedAt: now 67 | ), 68 | .init( 69 | id: UUID(), 70 | books: [], 71 | title: String(localized: "In progress"), 72 | priority: 2, 73 | hexColorString: "8460CC", 74 | createdAt: now, 75 | updatedAt: now 76 | ), 77 | .init( 78 | id: UUID(), 79 | books: [], 80 | title: String(localized: "Completed"), 81 | priority: 3, 82 | hexColorString: "769980", 83 | createdAt: now, 84 | updatedAt: now 85 | ), 86 | ] 87 | } 88 | } 89 | 90 | extension Status: EntityConvertible { 91 | func toEntity() -> Entity { 92 | Entity( 93 | books: books.map { $0.toEntity() }, 94 | id: id, 95 | title: title, 96 | priority: priority, 97 | hexColorString: hexColorString, 98 | createdAt: createdAt, 99 | updatedAt: updatedAt 100 | ) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Booklog/SwiftDataModel/Tag.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftData 3 | 4 | @Model 5 | final class Tag: Identifiable { 6 | #Unique([\.id], [\.name]) 7 | 8 | var id: UUID 9 | var name: String 10 | var books: [Book] 11 | var hexColorString: String 12 | var createdAt: Date 13 | var updatedAt: Date 14 | 15 | init(id: UUID, books: [Book], name: String, hexColorString: String, createdAt: Date, updatedAt: Date) { 16 | self.id = id 17 | self.books = books 18 | self.name = name 19 | self.hexColorString = hexColorString 20 | self.createdAt = createdAt 21 | self.updatedAt = updatedAt 22 | } 23 | 24 | struct Entity: EntityConvertibleType { 25 | var id: UUID 26 | var name: String 27 | var hexColorString: String 28 | var createdAt: Date 29 | var updatedAt: Date 30 | } 31 | } 32 | 33 | extension Tag: EntityConvertible { 34 | func toEntity() -> Entity { 35 | Entity( 36 | id: id, 37 | name: name, 38 | hexColorString: hexColorString, 39 | createdAt: createdAt, 40 | updatedAt: updatedAt 41 | ) 42 | } 43 | } 44 | 45 | extension Tag.Entity { 46 | func toOriginalModel(books: [Book]) -> Tag { 47 | Tag( 48 | id: id, 49 | books: books, 50 | name: name, 51 | hexColorString: hexColorString, 52 | createdAt: createdAt, 53 | updatedAt: updatedAt 54 | ) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Booklog/View/AddBookView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import PhotosUI 3 | import SwiftData 4 | 5 | struct AddBookView: View { 6 | enum Const { 7 | static let thumbnailWidth: CGFloat = 150 8 | static let thumbnailHeight: CGFloat = 211 9 | } 10 | 11 | enum CreateType { 12 | case original 13 | case book(GoogleBooksClient.FormattedResponse) 14 | } 15 | 16 | enum ViewType { 17 | case new(CreateType) 18 | case edit(Book) 19 | 20 | var book: Book? { 21 | switch self { 22 | case .new: nil 23 | case .edit(let book): 24 | book 25 | } 26 | } 27 | 28 | var isCreateMode: Bool { 29 | switch self { 30 | case .new: 31 | true 32 | case .edit: 33 | false 34 | } 35 | } 36 | } 37 | 38 | enum FieldType: Equatable { 39 | case title, pageCount 40 | } 41 | 42 | @Environment(\.modelContext) private var modelContext 43 | @Environment(\.dismiss) private var dismiss 44 | 45 | @State private var book: Book.Entity 46 | @State private var photoPickerItems: [PhotosPickerItem] = [] 47 | @State private var photoPickedImage: UIImage? 48 | @State private var isDeadlineEnabled: Bool = false 49 | @FocusState private var focusedField: FieldType? 50 | 51 | var saveButtonDisabled: Bool { 52 | book.title.isEmpty || 53 | (viewType.isCreateMode ? otherBooksTitles.contains(book.title) : otherBooksTitles.filter { viewType.book?.title != $0 }.contains(book.title)) || 54 | book.title.count > 100 || 55 | (book.bookDescription?.count ?? 0) > 1000 56 | } 57 | 58 | private let status: Status 59 | private let otherBooksTitles: [String] 60 | private let viewType: ViewType 61 | 62 | private let onAddButtonTap: (() -> Void)? 63 | 64 | init( 65 | status: Status, 66 | viewType: ViewType, 67 | onAddButtonTap: (() -> Void)? = nil 68 | ) { 69 | self.viewType = viewType 70 | self.otherBooksTitles = status.books.map(\.title) 71 | self.status = status 72 | self.onAddButtonTap = onAddButtonTap 73 | 74 | let now = Date() 75 | switch viewType { 76 | case .new(let createType): 77 | switch createType { 78 | case .original: 79 | self.book = Book.Entity( 80 | id: UUID(), 81 | tags: [], 82 | title: "", 83 | priority: status.books.count, 84 | createdAt: now, 85 | updatedAt: now 86 | ) 87 | case .book(let formattedResponse): 88 | self.book = Book.Entity( 89 | id: UUID(), 90 | tags: [], 91 | title: formattedResponse.title, 92 | priority: status.books.count, 93 | authors: formattedResponse.authors, 94 | publisher: formattedResponse.publisher, 95 | publishedDate: formattedResponse.publishedDate, 96 | bookDescription: formattedResponse.description, 97 | smallThumbnail: formattedResponse.smallThumbnail, 98 | thumbnail: formattedResponse.thumbnail, 99 | deadline: nil, 100 | createdAt: now, 101 | updatedAt: now 102 | ) 103 | } 104 | case .edit(let book): 105 | self.book = book.toEntity() 106 | if let thumbnailData = book.thumbnailData { 107 | let uiImage = UIImage(data: thumbnailData) 108 | self._photoPickedImage = State(initialValue: uiImage) 109 | } 110 | } 111 | } 112 | 113 | @MainActor 114 | var body: some View { 115 | List { 116 | thumbnail 117 | 118 | TextField("Title", text: $book.title, prompt: Text("Enter a book title")) 119 | .focused($focusedField, equals: .title) 120 | 121 | Section("Description") { 122 | TextField( 123 | "Enter a description", 124 | text: Binding( 125 | get: { book.bookDescription ?? "" }, 126 | set: { book.bookDescription = $0 } 127 | ), 128 | axis: .vertical 129 | ) 130 | } 131 | 132 | deadline 133 | 134 | pageCount 135 | 136 | Section("Tag") { 137 | NavigationLink { 138 | SelectTagView(book: $book) 139 | } label: { 140 | Group { 141 | if book.tags.isEmpty { 142 | Text("No tags have been added") 143 | .foregroundStyle(.secondary) 144 | } else { 145 | TagListView(tags: book.tags) 146 | } 147 | } 148 | .contentShape(Rectangle()) 149 | } 150 | } 151 | } 152 | .listStyle(.insetGrouped) 153 | .navigationTitle("Add book") 154 | .navigationBarTitleDisplayMode(.inline) 155 | .toolbar { 156 | ToolbarItem(placement: .topBarTrailing) { 157 | Button("Save") { 158 | saveBook() 159 | } 160 | .disabled(saveButtonDisabled) 161 | } 162 | } 163 | .onChange(of: photoPickerItems) { old, new in 164 | if let photoPickerItem = photoPickerItems.first { 165 | Task { 166 | if let loadedImage = try await photoPickerItem.loadTransferable(type: Data.self), 167 | let uiImage = UIImage(data: loadedImage), 168 | let resizedImage = uiImage.resize(size: CGSize(width: uiImage.size.width / 10, height: uiImage.size.height / 10)) 169 | { 170 | book.thumbnailData = resizedImage.pngData() 171 | photoPickedImage = resizedImage 172 | } 173 | } 174 | } 175 | } 176 | } 177 | 178 | private var thumbnail: some View { 179 | PhotosPicker( 180 | selection: $photoPickerItems, 181 | maxSelectionCount: 1, 182 | selectionBehavior: .ordered, 183 | matching: .images, 184 | preferredItemEncoding: .current, 185 | photoLibrary: .shared() 186 | ) { [thumbnailURL = book.thumbnailURL, photoPickedImage] in 187 | Group { 188 | if let photoPickedImage { 189 | Image(uiImage: photoPickedImage).thumbnail() 190 | } else { 191 | AsyncImage(url: thumbnailURL) { image in 192 | image.thumbnail() 193 | } placeholder: { 194 | Rectangle().fill(.gray) 195 | .frame(width: Const.thumbnailWidth, height: Const.thumbnailHeight) 196 | } 197 | } 198 | } 199 | .centered(.horizontal) 200 | } 201 | } 202 | 203 | private var pageCount: some View { 204 | Section("Page Count") { 205 | TextField( 206 | "Page Count", 207 | value: .init( 208 | get: { 209 | if let readData = book.readData { 210 | return readData.totalPage 211 | } else { 212 | return 0 213 | } 214 | }, 215 | set: { 216 | if $0 <= 0 { 217 | book.readData = nil 218 | } else if book.readData != nil { 219 | book.readData?.totalPage = $0 220 | } else { 221 | book.readData = Book.ReadData(totalPage: $0, currentPage: 0) 222 | } 223 | } 224 | ), 225 | format: .number, 226 | prompt: Text("Enter a page count") 227 | ) 228 | .keyboardType(.numbersAndPunctuation) 229 | .focused($focusedField, equals: .pageCount) 230 | .onSubmit(of: .text) { 231 | focusedField = nil 232 | } 233 | } 234 | } 235 | 236 | private var deadline: some View { 237 | Section("Deadline") { 238 | Toggle( 239 | "Enable", 240 | isOn: Binding( 241 | get: { isDeadlineEnabled }, 242 | set: { 243 | if !$0 { 244 | book.deadline = nil 245 | } 246 | isDeadlineEnabled = $0 247 | } 248 | ) 249 | ) 250 | 251 | if isDeadlineEnabled { 252 | let now = Date() 253 | DatePicker( 254 | "Deadline", 255 | selection: Binding( 256 | get: { book.deadline ?? now }, 257 | set: { book.deadline = $0 } 258 | ), 259 | in: Calendar.current.date(byAdding: .day, value: 1, to: now)! ... Calendar.current.date(byAdding: .day, value: 9999, to: now)!, 260 | displayedComponents: [.date] 261 | ) 262 | } 263 | } 264 | } 265 | 266 | private func saveBook() { 267 | do { 268 | let ids = book.tags.map(\.id) 269 | let tags = try modelContext.fetch( 270 | FetchDescriptor(predicate: #Predicate { 271 | ids.contains($0.id) 272 | }) 273 | ) 274 | let book = book.toOriginalModel(status: status, tags: tags, comments: viewType.book?.comments ?? []) 275 | modelContext.insert(book) 276 | try modelContext.save() 277 | dismiss() 278 | } catch {} 279 | } 280 | } 281 | 282 | fileprivate extension Image { 283 | func thumbnail() -> some View { 284 | self 285 | .resizable() 286 | .scaledToFit() 287 | .frame(width: AddBookView.Const.thumbnailWidth, height: AddBookView.Const.thumbnailHeight) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Booklog/View/BarcodeScannerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | @preconcurrency import Vision 3 | @preconcurrency import VisionKit 4 | 5 | struct BarcodeScannerView: UIViewControllerRepresentable { 6 | private let onRecognize: (RecognizedItem.Barcode) -> Void 7 | private let symbologies: [VNBarcodeSymbology] 8 | 9 | init(symbologies: [VNBarcodeSymbology] = [.ean13], onRecognize: @escaping (RecognizedItem.Barcode) -> Void) { 10 | self.onRecognize = onRecognize 11 | self.symbologies = symbologies 12 | } 13 | 14 | func makeUIViewController(context: Context) -> some UIViewController { 15 | let viewController = DataScannerViewController( 16 | recognizedDataTypes: [.barcode(symbologies: symbologies)], 17 | qualityLevel: .balanced, 18 | recognizesMultipleItems: false, 19 | isHighFrameRateTrackingEnabled: false, 20 | isHighlightingEnabled: true 21 | ) 22 | viewController.delegate = context.coordinator 23 | 24 | try? viewController.startScanning() 25 | return viewController 26 | } 27 | 28 | func updateUIViewController(_: UIViewControllerType, context _: Context) {} 29 | 30 | func makeCoordinator() -> Coordinator { 31 | Coordinator(parent: self) 32 | } 33 | 34 | final class Coordinator: NSObject, DataScannerViewControllerDelegate { 35 | let parent: BarcodeScannerView 36 | 37 | fileprivate init(parent: BarcodeScannerView) { 38 | self.parent = parent 39 | } 40 | 41 | func dataScanner(_: DataScannerViewController, didAdd _: [RecognizedItem], allItems: [RecognizedItem]) { 42 | guard let item = allItems.first else { return } 43 | switch item { 44 | case let .barcode(recognizedCode): 45 | parent.onRecognize(recognizedCode) 46 | default: 47 | break 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Booklog/View/BoardView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftData 3 | 4 | struct BoardView: View { 5 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 6 | @Environment(\.modelContext) private var modelContext 7 | 8 | @State private var newStatusName = "" 9 | @State private var newBoardName = "" 10 | @State private var isNewStatusNameFieldPresented = false 11 | @State private var isErrorAlertPresented = false 12 | @State private var presentingError: (any LocalizedError)? 13 | @State private var isRenameBoardAlertPresented = false 14 | @State private var isBoardDeleting = false 15 | 16 | var newStatusNameOKButtonDisabled: Bool { 17 | board.status.lazy.map(\.title).contains(newStatusName) || newStatusName.isEmpty || newStatusName.count > 20 18 | } 19 | 20 | var newBoardButtonDisabled: Bool { 21 | newBoardName.isEmpty || allBoardTitles.contains(newBoardName) 22 | } 23 | 24 | let board: Board 25 | let allBoardTitles: [String] 26 | @Query var status: [Status] 27 | 28 | init(board: Board, allBoardTitles: [String]) { 29 | self.board = board 30 | let id = board.id 31 | _status = Query( 32 | filter: #Predicate { 33 | $0.parentBoard?.id == id 34 | }, 35 | sort: [ 36 | SortDescriptor(\.priority) 37 | ], 38 | animation: .easeInOut 39 | ) 40 | self.allBoardTitles = allBoardTitles 41 | } 42 | 43 | var body: some View { 44 | let Stack = horizontalSizeClass == .compact ? AnyLayout(VStackLayout(spacing: 4)) : AnyLayout(HStackLayout(spacing: 4)) 45 | let axis: Axis.Set = horizontalSizeClass == .compact ? .vertical : .horizontal 46 | ScrollView(axis, showsIndicators: horizontalSizeClass != .compact) { 47 | Stack { 48 | ForEach(status) { status in 49 | StatusView(status: status) 50 | } 51 | } 52 | .padding(.horizontal, 4) 53 | } 54 | .navigationTitle(board.name) 55 | .navigationBarTitleDisplayMode(.inline) 56 | .ignoresSafeArea(edges: .bottom) 57 | .toolbar { 58 | ToolbarItem(placement: .topBarTrailing) { 59 | Button("", systemImage: "plus") { 60 | isNewStatusNameFieldPresented = true 61 | } 62 | } 63 | ToolbarItem(placement: .topBarTrailing) { 64 | Menu { 65 | Button("Rename", systemImage: "pencil") { 66 | isRenameBoardAlertPresented = true 67 | } 68 | Button("Delete \"\(board.name)\"", systemImage: "trash", role: .destructive) { 69 | isBoardDeleting = true 70 | } 71 | .disabled(allBoardTitles.count == 1) 72 | } label: { 73 | Image(systemName: "ellipsis") 74 | } 75 | } 76 | } 77 | .alert("Create a new status", isPresented: $isNewStatusNameFieldPresented) { 78 | TextField("Enter a new status name", text: $newStatusName) 79 | Button("Cancel", role: .cancel) {} 80 | Button("OK") { 81 | do { 82 | try modelContext.transaction { 83 | let now = Date() 84 | modelContext.insert( 85 | Status( 86 | id: UUID(), 87 | books: [], 88 | parentBoard: board, 89 | title: newStatusName, 90 | priority: board.status.count, 91 | hexColorString: Color.random().hexString(), 92 | createdAt: now, 93 | updatedAt: now 94 | ) 95 | ) 96 | } 97 | } catch { 98 | showError(BooklogError.unknownError) 99 | } 100 | } 101 | .disabled(newStatusNameOKButtonDisabled) 102 | } 103 | .alert("Rename", isPresented: $isRenameBoardAlertPresented) { 104 | TextField("Enter a new board name", text: $newBoardName) 105 | Button("Cancel", role: .cancel) {} 106 | Button("OK") { 107 | do { 108 | try modelContext.transaction { 109 | board.name = newBoardName 110 | } 111 | } catch { 112 | showError(BooklogError.unknownError) 113 | } 114 | } 115 | .disabled(newBoardButtonDisabled) 116 | } 117 | .alert("An error has occurred", isPresented: $isErrorAlertPresented, presenting: presentingError) { error in 118 | Button("OK", role: .cancel) { 119 | } 120 | } message: { error in 121 | Text(error.localizedDescription) 122 | } 123 | .alert("Do you really want to delete \"\(board.name)\"?", isPresented: $isBoardDeleting) { 124 | Button("Yes", role: .destructive) { 125 | do { 126 | try modelContext.transaction { 127 | modelContext.delete(board) 128 | } 129 | } catch { 130 | showError(BooklogError.unknownError) 131 | } 132 | } 133 | } 134 | } 135 | 136 | private func showError(_ error: any LocalizedError) { 137 | isErrorAlertPresented = true 138 | presentingError = error 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /Booklog/View/BookSearchView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BookSearchView: View { 4 | enum Const { 5 | static let thumbnailWidth: CGFloat = 55 6 | static let thumbnailHeight: CGFloat = 78 7 | } 8 | 9 | @State private var searchQuery: String = "" 10 | @State private var addingBook: GoogleBooksClient.FormattedResponse? 11 | @State private var books: [GoogleBooksClient.FormattedResponse] = [] 12 | @State private var task: Task? 13 | @State private var isAlertPresented = false 14 | @State private var presentingError: (any LocalizedError)? 15 | @State private var isSearchablePresented = true 16 | 17 | private let booksClient = GoogleBooksClient() 18 | 19 | let status: Status 20 | 21 | var body: some View { 22 | NavigationStack { 23 | List(books) { book in 24 | Button { 25 | addingBook = book 26 | } label: { 27 | HStack { 28 | AsyncImage(url: URL(string: book.thumbnail ?? book.smallThumbnail ?? "")) { image in 29 | image.resizable() 30 | .scaledToFit() 31 | .frame(width: Const.thumbnailWidth, height: Const.thumbnailHeight) 32 | } placeholder: { 33 | Rectangle().fill(Color.gray) 34 | .frame(width: Const.thumbnailWidth, height: Const.thumbnailHeight) 35 | } 36 | Text(book.title) 37 | .lineLimit(3) 38 | .font(.body) 39 | Spacer() 40 | } 41 | .contentShape(Rectangle()) 42 | } 43 | .buttonStyle(.plain) 44 | } 45 | .navigationTitle("Search books") 46 | .searchable(text: $searchQuery, isPresented: $isSearchablePresented, prompt: "Enter the name of the book you want to search") 47 | .onSubmit(of: .search) { 48 | task = Task { 49 | do { 50 | books = try await booksClient.getBooks(keyword: searchQuery) 51 | } catch { 52 | showError(error: BooklogError.requestError) 53 | } 54 | } 55 | } 56 | .navigationDestination(item: $addingBook) { book in 57 | AddBookView(status: status, viewType: .new(.book(book))) 58 | } 59 | .alert("An error has occurred", isPresented: $isAlertPresented, presenting: presentingError) { error in 60 | Button("OK", role: .cancel) { 61 | } 62 | } message: { error in 63 | Text(error.localizedDescription) 64 | } 65 | .onDisappear { 66 | task?.cancel() 67 | } 68 | } 69 | } 70 | 71 | private func showError(error: any LocalizedError) { 72 | isAlertPresented = true 73 | presentingError = error 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Booklog/View/BookView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct BookView: View { 4 | enum Const { 5 | static let thumbnailWidth: CGFloat = 55 6 | static let thumbnailHeight: CGFloat = 78 7 | } 8 | let book: Book.Entity 9 | 10 | var body: some View { 11 | VStack(alignment: .leading) { 12 | HStack { 13 | if let imageData = book.thumbnailData, 14 | let uiImage = UIImage(data: imageData) 15 | { 16 | Image(uiImage: uiImage) 17 | .resizable() 18 | .scaledToFit() 19 | .frame(width: Const.thumbnailWidth, height: Const.thumbnailHeight) 20 | } else { 21 | AsyncImage(url: book.thumbnailURL) { image in 22 | image.resizable() 23 | .scaledToFit() 24 | .frame(width: Const.thumbnailWidth, height: Const.thumbnailHeight) 25 | } placeholder: { 26 | Rectangle().fill(Color.gray) 27 | .frame(width: Const.thumbnailWidth, height: Const.thumbnailHeight) 28 | } 29 | } 30 | 31 | Text(book.title) 32 | .font(.headline) 33 | .lineLimit(4) 34 | .minimumScaleFactor(0.8) 35 | Spacer(minLength: 0) 36 | } 37 | 38 | if let bookDescription = book.bookDescription { 39 | Text(bookDescription) 40 | .font(.callout) 41 | .lineLimit(3) 42 | .minimumScaleFactor(0.9) 43 | } 44 | 45 | if let deadline = book.deadline { 46 | let remainingDays = max(0, Calendar.current.numberOfDaysBetween(Date(), and: deadline)) 47 | HStack { 48 | Label("Deadline", systemImage: "calendar") 49 | Spacer() 50 | Text(deadline, format: .dateTime.year().month().day()) 51 | Text("\(remainingDays) days remaining") 52 | } 53 | .font(.footnote) 54 | .lineLimit(1) 55 | .padding(.vertical, 4) 56 | } 57 | 58 | if let readData = book.readData { 59 | ProgressView(value: readData.progress) { 60 | Label( 61 | "Page \(readData.currentPage) / \(readData.totalPage) (\(Decimal.FormatStyle.Percent().format(Decimal(readData.progress))))", 62 | systemImage: "book.pages" 63 | ) 64 | .font(.footnote) 65 | } 66 | } 67 | 68 | TagListView(tags: book.tags) 69 | } 70 | .padding() 71 | .background(Color.background) 72 | .clipShape(RoundedRectangle(cornerRadius: 8)) 73 | } 74 | } 75 | 76 | #Preview("Normal") { 77 | BookView( 78 | book: Book.Entity( 79 | id: UUID(), 80 | tags: [ 81 | Tag.Entity( 82 | id: UUID(), 83 | name: "Swift", 84 | hexColorString: "6B94B7", 85 | createdAt: .now, 86 | updatedAt: .now 87 | ) 88 | ], 89 | title: "Mathematics Book", 90 | priority: 0, 91 | createdAt: .now, 92 | updatedAt: .now 93 | ) 94 | ) 95 | } 96 | 97 | #Preview("Long") { 98 | BookView( 99 | book: Book.Entity( 100 | id: UUID(), 101 | tags: [ 102 | Tag.Entity( 103 | id: UUID(), 104 | name: "Swift", 105 | hexColorString: "6B94B7", 106 | createdAt: .now, 107 | updatedAt: .now 108 | ), 109 | Tag.Entity( 110 | id: UUID(), 111 | name: "Swift", 112 | hexColorString: "8460CC", 113 | createdAt: .now, 114 | updatedAt: .now 115 | ) 116 | ], 117 | title: "Mathematics Book\nMathematics Book\nMathematics Book\nMathematics Book", 118 | priority: 0, 119 | createdAt: .now, 120 | updatedAt: .now 121 | ) 122 | ) 123 | } 124 | -------------------------------------------------------------------------------- /Booklog/View/ColorPickerView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | 4 | @MainActor 5 | struct ColorPickerWellView: UIViewRepresentable { 6 | private var selectedColor: Color 7 | let onColorPicked: (UIColor) -> Void 8 | 9 | init(selectedColor: Color, onColorPicked: @escaping (UIColor) -> Void) { 10 | self.selectedColor = selectedColor 11 | self.onColorPicked = onColorPicked 12 | } 13 | 14 | func makeUIView(context: Context) -> UIView { 15 | let colorWell = UIColorWell(frame: CGRect(x: 0, y: 0, width: 100, height: 50)) 16 | 17 | colorWell.title = "Select Color" 18 | colorWell.selectedColor = UIColor(selectedColor) 19 | colorWell.addTarget(context.coordinator, action: #selector(context.coordinator.colorWellChanged), for: .valueChanged) 20 | 21 | return colorWell 22 | } 23 | 24 | func updateUIView(_: UIView, context _: Context) {} 25 | 26 | func makeCoordinator() -> Coordinator { 27 | Coordinator( 28 | onColorPicked: self.onColorPicked 29 | ) 30 | } 31 | 32 | final class Coordinator: NSObject { 33 | private let onColorPicked: (UIColor) -> Void 34 | 35 | init(onColorPicked: @escaping (UIColor) -> Void) { 36 | self.onColorPicked = onColorPicked 37 | } 38 | 39 | @MainActor @objc func colorWellChanged(_ sender: UIColorWell) { 40 | if let color = sender.selectedColor { 41 | self.onColorPicked(color) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Booklog/View/FlowLayout.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct FlowLayout: Layout { 4 | var alignment: Alignment = .center 5 | var spacing: CGFloat? 6 | 7 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { 8 | let result = FlowResult( 9 | in: proposal.replacingUnspecifiedDimensions().width, 10 | subviews: subviews, 11 | alignment: alignment, 12 | spacing: spacing 13 | ) 14 | return result.bounds 15 | } 16 | 17 | func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { 18 | let result = FlowResult( 19 | in: proposal.replacingUnspecifiedDimensions().width, 20 | subviews: subviews, 21 | alignment: alignment, 22 | spacing: spacing 23 | ) 24 | for row in result.rows { 25 | let rowXOffset = (bounds.width - row.frame.width) * alignment.horizontal.percent 26 | for index in row.range { 27 | let xPos = rowXOffset + row.frame.minX + row.xOffsets[index - row.range.lowerBound] + bounds.minX 28 | let rowYAlignment = (row.frame.height - subviews[index].sizeThatFits(.unspecified).height) * 29 | alignment.vertical.percent 30 | let yPos = row.frame.minY + rowYAlignment + bounds.minY 31 | subviews[index].place(at: CGPoint(x: xPos, y: yPos), anchor: .topLeading, proposal: .unspecified) 32 | } 33 | } 34 | } 35 | 36 | struct FlowResult { 37 | var bounds = CGSize.zero 38 | var rows = [Row]() 39 | 40 | struct Row { 41 | var range: Range 42 | var xOffsets: [Double] 43 | var frame: CGRect 44 | } 45 | 46 | init(in maxPossibleWidth: Double, subviews: Subviews, alignment: Alignment, spacing: CGFloat?) { 47 | var itemsInRow = 0 48 | var remainingWidth = maxPossibleWidth.isFinite ? maxPossibleWidth : .greatestFiniteMagnitude 49 | var rowMinY = 0.0 50 | var rowHeight = 0.0 51 | var xOffsets: [Double] = [] 52 | for (index, subview) in zip(subviews.indices, subviews) { 53 | let idealSize = subview.sizeThatFits(.unspecified) 54 | if index != 0 && widthInRow(index: index, idealWidth: idealSize.width) > remainingWidth { 55 | finalizeRow(index: max(index - 1, 0), idealSize: idealSize) 56 | } 57 | addToRow(index: index, idealSize: idealSize) 58 | 59 | if index == subviews.count - 1 { 60 | finalizeRow(index: index, idealSize: idealSize) 61 | } 62 | } 63 | 64 | func spacingBefore(index: Int) -> Double { 65 | guard itemsInRow > 0 else { return 0 } 66 | return spacing ?? subviews[index - 1].spacing.distance(to: subviews[index].spacing, along: .horizontal) 67 | } 68 | 69 | func widthInRow(index: Int, idealWidth: Double) -> Double { 70 | idealWidth + spacingBefore(index: index) 71 | } 72 | 73 | func addToRow(index: Int, idealSize: CGSize) { 74 | let width = widthInRow(index: index, idealWidth: idealSize.width) 75 | 76 | xOffsets.append(maxPossibleWidth - remainingWidth + spacingBefore(index: index)) 77 | remainingWidth -= width 78 | rowHeight = max(rowHeight, idealSize.height) 79 | itemsInRow += 1 80 | } 81 | 82 | func finalizeRow(index: Int, idealSize: CGSize) { 83 | let rowWidth = maxPossibleWidth - remainingWidth 84 | rows.append( 85 | Row( 86 | range: index - max(itemsInRow - 1, 0) ..< index + 1, 87 | xOffsets: xOffsets, 88 | frame: CGRect(x: 0, y: rowMinY, width: rowWidth, height: rowHeight) 89 | ) 90 | ) 91 | bounds.width = max(bounds.width, rowWidth) 92 | let ySpacing = spacing ?? ViewSpacing().distance(to: ViewSpacing(), along: .vertical) 93 | bounds.height += rowHeight + (rows.count > 1 ? ySpacing : 0) 94 | rowMinY += rowHeight + ySpacing 95 | itemsInRow = 0 96 | rowHeight = 0 97 | xOffsets.removeAll() 98 | remainingWidth = maxPossibleWidth 99 | } 100 | } 101 | } 102 | } 103 | 104 | private extension HorizontalAlignment { 105 | var percent: Double { 106 | switch self { 107 | case .leading: return 0 108 | case .trailing: return 1 109 | default: return 0.5 110 | } 111 | } 112 | } 113 | 114 | private extension VerticalAlignment { 115 | var percent: Double { 116 | switch self { 117 | case .top: return 0 118 | case .bottom: return 1 119 | default: return 0.5 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Booklog/View/OpacityLevel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum OpacityLevel: CGFloat { 4 | case solid = 1.0 5 | case high = 0.8 6 | case medium = 0.4 7 | case low = 0.2 8 | } 9 | -------------------------------------------------------------------------------- /Booklog/View/SelectTagView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftData 3 | 4 | struct SelectTagView: View { 5 | @Environment(\.modelContext) private var modelContext 6 | @Query(sort: \Tag.createdAt, animation: .smooth) private var tags: [Tag] 7 | 8 | @Binding var book: Book.Entity 9 | @State private var isAddTagViewPresented = false 10 | @State private var isDialogPresented = false 11 | @State private var longPressedTag: Tag.Entity? 12 | @State private var tagToEdit: Tag.Entity? 13 | 14 | var body: some View { 15 | ScrollView { 16 | if tags.isEmpty { 17 | ContentUnavailableView("No tags have been added", image: "tag") 18 | } else { 19 | HStack(spacing: 0) { 20 | TagListView( 21 | tags: tags.map { $0.toEntity() }, 22 | selectedTags: Set(book.tags), 23 | onTapGesture: { tag in 24 | onTapTagGesture(tag) 25 | }, 26 | onLongPressGesture: { tag in 27 | onLongPressGesture(tag) 28 | } 29 | ) 30 | Spacer(minLength: 0) 31 | } 32 | .padding(.horizontal, 6) 33 | } 34 | } 35 | .frame(maxWidth: .infinity, maxHeight: .infinity) 36 | .toolbar { 37 | ToolbarItem(placement: .topBarTrailing) { 38 | Button("", systemImage: "plus") { 39 | isAddTagViewPresented = true 40 | } 41 | } 42 | } 43 | .sheet(isPresented: $isAddTagViewPresented) { 44 | NavigationStack { 45 | AddTagView { _, tag in 46 | addTag(tag) 47 | } 48 | .presentationSizing(.form) 49 | } 50 | } 51 | .sheet(item: $tagToEdit) { tag in 52 | NavigationStack { 53 | AddTagView( 54 | tag: tag, 55 | onTapAddButton: { oldTag, newTag in 56 | if let oldTag { 57 | updateTag(newTag, oldTag: oldTag) 58 | } 59 | } 60 | ) 61 | .presentationDetents([.medium]) 62 | } 63 | } 64 | .confirmationDialog("", isPresented: $isDialogPresented, presenting: longPressedTag) { tag in 65 | Button("Edit") { 66 | tagToEdit = tag 67 | } 68 | Button("Delete", role: .destructive) { 69 | deleteButtonTapped(tag) 70 | } 71 | Button("Cancel", role: .cancel) {} 72 | } 73 | } 74 | 75 | private func deleteButtonTapped(_ tag: Tag.Entity) { 76 | let id = tag.id 77 | try? modelContext.transaction { 78 | try? modelContext.delete( 79 | model: Tag.self, 80 | where: #Predicate { 81 | $0.id == id 82 | } 83 | ) 84 | } 85 | } 86 | 87 | private func onLongPressGesture(_ tag: Tag.Entity) { 88 | isDialogPresented = true 89 | longPressedTag = tag 90 | } 91 | 92 | private func updateTag(_ tag: Tag.Entity, oldTag: Tag.Entity) { 93 | let oldTag = getOriginalTag(oldTag) 94 | 95 | try? modelContext.transaction { 96 | oldTag?.name = tag.name 97 | oldTag?.hexColorString = tag.hexColorString 98 | oldTag?.updatedAt = tag.updatedAt 99 | } 100 | } 101 | 102 | private func onTapTagGesture(_ tag: Tag.Entity) { 103 | if book.tags.contains(tag) { 104 | book.tags.removeAll(where: { $0.id == tag.id }) 105 | book.updatedAt = .now 106 | } else { 107 | book.tags.append(tag) 108 | book.updatedAt = .now 109 | } 110 | } 111 | 112 | private func addTag(_ tag: Tag.Entity) { 113 | try? modelContext.transaction { 114 | modelContext.insert( 115 | Tag( 116 | id: tag.id, 117 | books: [], 118 | name: tag.name, 119 | hexColorString: tag.hexColorString, 120 | createdAt: tag.createdAt, 121 | updatedAt: tag.updatedAt 122 | ) 123 | ) 124 | } 125 | 126 | book.tags.append(tag) 127 | book.updatedAt = .now 128 | } 129 | 130 | private func getOriginalTag(_ entity: Tag.Entity) -> Tag? { 131 | let id = entity.id 132 | return try? modelContext.fetch( 133 | FetchDescriptor(predicate: #Predicate { 134 | $0.id == id 135 | }) 136 | ).first 137 | } 138 | } 139 | 140 | private struct AddTagView: View { 141 | @Environment(\.dismiss) private var dismiss 142 | 143 | @State var tagTitle: String 144 | @State var color: Color 145 | 146 | var oldTag: Tag.Entity? 147 | 148 | init( 149 | tag: Tag.Entity, 150 | onTapAddButton: @escaping (_ oldTag: Tag.Entity?, _ newTag: Tag.Entity) -> Void 151 | ) { 152 | self.oldTag = tag 153 | self.tagTitle = tag.name 154 | self.color = Color(hexString: tag.hexColorString) 155 | self.onTapAddButton = onTapAddButton 156 | } 157 | 158 | init( 159 | tagTitle: String? = nil, 160 | hexColorString: String? = nil, 161 | onTapAddButton: @escaping (_ oldTag: Tag.Entity?, _ newTag: Tag.Entity) -> Void 162 | ) { 163 | self.tagTitle = tagTitle ?? "" 164 | self.onTapAddButton = onTapAddButton 165 | self.color = if let hexColorString { 166 | Color(hexString: hexColorString) 167 | } else { 168 | .random() 169 | } 170 | } 171 | 172 | let onTapAddButton: (_ oldTag: Tag.Entity?, _ newTag: Tag.Entity) -> Void 173 | 174 | var body: some View { 175 | Form { 176 | TextField("", text: $tagTitle, prompt: Text("Enter a tag title")) 177 | ColorPicker("Color", selection: $color, supportsOpacity: false) 178 | } 179 | .toolbar { 180 | ToolbarItem(placement: .topBarTrailing) { 181 | Button("Save") { 182 | addButtonTapped() 183 | } 184 | .disabled(tagTitle.count > 20) 185 | } 186 | } 187 | } 188 | 189 | private func addButtonTapped() { 190 | let now = Date() 191 | let tag = Tag.Entity( 192 | id: UUID(), 193 | name: tagTitle, 194 | hexColorString: color.hexString(), 195 | createdAt: now, 196 | updatedAt: now 197 | ) 198 | onTapAddButton(oldTag, tag) 199 | dismiss() 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Booklog/View/StatusView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SwiftData 3 | import CoreTransferable 4 | 5 | struct StatusView: View { 6 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 7 | @Environment(\.mainWindowSize) private var mainWindowSize 8 | @Environment(\.modelContext) private var modelContext 9 | 10 | @State private var isBarcodeScannerPresented = false 11 | @State private var recognizedIsbn: String? 12 | @State private var isErrorAlertPresented = false 13 | @State private var localizedError: (any LocalizedError)? 14 | @State private var scannedBook: GoogleBooksClient.FormattedResponse? 15 | @State private var isAddBookViewPresented = false 16 | @State private var vstackHeight: CGFloat = 0 17 | @State private var scrollViewHeight: CGFloat = 0 18 | @State private var isRenameStatusNameAlertPresented = false 19 | @State private var newStatusName: String 20 | @State private var isAllDeleting = false 21 | @State private var isColorPalettePresented = false 22 | @State private var isDialogPresented = false 23 | @State private var editingBook: Book? 24 | @State private var deletingBook: Book? 25 | @State private var focusedBook: Book.Entity? 26 | @State private var isConfirmDeleteAlertPresented = false 27 | @State private var isCurrentNumberOfPagesFieldPresented = false 28 | @State private var isStatusDeleting = false 29 | @State private var isBookSearchViewPresented = false 30 | 31 | private let bookClient = BookClient() 32 | private let statusClient = StatusClient() 33 | 34 | let status: Status 35 | 36 | @Query var books: [Book] 37 | 38 | var bookEntities: [Book.Entity] { 39 | books.map { $0.toEntity() } 40 | } 41 | 42 | var newStatusNameOKButtonDisabled: Bool { 43 | status.parentBoard?.status.lazy.map(\.title).contains(newStatusName) ?? false || newStatusName.isEmpty || newStatusName.count > 20 44 | } 45 | 46 | var currentNumberOfPagesFieldDisabled: Bool { 47 | if let readData = focusedBook?.readData { 48 | readData.currentPage > readData.totalPage || 49 | readData.currentPage < 0 50 | } else { 51 | false 52 | } 53 | } 54 | 55 | init(status: Status) { 56 | self.status = status 57 | let id = status.id 58 | _books = Query( 59 | filter: #Predicate { 60 | $0.status.id == id 61 | }, 62 | sort: [ 63 | SortDescriptor(\.priority) 64 | ], 65 | animation: .easeInOut 66 | ) 67 | newStatusName = status.title 68 | } 69 | 70 | var body: some View { 71 | VStack { 72 | header 73 | ScrollView { 74 | LazyVStack { 75 | core 76 | } 77 | .onGeometryChange(for: CGFloat.self) { 78 | $0.size.height 79 | } action: { 80 | vstackHeight = $0 81 | } 82 | .padding(.bottom, horizontalSizeClass == .compact ? 0 : max(0, scrollViewHeight - vstackHeight)) 83 | .frame(width: horizontalSizeClass == .compact ? nil : 350) 84 | .contentShape(Rectangle()) 85 | .dropDestination(for: DraggableData.self) { draggableData, location in 86 | draggableData.lazy.map { 87 | switch $0 { 88 | case .book(let data): 89 | move( 90 | fromBookID: data.bookID, 91 | fromStatusID: data.statusID, 92 | toBookID: bookEntities.last?.id, 93 | toStatusID: status.id, 94 | insertLast: true 95 | ) 96 | case .status(let sourceStatus): 97 | move( 98 | fromStatusID: sourceStatus.statusID, 99 | toStatusID: status.id 100 | ) 101 | } 102 | } 103 | .allSatisfy { $0 } 104 | } 105 | } 106 | .onGeometryChange(for: CGFloat.self) { proxy in 107 | proxy.size.height 108 | } action: { 109 | scrollViewHeight = $0 110 | } 111 | } 112 | .padding(.vertical, 10) 113 | .padding(.horizontal, 6) 114 | .background(Color(hexString: status.hexColorString, opacity: .low)) 115 | .clipShape(RoundedRectangle(cornerRadius: 4)) 116 | .sheet(isPresented: $isBarcodeScannerPresented) { 117 | BarcodeScannerView { barcode in 118 | guard let isbn = barcode.payloadStringValue, !isbn.hasPrefix("192") else { 119 | return 120 | } 121 | recognizedIsbn = isbn 122 | } 123 | .ignoresSafeArea() 124 | } 125 | .task(id: recognizedIsbn) { 126 | defer { recognizedIsbn = nil } 127 | guard let recognizedIsbn else { 128 | return 129 | } 130 | await onRecognize(isbn: recognizedIsbn) 131 | } 132 | .alert("An error has occurred", isPresented: $isErrorAlertPresented, presenting: localizedError) { error in 133 | Button("OK", role: .cancel) { 134 | } 135 | } message: { error in 136 | Text(error.localizedDescription) 137 | } 138 | .sheet(item: $scannedBook) { book in 139 | NavigationStack { 140 | AddBookView(status: status, viewType: .new(.book(book))) 141 | } 142 | } 143 | .sheet(isPresented: $isAddBookViewPresented) { 144 | NavigationStack { 145 | AddBookView(status: status, viewType: .new(.original)) 146 | } 147 | } 148 | .sheet(item: $editingBook) { book in 149 | NavigationStack { 150 | AddBookView(status: status, viewType: .edit(book)) 151 | } 152 | } 153 | .alert("Rename", isPresented: $isRenameStatusNameAlertPresented) { 154 | TextField("Enter a new status name", text: $newStatusName) 155 | Button("Cancel", role: .cancel) {} 156 | Button("OK") { 157 | newStatusNameAlertOKButtonTapped() 158 | } 159 | .disabled(newStatusNameOKButtonDisabled) 160 | } 161 | .alert("Do you really want to delete all of them?", isPresented: $isAllDeleting) { 162 | Button("Yes", role: .destructive) { 163 | deleteAllBooksButtonTapped() 164 | } 165 | } message: { 166 | Text("This action cannot be undone.") 167 | } 168 | .alert("Do you really want to delete this book?", isPresented: $isConfirmDeleteAlertPresented, presenting: deletingBook) { book in 169 | Button("Yes", role: .destructive) { 170 | deleteBookButtonTapped(for: book) 171 | } 172 | } 173 | .alert("Do you really want to delete \"\(status.title)\"?", isPresented: $isStatusDeleting) { 174 | Button("Yes", role: .destructive) { 175 | deleteStatusButtonTapped() 176 | } 177 | } message: { 178 | Text("All books in \"\(status.title)\" will be deleted.") 179 | } 180 | .alert("Enter the current number of pages", isPresented: $isCurrentNumberOfPagesFieldPresented, presenting: focusedBook) { focusedBook in 181 | if let readData = focusedBook.readData { 182 | TextField( 183 | "", 184 | text: Binding( 185 | get: { readData.currentPage.description }, 186 | set: { 187 | if let page = Int($0) { 188 | self.focusedBook?.readData?.currentPage = page 189 | } 190 | } 191 | ) 192 | ) 193 | .keyboardType(.numberPad) 194 | 195 | Button("OK") { 196 | changeCurrentNumberOfPages(of: focusedBook, readData: self.focusedBook?.readData ?? readData) 197 | } 198 | .disabled(currentNumberOfPagesFieldDisabled) 199 | 200 | Button("Cancel", role: .cancel) {} 201 | } 202 | } 203 | .sheet(isPresented: $isColorPalettePresented) { 204 | ColorPickerWellView( 205 | selectedColor: Color(hexString: status.hexColorString), 206 | onColorPicked: { 207 | onColorSelected($0) 208 | } 209 | ) 210 | } 211 | .sheet(isPresented: $isBookSearchViewPresented) { 212 | BookSearchView(status: status) 213 | } 214 | .draggable(StatusDraggableData(statusID: status.id)) 215 | .dropDestination(for: DraggableData.self) { draggableData, location in 216 | draggableData.lazy.map { 217 | switch $0 { 218 | case .book(let data): 219 | move( 220 | fromBookID: data.bookID, 221 | fromStatusID: data.statusID, 222 | toBookID: bookEntities.last?.id, 223 | toStatusID: status.id 224 | ) 225 | case .status(let sourceStatus): 226 | move( 227 | fromStatusID: sourceStatus.statusID, 228 | toStatusID: status.id 229 | ) 230 | } 231 | } 232 | .allSatisfy { $0 } 233 | } 234 | } 235 | 236 | @ViewBuilder 237 | var core: some View { 238 | if bookEntities.isEmpty { 239 | ContentUnavailableView( 240 | "No books have been added to \"\(status.title)\"", 241 | systemImage: "book.closed" 242 | ) 243 | } else { 244 | ForEach(bookEntities) { book in 245 | Button { 246 | isDialogPresented = true 247 | focusedBook = book 248 | } label: { 249 | BookView(book: book) 250 | } 251 | .buttonStyle(.plain) 252 | .contentShape(Rectangle()) 253 | .confirmationDialog("", isPresented: $isDialogPresented, presenting: focusedBook) { bookEntity in 254 | if bookEntity.readData != nil { 255 | Button("Enter the current number of pages") { 256 | isCurrentNumberOfPagesFieldPresented = true 257 | } 258 | } 259 | Button("Edit") { 260 | do { 261 | let book = try bookClient.fetchBook(id: bookEntity.id, modelContext: modelContext) 262 | editingBook = book 263 | } catch {} 264 | } 265 | Button("Delete", role: .destructive) { 266 | do { 267 | let book = try bookClient.fetchBook(id: bookEntity.id, modelContext: modelContext) 268 | isConfirmDeleteAlertPresented = true 269 | deletingBook = book 270 | } catch {} 271 | } 272 | } 273 | .draggable( 274 | BookDraggableData( 275 | bookID: book.id, 276 | statusID: status.id 277 | ) 278 | ) 279 | .dropDestination(for: DraggableData.self) { draggableData, location in 280 | draggableData.lazy.map { 281 | switch $0 { 282 | case .book(let data): 283 | move( 284 | fromBookID: data.bookID, 285 | fromStatusID: data.statusID, 286 | toBookID: book.id, 287 | toStatusID: status.id 288 | ) 289 | case .status(let sourceStatus): 290 | move( 291 | fromStatusID: sourceStatus.statusID, 292 | toStatusID: status.id 293 | ) 294 | } 295 | } 296 | .allSatisfy { $0 } 297 | } 298 | } 299 | } 300 | } 301 | 302 | var header: some View { 303 | HStack { 304 | HStack(spacing: 8) { 305 | Circle() 306 | .fill(Color(hexString: status.hexColorString, opacity: .solid)) 307 | .frame(width: 12, height: 12) 308 | Text(status.title) 309 | .font(.headline) 310 | .lineLimit(2) 311 | .minimumScaleFactor(0.8) 312 | } 313 | .padding(.horizontal, 10) 314 | .padding(.vertical, 4) 315 | .background(Color(hexString: status.hexColorString, opacity: .medium)) 316 | .clipShape(Capsule()) 317 | 318 | Text(bookEntities.count.description) 319 | .font(.headline) 320 | .foregroundStyle(Color(hexString: status.hexColorString, opacity: .medium)) 321 | 322 | Spacer() 323 | 324 | HStack(alignment: .center) { 325 | Menu { 326 | Button("Read barcode", systemImage: "barcode.viewfinder") { 327 | isBarcodeScannerPresented = true 328 | } 329 | Button("Search for books", systemImage: "text.page.badge.magnifyingglass") { 330 | isBookSearchViewPresented = true 331 | } 332 | Button("Add a custom book", systemImage: "book.closed") { 333 | isAddBookViewPresented = true 334 | } 335 | } label: { 336 | Image(systemName: "plus") 337 | .padding(8) 338 | } 339 | 340 | Menu { 341 | Button("Rename", systemImage: "pencil") { 342 | isRenameStatusNameAlertPresented = true 343 | } 344 | Button("Change color theme", systemImage: "paintpalette") { 345 | isColorPalettePresented = true 346 | } 347 | Button("Delete all books", systemImage: "trash", role: .destructive) { 348 | isAllDeleting = true 349 | } 350 | .disabled(books.isEmpty) 351 | Button("Delete \"\(status.title)\"", systemImage: "trash", role: .destructive) { 352 | isStatusDeleting = true 353 | } 354 | } label: { 355 | Image(systemName: "ellipsis") 356 | .padding(8) 357 | } 358 | } 359 | .tint(.primary) 360 | } 361 | } 362 | 363 | private func newStatusNameAlertOKButtonTapped() { 364 | do { 365 | try modelContext.transaction { 366 | status.title = newStatusName 367 | } 368 | newStatusName = status.title 369 | } catch { 370 | showError(error: BooklogError.unknownError) 371 | } 372 | } 373 | 374 | private func move( 375 | fromStatusID sourceStatusID: Status.ID, 376 | toStatusID destinationStatusID: Status.ID 377 | ) -> Bool { 378 | guard sourceStatusID != destinationStatusID else { 379 | return false 380 | } 381 | 382 | do { 383 | let sourceStatus = try statusClient.fetchStatus(id: sourceStatusID, modelContext: modelContext) 384 | let destinationStatus = try statusClient.fetchStatus(id: destinationStatusID, modelContext: modelContext) 385 | 386 | guard let sourceParentBoard = sourceStatus.parentBoard, 387 | let destinationParentBoard = destinationStatus.parentBoard 388 | else { 389 | return false 390 | } 391 | 392 | var sourceStatuses = sourceParentBoard.status.sorted(by: { $0.priority < $1.priority }) 393 | var destinationStatuses = destinationParentBoard.status.sorted(by: { $0.priority < $1.priority }) 394 | 395 | guard let sourceStatusIndex = sourceStatuses.firstIndex(of: sourceStatus), 396 | let destinationStatusIndex = destinationStatuses.firstIndex(of: destinationStatus) 397 | else { 398 | return false 399 | } 400 | 401 | if sourceParentBoard.id == destinationParentBoard.id { 402 | sourceStatuses.remove(at: sourceStatusIndex) 403 | sourceStatuses.insert(sourceStatus, at: destinationStatusIndex) 404 | 405 | try modelContext.transaction { 406 | for (index, status) in sourceStatuses.enumerated() { 407 | status.priority = index 408 | } 409 | } 410 | } else { 411 | try modelContext.transaction { 412 | sourceStatuses.remove(at: sourceStatusIndex) 413 | destinationStatuses.insert(sourceStatus, at: destinationStatusIndex) 414 | 415 | sourceStatus.parentBoard = destinationStatus.parentBoard 416 | 417 | for (index, status) in sourceStatuses.enumerated() { 418 | status.priority = index 419 | } 420 | for (index, status) in destinationStatuses.enumerated() { 421 | status.priority = index 422 | } 423 | } 424 | } 425 | return true 426 | } catch { 427 | return false 428 | } 429 | } 430 | 431 | private func move( 432 | fromBookID sourceBookID: Book.ID, 433 | fromStatusID sourceStatusID: Status.ID, 434 | toBookID destinationBookID: Book.ID?, 435 | toStatusID destinationStatusID: Status.ID, 436 | insertLast: Bool = false 437 | ) -> Bool { 438 | guard sourceBookID != destinationBookID else { 439 | return false 440 | } 441 | 442 | do { 443 | if sourceStatusID == destinationStatusID { 444 | var bookEntities = try fetchBooks() 445 | guard let sourceBookIndex = bookEntities.firstIndex(where: { $0.id == sourceBookID }), 446 | let destinationBookIndex = bookEntities.firstIndex(where: { $0.id == destinationBookID }) 447 | else { 448 | return false 449 | } 450 | try modelContext.transaction { 451 | let sourceBook = bookEntities[sourceBookIndex] 452 | bookEntities.remove(at: sourceBookIndex) 453 | if insertLast { 454 | bookEntities.append(sourceBook) 455 | } else { 456 | bookEntities.insert(sourceBook, at: destinationBookIndex) 457 | } 458 | bookEntities.enumerated().forEach { index, book in 459 | book.priority = index 460 | } 461 | } 462 | } else if destinationBookID == nil { 463 | let book = try bookClient.fetchBook(id: sourceBookID, modelContext: modelContext) 464 | let status = try statusClient.fetchStatus(id: destinationStatusID, modelContext: modelContext) 465 | try modelContext.transaction { 466 | book.status = status 467 | book.priority = 0 468 | 469 | let bookEntities = try fetchBooks() 470 | bookEntities.enumerated().forEach { index, book in 471 | book.priority = index 472 | } 473 | } 474 | } else { 475 | var bookEntities = try fetchBooks(for: sourceStatusID) 476 | var destinationStatusBooks = try fetchBooks(for: destinationStatusID) 477 | let destinationStatus = try statusClient.fetchStatus(id: destinationStatusID, modelContext: modelContext) 478 | guard let sourceBookIndex = bookEntities.firstIndex(where: { $0.id == sourceBookID }), 479 | let destinationBookIndex = destinationStatusBooks.firstIndex(where: { $0.id == destinationBookID }) 480 | else { 481 | return false 482 | } 483 | let sourceBook = bookEntities[sourceBookIndex] 484 | try modelContext.transaction { 485 | sourceBook.status = destinationStatus 486 | bookEntities.remove(at: sourceBookIndex) 487 | bookEntities.enumerated().forEach { index, book in 488 | book.priority = index 489 | } 490 | if insertLast { 491 | destinationStatusBooks.append(sourceBook) 492 | } else { 493 | destinationStatusBooks.insert(sourceBook, at: destinationBookIndex) 494 | } 495 | destinationStatusBooks.enumerated().forEach { index, book in 496 | book.priority = index 497 | } 498 | } 499 | } 500 | return true 501 | } catch { 502 | return false 503 | } 504 | } 505 | 506 | private func fetchBooks() throws -> [Book] { 507 | let id = status.id 508 | return try bookClient.fetchBooks(for: id, modelContext: modelContext) 509 | } 510 | 511 | private func fetchBooks(for statusID: Status.ID) throws -> [Book] { 512 | return try bookClient.fetchBooks(for: statusID, modelContext: modelContext) 513 | } 514 | 515 | private func onRecognize(isbn: String) async { 516 | do { 517 | let book = try await GoogleBooksClient().getBook(isbn: isbn) 518 | isBarcodeScannerPresented = false 519 | scannedBook = book 520 | } catch let error as GoogleBooksClient.Error { 521 | showError(error: error) 522 | } catch { 523 | showError(error: BooklogError.requestError) 524 | } 525 | } 526 | 527 | private func showError(error: any LocalizedError) { 528 | isErrorAlertPresented = true 529 | localizedError = error 530 | } 531 | 532 | private func deleteAllBooksButtonTapped() { 533 | let statusID = status.id 534 | do { 535 | try modelContext.transaction { 536 | let booksToDelete = try modelContext.fetch( 537 | FetchDescriptor( 538 | predicate: #Predicate { $0.status.id == statusID } 539 | ) 540 | ) 541 | 542 | for book in booksToDelete { 543 | modelContext.delete(book) 544 | } 545 | } 546 | } catch { 547 | showError(error: BooklogError.unknownError) 548 | } 549 | } 550 | 551 | private func deleteBookButtonTapped(for book: Book) { 552 | do { 553 | try modelContext.transaction { 554 | modelContext.delete(book) 555 | } 556 | } catch { 557 | showError(error: BooklogError.unknownError) 558 | } 559 | } 560 | 561 | private func changeCurrentNumberOfPages(of focusedBook: Book.Entity, readData: Book.ReadData) { 562 | do { 563 | try modelContext.transaction { 564 | let book = try bookClient.fetchBook(id: focusedBook.id, modelContext: modelContext) 565 | book.readData = readData 566 | } 567 | } catch { 568 | showError(error: BooklogError.unknownError) 569 | } 570 | self.focusedBook = nil 571 | } 572 | 573 | private func deleteStatusButtonTapped() { 574 | do { 575 | try modelContext.transaction { 576 | modelContext.delete(status) 577 | } 578 | } catch { 579 | showError(error: BooklogError.unknownError) 580 | } 581 | } 582 | 583 | private func onColorSelected(_ uiColor: UIColor) { 584 | do { 585 | try modelContext.transaction { 586 | status.hexColorString = uiColor.hexString() 587 | } 588 | } catch { 589 | showError(error: BooklogError.unknownError) 590 | } 591 | } 592 | 593 | struct BookDraggableData: Transferable, Codable { 594 | let bookID: Book.ID 595 | let statusID: Status.ID 596 | 597 | static var transferRepresentation: some TransferRepresentation { 598 | CodableRepresentation(for: BookDraggableData.self, contentType: .book) 599 | } 600 | } 601 | 602 | struct StatusDraggableData: Transferable, Codable { 603 | let statusID: Status.ID 604 | 605 | static var transferRepresentation: some TransferRepresentation { 606 | CodableRepresentation(for: StatusDraggableData.self, contentType: .status) 607 | } 608 | } 609 | 610 | enum DraggableData: Transferable { 611 | case book(BookDraggableData) 612 | case status(StatusDraggableData) 613 | 614 | static var transferRepresentation: some TransferRepresentation { 615 | ProxyRepresentation(importing: { DraggableData.book($0) }) 616 | ProxyRepresentation(importing: { DraggableData.status($0) }) 617 | } 618 | } 619 | } 620 | -------------------------------------------------------------------------------- /Booklog/View/TagListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TagListView: View { 4 | let tags: [Tag.Entity] 5 | let selectedTags: Set 6 | 7 | let onTapGesture: ((Tag.Entity) -> Void)? 8 | let onLongPressGesture: ((Tag.Entity) -> Void)? 9 | 10 | init( 11 | tags: [Tag.Entity], 12 | selectedTags: Set = [], 13 | onTapGesture: ((Tag.Entity) -> Void)? = nil, 14 | onLongPressGesture: ((Tag.Entity) -> Void)? = nil 15 | ) { 16 | self.tags = tags 17 | self.selectedTags = selectedTags 18 | self.onTapGesture = onTapGesture 19 | self.onLongPressGesture = onLongPressGesture 20 | } 21 | 22 | var body: some View { 23 | FlowLayout(alignment: .leading, spacing: 7) { 24 | ForEach(tags) { tag in 25 | Text(tag.name) 26 | .foregroundStyle(Color(hexString: tag.hexColorString, opacity: 1)) 27 | .padding(.vertical, 5) 28 | .padding(.horizontal, 12) 29 | .background(Color(hexString: tag.hexColorString, opacity: 0.2)) 30 | .cornerRadius(15) 31 | .clipShape(Capsule()) 32 | .overlay { 33 | Capsule() 34 | .stroke(Color(hexString: tag.hexColorString, opacity: selectedTags.contains(tag) ? 1 : 0), lineWidth: 1) 35 | } 36 | .onTapGesture { 37 | onTapGesture?(tag) 38 | } 39 | .onLongPressGesture { 40 | onLongPressGesture?(tag) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /BooklogTests/BooklogTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import Booklog 3 | 4 | struct BooklogTests { 5 | 6 | @Test func example() async throws { 7 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Booklog 2 | Simple, lightweight book management app. 3 | 4 | # Screenshots 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | # Installation 13 | [![Image](https://github.com/user-attachments/assets/04178000-7b0f-452a-a420-f1020dd32d5a)](https://apps.apple.com/us/app/id6738736445) 14 | 15 | -------------------------------------------------------------------------------- /docs/privacy-policy/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Privacy Policy 7 | 8 | 9 | 10 | Privacy Policy

This privacy policy applies to the Booklog app (hereby referred to as "Application") for mobile devices that was created by Ryunosuke Shibuya (hereby referred to as "Service Provider") as an Open Source service. This service is intended for use "AS IS".


Information Collection and Use

The Application collects information when you download and use it. This information may include information such as

  • Your device's Internet Protocol address (e.g. IP address)
  • The pages of the Application that you visit, the time and date of your visit, the time spent on those pages
  • The time spent on the Application
  • The operating system you use on your mobile device


The Application does not gather precise information about the location of your mobile device.

The Application collects your device's location, which helps the Service Provider determine your approximate geographical location and make use of in below ways:

  • Geolocation Services: The Service Provider utilizes location data to provide features such as personalized content, relevant recommendations, and location-based services.
  • Analytics and Improvements: Aggregated and anonymized location data helps the Service Provider to analyze user behavior, identify trends, and improve the overall performance and functionality of the Application.
  • Third-Party Services: Periodically, the Service Provider may transmit anonymized location data to external services. These services assist them in enhancing the Application and optimizing their offerings.

The Service Provider may use the information you provided to contact you from time to time to provide you with important information, required notices and marketing promotions.


For a better experience, while using the Application, the Service Provider may require you to provide us with certain personally identifiable information. The information that the Service Provider request will be retained by them and used as described in this privacy policy.


Third Party Access

Only aggregated, anonymized data is periodically transmitted to external services to aid the Service Provider in improving the Application and their service. The Service Provider may share your information with third parties in the ways that are described in this privacy statement.


The Service Provider may disclose User Provided and Automatically Collected Information:

  • as required by law, such as to comply with a subpoena, or similar legal process;
  • when they believe in good faith that disclosure is necessary to protect their rights, protect your safety or the safety of others, investigate fraud, or respond to a government request;
  • with their trusted services providers who work on their behalf, do not have an independent use of the information we disclose to them, and have agreed to adhere to the rules set forth in this privacy statement.


Opt-Out Rights

You can stop all collection of information by the Application easily by uninstalling it. You may use the standard uninstall processes as may be available as part of your mobile device or via the mobile application marketplace or network.


Data Retention Policy

The Service Provider will retain User Provided data for as long as you use the Application and for a reasonable time thereafter. If you'd like them to delete User Provided Data that you have provided via the Application, please contact them at ryu.apps.info@gmail.com and they will respond in a reasonable time.


Children

The Service Provider does not use the Application to knowingly solicit data from or market to children under the age of 13.


The Service Provider does not knowingly collect personally 11 | identifiable information from children. The Service Provider 12 | encourages all children to never submit any personally 13 | identifiable information through the Application and/or Services. 14 | The Service Provider encourage parents and legal guardians to monitor 15 | their children's Internet usage and to help enforce this Policy by instructing 16 | their children never to provide personally identifiable information through the Application and/or Services without their permission. If you have reason to believe that a child 17 | has provided personally identifiable information to the Service Provider through the Application and/or Services, 18 | please contact the Service Provider (ryu.apps.info@gmail.com) so that they will be able to take the necessary actions. 19 | You must also be at least 16 years of age to consent to the processing 20 | of your personally identifiable information in your country (in some countries we may allow your parent 21 | or guardian to do so on your behalf).


Security

The Service Provider is concerned about safeguarding the confidentiality of your information. The Service Provider provides physical, electronic, and procedural safeguards to protect information the Service Provider processes and maintains.


Changes

This Privacy Policy may be updated from time to time for any reason. The Service Provider will notify you of any changes to the Privacy Policy by updating this page with the new Privacy Policy. You are advised to consult this Privacy Policy regularly for any changes, as continued use is deemed approval of all changes.


This privacy policy is effective as of 2024-11-29


Your Consent

By using the Application, you are consenting to the processing of your information as set forth in this Privacy Policy now and as amended by us.


Contact Us

If you have any questions regarding privacy while using the Application, or have questions about the practices, please contact the Service Provider via email at ryu.apps.info@gmail.com.


This privacy policy page was generated by App Privacy Policy Generator

22 | 23 | 24 | 25 | --------------------------------------------------------------------------------