diff --git a/Config/App-Info.plist b/Config/App-Info.plist index 155c3e2..735d16a 100644 --- a/Config/App-Info.plist +++ b/Config/App-Info.plist @@ -30,7 +30,7 @@ CFBundleVersion - 2 + $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS NSLocalNetworkUsageDescription @@ -41,8 +41,13 @@ FuelBoard uses your location to find the cheapest nearby petrol stations. NSSupportsLiveActivities + BGTaskSchedulerPermittedIdentifiers + + com.apt.fuelboard.smart-refresh + UIBackgroundModes + fetch location UILaunchScreen diff --git a/Config/WatchApp.entitlements b/Config/WatchApp.entitlements new file mode 100644 index 0000000..3f71578 --- /dev/null +++ b/Config/WatchApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.apt.fuelboard + + + diff --git a/Config/WatchExtension-Info.plist b/Config/WatchExtension-Info.plist new file mode 100644 index 0000000..d182095 --- /dev/null +++ b/Config/WatchExtension-Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + FuelBoardWatch + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + WKAppBundleIdentifier + com.apt.fuelboard.watchapp + + NSExtensionPointIdentifier + com.apple.watchkit + + + diff --git a/Config/WatchWrapper-Info.plist b/Config/WatchWrapper-Info.plist new file mode 100644 index 0000000..5e79dfc --- /dev/null +++ b/Config/WatchWrapper-Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + FuelBoard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + FuelBoard + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + WKCompanionAppBundleIdentifier + com.apt.fuelboard + WKWatchKitApp + + + diff --git a/Config/Widget-Info.plist b/Config/Widget-Info.plist index e7dbaa0..6470333 100644 --- a/Config/Widget-Info.plist +++ b/Config/Widget-Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.1 + $(MARKETING_VERSION) CFBundleVersion - 2 + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index e4ec4d9..e796819 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -1,7 +1,4 @@ // !$*UTF8*$! -// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders). -// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded). -// Shared/ is a synchronized root group included in BOTH targets. { archiveVersion = 1; classes = { @@ -10,10 +7,26 @@ objects = { /* Begin PBXBuildFile section */ + ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + ADCE5B643033A32D008C131C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BB0000000000000000000004 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ADCE5B4E3033A329008C131C; + remoteInfo = "FuelBoardWatch Watch App Extension"; + }; + ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BB0000000000000000000004 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ADCE5B673033A32D008C131C; + remoteInfo = "FuelBoardWatch Watch App"; + }; BB0000000000000000000003 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BB0000000000000000000004 /* Project object */; @@ -24,6 +37,28 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + ADCE5B653033A32D008C131C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; + ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; BB0000000000000000000006 /* Embed App Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -38,11 +73,23 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "FuelBoardWatch Watch App"; + sourceTree = ""; + }; + ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "FuelBoardWatch Wrapper"; + sourceTree = ""; + }; BB0000000000000000000008 /* FuelBoard */ = { isa = PBXFileSystemSynchronizedRootGroup; path = FuelBoard; @@ -61,6 +108,20 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + ADCE5B4C3033A329008C131C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ADCE5B6D3033A32E008C131C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; BB000000000000000000000B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -84,6 +145,8 @@ BB0000000000000000000008 /* FuelBoard */, BB0000000000000000000009 /* FuelBoardWidgets */, BB000000000000000000000A /* Shared */, + ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */, + ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */, BB000000000000000000000E /* Products */, ); sourceTree = ""; @@ -93,6 +156,8 @@ children = ( BB0000000000000000000007 /* FuelBoard.app */, BB0000000000000000000002 /* FuelBoardWidgets.appex */, + ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */, + ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */, ); name = Products; sourceTree = ""; @@ -100,30 +165,50 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - BB000000000000000000000F /* FuelBoard */ = { + ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = { isa = PBXNativeTarget; - buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */; + buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */; buildPhases = ( - BB0000000000000000000011 /* Sources */, - BB000000000000000000000B /* Frameworks */, - BB0000000000000000000012 /* Resources */, - BB0000000000000000000006 /* Embed App Extensions */, + ADCE5B4B3033A329008C131C /* Sources */, + ADCE5B4C3033A329008C131C /* Frameworks */, + ADCE5B4D3033A329008C131C /* Resources */, ); buildRules = ( ); dependencies = ( - BB0000000000000000000013 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - BB0000000000000000000008 /* FuelBoard */, - BB000000000000000000000A /* Shared */, + ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */, ); - name = FuelBoard; + name = "FuelBoardWatch Watch App Extension"; packageProductDependencies = ( ); - productName = FuelBoard; - productReference = BB0000000000000000000007 /* FuelBoard.app */; - productType = "com.apple.product-type.application"; + productName = "FuelBoardWatch Watch App Extension"; + productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; + productType = "com.apple.product-type.watchkit2-extension"; + }; + ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */; + buildPhases = ( + ADCE5B6E3033A32E008C131C /* Resources */, + ADCE5B653033A32D008C131C /* Embed App Extensions */, + ADCE5B6D3033A32E008C131C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ADCE5B683033A32D008C131C /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */, + ); + name = "FuelBoardWatch Watch App"; + packageProductDependencies = ( + ); + productName = "FuelBoardWatch Watch App"; + productReference = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; + productType = "com.apple.product-type.application.watchapp2"; }; BB0000000000000000000005 /* FuelBoardWidgets */ = { isa = PBXNativeTarget; @@ -148,6 +233,33 @@ productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */; productType = "com.apple.product-type.app-extension"; }; + BB000000000000000000000F /* FuelBoard */ = { + isa = PBXNativeTarget; + buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */; + buildPhases = ( + BB0000000000000000000011 /* Sources */, + BB000000000000000000000B /* Frameworks */, + BB0000000000000000000012 /* Resources */, + ADCE5B5B3033A32C008C131C /* Embed Watch Content */, + BB0000000000000000000006 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + ADCE5B703033A32F008C131C /* PBXTargetDependency */, + BB0000000000000000000013 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BB0000000000000000000008 /* FuelBoard */, + BB000000000000000000000A /* Shared */, + ); + name = FuelBoard; + packageProductDependencies = ( + ); + productName = FuelBoard; + productReference = BB0000000000000000000007 /* FuelBoard.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -155,15 +267,18 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1600; + LastSwiftUpdateCheck = 2630; LastUpgradeCheck = 1600; TargetAttributes = { - BB000000000000000000000F = { - CreatedOnToolsVersion = 16.0; + ADCE5B4E3033A329008C131C = { + CreatedOnToolsVersion = 26.3; }; BB0000000000000000000005 = { CreatedOnToolsVersion = 16.0; }; + BB000000000000000000000F = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */; @@ -182,11 +297,27 @@ targets = ( BB000000000000000000000F /* FuelBoard */, BB0000000000000000000005 /* FuelBoardWidgets */, + ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */, + ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + ADCE5B4D3033A329008C131C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ADCE5B6E3033A32E008C131C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; BB0000000000000000000012 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -204,6 +335,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + ADCE5B4B3033A329008C131C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; BB0000000000000000000011 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -221,6 +359,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + ADCE5B683033A32D008C131C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */; + targetProxy = ADCE5B643033A32D008C131C /* PBXContainerItemProxy */; + }; + ADCE5B703033A32F008C131C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */; + targetProxy = ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */; + }; BB0000000000000000000013 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = BB0000000000000000000005 /* FuelBoardWidgets */; @@ -229,6 +377,266 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + ADCE5B573033A32B008C131C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = F3BE6NE7U3; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; + INFOPLIST_FILE = Config/WatchExtension-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.1; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + ADCE5B583033A32B008C131C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = F3BE6NE7U3; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; + INFOPLIST_FILE = Config/WatchExtension-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.1; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; + ADCE5B6B3033A32D008C131C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = F3BE6NE7U3; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/WatchWrapper-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.1; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + ADCE5B6C3033A32D008C131C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 3; + DEVELOPMENT_TEAM = F3BE6NE7U3; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Config/WatchWrapper-Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0.1; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; BB0000000000000000000018 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -291,15 +699,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Config/App-Info.plist; + INFOPLIST_FILE = "Config/App-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -314,15 +722,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Config/App-Info.plist; + INFOPLIST_FILE = "Config/App-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -337,16 +745,16 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Config/Widget-Info.plist; + INFOPLIST_FILE = "Config/Widget-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -362,16 +770,16 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Config/Widget-Info.plist; + INFOPLIST_FILE = "Config/Widget-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -384,11 +792,20 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = { + ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = { isa = XCConfigurationList; buildConfigurations = ( - BB0000000000000000000018 /* Debug */, - BB0000000000000000000019 /* Release */, + ADCE5B573033A32B008C131C /* Debug */, + ADCE5B583033A32B008C131C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ADCE5B6B3033A32D008C131C /* Debug */, + ADCE5B6C3033A32D008C131C /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -411,6 +828,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BB0000000000000000000018 /* Debug */, + BB0000000000000000000019 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = BB0000000000000000000004 /* Project object */; diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json index 4f4b8fb..9c95f5f 100644 --- a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,38 +1,170 @@ { - "images" : [ + "images": [ { - "filename" : "AppIcon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "AppIcon.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "dark" + "appearance": "luminosity", + "value": "dark" } ], - "filename" : "AppIcon-dark.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "AppIcon-dark.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" }, { - "appearances" : [ + "appearances": [ { - "appearance" : "luminosity", - "value" : "tinted" + "appearance": "luminosity", + "value": "tinted" } ], - "filename" : "AppIcon-tinted.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" + "filename": "AppIcon-tinted.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "filename": "Watch-NotificationCenter-48.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "Watch-NotificationCenter-55.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "Watch-NotificationCenter-66.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "33x33", + "subtype": "45mm" + }, + { + "filename": "Watch-CompanionSettings-58.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "Watch-CompanionSettings-87.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "Watch-AppLauncher-80.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "Watch-AppLauncher-88.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "Watch-AppLauncher-92.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "46x46", + "subtype": "41mm" + }, + { + "filename": "Watch-AppLauncher-100.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "filename": "Watch-AppLauncher-102.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "51x51", + "subtype": "45mm" + }, + { + "filename": "Watch-AppLauncher-108.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "54x54", + "subtype": "49mm" + }, + { + "filename": "Watch-QuickLook-172.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "Watch-QuickLook-196.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "Watch-QuickLook-216.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, + { + "filename": "Watch-QuickLook-234.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "117x117", + "subtype": "45mm" + }, + { + "filename": "Watch-QuickLook-258.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "129x129", + "subtype": "49mm" + }, + { + "filename": "Watch-Marketing-1024.png", + "idiom": "watch-marketing", + "scale": "1x", + "size": "1024x1024" } ], - "info" : { - "author" : "xcode", - "version" : 1 + "info": { + "author": "xcode", + "version": 1 } } diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png new file mode 100644 index 0000000..917f7f0 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png new file mode 100644 index 0000000..9c7dfe4 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png new file mode 100644 index 0000000..d935aa4 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png new file mode 100644 index 0000000..2e81dba Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png new file mode 100644 index 0000000..2d702d0 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png new file mode 100644 index 0000000..da19a74 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png new file mode 100644 index 0000000..50ce1da Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png new file mode 100644 index 0000000..e2fab47 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png new file mode 100644 index 0000000..df5922d Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png new file mode 100644 index 0000000..529f72f Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png new file mode 100644 index 0000000..586f7de Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png new file mode 100644 index 0000000..e1fe95f Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png new file mode 100644 index 0000000..e7d00fa Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png new file mode 100644 index 0000000..02a4a59 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png new file mode 100644 index 0000000..90cd85d Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png new file mode 100644 index 0000000..5a3d0ec Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png differ diff --git a/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png new file mode 100644 index 0000000..6fd1854 Binary files /dev/null and b/FuelBoard/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png differ diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 07f98ae..dc9b915 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -16,6 +16,7 @@ struct ContentView: View { @State private var stations: [FuelStation] = FuelStore.loadStations() @State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel() + @State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode() @State private var sortMode: SortMode = FuelStore.loadSortMode() @State private var stationLimit: Int = FuelStore.loadStationLimit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @@ -275,6 +276,7 @@ struct ContentView: View { } if !favs.isEmpty { FuelStore.saveFavourites(favs) + WatchSyncManager.shared.pushSnapshot() } } if let i = args.firstIndex(of: "-tab"), i + 1 < args.count { @@ -384,7 +386,10 @@ struct ContentView: View { fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) updateLiveActivity() refreshRoadDistancesIfNeeded() - // No network fetch on foreground — pull-to-refresh is the override. + if FuelStore.hasPendingWatchRefreshRequest { + Task { await handlePendingWatchRefreshRequest() } + } + // No network fetch on foreground unless the watch explicitly requests one. } else { locationManager.stopForegroundTracking() } @@ -393,6 +398,7 @@ struct ContentView: View { if let newLocation { location = newLocation FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng) + WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() // Geofences follow the user's position, but the station list is // NOT re-fetched on every movement (cached, twice-a-day policy). @@ -411,6 +417,7 @@ struct ContentView: View { .onChange(of: selectedFuel) { _, _ in // No re-fetch needed — one response carries E5/E10/DIESEL prices. WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() } .onChange(of: stationLimit) { _, newValue in // Distance filter is LOCAL math now — the cache holds the full-UK @@ -787,6 +794,7 @@ struct ContentView: View { tipStore: tipStore, distanceUnit: $distanceUnit, priceDisplayStyle: $priceDisplayStyle, + dataRefreshMode: $dataRefreshMode, alertsFuel: alertsFuel, alertsRadiusKM: alertsRadius, testAlertResult: monitor.lastTestResult, @@ -815,6 +823,7 @@ struct ContentView: View { favourites.append(key) } FuelStore.saveFavourites(favourites) + WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) @@ -826,11 +835,17 @@ struct ContentView: View { private func reorderFavourites(_ newOrder: [FavouriteEntry]) { favourites = newOrder FuelStore.saveFavourites(favourites) + WatchSyncManager.shared.pushSnapshot() WidgetCenter.shared.reloadAllTimelines() monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) } + private func handlePendingWatchRefreshRequest() async { + guard FuelStore.hasPendingWatchRefreshRequest else { return } + await refresh(force: true) + } + /// Fetches fresh prices, but only when the cache is stale — unless /// `force` is true (pull-to-refresh is the manual override). private func refresh(force: Bool = false) async { @@ -849,6 +864,10 @@ struct ContentView: View { stations = fetched FuelStore.saveStations(fetched) FuelStore.saveLastRefresh() + if force, FuelStore.hasPendingWatchRefreshRequest { + FuelStore.markWatchRefreshHandled() + } + WatchSyncManager.shared.pushSnapshot() // Persist envelope metadata (source, station count, GOV.UK // dataset update time) for the Settings → About section — the // live chain records whichever leg served the fetch. @@ -871,6 +890,9 @@ struct ContentView: View { // Live data restored — any status banner no longer applies. dataStatus = .live } catch { + if force, FuelStore.hasPendingWatchRefreshRequest { + FuelStore.markWatchRefreshHandled() + } statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data." if FuelStore.loadStations().isEmpty { // No cached prices — last resort is the bundled REAL dump diff --git a/FuelBoard/FuelBoardApp.swift b/FuelBoard/FuelBoardApp.swift index bd508f7..c8ed83d 100644 --- a/FuelBoard/FuelBoardApp.swift +++ b/FuelBoard/FuelBoardApp.swift @@ -1,9 +1,11 @@ import ActivityKit +import BackgroundTasks import SwiftUI @main struct FuelBoardApp: App { init() { + WatchSyncManager.shared.activate() #if DEBUG // QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a // Live Activity with a long station name so the Lock Screen / island @@ -21,6 +23,12 @@ struct FuelBoardApp: App { WindowGroup { ContentView() .onOpenURL(perform: handleOpenURL) + .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in + WatchSyncManager.shared.pushSnapshot() + } + } + .backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) { + await SmartDataRefreshCoordinator.runBackgroundProbe() } } diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 3f0744f..7fe3080 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -18,6 +18,7 @@ struct SettingsView: View { @ObservedObject var tipStore: TipStore @Binding var distanceUnit: DistanceUnit @Binding var priceDisplayStyle: PriceDisplayStyle + @Binding var dataRefreshMode: DataRefreshMode /// The fuel + radius currently configured for alerts (mirrors the Alerts /// tab) so the test notification matches what real alerts will say. var alertsFuel: FuelType = .e10 @@ -103,6 +104,7 @@ struct SettingsView: View { .onChange(of: distanceUnit) { _, newValue in FuelStore.saveDistanceUnit(newValue) WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() } Picker("Price display", selection: $priceDisplayStyle) { ForEach(PriceDisplayStyle.allCases) { style in @@ -113,6 +115,7 @@ struct SettingsView: View { .onChange(of: priceDisplayStyle) { _, newValue in FuelStore.savePriceDisplayStyle(newValue) WidgetCenter.shared.reloadAllTimelines() + WatchSyncManager.shared.pushSnapshot() } } header: { Text("Units") @@ -120,6 +123,30 @@ struct SettingsView: View { Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).") } + Section { + Picker("Data checking", selection: $dataRefreshMode) { + ForEach(DataRefreshMode.allCases) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(.segmented) + .onChange(of: dataRefreshMode) { _, newValue in + FuelStore.saveDataRefreshMode(newValue) + SmartDataRefreshScheduler.scheduleNextIfNeeded() + WatchSyncManager.shared.pushSnapshot() + } + VStack(alignment: .leading, spacing: 8) { + Text(dataRefreshMode.summary) + Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.") + .foregroundStyle(.secondary) + } + .font(.footnote) + } header: { + Text("Data checking") + } footer: { + Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.") + } + Section { Button { onShowOnboarding() diff --git a/FuelBoard/SmartDataRefresh.swift b/FuelBoard/SmartDataRefresh.swift new file mode 100644 index 0000000..8d83d2a --- /dev/null +++ b/FuelBoard/SmartDataRefresh.swift @@ -0,0 +1,89 @@ +import BackgroundTasks +import Foundation +import WidgetKit + +enum SmartDataRefreshScheduler { + static let taskIdentifier = "com.apt.fuelboard.smart-refresh" + + static func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let task = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + handle(task) + } + } + + static func scheduleNextIfNeeded() { + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier) + guard FuelStore.loadDataRefreshMode() == .smart else { return } + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + #if DEBUG + print("SMART-REFRESH schedule failed: \(error)") + #endif + } + } + + private static func handle(_ task: BGAppRefreshTask) { + scheduleNextIfNeeded() + let refreshTask = Task { + let success = await SmartDataRefreshCoordinator.runBackgroundProbe() + task.setTaskCompleted(success: success) + } + task.expirationHandler = { + refreshTask.cancel() + } + } +} + +enum SmartDataRefreshCoordinator { + @discardableResult + static func runBackgroundProbe(now: Date = Date()) async -> Bool { + guard FuelStore.loadDataRefreshMode() == .smart else { return true } + guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else { + return true + } + FuelStore.saveLastSmartProbe(now) + guard !Task.isCancelled else { return false } + + guard let latest = await FuelHistoryStore.fetchLatest() else { + return false + } + + let latestDay = latest.availableTo ?? latest.date + let latestUpdated = latest.dataUpdated + let cachedDay = MirrorFuelProvider.loadDumpCache()?.day + let savedUpdated = FuelStore.loadDataUpdated() + let shouldFetchFullDump = FuelStore.loadStations().isEmpty + || !FuelStore.isCacheFresh(now: now) + || (latestDay != nil && latestDay != cachedDay) + || (latestUpdated != nil && latestUpdated != savedUpdated) + + guard shouldFetchFullDump, !Task.isCancelled else { return true } + + do { + let fetched = try await FuelPriceProvider.active.fetchStations( + near: nil, + lng: nil, + fuel: FuelStore.loadSelectedFuel(), + radiusKM: nil + ) + guard !Task.isCancelled else { return false } + FuelStore.saveStations(fetched) + FuelStore.saveLastRefresh(now) + await WatchSyncManager.shared.pushSnapshot() + if let meta = LiveChainProvider.latestMeta { + FuelStore.saveRelayMeta(meta) + } + WidgetCenter.shared.reloadAllTimelines() + return true + } catch { + return false + } + } +} diff --git a/FuelBoard/WatchSyncManager.swift b/FuelBoard/WatchSyncManager.swift new file mode 100644 index 0000000..5053eb0 --- /dev/null +++ b/FuelBoard/WatchSyncManager.swift @@ -0,0 +1,117 @@ +import Foundation +import WatchConnectivity + +extension Notification.Name { + static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested") +} + +@MainActor +final class WatchSyncManager: NSObject, WCSessionDelegate { + static let shared = WatchSyncManager() + + private var pendingSnapshotPush = false + + private override init() { + super.init() + } + + func activate() { + guard WCSession.isSupported() else { return } + pendingSnapshotPush = true + let session = WCSession.default + if session.delegate !== self { + session.delegate = self + } + session.activate() + } + + func pushSnapshot() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + guard session.activationState == .activated else { + pendingSnapshotPush = true + return + } + do { + try session.updateApplicationContext(snapshotContext()) + pendingSnapshotPush = false + } catch { + print("WATCH-SYNC push failed: \(error.localizedDescription)") + } + } + + private func snapshotContext() -> [String: Any] { + var context: [String: Any] = [:] + + if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) { + context[FuelStore.favouritesKey] = favourites + } + // Do NOT send the full station dump to watchOS via WatchConnectivity. + // The UK dataset is megabytes large and exceeds application-context + // payload limits, which prevents any snapshot from arriving. The + // favourites payload already carries station snapshots + prices, so the + // watch can render favourites without the full list. + + context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue + context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue + context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue + + if let lastRefresh = FuelStore.loadLastRefresh() { + context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970) + } + + if let handled = FuelStore.loadWatchRefreshHandled() { + context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970) + } + + if let location = FuelStore.loadLocationWithDate() { + context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)" + } + + return context + } + + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + if let error { + print("WATCH-SYNC activation failed: \(error.localizedDescription)") + return + } + Task { @MainActor in + if activationState == .activated, self.pendingSnapshotPush { + self.pushSnapshot() + } + } + } + + nonisolated func sessionDidBecomeInactive(_ session: WCSession) {} + + nonisolated func sessionDidDeactivate(_ session: WCSession) { + Task { @MainActor in + WCSession.default.activate() + } + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + handleIncomingMessage(userInfo) + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { + handleIncomingMessage(message) + } + + private nonisolated func handleIncomingMessage(_ payload: [String: Any]) { + if payload["fuelboard.snapshotRequest"] as? String != nil { + Task { @MainActor in + self.pushSnapshot() + } + } + + guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String, + let timestamp = TimeInterval(raw) else { return } + let date = Date(timeIntervalSince1970: timestamp) + Task { @MainActor in + FuelStore.requestWatchRefresh(date) + NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil) + } + } +} diff --git a/FuelBoardTests/Sources/FuelBoardShared/DataRefreshMode.swift b/FuelBoardTests/Sources/FuelBoardShared/DataRefreshMode.swift new file mode 120000 index 0000000..9d4b803 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/DataRefreshMode.swift @@ -0,0 +1 @@ +../../../Shared/DataRefreshMode.swift \ No newline at end of file diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/FuelBoardWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/FuelBoardWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ba1abfd --- /dev/null +++ b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,156 @@ +{ + "images": [ + { + "filename": "Watch-NotificationCenter-48.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "Watch-NotificationCenter-55.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "Watch-NotificationCenter-66.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "33x33", + "subtype": "45mm" + }, + { + "filename": "Watch-CompanionSettings-58.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "Watch-CompanionSettings-87.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "Watch-AppLauncher-80.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "Watch-AppLauncher-88.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "Watch-AppLauncher-92.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "46x46", + "subtype": "41mm" + }, + { + "filename": "Watch-AppLauncher-100.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "filename": "Watch-AppLauncher-102.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "51x51", + "subtype": "45mm" + }, + { + "filename": "Watch-AppLauncher-108.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "54x54", + "subtype": "49mm" + }, + { + "filename": "Watch-QuickLook-172.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "Watch-QuickLook-196.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "Watch-QuickLook-216.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, + { + "filename": "Watch-QuickLook-234.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "117x117", + "subtype": "45mm" + }, + { + "filename": "Watch-QuickLook-258.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "129x129", + "subtype": "49mm" + }, + { + "filename": "Watch-Marketing-1024.png", + "idiom": "watch-marketing", + "scale": "1x", + "size": "1024x1024" + }, + { + "filename": "Watch-LongLook-88.png", + "idiom": "watch", + "role": "longLook", + "scale": "2x", + "size": "44x44", + "subtype": "42mm" + }, + { + "filename": "Watch-LongLook-100.png", + "idiom": "watch", + "role": "longLook", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png new file mode 100644 index 0000000..917f7f0 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png new file mode 100644 index 0000000..9c7dfe4 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png new file mode 100644 index 0000000..d935aa4 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png new file mode 100644 index 0000000..2e81dba Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png new file mode 100644 index 0000000..2d702d0 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png new file mode 100644 index 0000000..da19a74 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png new file mode 100644 index 0000000..50ce1da Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png new file mode 100644 index 0000000..e2fab47 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-LongLook-100.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-LongLook-100.png new file mode 100644 index 0000000..917f7f0 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-LongLook-100.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-LongLook-88.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-LongLook-88.png new file mode 100644 index 0000000..2d702d0 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-LongLook-88.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png new file mode 100644 index 0000000..df5922d Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png new file mode 100644 index 0000000..529f72f Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png new file mode 100644 index 0000000..586f7de Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png new file mode 100644 index 0000000..e1fe95f Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png new file mode 100644 index 0000000..e7d00fa Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png new file mode 100644 index 0000000..02a4a59 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png new file mode 100644 index 0000000..90cd85d Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png new file mode 100644 index 0000000..5a3d0ec Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png new file mode 100644 index 0000000..6fd1854 Binary files /dev/null and b/FuelBoardWatch Watch App/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png differ diff --git a/FuelBoardWatch Watch App/Assets.xcassets/Contents.json b/FuelBoardWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/FuelBoardWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FuelBoardWatch Watch App/ContentView.swift b/FuelBoardWatch Watch App/ContentView.swift new file mode 100644 index 0000000..ebcf30d --- /dev/null +++ b/FuelBoardWatch Watch App/ContentView.swift @@ -0,0 +1,609 @@ +import SwiftUI +import Foundation +import MapKit + +private enum WatchFuelType: String, CaseIterable, Identifiable, Codable { + case e10 + case e5 + case diesel + + var id: String { rawValue } + + var displayName: String { + switch self { + case .e10: return "Unleaded" + case .e5: return "Premium" + case .diesel: return "Diesel" + } + } + + var shortName: String { + switch self { + case .e10: return "E10" + case .e5: return "E5" + case .diesel: return "B7" + } + } + + var color: Color { + switch self { + case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158 + case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A + case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF + } + } + + var symbol: String { "fuelpump.fill" } + + var sortRank: Int { + switch self { + case .e10: return 0 + case .e5: return 1 + case .diesel: return 2 + } + } +} + +private enum WatchDistanceUnit: String, Codable { + case miles + case kilometers + + func format(km: Double) -> String { + switch self { + case .miles: + return String(format: "%.1f mi", km * 0.621371) + case .kilometers: + return String(format: "%.1f km", km) + } + } +} + +private enum WatchPriceStyle: String, Codable { + case stationSign + case poundsPence +} + +private struct WatchCoordinate { + let lat: Double + let lng: Double +} + +private struct WatchStation: Codable, Identifiable { + let id: String + let name: String + let brand: String + let address: String + let postcode: String + let lat: Double + let lng: Double + let prices: [WatchFuelType: Double] + let priceUpdated: TimeInterval? + + func distanceKM(to location: WatchCoordinate) -> Double { + let r = 6371.0 + let dLat = (location.lat - lat) * .pi / 180 + let dLng = (location.lng - lng) * .pi / 180 + let a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat * .pi / 180) * cos(location.lat * .pi / 180) + * sin(dLng / 2) * sin(dLng / 2) + return r * 2 * atan2(sqrt(a), sqrt(1 - a)) + } +} + +private struct WatchFavouriteEntry: Codable, Identifiable { + let station: WatchStation + let fuel: WatchFuelType + + var id: String { "\(fuel.rawValue)|\(station.id)" } +} + +private struct WatchFavouriteRow: Identifiable { + let id: String + let name: String + let priceText: String + let distanceText: String? + let isCheapest: Bool + let latitude: Double + let longitude: Double +} + +private enum WatchRefreshState { + case idle(lastUpdated: String) + case stale(lastUpdated: String) + case checking + case requested(lastUpdated: String) + case checkedNoNewData(lastUpdated: String) + case needsPhone + case noData + + var label: String { + switch self { + case .idle(let lastUpdated): return "Updated \(lastUpdated)" + case .stale(let lastUpdated): return "Cache from \(lastUpdated)" + case .checking: return "Checking…" + case .requested(let lastUpdated): return "Updated · \(lastUpdated)" + case .checkedNoNewData(let lastUpdated): return "No newer data · \(lastUpdated)" + case .needsPhone: return "Refresh on iPhone" + case .noData: return "No cached prices yet" + } + } + + var tint: Color { + switch self { + case .idle: return .secondary + case .stale: return .yellow + case .checking: return .yellow + case .requested: return .green + case .checkedNoNewData: return .secondary + case .needsPhone: return .orange + case .noData: return .secondary + } + } + + var icon: String { + switch self { + case .idle: return "clock" + case .stale: return "exclamationmark.circle" + case .checking: return "arrow.triangle.2.circlepath" + case .requested: return "checkmark.circle" + case .checkedNoNewData: return "clock.badge.exclamationmark" + case .needsPhone: return "iphone" + case .noData: return "tray" + } + } +} + +private enum WatchFuelCache { + static let appGroupSuite = "group.com.apt.fuelboard" + static let favouritesKey = "fuelboard.favourites" + static let stationsKey = "fuelboard.stations" + static let fuelKey = "fuelboard.selectedFuel" + static let distanceUnitKey = "fuelboard.distanceUnit" + static let priceStyleKey = "fuelboard.priceDisplayStyle" + static let locationKey = "fuelboard.lastLocation" + static let lastRefreshKey = "fuelboard.lastRefresh" + static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" + static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" + static let refreshInterval: TimeInterval = 12 * 60 * 60 + + static func defaults() -> UserDefaults? { + UserDefaults(suiteName: appGroupSuite) + } + + static func loadSelectedFuel() -> WatchFuelType { + guard let raw = defaults()?.string(forKey: fuelKey), let fuel = WatchFuelType(rawValue: raw) else { + return .e10 + } + return fuel + } + + static func loadDistanceUnit() -> WatchDistanceUnit { + guard let raw = defaults()?.string(forKey: distanceUnitKey), let unit = WatchDistanceUnit(rawValue: raw) else { + return .miles + } + return unit + } + + static func loadPriceStyle() -> WatchPriceStyle { + guard let raw = defaults()?.string(forKey: priceStyleKey), let style = WatchPriceStyle(rawValue: raw) else { + return .stationSign + } + return style + } + + static func loadLocation() -> WatchCoordinate? { + guard let raw = defaults()?.string(forKey: locationKey) else { return nil } + let parts = raw.split(separator: ",").compactMap { Double($0) } + guard parts.count >= 2 else { return nil } + return WatchCoordinate(lat: parts[0], lng: parts[1]) + } + + static func loadLastRefresh() -> Date? { + guard let raw = defaults()?.string(forKey: lastRefreshKey), let ts = TimeInterval(raw) else { return nil } + return Date(timeIntervalSince1970: ts) + } + + static func isCacheFresh(lastRefresh: Date?) -> Bool { + guard let lastRefresh else { return false } + return Date().timeIntervalSince(lastRefresh) < refreshInterval + } + + static func requestRefresh(_ date: Date = Date()) { + defaults()?.set(String(date.timeIntervalSince1970), forKey: watchRefreshRequestKey) + } + + static func loadRefreshRequest() -> Date? { + guard let raw = defaults()?.string(forKey: watchRefreshRequestKey), let ts = TimeInterval(raw) else { return nil } + return Date(timeIntervalSince1970: ts) + } + + static func loadRefreshHandled() -> Date? { + guard let raw = defaults()?.string(forKey: watchRefreshHandledKey), let ts = TimeInterval(raw) else { return nil } + return Date(timeIntervalSince1970: ts) + } + + static func wasRefreshHandled(since requestDate: Date) -> Bool { + guard let handled = loadRefreshHandled() else { return false } + return handled >= requestDate + } + + static func loadStations() -> [WatchStation] { + guard let data = defaults()?.data(forKey: stationsKey), + let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else { + return [] + } + return stations + } + + static func loadFavourites() -> [WatchFavouriteEntry] { + guard let data = defaults()?.data(forKey: favouritesKey), + let favourites = try? JSONDecoder().decode([WatchFavouriteEntry].self, from: data) else { + return [] + } + return favourites + } + + static func refreshedFavourites(_ favourites: [WatchFavouriteEntry], from stations: [WatchStation]) -> [WatchFavouriteEntry] { + favourites.map { favourite in + guard let fresh = stations.first(where: { $0.id == favourite.station.id }) else { return favourite } + return WatchFavouriteEntry(station: fresh, fuel: favourite.fuel) + } + } + + static func cheapestFavourite(in favourites: [WatchFavouriteEntry], fuel: WatchFuelType) -> WatchFavouriteEntry? { + var best: WatchFavouriteEntry? + for favourite in favourites where favourite.fuel == fuel { + guard let price = favourite.station.prices[fuel] else { continue } + guard let current = best, let currentPrice = current.station.prices[fuel] else { + best = favourite + continue + } + if price < currentPrice { + best = favourite + } + } + return best + } + + static func priceText(_ pence: Double, style: WatchPriceStyle) -> String { + switch style { + case .stationSign: + return String(format: "%.1f", pence) + case .poundsPence: + let tenths = Int((pence * 10).rounded()) + let whole = tenths / 1000 + let major = (tenths % 1000) / 10 + let minor = tenths % 10 + let superscripts: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"] + return String(format: "£%d.%02d", whole, major) + String(superscripts[minor]) + "/L" + } + } +} + +struct ContentView: View { + @Environment(\.scenePhase) private var scenePhase + @State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel() + @State private var refreshState: WatchRefreshState = .noData + @State private var favourites: [WatchFavouriteEntry] = [] + @State private var stations: [WatchStation] = [] + @State private var lastLocation: WatchCoordinate? + @State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit() + @State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle() + + private var displayedFuels: [WatchFuelType] { + let fuels = Set(favourites.map(\.fuel)) + let sorted = fuels.sorted { $0.sortRank < $1.sortRank } + return sorted.isEmpty ? [fuel] : sorted + } + + private var hasAnyFavourites: Bool { + !favourites.isEmpty + } + + var body: some View { + Group { + if hasAnyFavourites { + TabView(selection: $fuel) { + ForEach(displayedFuels) { fuelCase in + fuelPage(for: fuelCase) + .tag(fuelCase) + } + } + .tabViewStyle(.verticalPage) + } else { + emptyStateView + } + } + .onAppear { + reloadFromCache() + WatchSyncManager.shared.requestSnapshot() + } + .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in + reloadFromCache() + } + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + reloadFromCache() + WatchSyncManager.shared.requestSnapshot() + } + } + } + + @ViewBuilder + private func fuelPage(for fuelCase: WatchFuelType) -> some View { + let fuelFavourites = favouritesForFuel(fuelCase) + let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase) + let rows = rows(for: fuelCase) + + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header(for: fuelCase) + + if let cheapest { + cheapestCard(for: fuelCase, favourite: cheapest) + } + + statusRow + + Button { + runCheckNow() + } label: { + HStack { + Image(systemName: refreshState.icon) + Text("Check now") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(fuelCase.color) + + VStack(alignment: .leading, spacing: 8) { + Text("Favourites") + .font(.headline) + + ForEach(rows) { row in + favouriteRow(row, fuel: fuelCase) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 12) + } + } + + private func favouritesForFuel(_ fuelCase: WatchFuelType) -> [WatchFavouriteEntry] { + favourites.filter { $0.fuel == fuelCase } + } + + private func rows(for fuelCase: WatchFuelType) -> [WatchFavouriteRow] { + let fuelFavourites = favouritesForFuel(fuelCase) + let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase) + + return fuelFavourites.map { favourite in + let distanceText = lastLocation.map { distanceUnit.format(km: favourite.station.distanceKM(to: $0)) } + let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—" + return WatchFavouriteRow( + id: favourite.id, + name: favourite.station.name, + priceText: priceText, + distanceText: distanceText, + isCheapest: favourite.id == cheapest?.id, + latitude: favourite.station.lat, + longitude: favourite.station.lng + ) + } + } + + private func header(for fuel: WatchFuelType) -> some View { + HStack(alignment: .center, spacing: 8) { + Image(systemName: fuel.symbol) + .foregroundStyle(fuel.color) + VStack(alignment: .leading, spacing: 1) { + Text("FuelBoard") + .font(.headline) + Text(fuel.displayName) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + private var emptyStateView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 8) { + Image(systemName: "fuelpump.fill") + .foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0)) + Text("FuelBoard") + .font(.headline) + Spacer() + } + + statusRow + + Button { + runCheckNow() + } label: { + HStack { + Image(systemName: refreshState.icon) + Text("Check now") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0)) + + VStack(alignment: .leading, spacing: 6) { + Text("No favourites yet") + .font(.headline) + Text("Add favourites on iPhone and they’ll appear here.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .padding(.horizontal, 10) + .padding(.vertical, 12) + } + } + + private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View { + Button { + openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng) + } label: { + VStack(alignment: .leading, spacing: 6) { + Text("Cheapest favourite") + .font(.caption2) + .foregroundStyle(.secondary) + Text(favourite.station.name) + .font(.headline) + .lineLimit(2) + HStack(alignment: .firstTextBaseline) { + Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—") + .font(.title3) + .fontWeight(.bold) + Spacer() + if let location = lastLocation { + Text(distanceUnit.format(km: favourite.station.distanceKM(to: location))) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .buttonStyle(.plain) + .background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var statusRow: some View { + Label(refreshState.label, systemImage: refreshState.icon) + .font(.caption) + .foregroundStyle(refreshState.tint) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View { + Button { + openMaps(latitude: favourite.latitude, longitude: favourite.longitude) + } label: { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(favourite.name) + .font(.caption) + .fontWeight(favourite.isCheapest ? .semibold : .regular) + .lineLimit(1) + if favourite.isCheapest { + Text("TOP") + .font(.system(size: 9, weight: .bold)) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(fuel.color.opacity(0.18), in: Capsule()) + } + } + if let distanceText = favourite.distanceText { + Text(distanceText) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Spacer() + Text(favourite.priceText) + .font(.callout.monospacedDigit()) + .fontWeight(.semibold) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private func reloadFromCache() { + stations = WatchFuelCache.loadStations() + favourites = WatchFuelCache.refreshedFavourites(WatchFuelCache.loadFavourites(), from: stations) + distanceUnit = WatchFuelCache.loadDistanceUnit() + priceStyle = WatchFuelCache.loadPriceStyle() + lastLocation = WatchFuelCache.loadLocation() + refreshState = statusFromCache() + + let availableFuels = Set(favourites.map(\.fuel)) + if !availableFuels.isEmpty, !availableFuels.contains(fuel) { + fuel = availableFuels.sorted { $0.sortRank < $1.sortRank }.first ?? .e10 + } + } + + private func statusFromCache() -> WatchRefreshState { + let hasUsablePrices = !stations.isEmpty + || favourites.contains { favourite in favourite.station.prices[fuel] != nil } + guard hasUsablePrices else { return .noData } + guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") } + let formatted = relativeRefreshText(for: lastRefresh) + return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh) + ? .idle(lastUpdated: formatted) + : .stale(lastUpdated: formatted) + } + + private func runCheckNow() { + refreshState = .checking + let previousLastRefresh = WatchFuelCache.loadLastRefresh() + let requestDate = Date() + WatchFuelCache.requestRefresh(requestDate) + WatchSyncManager.shared.requestRefresh(at: requestDate) + Task { + for _ in 0..<12 { + try? await Task.sleep(for: .seconds(1)) + if WatchFuelCache.wasRefreshHandled(since: requestDate) { + await MainActor.run { + reloadFromCache() + let currentLastRefresh = WatchFuelCache.loadLastRefresh() + if let currentLastRefresh, + currentLastRefresh >= requestDate, + currentLastRefresh > (previousLastRefresh ?? .distantPast) { + refreshState = .requested(lastUpdated: relativeRefreshText(for: currentLastRefresh)) + } else if let currentLastRefresh { + refreshState = .checkedNoNewData(lastUpdated: relativeRefreshText(for: currentLastRefresh)) + } else { + refreshState = .noData + } + } + return + } + } + await MainActor.run { + reloadFromCache() + refreshState = .needsPhone + } + } + } + + private func relativeRefreshText(for date: Date) -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private func openMaps(latitude: Double, longitude: Double) { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + let destination = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + _ = MKMapItem.openMaps( + with: [MKMapItem.forCurrentLocation(), destination], + launchOptions: [ + MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving + ] + ) + } +} + +#Preview { + ContentView() +} diff --git a/FuelBoardWatch Watch App/FuelBoardWatchApp.swift b/FuelBoardWatch Watch App/FuelBoardWatchApp.swift new file mode 100644 index 0000000..75a8e7a --- /dev/null +++ b/FuelBoardWatch Watch App/FuelBoardWatchApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct FuelBoardWatchApp: App { + init() { + WatchSyncManager.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/FuelBoardWatch Watch App/WatchSyncManager.swift b/FuelBoardWatch Watch App/WatchSyncManager.swift new file mode 100644 index 0000000..ed6b500 --- /dev/null +++ b/FuelBoardWatch Watch App/WatchSyncManager.swift @@ -0,0 +1,101 @@ +import Foundation +import WatchConnectivity + +extension Notification.Name { + static let fuelBoardWatchDataDidUpdate = Notification.Name("FuelBoardWatchDataDidUpdate") +} + +final class WatchSyncManager: NSObject, WCSessionDelegate { + static let shared = WatchSyncManager() + + private let suiteName = "group.com.apt.fuelboard" + private let snapshotRequestKey = "fuelboard.snapshotRequest" + + private override init() { + super.init() + } + + func activate() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + if session.delegate !== self { + session.delegate = self + } + session.activate() + } + + func requestRefresh(at date: Date = Date()) { + guard WCSession.isSupported() else { return } + let raw = String(date.timeIntervalSince1970) + let payload = ["fuelboard.watchRefreshRequest": raw] + let session = WCSession.default + + if session.isReachable { + session.sendMessage(payload, replyHandler: nil, errorHandler: nil) + } else { + session.transferUserInfo(payload) + } + } + + func requestSnapshot() { + guard WCSession.isSupported() else { return } + let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)] + let session = WCSession.default + + if session.isReachable { + session.sendMessage(payload, replyHandler: nil, errorHandler: nil) + } else { + session.transferUserInfo(payload) + } + } + + private func sharedDefaults() -> UserDefaults? { + UserDefaults(suiteName: suiteName) + } + + private func storeSnapshot(_ payload: [String: Any]) { + guard let defaults = sharedDefaults() else { return } + + if let favourites = payload["fuelboard.favourites"] as? Data { + defaults.set(favourites, forKey: "fuelboard.favourites") + } + if let fuel = payload["fuelboard.selectedFuel"] as? String { + defaults.set(fuel, forKey: "fuelboard.selectedFuel") + } + if let distanceUnit = payload["fuelboard.distanceUnit"] as? String { + defaults.set(distanceUnit, forKey: "fuelboard.distanceUnit") + } + if let priceStyle = payload["fuelboard.priceDisplayStyle"] as? String { + defaults.set(priceStyle, forKey: "fuelboard.priceDisplayStyle") + } + if let location = payload["fuelboard.lastLocation"] as? String { + defaults.set(location, forKey: "fuelboard.lastLocation") + } + if let lastRefresh = payload["fuelboard.lastRefresh"] as? String { + defaults.set(lastRefresh, forKey: "fuelboard.lastRefresh") + } + if let handled = payload["fuelboard.watchRefreshHandled"] as? String { + defaults.set(handled, forKey: "fuelboard.watchRefreshHandled") + } + + NotificationCenter.default.post(name: .fuelBoardWatchDataDidUpdate, object: nil) + } + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + if let error { + print("WATCH-SYNC activation failed: \(error.localizedDescription)") + return + } + if activationState == .activated { + requestSnapshot() + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + storeSnapshot(applicationContext) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { + storeSnapshot(userInfo) + } +} diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AccentColor.colorset/Contents.json b/FuelBoardWatch Wrapper/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/FuelBoardWatch Wrapper/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Contents.json b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4b38a91 --- /dev/null +++ b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,140 @@ +{ + "images": [ + { + "filename": "Watch-NotificationCenter-48.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "24x24", + "subtype": "38mm" + }, + { + "filename": "Watch-NotificationCenter-55.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "27.5x27.5", + "subtype": "42mm" + }, + { + "filename": "Watch-NotificationCenter-66.png", + "idiom": "watch", + "role": "notificationCenter", + "scale": "2x", + "size": "33x33", + "subtype": "45mm" + }, + { + "filename": "Watch-CompanionSettings-58.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "2x", + "size": "29x29" + }, + { + "filename": "Watch-CompanionSettings-87.png", + "idiom": "watch", + "role": "companionSettings", + "scale": "3x", + "size": "29x29" + }, + { + "filename": "Watch-AppLauncher-80.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "40x40", + "subtype": "38mm" + }, + { + "filename": "Watch-AppLauncher-88.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "44x44", + "subtype": "40mm" + }, + { + "filename": "Watch-AppLauncher-92.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "46x46", + "subtype": "41mm" + }, + { + "filename": "Watch-AppLauncher-100.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "50x50", + "subtype": "44mm" + }, + { + "filename": "Watch-AppLauncher-102.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "51x51", + "subtype": "45mm" + }, + { + "filename": "Watch-AppLauncher-108.png", + "idiom": "watch", + "role": "appLauncher", + "scale": "2x", + "size": "54x54", + "subtype": "49mm" + }, + { + "filename": "Watch-QuickLook-172.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "86x86", + "subtype": "38mm" + }, + { + "filename": "Watch-QuickLook-196.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "98x98", + "subtype": "42mm" + }, + { + "filename": "Watch-QuickLook-216.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "108x108", + "subtype": "44mm" + }, + { + "filename": "Watch-QuickLook-234.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "117x117", + "subtype": "45mm" + }, + { + "filename": "Watch-QuickLook-258.png", + "idiom": "watch", + "role": "quickLook", + "scale": "2x", + "size": "129x129", + "subtype": "49mm" + }, + { + "filename": "Watch-Marketing-1024.png", + "idiom": "watch-marketing", + "scale": "1x", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png new file mode 100644 index 0000000..917f7f0 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-100.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png new file mode 100644 index 0000000..9c7dfe4 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-102.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png new file mode 100644 index 0000000..d935aa4 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-108.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png new file mode 100644 index 0000000..2e81dba Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-80.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png new file mode 100644 index 0000000..2d702d0 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-88.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png new file mode 100644 index 0000000..da19a74 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-AppLauncher-92.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png new file mode 100644 index 0000000..50ce1da Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-58.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png new file mode 100644 index 0000000..e2fab47 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-CompanionSettings-87.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png new file mode 100644 index 0000000..df5922d Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-Marketing-1024.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png new file mode 100644 index 0000000..529f72f Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-48.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png new file mode 100644 index 0000000..586f7de Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-55.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png new file mode 100644 index 0000000..e1fe95f Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-NotificationCenter-66.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png new file mode 100644 index 0000000..e7d00fa Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-172.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png new file mode 100644 index 0000000..02a4a59 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-196.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png new file mode 100644 index 0000000..90cd85d Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-216.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png new file mode 100644 index 0000000..5a3d0ec Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-234.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png new file mode 100644 index 0000000..6fd1854 Binary files /dev/null and b/FuelBoardWatch Wrapper/Assets.xcassets/AppIcon.appiconset/Watch-QuickLook-258.png differ diff --git a/FuelBoardWatch Wrapper/Assets.xcassets/Contents.json b/FuelBoardWatch Wrapper/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/FuelBoardWatch Wrapper/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 8b214f4..6f98ae9 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -147,9 +147,9 @@ private struct FuelBoardLiveActivityView: View { } /// Minimal strip for small space (CarPlay small / Watch smart stack): - /// fuel type + bold price on one line, station · distance below. - /// Deliberately no app name and no "Tap for directions" — CarPlay is - /// display-only, and the user's asks here are just fuel + price + distance. + /// fuel type + distance + bold price on one line, station name below. + /// Deliberately no app name and no full address — the watch-sized slot is + /// too tight, so the second line is reserved for the station name only. private var compactBody: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { @@ -157,16 +157,21 @@ private struct FuelBoardLiveActivityView: View { .font(.caption.bold()) .lineLimit(1) Spacer(minLength: 4) + Text(context.state.distanceText) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 4) FuelStore.priceTextAttributed(context.state.pricePence, style: context.state.priceDisplayStyle, size: 15, weight: .bold) .lineLimit(1) } - Text("\(context.state.stationName) · \(context.state.distanceText)") - .font(.system(size: 9)) + Text(context.state.stationName) + .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(1) - .minimumScaleFactor(0.7) + .minimumScaleFactor(0.75) .truncationMode(.tail) } .padding(8) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..9a98663 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,39 @@ +# FuelBoard Release Notes + +## Next TestFlight build + +### Summary +- Added a new Data checking setting with Standard and Smart modes. +- Clarified the behavior and background-update expectations in Settings. + +### What changed +- Added a dedicated **Data checking** section in Settings. +- Added two user-facing modes: + - **Standard** — Saves battery. Refreshes price data up to twice daily. + - **Smart** — Checks for newer data more often in the background and refreshes full prices only when an update is available. +- Smart mode now uses a lightweight metadata probe first, then only refreshes the full dataset if newer data is available. +- Added clear explanatory copy that background checks are best-effort and happen only when iOS allows. +- Kept larger refresh behavior conservative rather than implying exact hourly updates. + +### Why +- The previous refresh model was conservative but not very configurable. +- The new setting makes the behavior clearer and gives users a choice between battery-friendly default behavior and a smarter background-check mode. +- The wording is designed to be honest about iOS background limits. + +### Suggested TestFlight notes +- Added new Data checking modes in Settings: Standard and Smart. +- Smart mode now checks for newer data more intelligently in the background when iOS allows. +- Improved settings copy to better explain data freshness behavior. + +### Suggested What to Test +- Open Settings and confirm **Data checking** appears as its own section. +- Confirm the two options read clearly and feel understandable: + - Standard + - Smart +- Switch between Standard and Smart and confirm the selection persists. +- Confirm the explanatory copy feels accurate and not misleading about iOS background behavior. + +### Implementation note +- Standard keeps the existing cache-first refresh behavior. +- Smart adds a lightweight probe path before deciding whether to download the full data set. +- Background timing remains best-effort under iOS scheduling. diff --git a/Shared/DataRefreshMode.swift b/Shared/DataRefreshMode.swift new file mode 100644 index 0000000..6179099 --- /dev/null +++ b/Shared/DataRefreshMode.swift @@ -0,0 +1,24 @@ +import Foundation + +enum DataRefreshMode: String, CaseIterable, Identifiable { + case standard + case smart + + var id: String { rawValue } + + var displayName: String { + switch self { + case .standard: return "Standard" + case .smart: return "Smart" + } + } + + var summary: String { + switch self { + case .standard: + return "Saves battery. Refreshes price data up to twice daily." + case .smart: + return "Checks for newer data more often in the background and refreshes full prices only when an update is available." + } + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 2ae5e16..51ff958 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -370,6 +370,10 @@ struct FuelStore { static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15) static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970) + static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value + static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970) + static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970) + static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970) static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv" static let stationCountKey = "fuelboard.stationCount" // String — station count static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time @@ -861,10 +865,24 @@ struct FuelStore { saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey) } - // MARK: Refresh policy — data is cached; the app only auto-refreshes - // twice a day (pull-to-refresh is the manual override). + // MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps + // the same full-dump freshness cap but may probe the tiny mirror pointer + // more often in the background and only refresh the full dump if it changed. static let refreshInterval: TimeInterval = 12 * 60 * 60 + static let smartProbeInterval: TimeInterval = 4 * 60 * 60 + + static func loadDataRefreshMode() -> DataRefreshMode { + if let raw = loadString(service: dataRefreshModeKey), + let mode = DataRefreshMode(rawValue: raw) { + return mode + } + return .standard + } + + static func saveDataRefreshMode(_ mode: DataRefreshMode) { + saveString(mode.rawValue, service: dataRefreshModeKey) + } static func loadLastRefresh() -> Date? { if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) { @@ -877,6 +895,45 @@ struct FuelStore { saveString(String(date.timeIntervalSince1970), service: lastRefreshKey) } + static func loadLastSmartProbe() -> Date? { + if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) { + return Date(timeIntervalSince1970: ts) + } + return nil + } + + static func saveLastSmartProbe(_ date: Date = Date()) { + saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey) + } + + static func requestWatchRefresh(_ date: Date = Date()) { + saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey) + } + + static func loadWatchRefreshRequest() -> Date? { + if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) { + return Date(timeIntervalSince1970: ts) + } + return nil + } + + static func markWatchRefreshHandled(_ date: Date = Date()) { + saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey) + } + + static func loadWatchRefreshHandled() -> Date? { + if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) { + return Date(timeIntervalSince1970: ts) + } + return nil + } + + static var hasPendingWatchRefreshRequest: Bool { + guard let requested = loadWatchRefreshRequest() else { return false } + guard let handled = loadWatchRefreshHandled() else { return true } + return requested > handled + } + // MARK: Relay metadata — shown in Settings → About. Written after each // successful full fetch so the About section reflects the live source. @@ -929,8 +986,17 @@ struct FuelStore { /// True when the cached data is fresh enough that a scheduled auto-refresh /// should be skipped (twice-a-day policy). static var isCacheFresh: Bool { + isCacheFresh(now: Date()) + } + + static func isCacheFresh(now: Date) -> Bool { guard let last = loadLastRefresh() else { return false } - return Date().timeIntervalSince(last) < refreshInterval + return now.timeIntervalSince(last) < refreshInterval + } + + static func isSmartProbeDue(now: Date = Date()) -> Bool { + guard let last = loadLastSmartProbe() else { return true } + return now.timeIntervalSince(last) >= smartProbeInterval } // MARK: Onboarding — the app shows the intro screen on first launch only diff --git a/scripts/build_ipas.sh b/scripts/build_ipas.sh new file mode 100755 index 0000000..4cd9d54 --- /dev/null +++ b/scripts/build_ipas.sh @@ -0,0 +1,122 @@ +#!/bin/bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +PROJECT="$PWD/FuelBoard.xcodeproj" +SCHEME="FuelBoard" +DERIVED_DATA="/tmp/fuelboard-build-ipas" +SERVE_DIR="/tmp/ipa-serve" +FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa" +SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa" +HOST="192.168.1.131" +PORT="8765" + +APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app" +STAGE_FULL="/tmp/fuelboard-ipa-stage-full" +STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload" + +echo "==> Refresh bundled dump" +"$PWD/scripts/refresh_bundled_dump.sh" + +echo "==> Clean build dirs" +rm -rf "$DERIVED_DATA" "$STAGE_FULL" "$STAGE_SIDELOAD" +mkdir -p "$SERVE_DIR" + +echo "==> Build Release app (generic iOS destination)" +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -derivedDataPath "$DERIVED_DATA" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY='' \ + build >/tmp/fuelboard-build-ipas-xcodebuild.log + +if [ ! -d "$APP_PATH" ]; then + echo "missing built app: $APP_PATH" >&2 + exit 1 +fi + +echo "==> Package full watch-capable IPA" +python3 - <<'PY' +import os, shutil, zipfile +app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app' +stage = '/tmp/fuelboard-ipa-stage-full' +payload = os.path.join(stage, 'Payload') +out = '/tmp/ipa-serve/FuelBoard-watch.ipa' +if os.path.exists(stage): + shutil.rmtree(stage) +os.makedirs(payload, exist_ok=True) +shutil.copytree(app, os.path.join(payload, 'FuelBoard.app')) +tmp = out + '.tmp' +if os.path.exists(tmp): + os.remove(tmp) +with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for root, dirs, files in os.walk(stage): + dirs.sort(); files.sort() + for name in dirs: + path = os.path.join(root, name) + zf.write(path, os.path.relpath(path, stage)) + for name in files: + path = os.path.join(root, name) + zf.write(path, os.path.relpath(path, stage)) +os.replace(tmp, out) +print(out) +PY + +echo "==> Package sideload-safe IPA (strip Watch/)" +python3 - <<'PY' +import os, shutil, zipfile +app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app' +stage = '/tmp/fuelboard-ipa-stage-sideload' +payload = os.path.join(stage, 'Payload') +out = '/tmp/ipa-serve/FuelBoard-sideload.ipa' +if os.path.exists(stage): + shutil.rmtree(stage) +os.makedirs(payload, exist_ok=True) +dst = os.path.join(payload, 'FuelBoard.app') +shutil.copytree(app, dst) +watch_dir = os.path.join(dst, 'Watch') +if os.path.exists(watch_dir): + shutil.rmtree(watch_dir) +tmp = out + '.tmp' +if os.path.exists(tmp): + os.remove(tmp) +with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for root, dirs, files in os.walk(stage): + dirs.sort(); files.sort() + for name in dirs: + path = os.path.join(root, name) + zf.write(path, os.path.relpath(path, stage)) + for name in files: + path = os.path.join(root, name) + zf.write(path, os.path.relpath(path, stage)) +os.replace(tmp, out) +print(out) +PY + +echo "==> Verify artifacts" +ls -lh "$FULL_IPA" "$SIDELOAD_IPA" +python3 - <<'PY' +import os, plistlib, json +base = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app' +paths = { + 'host': os.path.join(base, 'Info.plist'), + 'watch': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'Info.plist'), + 'watch_ext': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'PlugIns', 'FuelBoardWatch Watch App Extension.appex', 'Info.plist'), +} +for name, path in paths.items(): + print(f'## {name} {os.path.exists(path)}') + if os.path.exists(path): + with open(path, 'rb') as f: + pl = plistlib.load(f) + print(json.dumps({k: pl.get(k) for k in ['CFBundleIdentifier','CFBundleShortVersionString','CFBundleVersion']}, indent=2)) +PY + +echo "==> URLs" +echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa" +echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa" +echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"