diff --git a/.github/workflows/app-ios.yml b/.github/workflows/app-ios.yml index 45448c00..16270362 100644 --- a/.github/workflows/app-ios.yml +++ b/.github/workflows/app-ios.yml @@ -19,16 +19,19 @@ jobs: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }} BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} + BUILD_WATCH_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_WATCH_PROVISION_PROFILE_BASE64 }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | # create variables CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision + WATCH_PP_PATH=$RUNNER_TEMP/watch_pp.mobileprovision KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - # import certificate and provisioning profile from secrets + # import certificate and provisioning profiles from secrets echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH + echo -n "$BUILD_WATCH_PROVISION_PROFILE_BASE64" | base64 --decode -o $WATCH_PP_PATH # create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH @@ -39,9 +42,10 @@ jobs: security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH - # apply provisioning profile + # apply provisioning profiles mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + cp $WATCH_PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles - name: Set up XCode uses: maxim-lobanov/setup-xcode@v1 diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..2614552a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,55 @@ +# Dust + +Dust is a guide to a Burning Man-style event: who is on the playa, where they are, and how to walk there. + +## Language + +**Camp**: +A themed camp from the event directory, with a name and a place on the playa. +_Avoid_: My Camp, home camp + +**My Camp**: +A GPS pin the wearer drops for their own living place. It is a Thing, not a Camp. +_Avoid_: Camp + +**Art**: +A placed art installation from the event directory. + +**Event**: +A scheduled happening, usually hosted by a Camp or located at Art. An Event has one or more occurrences in time. +_Avoid_: Party, set, RSL + +**Party**: +A music set from Rock Star Librarian, starred separately from Events. +_Avoid_: Event + +**Restroom**: +A porta-potty block. Restrooms are interchangeable; the useful Restroom is the nearest. +_Avoid_: bathroom, toilet, Block of restrooms (as a thing you pick from a list) + +**Ice**: +An ice sales location. Like Restroom, the useful Ice is the nearest. + +**Medical**: +A first-aid or medical station. Like Restroom, the useful Medical is the nearest. + +**Friend**: +A person whose playa address or Camp you saved. +_Avoid_: contact + +**Reminder**: +A private timed note the user created. It is not an Event. +_Avoid_: Event, private event + +**Favorite**: +A Camp, Art, Event, or Party the user has starred. + +**Thing**: +A named GPS pin the user drops (My Camp, My Bike, or a custom pin). + +**Watch Catalog**: +A snapshot the user copies to the watch: Favorite Camps, Favorite Art, Friends, upcoming Favorite Events and Parties, upcoming Reminders, plus every Restroom, Ice, and Medical point. The watch chooses from this snapshot without the iPhone. Items with no place still appear; they are not Watch Destinations. +_Avoid_: dataset, sync payload + +**Watch Destination**: +The single named coordinate the watch compass currently points toward. Only a Catalog item with a place can become one. diff --git a/android/app/build.gradle b/android/app/build.gradle index dcafa5d8..bea30dad 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "nexus.concepts.dust" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 191 - versionName "2.135" + versionCode 192 + versionName "2.136" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/docs/watch-app.md b/docs/watch-app.md new file mode 100644 index 00000000..b441fcd9 --- /dev/null +++ b/docs/watch-app.md @@ -0,0 +1,148 @@ +# Apple Watch companion — Update Watch + +The Dust iPhone app copies a **Watch Catalog** to a paired Apple Watch from +Favorites **⋯ ▸ Update Watch**. The watch then chooses locally: nearest +Restroom, Ice, or Medical, Favorite Camps, Art, and Friends (nearest first), upcoming Favorite +Events and Parties, and upcoming Reminders. Tracking uses the watch's **own GPS** and shows a +compass-needle arrow, live straight-line distance, the destination name, and +**Stop**. + +This is a **native** watchOS feature. watchOS cannot run the Capacitor/Angular +stack, so it lives as a SwiftUI companion app bridged to the JS layer via +WatchConnectivity. + +## Architecture + +``` +Angular Favorites page (favs.page.html) + └─ "Update Watch" menu item (only on iOS + installed watch) + └─ buildWatchCatalog + WatchService.sendCatalog + └─ @capacitor/core registerPlugin('WatchConnectivity') + └─ iOS: WatchConnectivityPlugin (ios/App/WatchConnectivityPlugin.swift) + └─ WCSession.updateApplicationContext + sendMessage + └─ watchOS: WatchViewModel (Watch Catalog JSON) + └─ SwiftUI: chooser list → compass + └─ CoreLocation: nearest sort, live distance +``` + +## What's already in the repo + +- `src/app/watch/watch.catalog.ts` — builds the catalog from Favorites plus + Restroom, Ice, and Medical points. Upcoming-only Events, Parties, and Reminders. Includes Friends. Unit tested. +- `src/app/watch/watch.service.ts` — JS wrapper (`isWatchAvailable`, + `sendCatalog`). Guards non-iOS and structured errors. Unit tested. +- `src/app/favs/favs.page.ts` / `.html` — "Update Watch" in the Favorites "…" + menu, hidden unless a watch is available; success toast + haptic or a + "Watch not available" alert. +- `ios/App/WatchConnectivityPlugin.swift` — the native Capacitor plugin. +- `ios/App/AppBridgeViewController.swift` — registers the plugin once the + Capacitor bridge is ready. +- `ios/dust-watch/DustWatch/` — the watchOS app sources (seed for the Xcode + target). Live target: `ios/App/Dust Watch App/`. + +## Manual Xcode steps (required once) + +These steps require Xcode + an Apple Developer account and cannot be done from +the CLI reliably, so they are documented here. + +### 1. Add the native bridge to the iOS app target + +1. Open `ios/App/App.xcworkspace` (use the workspace so CocoaPods are linked). +2. Add **`App/AppBridgeViewController.swift`** and + **`App/WatchConnectivityPlugin.swift`** to the **`App`** target + (File ▸ Add Files… ▸ check the App target; enable "Add to target"). +3. In **`App/App/Base.lproj/Main.storyboard`**, change the root view + controller's class from `CAPBridgeViewController` (module `Capacitor`) to + **`AppBridgeViewController`** (module `App`). + - This subclass registers `WatchConnectivityPlugin` in `capacitorDidLoad()`. +4. Link the **WatchConnectivity** framework (it's part of the SDK; add via + target ▸ Frameworks, Libraries ▸ `+` ▸ WatchConnectivity). + +> Note: `npx cap sync` copies web assets and syncs pods but does **not** +> regenerate the Xcode project, so your additions persist. Avoid deleting and +> re-running `cap add ios`. + +### 2. Create the watchOS app target + +1. **File ▸ New ▸ Target ▸ watchOS ▸ Watch App**. Name the product + **Dust Watch**, keep **SwiftUI** lifecycle, and check **Embed in Application** + = the **`App`** target. +2. Delete the generated template files and add the sources from + `ios/dust-watch/DustWatch/`: + - `DustWatchApp.swift` + - `WatchCatalog.swift` + - `WatchViewModel.swift` + - `ContentView.swift` +3. Set **deployment target ≥ watchOS 10.0** (the code uses SwiftUI + `.animation(_:value:)`, which requires watchOS 10+, and CoreLocation `heading`). + +3. **Location permission (watch)**: The watch target uses + `GENERATE_INFOPLIST_FILE = YES` (no `Info.plist` file), so the permission is + a **build setting**, not a plist edit. Both watch configurations already set + `INFOPLIST_KEY_NSLocationWhenInUseUsageDescription`. If you recreate the + target, add it under Build Settings (Debug + Release): + `INFOPLIST_KEY_NSLocationWhenInUseUsageDescription` = `Uses your location to + point toward places on the playa.` + (`requestWhenInUseAuthorization()` is called while the catalog is on the + watch so lists can sort by nearest; a foreground "when in use" permission is + all this feature needs.) + +### 4. Signing / provisioning + +1. Select the watch app + extension targets, set your **Team**. +2. Add the watch bundle IDs to your App ID, or enable **Automatically manage + signing** on all three targets (App, watch app, extension) so Xcode registers + the companion identifiers. +3. WatchConnectivity requires the iOS app and watch app to be installed together + — they are, because the watch app is embedded in the iOS app. + +### 5. Build & verify + +1. Build the iOS app with the watch app embedded (Product ▸ Build). +2. Pair a real Apple Watch (a watchOS **simulator** does not support + WatchConnectivity messaging reliably — test on a device). +3. Launch the iPhone app → **Favorites ▸ ⋯ ▸ Update Watch**. + - Success toast appears and the watch shows Restroom, Ice, Medical, Camps, Art, Friends, Events, Reminders. + - Restroom, Ice, and Medical start the compass to the nearest point. + - Camps / Art / Friends / Events / Reminders open a shortlist; tap a placed row for the compass. + - Distance on the compass counts down as you walk. **Stop** returns to the list. + +## Behavior notes (as designed) + +- The watch uses its **own GPS** after Update Watch; choosing and tracking work + even when the iPhone is back at camp. +- Star something later, then Update Watch again — the catalog is a snapshot. +- Restroom, Ice, and Medical pick the nearest point **at tap time** and stick to it. + Distance and bearing to that point update as you walk. +- Camps and Art lists sort nearest-first. Events and Parties sort soonest-first + and drop off the watch when they end. +- Rows without a place (art-car Parties) are listed; tap does not start the + compass. +- The arrow is a **compass needle**: it rotates so "up" on screen = the way to + walk, using the watch heading, falling back to GPS course when no compass + heading is available. +- Distance is **straight-line, imperial** ("0.6 mi" / "950 ft"). +- Entering ~15 m of the destination shows an **"You've arrived"** state + haptic; + Stop still exits. + +## Things to double-check before building + +- **Plugin loads**: `WatchConnectivityPlugin` must conform to `CAPBridgedPlugin` + (it declares `identifier`/`jsName`/`pluginMethods`). If the plugin didn't + conform, `registerPluginInstance` logs "Not loading plugin" and the JS call + silently fails — check the Xcode console if nothing happens on tap. +- **No iPhone-side permission needed**: the iOS app already declares + `NSLocationWhenInUseUsageDescription`. The watch app is the one that needs + its own location key (added in step 3 above). +- **WatchConnectivity has no entitlements requirement** — no App Group, no + special capability. Just the same Team on all targets and the watch app + embedded in the iOS app. +- **Delivery when the watch app is closed**: `sendCatalog` always writes the + application context; the catalog appears the next time the watch app is + opened. A reachable live message also refreshes an already-open watch app. +- **Compatibility**: embedding a watchOS 10 companion app needs **Xcode 15+**, and + it only pairs with an iPhone running **iOS 17+**. The iOS app's own deployment + target must be high enough to embed the watch app. +- **Don't run `npx cap add ios` again** after wiring the bridge, as it rebuilds + the native project. `npx cap sync` (web assets + pods) is fine and won't + remove your added Swift files or the storyboard edit. diff --git a/ios/.gitignore b/ios/.gitignore index f4702997..efe19e5f 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -2,7 +2,7 @@ App/build App/Pods App/output App/App/public -DerivedData +DerivedData* xcuserdata # Cordova plugins for Capacitor diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 5c2473c8..4173d9f7 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -17,9 +17,37 @@ 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 579D95A0D54C4589B058ED9D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4BE7D7D8D26A45338B9E7902 /* PrivacyInfo.xcprivacy */; }; 618F86592CD42367004D06F5 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 618F86582CD42367004D06F5 /* GoogleService-Info.plist */; }; + 61DA5068303A89DA00AF4161 /* WatchConnectivityPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DA5067303A89DA00AF4161 /* WatchConnectivityPlugin.swift */; }; + 61DA5069303A89DA00AF4161 /* AppBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DA5066303A89DA00AF4161 /* AppBridgeViewController.swift */; }; + 61DA506B303A8B5500AF4161 /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61DA506A303A8B5500AF4161 /* WatchConnectivity.framework */; }; + 61DA507A303A8BD400AF4161 /* Dust Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 61DA5070303A8BD300AF4161 /* Dust Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 6187E423303B4AA40083BFFD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 504EC2FC1FED79650016851F /* Project object */; + proxyType = 1; + remoteGlobalIDString = 61DA506F303A8BD300AF4161; + remoteInfo = "Dust Watch App"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 61DA507E303A8BD400AF4161 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 61DA507A303A8BD400AF4161 /* Dust Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 2D25B0082A7DCE490087C17D /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 2D410B662A965629005327E4 /* blazing2.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = blazing2.wav; sourceTree = ""; }; @@ -34,17 +62,33 @@ 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 618F86582CD42367004D06F5 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 61DA5066303A89DA00AF4161 /* AppBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBridgeViewController.swift; sourceTree = ""; }; + 61DA5067303A89DA00AF4161 /* WatchConnectivityPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnectivityPlugin.swift; sourceTree = ""; }; + 61DA506A303A8B5500AF4161 /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = System/Library/Frameworks/WatchConnectivity.framework; sourceTree = SDKROOT; }; + 61DA5070303A8BD300AF4161 /* Dust Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Dust Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = ""; }; FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 61DA5071303A8BD300AF4161 /* Dust Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Dust Watch App"; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 504EC3011FED79650016851F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, + 61DA506B303A8B5500AF4161 /* WatchConnectivity.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 61DA506D303A8BD300AF4161 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -54,6 +98,7 @@ 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { isa = PBXGroup; children = ( + 61DA506A303A8B5500AF4161 /* WatchConnectivity.framework */, AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, ); name = Frameworks; @@ -62,7 +107,10 @@ 504EC2FB1FED79650016851F = { isa = PBXGroup; children = ( + 61DA5066303A89DA00AF4161 /* AppBridgeViewController.swift */, + 61DA5067303A89DA00AF4161 /* WatchConnectivityPlugin.swift */, 504EC3061FED79650016851F /* App */, + 61DA5071303A8BD300AF4161 /* Dust Watch App */, 504EC3051FED79650016851F /* Products */, 7F8756D8B27F46E3366F6CEA /* Pods */, 27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, @@ -74,6 +122,7 @@ isa = PBXGroup; children = ( 504EC3041FED79650016851F /* App.app */, + 61DA5070303A8BD300AF4161 /* Dust Watch App.app */, ); name = Products; sourceTree = ""; @@ -117,29 +166,56 @@ 504EC3011FED79650016851F /* Frameworks */, 504EC3021FED79650016851F /* Resources */, 9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, + 61DA507E303A8BD400AF4161 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + 6187E424303B4AA40083BFFD /* PBXTargetDependency */, ); name = App; productName = App; productReference = 504EC3041FED79650016851F /* App.app */; productType = "com.apple.product-type.application"; }; + 61DA506F303A8BD300AF4161 /* Dust Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 61DA507B303A8BD400AF4161 /* Build configuration list for PBXNativeTarget "Dust Watch App" */; + buildPhases = ( + 61DA506C303A8BD300AF4161 /* Sources */, + 61DA506D303A8BD300AF4161 /* Frameworks */, + 61DA506E303A8BD300AF4161 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 61DA5071303A8BD300AF4161 /* Dust Watch App */, + ); + name = "Dust Watch App"; + packageProductDependencies = ( + ); + productName = "Dust Watch Watch App"; + productReference = 61DA5070303A8BD300AF4161 /* Dust Watch App.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 504EC2FC1FED79650016851F /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 920; + LastSwiftUpdateCheck = 2660; LastUpgradeCheck = 920; TargetAttributes = { 504EC3031FED79650016851F = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; - ProvisioningStyle = Manual; + }; + 61DA506F303A8BD300AF4161 = { + CreatedOnToolsVersion = 26.6; + LastSwiftMigration = 2660; }; }; }; @@ -157,6 +233,7 @@ projectRoot = ""; targets = ( 504EC3031FED79650016851F /* App */, + 61DA506F303A8BD300AF4161 /* Dust Watch App */, ); }; /* End PBXProject section */ @@ -178,6 +255,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 61DA506E303A8BD300AF4161 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -221,12 +305,29 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 61DA5068303A89DA00AF4161 /* WatchConnectivityPlugin.swift in Sources */, + 61DA5069303A89DA00AF4161 /* AppBridgeViewController.swift in Sources */, 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 61DA506C303A8BD300AF4161 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 6187E424303B4AA40083BFFD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 61DA506F303A8BD300AF4161 /* Dust Watch App */; + targetProxy = 6187E423303B4AA40083BFFD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 504EC30B1FED79650016851F /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -349,7 +450,8 @@ IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; @@ -363,13 +465,17 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 191; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = DCC5P9FAQ3; INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Dust; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.135; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.136; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = nexus.concepts.dust; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -390,13 +496,17 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 191; + CURRENT_PROJECT_VERSION = 192; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = DCC5P9FAQ3; INFOPLIST_FILE = App/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Dust; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.135; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 2.136; PRODUCT_BUNDLE_IDENTIFIER = nexus.concepts.dust; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -407,6 +517,113 @@ }; name = Release; }; + 61DA507C303A8BD400AF4161 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = DCC5P9FAQ3; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Dust; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Uses your location to point toward places on the playa."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = nexus.concepts.dust; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = nexus.concepts.dust.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "Dust Watch App"; + 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_OBJC_BRIDGING_HEADER = "Dust Watch App/Dust Watch Watch App-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + 61DA507D303A8BD400AF4161 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=watchos*]" = DCC5P9FAQ3; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Dust; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Uses your location to point toward places on the playa."; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = nexus.concepts.dust; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = nexus.concepts.dust.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=watchos*]" = "Dust Watch App"; + 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_OBJC_BRIDGING_HEADER = "Dust Watch App/Dust Watch Watch App-Bridging-Header.h"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -428,6 +645,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 61DA507B303A8BD400AF4161 /* Build configuration list for PBXNativeTarget "Dust Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 61DA507C303A8BD400AF4161 /* Debug */, + 61DA507D303A8BD400AF4161 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; diff --git a/ios/App/App/Base.lproj/Main.storyboard b/ios/App/App/Base.lproj/Main.storyboard index b44df7be..05a31bc9 100644 --- a/ios/App/App/Base.lproj/Main.storyboard +++ b/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/ios/App/AppBridgeViewController.swift b/ios/App/AppBridgeViewController.swift new file mode 100644 index 00000000..7b60c4e5 --- /dev/null +++ b/ios/App/AppBridgeViewController.swift @@ -0,0 +1,21 @@ +import UIKit +import Capacitor + +/// Root bridge view controller used by the Capacitor iOS app. Registers the +/// custom `WatchConnectivityPlugin` once the Capacitor bridge is created. +/// +/// To enable: point the Main storyboard's initial view controller at this class +/// (see docs/watch-app.md) and make sure this file is in the app target. +class AppBridgeViewController: CAPBridgeViewController { + + override func capacitorDidLoad() { + super.capacitorDidLoad() + NSLog("[App] capacitorDidLoad fired; bridge=%@", String(describing: self.bridge)) + guard let bridge = self.bridge else { + NSLog("[App] bridge is nil - WatchConnectivityPlugin NOT registered") + return + } + bridge.registerPluginInstance(WatchConnectivityPlugin()) + NSLog("[App] registered WatchConnectivityPlugin") + } +} diff --git a/ios/App/Dust Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/App/Dust Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/App/Dust Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/Dust Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/App/Dust Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9b566e96 --- /dev/null +++ b/ios/App/Dust Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "dust2.png", + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/Dust Watch App/Assets.xcassets/AppIcon.appiconset/dust2.png b/ios/App/Dust Watch App/Assets.xcassets/AppIcon.appiconset/dust2.png new file mode 100644 index 00000000..3a5aa898 Binary files /dev/null and b/ios/App/Dust Watch App/Assets.xcassets/AppIcon.appiconset/dust2.png differ diff --git a/ios/App/Dust Watch App/Assets.xcassets/Contents.json b/ios/App/Dust Watch App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/App/Dust Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/App/Dust Watch App/ContentView.swift b/ios/App/Dust Watch App/ContentView.swift new file mode 100644 index 00000000..5bd0ed80 --- /dev/null +++ b/ios/App/Dust Watch App/ContentView.swift @@ -0,0 +1,300 @@ +import CoreLocation +import SwiftUI + +private let dustPink = Color(red: 246 / 255, green: 16 / 255, blue: 103 / 255) + +struct ContentView: View { + @EnvironmentObject private var model: WatchViewModel + + var body: some View { + Group { + switch model.state { + case .idle: + if model.hasCatalog { + CatalogHomeView() + } else { + EmptyCatalogView() + } + case .tracking, .arrived: + TrackingView() + } + } + } +} + +struct EmptyCatalogView: View { + var body: some View { + NavigationStack { + VStack(spacing: 6) { + Image(systemName: "applewatch") + .font(.system(size: 34)) + .foregroundStyle(.secondary) + Text("Update Watch from Favorites") + .font(.footnote) + .multilineTextAlignment(.center) + Text("on your iPhone.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding() + .dustTitle() + } + } +} + +struct CatalogHomeView: View { + @EnvironmentObject private var model: WatchViewModel + + var body: some View { + NavigationStack { + List { + Button { + model.navigateToNearest(.restroom) + } label: { + Label("Restroom", systemImage: "toilet") + } + + Button { + model.navigateToNearest(.ice) + } label: { + Label("Ice", systemImage: "snowflake") + } + + Button { + model.navigateToNearest(.medical) + } label: { + Label("Medical", systemImage: "staroflife") + } + + NavigationLink { + PlaceListView(title: "Camps", kind: .camp) + } label: { + Label("Camps", systemImage: "tent") + } + + NavigationLink { + PlaceListView(title: "Art", kind: .art) + } label: { + Label("Art", systemImage: "paintpalette") + } + + NavigationLink { + PlaceListView(title: "Friends", kind: .friend) + } label: { + Label("Friends", systemImage: "person.2") + } + + NavigationLink { + TimedListView( + title: "Events", + empty: "No upcoming Events or Parties.", + events: model.upcomingEvents + ) + } label: { + Label("Events", systemImage: "calendar") + } + + NavigationLink { + TimedListView( + title: "Reminders", + empty: "No upcoming Reminders.", + events: model.upcomingReminders + ) + } label: { + Label("Reminders", systemImage: "bell") + } + } + .dustTitle() + .onAppear { model.startBrowsingLocation() } + } + } +} + +enum PlaceKind { + case camp + case art + case friend +} + +struct PlaceListView: View { + @EnvironmentObject private var model: WatchViewModel + let title: String + let kind: PlaceKind + + private var places: [WatchPlace] { + switch kind { + case .camp: return model.campsByDistance + case .art: return model.artByDistance + case .friend: return model.friendsByDistance + } + } + + var body: some View { + List { + if places.isEmpty { + Text("Add them on iPhone, then Update Watch.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(places) { place in + if let coordinate = place.coordinate { + Button { + model.beginTracking(to: coordinate, name: place.name) + } label: { + placeRow(place, coordinate: coordinate) + } + .buttonStyle(.plain) + } else { + placeRow(place, coordinate: nil) + } + } + } + .navigationTitle(title) + } + + private func placeRow(_ place: WatchPlace, coordinate: CLLocationCoordinate2D?) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(place.name) + .foregroundStyle(coordinate == nil ? .secondary : .primary) + .lineLimit(2) + if let distance = model.formattedDistance(to: coordinate) { + Text(distance) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } +} + +struct TimedListView: View { + @EnvironmentObject private var model: WatchViewModel + let title: String + let empty: String + let events: [WatchTimed] + + var body: some View { + List { + if events.isEmpty { + Text(empty) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(events) { event in + if let coordinate = event.coordinate { + Button { + model.beginTracking(to: coordinate, name: event.name) + } label: { + eventRow(event, tappable: true) + } + .buttonStyle(.plain) + } else { + eventRow(event, tappable: false) + } + } + } + .navigationTitle(title) + } + + private func eventRow(_ event: WatchTimed, tappable: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(event.name) + .foregroundStyle(tappable ? .primary : .secondary) + .lineLimit(2) + Text(event.when ?? Self.whenText(event.start)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private static func whenText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEE h:mma" + return formatter.string(from: date) + } +} + +struct TrackingView: View { + @EnvironmentObject private var model: WatchViewModel + + var body: some View { + VStack(spacing: 6) { + DirectionArrowView(rotation: model.arrowRotation, arrived: model.state == .arrived) + .frame(width: 110, height: 110) + + Text(model.distanceText) + .font(.title3.weight(.semibold)) + .monospacedDigit() + .lineLimit(1) + + Text(model.targetName ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if model.state == .arrived { + Text("You've arrived") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.green) + } else if model.signalLost { + Text("Weak signal") + .font(.caption2) + .foregroundStyle(.orange) + } + + Button { + model.stopTracking() + } label: { + Text("Stop") + .fontWeight(.semibold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(dustPink) + } + .padding(.vertical, 4) + } +} + +/// Compass-needle style arrow. Rotates so "up" on screen points toward the +/// destination relative to the direction the wearer is facing. +struct DirectionArrowView: View { + let rotation: Double + let arrived: Bool + + var body: some View { + ZStack { + Circle() + .strokeBorder( + arrived ? Color.green.opacity(0.6) : Color.primary.opacity(0.2), + lineWidth: 2 + ) + + if arrived { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 58)) + .foregroundStyle(.green) + } else { + Image(systemName: "triangle.fill") + .font(.system(size: 6)) + .foregroundStyle(.secondary) + .offset(y: -44) + + Image(systemName: "location.north.fill") + .font(.system(size: 56)) + .foregroundStyle(Color.primary) + .rotationEffect(.degrees(rotation)) + .animation(.easeOut(duration: 0.2), value: rotation) + } + } + } +} + +private extension View { + func dustTitle() -> some View { + navigationTitle("dust.") + .toolbarBackground(dustPink, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } +} diff --git a/ios/App/Dust Watch App/Dust Watch Watch App-Bridging-Header.h b/ios/App/Dust Watch App/Dust Watch Watch App-Bridging-Header.h new file mode 100644 index 00000000..1b2cb5d6 --- /dev/null +++ b/ios/App/Dust Watch App/Dust Watch Watch App-Bridging-Header.h @@ -0,0 +1,4 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + diff --git a/ios/App/Dust Watch App/DustWatchApp.swift b/ios/App/Dust Watch App/DustWatchApp.swift new file mode 100644 index 00000000..bb177c75 --- /dev/null +++ b/ios/App/Dust Watch App/DustWatchApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct DustWatchApp: App { + @StateObject private var model = WatchViewModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + } + } +} diff --git a/ios/App/Dust Watch App/WatchCatalog.swift b/ios/App/Dust Watch App/WatchCatalog.swift new file mode 100644 index 00000000..f185792a --- /dev/null +++ b/ios/App/Dust Watch App/WatchCatalog.swift @@ -0,0 +1,116 @@ +import CoreLocation +import Foundation + +struct WatchPlace: Identifiable, Equatable { + let id: String + let name: String + let lat: Double? + let lng: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let lat, let lng else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lng) + } +} + +struct WatchTimed: Identifiable, Equatable { + let id: String + let name: String + let start: Date + let end: Date + let when: String? + let lat: Double? + let lng: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let lat, let lng else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lng) + } + + func isUpcoming(at now: Date) -> Bool { + end > now + } +} + +struct WatchPoint: Identifiable, Equatable { + let id: String + let lat: Double + let lng: Double + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: lat, longitude: lng) + } + + var location: CLLocation { + CLLocation(latitude: lat, longitude: lng) + } +} + +struct WatchCatalog: Equatable { + var camps: [WatchPlace] = [] + var art: [WatchPlace] = [] + var events: [WatchTimed] = [] + var friends: [WatchPlace] = [] + var reminders: [WatchTimed] = [] + var restrooms: [WatchPoint] = [] + var ice: [WatchPoint] = [] + var medical: [WatchPoint] = [] + + static func parse(_ json: String) -> WatchCatalog? { + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return WatchCatalog( + camps: places(from: obj["camps"]), + art: places(from: obj["art"]), + events: timed(from: obj["events"]), + friends: places(from: obj["friends"]), + reminders: timed(from: obj["reminders"]), + restrooms: points(from: obj["restrooms"]), + ice: points(from: obj["ice"]), + medical: points(from: obj["medical"]) + ) + } + + private static func places(from value: Any?) -> [WatchPlace] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.enumerated().compactMap { index, row in + guard let name = row["name"] as? String, !name.isEmpty else { return nil } + return WatchPlace(id: "\(index)-\(name)", name: name, lat: jsonDouble(row["lat"]), lng: jsonDouble(row["lng"])) + } + } + + private static func timed(from value: Any?) -> [WatchTimed] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.enumerated().compactMap { index, row in + guard let name = row["name"] as? String, !name.isEmpty, + let start = jsonDouble(row["start"]), + let end = jsonDouble(row["end"]) else { return nil } + return WatchTimed( + id: "\(index)-\(name)-\(start)", + name: name, + start: Date(timeIntervalSince1970: start / 1000), + end: Date(timeIntervalSince1970: end / 1000), + when: row["when"] as? String, + lat: jsonDouble(row["lat"]), + lng: jsonDouble(row["lng"]) + ) + } + } + + private static func points(from value: Any?) -> [WatchPoint] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.enumerated().compactMap { index, row in + guard let lat = jsonDouble(row["lat"]), let lng = jsonDouble(row["lng"]) else { return nil } + return WatchPoint(id: "\(index)-\(lat)-\(lng)", lat: lat, lng: lng) + } + } +} + +private func jsonDouble(_ value: Any?) -> Double? { + if let n = value as? NSNumber { return n.doubleValue } + if let d = value as? Double { return d } + if let i = value as? Int { return Double(i) } + return nil +} diff --git a/ios/App/Dust Watch App/WatchViewModel.swift b/ios/App/Dust Watch App/WatchViewModel.swift new file mode 100644 index 00000000..5680746e --- /dev/null +++ b/ios/App/Dust Watch App/WatchViewModel.swift @@ -0,0 +1,285 @@ +import Combine +import CoreLocation +import Foundation +import WatchConnectivity +import WatchKit + +enum WatchState { + /// Chooser (or empty prompt) when not navigating. + case idle + /// Live direction/distance to a Watch Destination. + case tracking + /// Within ~15 m of the destination. + case arrived +} + +enum WatchAmenity { + case restroom + case ice + case medical + + var title: String { + switch self { + case .restroom: return "Restroom" + case .ice: return "Ice" + case .medical: return "Medical" + } + } +} + +/// Owns WatchConnectivity + CoreLocation for the watch companion app. +/// +/// The iPhone pushes a Watch Catalog over WatchConnectivity. This model +/// chooses locally (nearest Restroom/Ice/Medical, Favorite lists) and tracks the +/// watch's own GPS for a compass-needle and live straight-line distance. +final class WatchViewModel: NSObject, ObservableObject { + @Published var state: WatchState = .idle + @Published var targetName: String? + @Published var distance: CLLocationDistance = 0 + @Published var arrowRotation: Double = 0 + @Published var signalLost = false + @Published var catalog = WatchCatalog() + @Published private(set) var hasCatalog = false + @Published private(set) var currentLocation: CLLocation? + @Published var now = Date() + + private var target: CLLocationCoordinate2D? + private var heading: Double? + private var pendingAmenity: WatchAmenity? + private var clockTimer: Timer? + + private let locationManager = CLLocationManager() + private let session: WCSession + private let catalogDefaultsKey = "dustCatalogJSON" + + override init() { + self.session = WCSession.default + super.init() + + if let json = UserDefaults.standard.string(forKey: catalogDefaultsKey) { + applyCatalogJSON(json, persist: false) + } + + session.delegate = self + if WCSession.isSupported() { + session.activate() + } + + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation + locationManager.distanceFilter = 5 + locationManager.headingFilter = 5 + + clockTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + DispatchQueue.main.async { self?.now = Date() } + } + if let clockTimer { + RunLoop.main.add(clockTimer, forMode: .common) + } + + if hasCatalog { + startBrowsingLocation() + } + } + + deinit { + clockTimer?.invalidate() + } + + var upcomingEvents: [WatchTimed] { + catalog.events.filter { $0.isUpcoming(at: now) }.sorted { $0.start < $1.start } + } + + var upcomingReminders: [WatchTimed] { + catalog.reminders.filter { $0.isUpcoming(at: now) }.sorted { $0.start < $1.start } + } + + var campsByDistance: [WatchPlace] { + sortedByDistance(catalog.camps) + } + + var artByDistance: [WatchPlace] { + sortedByDistance(catalog.art) + } + + var friendsByDistance: [WatchPlace] { + sortedByDistance(catalog.friends) + } + + func beginTracking(to coordinate: CLLocationCoordinate2D, name: String) { + target = coordinate + targetName = name + pendingAmenity = nil + startBrowsingLocation() + locationManager.startUpdatingHeading() + state = .tracking + signalLost = false + updateDirection() + } + + func stopTracking() { + locationManager.stopUpdatingHeading() + target = nil + targetName = nil + heading = nil + distance = 0 + arrowRotation = 0 + signalLost = false + pendingAmenity = nil + state = .idle + if !hasCatalog { + locationManager.stopUpdatingLocation() + currentLocation = nil + } + } + + func navigateToNearest(_ amenity: WatchAmenity) { + let points: [WatchPoint] + switch amenity { + case .restroom: points = catalog.restrooms + case .ice: points = catalog.ice + case .medical: points = catalog.medical + } + guard !points.isEmpty else { + WKInterfaceDevice.current().play(.failure) + return + } + if let here = currentLocation { + startNearest(points: points, name: amenity.title, from: here) + } else { + pendingAmenity = amenity + startBrowsingLocation() + } + } + + func formattedDistance(to coordinate: CLLocationCoordinate2D?) -> String? { + guard let coordinate, let here = currentLocation else { return nil } + let meters = here.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) + return Self.formatDistance(meters) + } + + var distanceText: String { + Self.formatDistance(distance) + } + + func startBrowsingLocation() { + locationManager.requestWhenInUseAuthorization() + locationManager.startUpdatingLocation() + } + + private func startNearest(points: [WatchPoint], name: String, from here: CLLocation) { + guard let closest = points.min(by: { here.distance(from: $0.location) < here.distance(from: $1.location) }) else { + return + } + beginTracking(to: closest.coordinate, name: name) + } + + private func sortedByDistance(_ places: [WatchPlace]) -> [WatchPlace] { + guard let here = currentLocation else { return places } + return places.sorted { a, b in + distance(from: here, to: a.coordinate) < distance(from: here, to: b.coordinate) + } + } + + private func distance(from here: CLLocation, to coordinate: CLLocationCoordinate2D?) -> CLLocationDistance { + guard let coordinate else { return .greatestFiniteMagnitude } + return here.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) + } + + private func applyCatalogJSON(_ json: String, persist: Bool) { + guard let parsed = WatchCatalog.parse(json) else { return } + catalog = parsed + hasCatalog = true + if persist { + UserDefaults.standard.set(json, forKey: catalogDefaultsKey) + } + startBrowsingLocation() + } + + private func updateDirection() { + guard let target, let current = currentLocation else { return } + let from = current.coordinate + let bearing = Self.bearing(from: from, to: target) + distance = current.distance(from: CLLocation(latitude: target.latitude, longitude: target.longitude)) + let facing = heading ?? current.course + arrowRotation = Self.normalizeDegrees(bearing - facing) + } + + static func formatDistance(_ meters: CLLocationDistance) -> String { + if meters >= 160.934 { + let miles = meters / 1609.344 + return String(format: "%.1f mi", miles) + } + let feet = meters * 3.28084 + return String(format: "%.0f ft", feet) + } + + static func bearing(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> Double { + let lat1 = from.latitude * .pi / 180 + let lat2 = to.latitude * .pi / 180 + let dLon = (to.longitude - from.longitude) * .pi / 180 + let y = sin(dLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) + return normalizeDegrees(atan2(y, x) * 180 / .pi) + } + + static func normalizeDegrees(_ value: Double) -> Double { + let remainder = value.truncatingRemainder(dividingBy: 360) + return remainder < 0 ? remainder + 360 : remainder + } +} + +extension WatchViewModel: WCSessionDelegate { + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + guard activationState == .activated else { return } + if let json = session.receivedApplicationContext["dustCatalog"] as? String { + DispatchQueue.main.async { self.applyCatalogJSON(json, persist: true) } + } + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + let handled = handle(payload: message) + replyHandler(["ok": handled]) + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + _ = handle(payload: applicationContext) + } + + private func handle(payload: [String: Any]) -> Bool { + let json = (payload["catalogJson"] as? String) ?? (payload["dustCatalog"] as? String) + guard let json else { return false } + DispatchQueue.main.async { self.applyCatalogJSON(json, persist: true) } + return true + } +} + +extension WatchViewModel: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + currentLocation = location + signalLost = false + updateDirection() + + if let amenity = pendingAmenity { + pendingAmenity = nil + navigateToNearest(amenity) + } + + if let target, + location.distance(from: CLLocation(latitude: target.latitude, longitude: target.longitude)) < 15, + state == .tracking { + state = .arrived + WKInterfaceDevice.current().play(.notification) + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading + updateDirection() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + signalLost = true + } +} diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index e1f3003c..a0ac1a1d 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -49,51 +49,51 @@ PODS: - Capacitor - CordovaPlugins (8.4.2): - CapacitorCordova - - FirebaseCore (12.13.0): - - FirebaseCoreInternal (~> 12.13.0) + - FirebaseCore (12.18.0): + - FirebaseCoreInternal (~> 12.18.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (12.13.0): + - FirebaseCoreInternal (12.18.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (12.13.0): - - FirebaseCore (~> 12.13.0) + - FirebaseInstallations (12.18.0): + - FirebaseCore (~> 12.18.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.13.0): - - FirebaseCore (~> 12.13.0) - - FirebaseInstallations (~> 12.13.0) + - FirebaseMessaging (12.18.0): + - FirebaseCore (~> 12.18.0) + - FirebaseInstallations (~> 12.18.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Reachability (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleDataTransport (10.1.0): + - GoogleDataTransport (10.1.1): - nanopb (~> 3.30910.0) - PromisesObjC (~> 2.4) - - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/AppDelegateSwizzler (8.1.2): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Environment (8.1.2): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Logger (8.1.2): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Network (8.1.2): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (8.1.0)": + - "GoogleUtilities/NSData+zlib (8.1.2)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (8.1.0) - - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Privacy (8.1.2) + - GoogleUtilities/Reachability (8.1.2): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/UserDefaults (8.1.2): - GoogleUtilities/Logger - GoogleUtilities/Privacy - IONFilesystemLib (1.1.2) @@ -104,7 +104,7 @@ PODS: - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) - OSBarcodeLib (2.2.0) - - PromisesObjC (2.4.0) + - PromisesObjC (2.4.1) - Sentry/HybridSDK (8.56.2) - SentryCapacitor (2.4.1): - Capacitor @@ -245,17 +245,17 @@ SPEC CHECKSUMS: CapacitorTextZoom: b3c80b09abef831245bc3434b1c03aa898ad4762 CapawesomeCapacitorAppUpdate: 3a7537c6a6696837b51d06f26b2e909fd7b9226f CordovaPlugins: 0b73119a3d22510a69a4602338eae73a1e4bd496 - FirebaseCore: 58905958aa00a061397a0fd759ae4b55bddb3576 - FirebaseCoreInternal: 37bee58388fc6d183f0ab1b32d69ae44f2cf8aad - FirebaseInstallations: 134bde50e477628ded76070efdb12d515d53f948 - FirebaseMessaging: 30564b85d2f81a96f9d312bd23acf8186ff092ae - GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 - GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + FirebaseCore: 96787d19ee32dc2242cc487b301bc616afeb398b + FirebaseCoreInternal: 93b2d5f9e184c6818a04433891c358b0720a6a38 + FirebaseInstallations: 933b2747e8594084c9117b65e8358fce29d7ced7 + FirebaseMessaging: 422bb80cbf8ff83e69e50bea4d4984fc4704f665 + GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539 + GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850 IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a IONGeolocationLib: 6a7b6f76063fb1b01cfb14fae7d0f526e3c9b271 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 OSBarcodeLib: aa520d51576362d0dfea290b82653e31df1a6fc4 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7 SentryCapacitor: 84549a1440a7f58a336ed3175db0f067d571cd5b WebnativellcCapacitorFilesharer: cb81b822c25f152358f0ce05289945b8964386d0 @@ -264,4 +264,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 3ab124fa10d8ee89827abca62649b0532d273085 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/ios/App/WatchConnectivityPlugin.swift b/ios/App/WatchConnectivityPlugin.swift new file mode 100644 index 00000000..0f319f79 --- /dev/null +++ b/ios/App/WatchConnectivityPlugin.swift @@ -0,0 +1,102 @@ +import Foundation +import Capacitor +import WatchConnectivity + +/// Bridges the Angular "Update Watch" flow to the paired Apple Watch via +/// WatchConnectivity. Registered natively (see AppBridgeViewController) and +/// invoked from `WatchService` in src/app/watch/watch.service.ts. +@objc(WatchConnectivityPlugin) +class WatchConnectivityPlugin: CAPInstancePlugin, CAPBridgedPlugin, WCSessionDelegate { + + let identifier = "WatchConnectivityPlugin" + let jsName = "WatchConnectivity" + let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "isWatchAppInstalled", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "sendCatalog", returnType: CAPPluginReturnPromise), + ] + + private let session: WCSession + + /// Calls awaiting WCSession activation before they can report readiness. + private var pendingInstalledCalls: [CAPPluginCall] = [] + + override init() { + self.session = WCSession.default + super.init() + if WCSession.isSupported() { + session.delegate = self + session.activate() + } + } + + // MARK: - Plugin methods + + /// Reports whether a Dust Apple Watch companion app is installed & paired. + /// Waits for WCSession activation before reading `isWatchAppInstalled`, since + /// this flag only reflects reality once the session is `.activated`. + @objc func isWatchAppInstalled(_ call: CAPPluginCall) { + guard WCSession.isSupported() else { + call.resolve(["installed": false]) + return + } + guard session.activationState == .activated else { + pendingInstalledCalls.append(call) + session.activate() + return + } + call.resolve(["installed": session.isWatchAppInstalled]) + } + + /// Replaces the Watch Catalog. Always writes application context so a closed + /// watch app picks it up on open; also sends a live message when reachable. + @objc func sendCatalog(_ call: CAPPluginCall) { + guard let catalogJson = call.getString("catalogJson"), !catalogJson.isEmpty else { + call.reject("Missing catalog") + return + } + + guard WCSession.isSupported(), session.activationState == .activated else { + call.reject("The Watch session is not activated.") + return + } + + guard session.isWatchAppInstalled else { + call.reject("The Dust Apple Watch app is not installed.") + return + } + + do { + try session.updateApplicationContext(["dustCatalog": catalogJson]) + } catch { + call.reject(error.localizedDescription) + return + } + + let payload: [String: Any] = ["type": "catalog", "catalogJson": catalogJson] + if session.isReachable { + session.sendMessage(payload, replyHandler: { _ in + call.resolve(["success": true]) + }, errorHandler: { _ in + // Context already persisted; the watch will read it on open. + call.resolve(["success": true]) + }) + } else { + call.resolve(["success": true]) + } + } + + // MARK: - WCSessionDelegate + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + let pending = pendingInstalledCalls + pendingInstalledCalls.removeAll() + let installed = (activationState == .activated) && session.isWatchAppInstalled + pending.forEach { $0.resolve(["installed": installed]) } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + WCSession.default.activate() + } +} diff --git a/ios/archive.plist b/ios/archive.plist index c6576c3d..8f043583 100644 --- a/ios/archive.plist +++ b/ios/archive.plist @@ -8,6 +8,8 @@ nexus.concepts.dust dust-distribution-2026 + nexus.concepts.dust.watchkitapp + Dust Watch App \ No newline at end of file diff --git a/ios/dust-watch/DustWatch/ContentView.swift b/ios/dust-watch/DustWatch/ContentView.swift new file mode 100644 index 00000000..5bd0ed80 --- /dev/null +++ b/ios/dust-watch/DustWatch/ContentView.swift @@ -0,0 +1,300 @@ +import CoreLocation +import SwiftUI + +private let dustPink = Color(red: 246 / 255, green: 16 / 255, blue: 103 / 255) + +struct ContentView: View { + @EnvironmentObject private var model: WatchViewModel + + var body: some View { + Group { + switch model.state { + case .idle: + if model.hasCatalog { + CatalogHomeView() + } else { + EmptyCatalogView() + } + case .tracking, .arrived: + TrackingView() + } + } + } +} + +struct EmptyCatalogView: View { + var body: some View { + NavigationStack { + VStack(spacing: 6) { + Image(systemName: "applewatch") + .font(.system(size: 34)) + .foregroundStyle(.secondary) + Text("Update Watch from Favorites") + .font(.footnote) + .multilineTextAlignment(.center) + Text("on your iPhone.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding() + .dustTitle() + } + } +} + +struct CatalogHomeView: View { + @EnvironmentObject private var model: WatchViewModel + + var body: some View { + NavigationStack { + List { + Button { + model.navigateToNearest(.restroom) + } label: { + Label("Restroom", systemImage: "toilet") + } + + Button { + model.navigateToNearest(.ice) + } label: { + Label("Ice", systemImage: "snowflake") + } + + Button { + model.navigateToNearest(.medical) + } label: { + Label("Medical", systemImage: "staroflife") + } + + NavigationLink { + PlaceListView(title: "Camps", kind: .camp) + } label: { + Label("Camps", systemImage: "tent") + } + + NavigationLink { + PlaceListView(title: "Art", kind: .art) + } label: { + Label("Art", systemImage: "paintpalette") + } + + NavigationLink { + PlaceListView(title: "Friends", kind: .friend) + } label: { + Label("Friends", systemImage: "person.2") + } + + NavigationLink { + TimedListView( + title: "Events", + empty: "No upcoming Events or Parties.", + events: model.upcomingEvents + ) + } label: { + Label("Events", systemImage: "calendar") + } + + NavigationLink { + TimedListView( + title: "Reminders", + empty: "No upcoming Reminders.", + events: model.upcomingReminders + ) + } label: { + Label("Reminders", systemImage: "bell") + } + } + .dustTitle() + .onAppear { model.startBrowsingLocation() } + } + } +} + +enum PlaceKind { + case camp + case art + case friend +} + +struct PlaceListView: View { + @EnvironmentObject private var model: WatchViewModel + let title: String + let kind: PlaceKind + + private var places: [WatchPlace] { + switch kind { + case .camp: return model.campsByDistance + case .art: return model.artByDistance + case .friend: return model.friendsByDistance + } + } + + var body: some View { + List { + if places.isEmpty { + Text("Add them on iPhone, then Update Watch.") + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(places) { place in + if let coordinate = place.coordinate { + Button { + model.beginTracking(to: coordinate, name: place.name) + } label: { + placeRow(place, coordinate: coordinate) + } + .buttonStyle(.plain) + } else { + placeRow(place, coordinate: nil) + } + } + } + .navigationTitle(title) + } + + private func placeRow(_ place: WatchPlace, coordinate: CLLocationCoordinate2D?) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(place.name) + .foregroundStyle(coordinate == nil ? .secondary : .primary) + .lineLimit(2) + if let distance = model.formattedDistance(to: coordinate) { + Text(distance) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } +} + +struct TimedListView: View { + @EnvironmentObject private var model: WatchViewModel + let title: String + let empty: String + let events: [WatchTimed] + + var body: some View { + List { + if events.isEmpty { + Text(empty) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(events) { event in + if let coordinate = event.coordinate { + Button { + model.beginTracking(to: coordinate, name: event.name) + } label: { + eventRow(event, tappable: true) + } + .buttonStyle(.plain) + } else { + eventRow(event, tappable: false) + } + } + } + .navigationTitle(title) + } + + private func eventRow(_ event: WatchTimed, tappable: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(event.name) + .foregroundStyle(tappable ? .primary : .secondary) + .lineLimit(2) + Text(event.when ?? Self.whenText(event.start)) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private static func whenText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEE h:mma" + return formatter.string(from: date) + } +} + +struct TrackingView: View { + @EnvironmentObject private var model: WatchViewModel + + var body: some View { + VStack(spacing: 6) { + DirectionArrowView(rotation: model.arrowRotation, arrived: model.state == .arrived) + .frame(width: 110, height: 110) + + Text(model.distanceText) + .font(.title3.weight(.semibold)) + .monospacedDigit() + .lineLimit(1) + + Text(model.targetName ?? "") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + if model.state == .arrived { + Text("You've arrived") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.green) + } else if model.signalLost { + Text("Weak signal") + .font(.caption2) + .foregroundStyle(.orange) + } + + Button { + model.stopTracking() + } label: { + Text("Stop") + .fontWeight(.semibold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(dustPink) + } + .padding(.vertical, 4) + } +} + +/// Compass-needle style arrow. Rotates so "up" on screen points toward the +/// destination relative to the direction the wearer is facing. +struct DirectionArrowView: View { + let rotation: Double + let arrived: Bool + + var body: some View { + ZStack { + Circle() + .strokeBorder( + arrived ? Color.green.opacity(0.6) : Color.primary.opacity(0.2), + lineWidth: 2 + ) + + if arrived { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 58)) + .foregroundStyle(.green) + } else { + Image(systemName: "triangle.fill") + .font(.system(size: 6)) + .foregroundStyle(.secondary) + .offset(y: -44) + + Image(systemName: "location.north.fill") + .font(.system(size: 56)) + .foregroundStyle(Color.primary) + .rotationEffect(.degrees(rotation)) + .animation(.easeOut(duration: 0.2), value: rotation) + } + } + } +} + +private extension View { + func dustTitle() -> some View { + navigationTitle("dust.") + .toolbarBackground(dustPink, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } +} diff --git a/ios/dust-watch/DustWatch/DustWatchApp.swift b/ios/dust-watch/DustWatch/DustWatchApp.swift new file mode 100644 index 00000000..bb177c75 --- /dev/null +++ b/ios/dust-watch/DustWatch/DustWatchApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct DustWatchApp: App { + @StateObject private var model = WatchViewModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(model) + } + } +} diff --git a/ios/dust-watch/DustWatch/WatchCatalog.swift b/ios/dust-watch/DustWatch/WatchCatalog.swift new file mode 100644 index 00000000..f185792a --- /dev/null +++ b/ios/dust-watch/DustWatch/WatchCatalog.swift @@ -0,0 +1,116 @@ +import CoreLocation +import Foundation + +struct WatchPlace: Identifiable, Equatable { + let id: String + let name: String + let lat: Double? + let lng: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let lat, let lng else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lng) + } +} + +struct WatchTimed: Identifiable, Equatable { + let id: String + let name: String + let start: Date + let end: Date + let when: String? + let lat: Double? + let lng: Double? + + var coordinate: CLLocationCoordinate2D? { + guard let lat, let lng else { return nil } + return CLLocationCoordinate2D(latitude: lat, longitude: lng) + } + + func isUpcoming(at now: Date) -> Bool { + end > now + } +} + +struct WatchPoint: Identifiable, Equatable { + let id: String + let lat: Double + let lng: Double + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: lat, longitude: lng) + } + + var location: CLLocation { + CLLocation(latitude: lat, longitude: lng) + } +} + +struct WatchCatalog: Equatable { + var camps: [WatchPlace] = [] + var art: [WatchPlace] = [] + var events: [WatchTimed] = [] + var friends: [WatchPlace] = [] + var reminders: [WatchTimed] = [] + var restrooms: [WatchPoint] = [] + var ice: [WatchPoint] = [] + var medical: [WatchPoint] = [] + + static func parse(_ json: String) -> WatchCatalog? { + guard let data = json.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return WatchCatalog( + camps: places(from: obj["camps"]), + art: places(from: obj["art"]), + events: timed(from: obj["events"]), + friends: places(from: obj["friends"]), + reminders: timed(from: obj["reminders"]), + restrooms: points(from: obj["restrooms"]), + ice: points(from: obj["ice"]), + medical: points(from: obj["medical"]) + ) + } + + private static func places(from value: Any?) -> [WatchPlace] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.enumerated().compactMap { index, row in + guard let name = row["name"] as? String, !name.isEmpty else { return nil } + return WatchPlace(id: "\(index)-\(name)", name: name, lat: jsonDouble(row["lat"]), lng: jsonDouble(row["lng"])) + } + } + + private static func timed(from value: Any?) -> [WatchTimed] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.enumerated().compactMap { index, row in + guard let name = row["name"] as? String, !name.isEmpty, + let start = jsonDouble(row["start"]), + let end = jsonDouble(row["end"]) else { return nil } + return WatchTimed( + id: "\(index)-\(name)-\(start)", + name: name, + start: Date(timeIntervalSince1970: start / 1000), + end: Date(timeIntervalSince1970: end / 1000), + when: row["when"] as? String, + lat: jsonDouble(row["lat"]), + lng: jsonDouble(row["lng"]) + ) + } + } + + private static func points(from value: Any?) -> [WatchPoint] { + guard let rows = value as? [[String: Any]] else { return [] } + return rows.enumerated().compactMap { index, row in + guard let lat = jsonDouble(row["lat"]), let lng = jsonDouble(row["lng"]) else { return nil } + return WatchPoint(id: "\(index)-\(lat)-\(lng)", lat: lat, lng: lng) + } + } +} + +private func jsonDouble(_ value: Any?) -> Double? { + if let n = value as? NSNumber { return n.doubleValue } + if let d = value as? Double { return d } + if let i = value as? Int { return Double(i) } + return nil +} diff --git a/ios/dust-watch/DustWatch/WatchViewModel.swift b/ios/dust-watch/DustWatch/WatchViewModel.swift new file mode 100644 index 00000000..5680746e --- /dev/null +++ b/ios/dust-watch/DustWatch/WatchViewModel.swift @@ -0,0 +1,285 @@ +import Combine +import CoreLocation +import Foundation +import WatchConnectivity +import WatchKit + +enum WatchState { + /// Chooser (or empty prompt) when not navigating. + case idle + /// Live direction/distance to a Watch Destination. + case tracking + /// Within ~15 m of the destination. + case arrived +} + +enum WatchAmenity { + case restroom + case ice + case medical + + var title: String { + switch self { + case .restroom: return "Restroom" + case .ice: return "Ice" + case .medical: return "Medical" + } + } +} + +/// Owns WatchConnectivity + CoreLocation for the watch companion app. +/// +/// The iPhone pushes a Watch Catalog over WatchConnectivity. This model +/// chooses locally (nearest Restroom/Ice/Medical, Favorite lists) and tracks the +/// watch's own GPS for a compass-needle and live straight-line distance. +final class WatchViewModel: NSObject, ObservableObject { + @Published var state: WatchState = .idle + @Published var targetName: String? + @Published var distance: CLLocationDistance = 0 + @Published var arrowRotation: Double = 0 + @Published var signalLost = false + @Published var catalog = WatchCatalog() + @Published private(set) var hasCatalog = false + @Published private(set) var currentLocation: CLLocation? + @Published var now = Date() + + private var target: CLLocationCoordinate2D? + private var heading: Double? + private var pendingAmenity: WatchAmenity? + private var clockTimer: Timer? + + private let locationManager = CLLocationManager() + private let session: WCSession + private let catalogDefaultsKey = "dustCatalogJSON" + + override init() { + self.session = WCSession.default + super.init() + + if let json = UserDefaults.standard.string(forKey: catalogDefaultsKey) { + applyCatalogJSON(json, persist: false) + } + + session.delegate = self + if WCSession.isSupported() { + session.activate() + } + + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation + locationManager.distanceFilter = 5 + locationManager.headingFilter = 5 + + clockTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in + DispatchQueue.main.async { self?.now = Date() } + } + if let clockTimer { + RunLoop.main.add(clockTimer, forMode: .common) + } + + if hasCatalog { + startBrowsingLocation() + } + } + + deinit { + clockTimer?.invalidate() + } + + var upcomingEvents: [WatchTimed] { + catalog.events.filter { $0.isUpcoming(at: now) }.sorted { $0.start < $1.start } + } + + var upcomingReminders: [WatchTimed] { + catalog.reminders.filter { $0.isUpcoming(at: now) }.sorted { $0.start < $1.start } + } + + var campsByDistance: [WatchPlace] { + sortedByDistance(catalog.camps) + } + + var artByDistance: [WatchPlace] { + sortedByDistance(catalog.art) + } + + var friendsByDistance: [WatchPlace] { + sortedByDistance(catalog.friends) + } + + func beginTracking(to coordinate: CLLocationCoordinate2D, name: String) { + target = coordinate + targetName = name + pendingAmenity = nil + startBrowsingLocation() + locationManager.startUpdatingHeading() + state = .tracking + signalLost = false + updateDirection() + } + + func stopTracking() { + locationManager.stopUpdatingHeading() + target = nil + targetName = nil + heading = nil + distance = 0 + arrowRotation = 0 + signalLost = false + pendingAmenity = nil + state = .idle + if !hasCatalog { + locationManager.stopUpdatingLocation() + currentLocation = nil + } + } + + func navigateToNearest(_ amenity: WatchAmenity) { + let points: [WatchPoint] + switch amenity { + case .restroom: points = catalog.restrooms + case .ice: points = catalog.ice + case .medical: points = catalog.medical + } + guard !points.isEmpty else { + WKInterfaceDevice.current().play(.failure) + return + } + if let here = currentLocation { + startNearest(points: points, name: amenity.title, from: here) + } else { + pendingAmenity = amenity + startBrowsingLocation() + } + } + + func formattedDistance(to coordinate: CLLocationCoordinate2D?) -> String? { + guard let coordinate, let here = currentLocation else { return nil } + let meters = here.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) + return Self.formatDistance(meters) + } + + var distanceText: String { + Self.formatDistance(distance) + } + + func startBrowsingLocation() { + locationManager.requestWhenInUseAuthorization() + locationManager.startUpdatingLocation() + } + + private func startNearest(points: [WatchPoint], name: String, from here: CLLocation) { + guard let closest = points.min(by: { here.distance(from: $0.location) < here.distance(from: $1.location) }) else { + return + } + beginTracking(to: closest.coordinate, name: name) + } + + private func sortedByDistance(_ places: [WatchPlace]) -> [WatchPlace] { + guard let here = currentLocation else { return places } + return places.sorted { a, b in + distance(from: here, to: a.coordinate) < distance(from: here, to: b.coordinate) + } + } + + private func distance(from here: CLLocation, to coordinate: CLLocationCoordinate2D?) -> CLLocationDistance { + guard let coordinate else { return .greatestFiniteMagnitude } + return here.distance(from: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)) + } + + private func applyCatalogJSON(_ json: String, persist: Bool) { + guard let parsed = WatchCatalog.parse(json) else { return } + catalog = parsed + hasCatalog = true + if persist { + UserDefaults.standard.set(json, forKey: catalogDefaultsKey) + } + startBrowsingLocation() + } + + private func updateDirection() { + guard let target, let current = currentLocation else { return } + let from = current.coordinate + let bearing = Self.bearing(from: from, to: target) + distance = current.distance(from: CLLocation(latitude: target.latitude, longitude: target.longitude)) + let facing = heading ?? current.course + arrowRotation = Self.normalizeDegrees(bearing - facing) + } + + static func formatDistance(_ meters: CLLocationDistance) -> String { + if meters >= 160.934 { + let miles = meters / 1609.344 + return String(format: "%.1f mi", miles) + } + let feet = meters * 3.28084 + return String(format: "%.0f ft", feet) + } + + static func bearing(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) -> Double { + let lat1 = from.latitude * .pi / 180 + let lat2 = to.latitude * .pi / 180 + let dLon = (to.longitude - from.longitude) * .pi / 180 + let y = sin(dLon) * cos(lat2) + let x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon) + return normalizeDegrees(atan2(y, x) * 180 / .pi) + } + + static func normalizeDegrees(_ value: Double) -> Double { + let remainder = value.truncatingRemainder(dividingBy: 360) + return remainder < 0 ? remainder + 360 : remainder + } +} + +extension WatchViewModel: WCSessionDelegate { + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + guard activationState == .activated else { return } + if let json = session.receivedApplicationContext["dustCatalog"] as? String { + DispatchQueue.main.async { self.applyCatalogJSON(json, persist: true) } + } + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + let handled = handle(payload: message) + replyHandler(["ok": handled]) + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + _ = handle(payload: applicationContext) + } + + private func handle(payload: [String: Any]) -> Bool { + let json = (payload["catalogJson"] as? String) ?? (payload["dustCatalog"] as? String) + guard let json else { return false } + DispatchQueue.main.async { self.applyCatalogJSON(json, persist: true) } + return true + } +} + +extension WatchViewModel: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + currentLocation = location + signalLost = false + updateDirection() + + if let amenity = pendingAmenity { + pendingAmenity = nil + navigateToNearest(amenity) + } + + if let target, + location.distance(from: CLLocation(latitude: target.latitude, longitude: target.longitude)) < 15, + state == .tracking { + state = .arrived + WKInterfaceDevice.current().play(.notification) + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) { + heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading + updateDirection() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + signalLost = true + } +} diff --git a/src/app/camp/camp.page.html b/src/app/camp/camp.page.html index f10a4677..9b622ac9 100644 --- a/src/app/camp/camp.page.html +++ b/src/app/camp/camp.page.html @@ -59,7 +59,15 @@ @if (!camp.url) {

{{camp.name}}

} @if (camp.url) { -

{{camp.name}}

+

+ {{camp.name}} +

} @if (camp.location_string) { @@ -129,11 +137,6 @@

Parties

}
@if (showMap) { - + } diff --git a/src/app/camp/camp.page.ts b/src/app/camp/camp.page.ts index 2daa3224..5c70c102 100644 --- a/src/app/camp/camp.page.ts +++ b/src/app/camp/camp.page.ts @@ -177,7 +177,7 @@ export class CampPage implements OnInit { this.closePopover(); } - openPopover() { + async openPopover() { this.isPopoverOpen = true; } diff --git a/src/app/favs/favs.page.html b/src/app/favs/favs.page.html index d7350c4f..7cdfcbba 100644 --- a/src/app/favs/favs.page.html +++ b/src/app/favs/favs.page.html @@ -21,6 +21,12 @@ + @if (watchAvailable) { + + + Update Watch + + } Share Favorites diff --git a/src/app/favs/favs.page.ts b/src/app/favs/favs.page.ts index 5f0c3aa0..a4905d12 100644 --- a/src/app/favs/favs.page.ts +++ b/src/app/favs/favs.page.ts @@ -51,6 +51,7 @@ import { shareOutline, downloadOutline, qrCodeOutline, + watchOutline, } from 'ionicons/icons'; import { CalendarService } from '../calendar.service'; import { ToastController, AlertController } from '@ionic/angular'; @@ -59,10 +60,14 @@ import { GetFavoritesComponent, GetFavoritesResult } from '../get-favorites/get- import { ShareFavoritesComponent } from '../share-favorites/share-favorites.component'; import { ModalController } from '@ionic/angular/standalone'; import { Network } from '@capacitor/network'; +import { Capacitor } from '@capacitor/core'; +import { Haptics, NotificationType } from '@capacitor/haptics'; import { getTimeZoneOffsetHours } from '../utils/date-utils'; import { BadgeComponent } from '../badge/badge.component'; import { MapPolygon } from '../map/map-model'; import { buildCampMapFeatures, buildEventMapFeatures } from '../map/camp-polygon.utils'; +import { WatchService } from '../watch/watch.service'; +import { buildWatchCatalog } from '../watch/watch.catalog'; enum Filter { All = '', @@ -155,10 +160,12 @@ export class FavsPage implements OnInit { private _change = inject(ChangeDetectorRef); private calendarUrl: string | undefined; private router = inject(Router); + private watch = inject(WatchService); vm: FavsState = initialState(); ionContent = viewChild.required(IonContent); isPopoverOpen = false; + watchAvailable = false; constructor() { addIcons({ @@ -171,6 +178,7 @@ export class FavsPage implements OnInit { shareOutline, downloadOutline, qrCodeOutline, + watchOutline, }); effect(async () => { this.fav.changed(); @@ -366,12 +374,33 @@ export class FavsPage implements OnInit { openPopover() { this.isPopoverOpen = true; + void this.refreshWatchAvailable(); } closePopover() { this.isPopoverOpen = false; } + private async refreshWatchAvailable() { + this.watchAvailable = await this.watch.isWatchAvailable(); + this._change.detectChanges(); + } + + async updateWatch() { + this.closePopover(); + const catalog = await buildWatchCatalog(this.fav, this.db); + const result = await this.watch.sendCatalog(catalog); + if (result.ok) { + if (Capacitor.isNativePlatform()) { + await Haptics.notification({ type: NotificationType.Success }); + } + this.ui.presentToast('Watch updated with your favorites.', this.toastController); + } else { + const message = result.error ?? 'Your Apple Watch could not be reached right now.'; + await this.ui.presentAlert(this.alertController, message, 'Watch not available'); + } + } + async groupClick(gevent: Event) { const points: MapPoint[] = []; const polygons: MapPolygon[] = []; diff --git a/src/app/map/map.ts b/src/app/map/map.ts index 74cebf99..36620a19 100644 --- a/src/app/map/map.ts +++ b/src/app/map/map.ts @@ -19,12 +19,10 @@ import { DoubleSide, Shape, ShapeGeometry, - ExtrudeGeometry, - EdgesGeometry, - LineSegments, LineBasicMaterial, + LineLoop, BufferGeometry, - CylinderGeometry, + CircleGeometry, DirectionalLight, MeshPhongMaterial, Texture, @@ -56,26 +54,39 @@ interface AddPolygonResult { const GEOJSON_MAP_SIZE = 10000; /** - * Everything is drawn flat on the XZ plane and viewed from above, so geometry - * sharing a height z-fights and flickers. Each layer gets its own height band - * with a gap between, and pins are posts tall enough to clear every polygon. + * Everything is drawn flat on the XZ plane and viewed from above. All layers - + * polygons, pin discs, icons and labels - are flat shapes composited in paint + * order (see paintOrder below) with depth writes disabled, so coplanar layers + * can never z-fight and no extrusion or logarithmic depth buffer is needed. + * The small height offsets only keep raycast hit order and the transparent + * paint order deterministic. */ const layer = { cityBlockBase: 0.1, - cityBlockDepth: 0.4, // Top at 0.5 campBase: 0.6, - campDepth: 1.4, // Top at 2.0 campLabel: 2.4, - pinBase: 0.3, - pinTop: 10, - pinIcon: 10.6, - compassRaise: 1, // Your own location sits above every other pin + pin: 3.0, + pinIcon: 3.6, + compassRaise: 0.6, // Your own location sits above every other pin }; /** - * Adjacent camp polygons often overlap slightly, and coplanar overlaps flicker. - * Nudging each one by a bounded, index-derived amount keeps them separated - * without letting any polygon reach the label layer. + * Draw order for the flat layers. Higher layers paint over lower ones, so + * stacking never depends on the depth buffer. + */ +const paintOrder = { + cityBlockFill: 1, + cityBlockOutline: 2, + campFill: 3, + campOutline: 4, + pin: 5, + overlay: 6, // Pin icons and text labels +}; + +/** + * Adjacent camp polygons often overlap slightly. Nudging each height by a + * bounded, index-derived amount keeps the transparent paint order stable for + * the overlaps. */ function campPolygonOffset(index: number): number { return (index % 16) * 0.02; @@ -86,7 +97,7 @@ async function mapImage(map: MapModel, disposables: any[]): Promise { - event.preventDefault(); - console.warn('WebGL context lost. Stopping animation loop.'); - renderer.setAnimationLoop(null); - }; + container.appendChild(renderer.domElement); - const handleContextRestored = () => { - console.log('WebGL context restored. Resuming animation loop.'); - renderer.setAnimationLoop(renderFn); + // This is a mostly static 2D map: only render when a change was requested or + // while pin animations are running, instead of re-rendering every frame. + let needsRender = true; + const requestRender = () => { + needsRender = true; }; - canvas.addEventListener('webglcontextlost', handleContextLost, false); - canvas.addEventListener('webglcontextrestored', handleContextRestored, false); - - container.appendChild(renderer.domElement); - const renderFn = () => { // Prevent NaN camera positions that break rendering if (!isFinite(camera.position.x) || !isFinite(camera.position.y) || !isFinite(camera.position.z)) { @@ -239,11 +240,6 @@ export async function init3D(container: HTMLElement, map: MapModel): Promise { + const delta = clock.getDelta(); + for (const mixer of mixers) { + mixer.update(delta); + } + if (needsRender || mixers.length > 0) { + needsRender = false; + renderFn(); + } + }; + + // Add WebGL context loss/restore event handlers + const handleContextLost = (event: Event) => { + event.preventDefault(); + console.warn('WebGL context lost. Stopping animation loop.'); + renderer.setAnimationLoop(null); + }; + + const handleContextRestored = () => { + console.log('WebGL context restored. Resuming animation loop.'); + needsRender = true; + renderer.setAnimationLoop(renderLoop); + }; + + canvas.addEventListener('webglcontextlost', handleContextLost, false); + canvas.addEventListener('webglcontextrestored', handleContextRestored, false); + + // Create local camera instance instead of global. The camera never comes nearer + // than 20 units (controls.minDistance), so a near plane of 10 tightens depth + // precision 10x. Far covers the map diagonal when fully zoomed out. + const camera = new PerspectiveCamera(130, w / h, 10, 20000); camera.position.set(0, map.height / targetZoomLevel, 20); // Create local controls instance instead of global const controls = new MapControls(camera, renderer.domElement); - //controls.addEventListener( 'change', render ); // call this only in static scenes (i.e., if there is no animation loop) + controls.addEventListener('change', requestRender); // damping is off, so this fires for every interaction controls.enableDamping = false; // an animation loop is required when either damping or auto-rotation are enabled controls.dampingFactor = 0.05; controls.screenSpacePanning = false; @@ -278,6 +305,9 @@ export async function init3D(container: HTMLElement, map: MapModel): Promise { result.selectedPinIndex = undefined; + stopMixer(selectedAnimMixer, mixers); + selectedAnimMixer = undefined; for (const key of Object.keys(result.pinData)) { const mat: Material = result.pinData[key].background.material as Material; mat.opacity = 1; } updatePolygonSelection(result); + requestRender(); }; result.capture = async () => { @@ -422,6 +459,7 @@ export async function init3D(container: HTMLElement, map: MapModel): Promise { @@ -464,7 +502,7 @@ export async function init3D(container: HTMLElement, map: MapModel): Promise { // Prevent double-disposal @@ -531,7 +569,7 @@ export async function init3D(container: HTMLElement, map: MapModel): Promise polygon.pinIndex).filter((idx): idx is number => idx !== undefined)); + const polygonPinIndexes = new Set( + (map.polygons ?? []).map((polygon) => polygon.pinIndex).filter((idx): idx is number => idx !== undefined), + ); let p: AddPinResult | undefined = undefined; for (const pin of map.pins) { scaleToMap(pin, map.width, map.height, map.pinSizeMultiplier); @@ -564,7 +604,6 @@ async function createScene( addPolygon(polygon, map.width, map.height, font, scene, disposables, { interactive: false, base: layer.cityBlockBase, - depth: layer.cityBlockDepth, labels: polygonLabels, }); } @@ -575,7 +614,6 @@ async function createScene( for (const [i, polygon] of map.polygons.entries()) { const polygonResult = addPolygon(polygon, map.width, map.height, font, scene, disposables, { base: layer.campBase + campPolygonOffset(i), - depth: layer.campDepth, labels: polygonLabels, }); if (polygonResult) { @@ -661,8 +699,7 @@ function highlight(mesh: Mesh, result: MapResult) { function updatePolygonSelection(result: MapResult) { for (const polygon of result.polygonData ?? []) { const fillMaterial = polygon.fill.material as MeshPhongMaterial; - const isSelected = - result.selectedPinIndex !== undefined && polygon.pinIndex === result.selectedPinIndex; + const isSelected = result.selectedPinIndex !== undefined && polygon.pinIndex === result.selectedPinIndex; fillMaterial.color.setHex(isSelected ? polygon.selectedColor : polygon.baseColor); fillMaterial.opacity = isSelected ? polygon.selectedOpacity : polygon.baseOpacity; fillMaterial.emissive.setHex(0x000000); @@ -704,7 +741,7 @@ function getMaterial(pinColor: PinColor): MeshPhongMaterial { } } -function animateMesh(mesh: Mesh | Group, mixers: AnimationMixer[], scaleFrom = 1, scaleTo = 2) { +function animateMesh(mesh: Mesh | Group, mixers: AnimationMixer[], scaleFrom = 1, scaleTo = 2): AnimationMixer { const duration = 2; //const track = new NumberKeyframeTrack('.material.opacity', [0, 1, 2], [1, 0, 1]); @@ -716,6 +753,20 @@ function animateMesh(mesh: Mesh | Group, mixers: AnimationMixer[], scaleFrom = 1 action.setLoop(LoopRepeat, 9999); action.play(); mixers.push(mixer); + return mixer; +} + +/** + * Stop a running animation and remove it from the shared mixer list so the + * render loop no longer steps it. Used to stop the previously selected pin's + * pulse when a different pin is selected. + */ +function stopMixer(mixer: AnimationMixer | undefined, mixers: AnimationMixer[]) { + if (!mixer) return; + const index = mixers.indexOf(mixer); + if (index !== -1) mixers.splice(index, 1); + mixer.stopAllAction(); + mixer.uncacheRoot(mixer.getRoot()); } async function addPin( @@ -729,11 +780,16 @@ async function addPin( disposables: MapDisposable[], raise = 0, ): Promise { - // A post rather than a disc, so pins stay above (and clickable in front of) polygons - const pinHeight = layer.pinTop + raise - layer.pinBase; - const geometry = new CylinderGeometry(pin.size, pin.size, pinHeight, 24); + // A flat disc rather than a 3D post: the map has no height, so pins are just + // circles painted above (and clickable in front of) the polygons. + const geometry = new CircleGeometry(pin.size, 24); + // Bake the rotation into the geometry so the pulse animation scales in the map plane + geometry.rotateX(-Math.PI / 2); + material.transparent = true; + material.depthWrite = false; const mesh = new Mesh(geometry, material); - mesh.position.set(pin.x, layer.pinBase + pinHeight / 2, pin.z); + mesh.position.set(pin.x, layer.pin + raise, pin.z); + mesh.renderOrder = paintOrder.pin; mesh.uuid = pin.uuid; if (pin.animated) { animateMesh(mesh, mixers); @@ -808,7 +864,7 @@ function loadFont(name: string): Promise { function (error) { console.error(`Failed to load font: ${name}`, error); reject(new Error(`Failed to load font: ${name}`)); - } + }, ); }); } @@ -825,7 +881,7 @@ async function loadSVG(name: string): Promise { function (error) { console.error(`Failed to load SVG: ${name}`, error); reject(new Error(`Failed to load SVG: ${name}`)); - } + }, ); }); } @@ -842,7 +898,7 @@ async function loadTexture(name: string): Promise { function (error) { console.error(`Failed to load texture: ${name}`, error); reject(new Error(`Failed to load map image: ${name}`)); - } + }, ); }); } @@ -864,11 +920,7 @@ async function addSVG( const group = new Group(); for (const path of svg.paths) { - const material = new MeshBasicMaterial({ - color: path.color, - // side: DoubleSide, - // depthWrite: false - }); + const material = new MeshBasicMaterial({ color: path.color, transparent: true, depthWrite: false }); const shapes = SVGLoader.createShapes(path); const geometry = new ShapeGeometry(shapes); geometry.scale(scale, scale, scale); @@ -878,6 +930,7 @@ async function addSVG( disposables.push(mesh.geometry); disposables.push(mesh.material); mesh.uuid = uuid; + mesh.renderOrder = paintOrder.overlay; group.add(mesh); } group.position.y = 2; @@ -890,8 +943,9 @@ function addText(message: string, font: any, size: number, disposables: MapDispo // Return empty mesh if font is not available if (!font) { const geometry = new PlaneGeometry(size, size); - const material = new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0 }); + const material = new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0, depthWrite: false }); const text = new Mesh(geometry, material); + text.renderOrder = paintOrder.overlay; text.position.y = 2; text.rotation.x = -Math.PI / 2; disposables.push(geometry); @@ -907,11 +961,14 @@ function addText(message: string, font: any, size: number, disposables: MapDispo const material = new MeshBasicMaterial({ color: 0xffffff, side: DoubleSide, + transparent: true, + depthWrite: false, }); disposables.push(geometry); disposables.push(material); const text = new Mesh(geometry, material); + text.renderOrder = paintOrder.overlay; text.position.y = 2; text.rotation.x = -Math.PI / 2; return text; @@ -927,13 +984,11 @@ function addPolygon( options: { interactive?: boolean; base?: number; - depth?: number; labels?: { short: Mesh; full?: Mesh }[]; } = {}, ): AddPolygonResult | undefined { if (!polygon.points || polygon.points.length < 3) return undefined; const interactive = options.interactive !== false; - const depth = options.depth ?? layer.campDepth; const base = options.base ?? (interactive ? layer.campBase : layer.cityBlockBase); const shape = new Shape(); @@ -948,26 +1003,20 @@ function addPolygon( } shape.closePath(); - const geometry = new ExtrudeGeometry(shape, { - depth, - bevelEnabled: false, - }); + // Flat shape: layering is resolved by paint order, not by extrusion height + const geometry = new ShapeGeometry(shape); const baseColor = polygon.colorHex ?? getPolygonColor(polygon.color); const selectedColor = lightenColor(baseColor, 0.35); const baseOpacity = polygon.opacity ?? 1; const selectedOpacity = 1; + // Flat layers composite in paint order and never write depth, so adjacent + // polygons cannot z-fight, even when fully zoomed out on a 10,000-unit map. const material = new MeshPhongMaterial({ color: baseColor, - side: DoubleSide, - transparent: baseOpacity < 1, + transparent: true, opacity: baseOpacity, - depthWrite: true, - flatShading: true, + depthWrite: false, shininess: 8, - // Pushes the fill back so the outline below does not z-fight with the top face - polygonOffset: true, - polygonOffsetFactor: 1, - polygonOffsetUnits: 1, }); disposables.push(geometry); @@ -976,6 +1025,7 @@ function addPolygon( const mesh = new Mesh(geometry, material); mesh.position.y = base; mesh.rotation.x = -Math.PI / 2; + mesh.renderOrder = interactive ? paintOrder.campFill : paintOrder.cityBlockFill; mesh.uuid = interactive ? 'polygon' : 'map'; if (interactive && polygon.pinIndex !== undefined) mesh.userData['pinIndex'] = polygon.pinIndex; if (!interactive) { @@ -983,21 +1033,23 @@ function addPolygon( } scene.add(mesh); - // Dark outline so adjacent camp borders stay readable - const edgesGeometry = new EdgesGeometry(geometry, 20); - const edgeMaterial = new LineBasicMaterial({ + // Dark outline so adjacent camp borders stay readable. Built straight from + // the ring points - much cheaper than edge extraction on generated geometry. + const outlineGeometry = new BufferGeometry().setFromPoints(scaledPoints.map((pt) => new Vector3(pt.x, -pt.z, 0))); + const outlineMaterial = new LineBasicMaterial({ color: darkenColor(baseColor, interactive ? 0.55 : 0.35), - linewidth: 2, + transparent: true, + depthWrite: false, }); - disposables.push(edgesGeometry); - disposables.push(edgeMaterial); - const edges = new LineSegments(edgesGeometry, edgeMaterial); - edges.position.copy(mesh.position); - edges.rotation.copy(mesh.rotation); - edges.renderOrder = 1; + disposables.push(outlineGeometry); + disposables.push(outlineMaterial); + const outline = new LineLoop(outlineGeometry, outlineMaterial); + outline.position.copy(mesh.position); + outline.rotation.copy(mesh.rotation); + outline.renderOrder = interactive ? paintOrder.campOutline : paintOrder.cityBlockOutline; // Visual only — clicks should hit the fill mesh - edges.raycast = () => {}; - scene.add(edges); + outline.raycast = () => {}; + scene.add(outline); let labelMesh: Mesh | undefined; if (polygon.label) { diff --git a/src/app/watch/watch.catalog.spec.ts b/src/app/watch/watch.catalog.spec.ts new file mode 100644 index 00000000..4ffed732 --- /dev/null +++ b/src/app/watch/watch.catalog.spec.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; +import { Art, Camp, Event, MapPoint, Reminder, RSLEvent } from '../data/models'; +import { + amenityWatchPoints, + artCoord, + campCoord, + hasPlayaAddress, + placesFromArt, + placesFromCamps, + pointsFromMapPoints, + reminderDurationMs, + timedFromOfficialEvents, + timedFromParties, + timedFromReminders, + validGps, +} from './watch.catalog'; + +describe('validGps', () => { + it('rejects missing, non-finite, and 0,0 coordinates', () => { + expect(validGps(undefined)).toBeUndefined(); + expect(validGps({ lat: 0, lng: 0 })).toBeUndefined(); + expect(validGps({ lat: Number.NaN, lng: -119 })).toBeUndefined(); + }); + + it('keeps a real playa coordinate', () => { + expect(validGps({ lat: 40.78, lng: -119.21 })).toEqual({ lat: 40.78, lng: -119.21 }); + }); +}); + +describe('placesFromCamps', () => { + it('prefers precise gps and still lists camps without coordinates', () => { + const withGps = { + name: 'Center Camp', + gps: { lat: 40.78, lng: -119.21 }, + gpsCoord: { lat: 1, lng: 2 }, + } as Camp; + const unplaced = { name: 'Ghost Camp', gpsCoord: { lat: 0, lng: 0 } } as Camp; + expect(placesFromCamps([withGps, unplaced])).toEqual([ + { name: 'Center Camp', lat: 40.78, lng: -119.21 }, + { name: 'Ghost Camp' }, + ]); + }); +}); + +describe('placesFromArt', () => { + it('uses location GPS then gpsCoords', () => { + const fromLocation = { + name: 'The Man', + location: { gps_latitude: 40.8, gps_longitude: -119.2 }, + gpsCoords: { lat: 1, lng: 2 }, + } as Art; + const fromCoords = { name: 'Temple', gpsCoords: { lat: 40.79, lng: -119.19 } } as Art; + expect(artCoord(fromLocation)).toEqual({ lat: 40.8, lng: -119.2 }); + expect(placesFromArt([fromCoords])).toEqual([{ name: 'Temple', lat: 40.79, lng: -119.19 }]); + }); +}); + +describe('timedFromOfficialEvents', () => { + const now = new Date('2026-08-28T20:00:00.000Z'); + + it('keeps upcoming occurrences and drops ended ones', () => { + const upcoming = { + title: 'Brunch', + timeString: '9:00pm', + gpsCoords: { lat: 40.78, lng: -119.21 }, + occurrence_set: [ + { + start_time: '2026-08-28T21:00:00.000Z', + end_time: '2026-08-28T22:00:00.000Z', + }, + ], + } as Event; + const ended = { + title: 'Yesterday', + occurrence_set: [ + { + start_time: '2026-08-27T21:00:00.000Z', + end_time: '2026-08-27T22:00:00.000Z', + }, + ], + } as Event; + const rows = timedFromOfficialEvents([upcoming, ended], now); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Brunch'); + expect(rows[0].when).toBe('9:00pm'); + expect(rows[0].lat).toBe(40.78); + }); +}); + +describe('timedFromParties', () => { + const now = new Date('2026-08-28T20:00:00.000Z'); + + it('lists art-car parties without GPS and skips ended sets', () => { + const party = { + camp: 'Robot Heart', + title: 'the party', + occurrences: [ + { + who: 'DJ Dust', + startTime: '2026-08-28T21:00:00.000Z', + endTime: '2026-08-28T22:00:00.000Z', + timeRange: '9:00-10:00pm', + time: '9:00pm', + id: '1', + }, + { + who: 'Old Set', + startTime: '2026-08-27T21:00:00.000Z', + endTime: '2026-08-27T22:00:00.000Z', + timeRange: 'done', + time: 'done', + id: '2', + }, + ], + } as RSLEvent; + const rows = timedFromParties([{ party }], now); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ name: 'DJ Dust', when: '9:00-10:00pm' }); + expect(rows[0].lat).toBeUndefined(); + }); +}); + +describe('pointsFromMapPoints', () => { + it('drops points without usable GPS', () => { + const points = [ + { street: '', clock: '', gps: { lat: 40.7, lng: -119.2 } }, + { street: '', clock: '', gps: { lat: 0, lng: 0 } }, + { street: '', clock: '' }, + ] as MapPoint[]; + expect(pointsFromMapPoints(points)).toEqual([{ lat: 40.7, lng: -119.2 }]); + }); +}); + +describe('amenityWatchPoints', () => { + it('calculates GPS from map pins when lat/lng is missing', async () => { + const points = await amenityWatchPoints( + [ + async () => ({ points: [] }), + async () => ({ points: [{ street: '', clock: '', x: 100, y: 200 }] as MapPoint[] }), + ], + async (pts) => pts.map((p) => ({ ...p, gps: { lat: 40.78, lng: -119.21 } })), + ); + expect(points).toEqual([{ lat: 40.78, lng: -119.21 }]); + }); + + it('skips a GPS file of 0,0 and uses the next source', async () => { + const points = await amenityWatchPoints( + [ + async () => ({ points: [{ street: '', clock: '', gps: { lat: 0, lng: 0 } }] as MapPoint[] }), + async () => ({ points: [{ street: '', clock: '', gps: { lat: 40.7, lng: -119.2 } }] as MapPoint[] }), + ], + async (pts) => pts, + ); + expect(points).toEqual([{ lat: 40.7, lng: -119.2 }]); + }); +}); + +describe('hasPlayaAddress', () => { + it('rejects empty and the Choose Address placeholder', () => { + expect(hasPlayaAddress(undefined)).toBe(false); + expect(hasPlayaAddress('')).toBe(false); + expect(hasPlayaAddress('Choose Address')).toBe(false); + expect(hasPlayaAddress('3:00 & C')).toBe(true); + }); +}); + +describe('timedFromReminders', () => { + const now = new Date('2026-08-28T20:00:00.000Z'); + + it('keeps upcoming reminders, drops ended ones, and lists those without GPS', () => { + const upcoming: Reminder = { + id: '1', + title: 'Meet at 9', + start: '2026-08-28T21:00:00.000Z', + address: '3:00 & C', + notes: '', + }; + const ended: Reminder = { + id: '2', + title: 'Yesterday', + start: '2026-08-27T21:00:00.000Z', + address: undefined, + notes: '', + }; + const noPlace: Reminder = { + id: '3', + title: 'Call camp', + start: '2026-08-28T22:00:00.000Z', + address: '', + notes: '', + }; + const rows = timedFromReminders( + [upcoming, ended, noPlace], + [{ lat: 40.78, lng: -119.21 }, undefined, undefined], + now, + ); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ name: 'Meet at 9', lat: 40.78, lng: -119.21 }); + expect(rows[0].end - rows[0].start).toBe(reminderDurationMs); + expect(rows[1]).toMatchObject({ name: 'Call camp' }); + expect(rows[1].lat).toBeUndefined(); + }); +}); + +describe('campCoord', () => { + it('falls back to derived gpsCoord when precise gps is missing', () => { + const camp = { name: 'X', gpsCoord: { lat: 40.1, lng: -119.1 } } as Camp; + expect(campCoord(camp)).toEqual({ lat: 40.1, lng: -119.1 }); + }); +}); diff --git a/src/app/watch/watch.catalog.ts b/src/app/watch/watch.catalog.ts new file mode 100644 index 00000000..474f9c8b --- /dev/null +++ b/src/app/watch/watch.catalog.ts @@ -0,0 +1,274 @@ +import { Art, Camp, Event, Friend, MapPoint, Names, Reminder, RSLEvent } from '../data/models'; +import { FavoritesService } from '../favs/favorites.service'; +import { DbService } from '../data/db.service'; +import { GpsCoord } from '../map/geo.utils'; +import { getCampCenterGps } from '../map/camp-polygon.utils'; +import { toMapPoint } from '../map/map.utils'; +import { now } from '../utils/utils'; + +/** A named catalog row. lat/lng omitted when the place is unknown. */ +export interface WatchPlace { + name: string; + lat?: number; + lng?: number; +} + +/** An Event or Party row. start/end are unix milliseconds. */ +export interface WatchTimed { + name: string; + start: number; + end: number; + when?: string; + lat?: number; + lng?: number; +} + +export interface WatchPoint { + lat: number; + lng: number; +} + +export interface WatchCatalog { + camps: WatchPlace[]; + art: WatchPlace[]; + events: WatchTimed[]; + friends: WatchPlace[]; + reminders: WatchTimed[]; + restrooms: WatchPoint[]; + ice: WatchPoint[]; + medical: WatchPoint[]; +} + +export function validGps(coord?: GpsCoord | null): GpsCoord | undefined { + if (!coord) return undefined; + const lat = Number(coord.lat); + const lng = Number(coord.lng); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined; + if (lat === 0 && lng === 0) return undefined; + return { lat, lng }; +} + +export function campCoord(camp: Camp): GpsCoord | undefined { + return getCampCenterGps(camp) ?? validGps(camp.gpsCoord); +} + +export function artCoord(art: Art): GpsCoord | undefined { + if (art.location?.gps_latitude != null && art.location?.gps_longitude != null) { + const fromLocation = validGps({ lat: art.location.gps_latitude, lng: art.location.gps_longitude }); + if (fromLocation) return fromLocation; + } + return validGps(art.gpsCoords); +} + +export function placesFromCamps(camps: Camp[]): WatchPlace[] { + return camps.map((camp) => place(camp.name, campCoord(camp))); +} + +export function placesFromArt(art: Art[]): WatchPlace[] { + return art.map((item) => place(item.name, artCoord(item))); +} + +export function hasPlayaAddress(address?: string | null): boolean { + const value = address?.trim(); + return !!value && value !== 'Choose Address'; +} + +/** Reminders have a start time only; treat them as one hour long, like the phone calendar. */ +export const reminderDurationMs = 60 * 60 * 1000; + +export function timedFromReminders(reminders: Reminder[], gpsFor: Array, at: Date): WatchTimed[] { + const result: WatchTimed[] = []; + reminders.forEach((reminder, index) => { + const start = Date.parse(reminder.start); + if (!Number.isFinite(start)) return; + const end = start + reminderDurationMs; + if (end <= at.getTime()) return; + const name = reminder.title?.trim() || 'Reminder'; + result.push(timed(name, start, end, undefined, gpsFor[index])); + }); + return result; +} + +export function timedFromOfficialEvents(events: Event[], at: Date): WatchTimed[] { + const result: WatchTimed[] = []; + for (const event of events) { + const occurrence = event.occurrence_set?.[0]; + if (!occurrence) continue; + const start = Date.parse(occurrence.start_time); + const end = Date.parse(occurrence.end_time); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= at.getTime()) continue; + result.push(timed(event.title, start, end, event.timeString, validGps(event.gpsCoords))); + } + return result; +} + +export function timedFromParties(parties: Array<{ party: RSLEvent; gps?: GpsCoord }>, at: Date): WatchTimed[] { + const result: WatchTimed[] = []; + for (const { party, gps } of parties) { + for (const occurrence of party.occurrences ?? []) { + const start = Date.parse(occurrence.startTime); + const end = Date.parse(occurrence.endTime); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= at.getTime()) continue; + const name = occurrence.who || party.title || party.camp || 'Party'; + result.push(timed(name, start, end, occurrence.timeRange || occurrence.time, gps)); + } + } + return result; +} + +export function pointsFromMapPoints(points: MapPoint[]): WatchPoint[] { + const result: WatchPoint[] = []; + for (const point of points) { + const gps = validGps(point.gps); + if (gps) result.push(gps); + } + return result; +} + +/** + * Load Restroom/Ice/Medical points, filling GPS from map x/y or street/clock when the + * dataset has no lat/lng. Tries each loader until one yields usable coordinates. + */ +export async function amenityWatchPoints( + loaders: Array<() => Promise<{ points?: MapPoint[] }>>, + fillGps: (points: MapPoint[]) => Promise, +): Promise { + for (const load of loaders) { + const set = await load(); + if (!set?.points?.length) continue; + const filled = await fillGps(set.points); + const points = pointsFromMapPoints(filled); + if (points.length) return points; + } + return []; +} + +/** + * Snapshot of Favorites plus Restroom, Ice, and Medical points for the watch. + * Upcoming-only for Events, Parties, and Reminders; items without GPS are still listed. + */ +export async function buildWatchCatalog(fav: FavoritesService, db: DbService, at: Date = now()): Promise { + const favs = await fav.getFavorites(); + const friends = favs.friends ?? []; + const reminders = favs.privateEvents ?? []; + const [camps, art, events, parties, restrooms, ice, medical, friendPlaces, reminderRows] = await Promise.all([ + db.getCampList(favs.camps ?? []), + db.getArtList(favs.art ?? []), + fav.getEventList(favs.events ?? [], false, [], false), + fav.getRSLEventList(favs.rslEvents ?? []), + amenityWatchPoints( + [ + () => db.getGPSPoints(Names.restrooms, 'Restrooms'), + () => db.getPins('Restrooms'), + () => db.getMapPoints(Names.restrooms), + ], + (points) => db.setMapPointsGPS(points), + ), + amenityWatchPoints( + [() => db.getMapPoints(Names.ice), () => db.getPins('Ice'), () => db.getGPSPoints(Names.ice, 'Ice')], + (points) => db.setMapPointsGPS(points), + ), + amenityWatchPoints( + [ + () => db.getMapPoints(Names.medical), + () => db.getPins('Medical'), + () => db.getGPSPoints(Names.medical, 'Medical'), + ], + (points) => db.setMapPointsGPS(points), + ), + placesFromFriends(friends, db), + gpsListForAddresses( + db, + reminders.map((reminder) => reminder.address), + ), + ]); + + const partyRows = await Promise.all( + (parties ?? []).map(async (party) => ({ party, gps: await gpsForParty(party, db) })), + ); + + const timedEvents = [...timedFromOfficialEvents(events ?? [], at), ...timedFromParties(partyRows, at)].sort( + (a, b) => a.start - b.start, + ); + + return { + camps: placesFromCamps(camps ?? []), + art: placesFromArt(art ?? []), + events: timedEvents, + friends: friendPlaces, + reminders: timedFromReminders(reminders, reminderRows, at).sort((a, b) => a.start - b.start), + restrooms, + ice, + medical, + }; +} + +async function placesFromFriends(friends: Friend[], db: DbService): Promise { + return Promise.all( + friends.map(async (friend) => { + let gps = await gpsFromAddress(db, friend.address); + if (!gps && friend.camp) { + try { + const camp = await db.findCamp(friend.camp); + gps = camp ? campCoord(camp) : undefined; + } catch { + // Camp may have been removed from the dataset. + } + } + return place(friend.name || 'Friend', gps); + }), + ); +} + +async function gpsListForAddresses( + db: DbService, + addresses: Array, +): Promise> { + return Promise.all(addresses.map((address) => gpsFromAddress(db, address))); +} + +export async function gpsFromAddress(db: DbService, address?: string): Promise { + if (!hasPlayaAddress(address)) return undefined; + const point = toMapPoint(address); + if (point.street === 'unplaced') return undefined; + const filled = await db.setMapPointsGPS([point]); + return validGps(filled[0]?.gps); +} + +async function gpsForParty(party: RSLEvent, db: DbService): Promise { + const direct = validGps(party.gpsCoords); + if (direct) return direct; + if (party.artId) { + try { + const art = await db.findArt(party.artId); + const gps = art ? artCoord(art) : undefined; + if (gps) return gps; + } catch { + // Art may have been removed from the dataset. + } + } + if (party.campId) { + try { + const camp = await db.findCamp(party.campId); + const gps = camp ? campCoord(camp) : undefined; + if (gps) return gps; + } catch { + // Camp may have been removed from the dataset. + } + } + return undefined; +} + +function place(name: string, gps?: GpsCoord): WatchPlace { + return gps ? { name, lat: gps.lat, lng: gps.lng } : { name }; +} + +function timed(name: string, start: number, end: number, when: string | undefined, gps?: GpsCoord): WatchTimed { + const row: WatchTimed = { name, start, end }; + if (when) row.when = when; + if (gps) { + row.lat = gps.lat; + row.lng = gps.lng; + } + return row; +} diff --git a/src/app/watch/watch.service.spec.ts b/src/app/watch/watch.service.spec.ts new file mode 100644 index 00000000..16d3ef91 --- /dev/null +++ b/src/app/watch/watch.service.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const { mockCapacitor, mockPlugin } = vi.hoisted(() => ({ + mockCapacitor: { + isNativePlatform: vi.fn(), + getPlatform: vi.fn(), + }, + mockPlugin: { + isWatchAppInstalled: vi.fn(), + sendCatalog: vi.fn(), + }, +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: mockCapacitor, + registerPlugin: () => mockPlugin, +})); + +import { WatchService } from './watch.service'; +import { WatchCatalog } from './watch.catalog'; + +const catalog: WatchCatalog = { + camps: [{ name: 'Center Camp', lat: 40.78, lng: -119.21 }], + art: [], + events: [], + friends: [], + reminders: [], + restrooms: [{ lat: 40.77, lng: -119.2 }], + ice: [], + medical: [], +}; + +describe('WatchService', () => { + let service: WatchService; + + beforeEach(() => { + service = new WatchService(); + vi.clearAllMocks(); + }); + + describe('isWatchAvailable', () => { + it('returns false on non-native platforms', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false); + expect(await service.isWatchAvailable()).toBe(false); + expect(mockPlugin.isWatchAppInstalled).not.toHaveBeenCalled(); + }); + + it('returns false on non-iOS native platforms (Android)', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true); + mockCapacitor.getPlatform.mockReturnValue('android'); + expect(await service.isWatchAvailable()).toBe(false); + }); + + it('returns true on iOS when the watch app is installed', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true); + mockCapacitor.getPlatform.mockReturnValue('ios'); + mockPlugin.isWatchAppInstalled.mockResolvedValue({ installed: true }); + expect(await service.isWatchAvailable()).toBe(true); + }); + + it('returns false on iOS when the watch app is not installed', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true); + mockCapacitor.getPlatform.mockReturnValue('ios'); + mockPlugin.isWatchAppInstalled.mockResolvedValue({ installed: false }); + expect(await service.isWatchAvailable()).toBe(false); + }); + + it('returns false when the native call throws', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true); + mockCapacitor.getPlatform.mockReturnValue('ios'); + mockPlugin.isWatchAppInstalled.mockRejectedValue(new Error('nope')); + expect(await service.isWatchAvailable()).toBe(false); + }); + }); + + describe('sendCatalog', () => { + it('returns a structured error on non-iOS platforms', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(false); + const result = await service.sendCatalog(catalog); + expect(result.ok).toBe(false); + expect(result.reachable).toBe(false); + expect(result.error).toBeTruthy(); + expect(mockPlugin.sendCatalog).not.toHaveBeenCalled(); + }); + + it('stringifies the catalog on iOS when the native send succeeds', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true); + mockCapacitor.getPlatform.mockReturnValue('ios'); + mockPlugin.sendCatalog.mockResolvedValue({ success: true }); + const result = await service.sendCatalog(catalog); + expect(result.ok).toBe(true); + expect(result.reachable).toBe(true); + expect(mockPlugin.sendCatalog).toHaveBeenCalledWith({ + catalogJson: JSON.stringify(catalog), + }); + }); + + it('maps native errors into the result', async () => { + mockCapacitor.isNativePlatform.mockReturnValue(true); + mockCapacitor.getPlatform.mockReturnValue('ios'); + mockPlugin.sendCatalog.mockResolvedValue({ success: false, error: 'Watch not installed' }); + const result = await service.sendCatalog(catalog); + expect(result.ok).toBe(false); + expect(result.reachable).toBe(true); + expect(result.error).toBe('Watch not installed'); + }); + }); +}); diff --git a/src/app/watch/watch.service.ts b/src/app/watch/watch.service.ts new file mode 100644 index 00000000..a7f6cf87 --- /dev/null +++ b/src/app/watch/watch.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import { Capacitor, registerPlugin } from '@capacitor/core'; +import { WatchCatalog } from './watch.catalog'; + +export interface SendCatalogResult { + ok: boolean; + /** True when the request reached the native iOS bridge. */ + reachable: boolean; + error?: string; +} + +/** + * Native contract implemented by the iOS WatchConnectivity plugin + * (see ios/App/WatchConnectivityPlugin.swift). + */ +export interface WatchConnectivityPlugin { + isWatchAppInstalled(): Promise<{ installed: boolean }>; + sendCatalog(options: { catalogJson: string }): Promise<{ success: boolean; error?: string }>; +} + +const WatchConnectivity = registerPlugin('WatchConnectivity'); + +@Injectable({ + providedIn: 'root', +}) +export class WatchService { + /** + * True only when running on iOS with an installed Dust Apple Watch companion + * app. Used to conditionally reveal the "Update Watch" menu item. + */ + public async isWatchAvailable(): Promise { + const native = Capacitor.isNativePlatform(); + const platform = Capacitor.getPlatform(); + console.log(`[watch] isWatchAvailable() native=%o platform=%o`, native, platform); + if (!native || platform !== 'ios') { + console.log(`[watch] not native iOS -> unavailable`); + return false; + } + try { + const result = await WatchConnectivity.isWatchAppInstalled(); + console.log(`[watch] isWatchAppInstalled() result=%o`, result); + return result.installed === true; + } catch (e) { + console.warn(`[watch] isWatchAppInstalled() threw:`, e); + return false; + } + } + + /** + * Replace the Watch Catalog on the paired Apple Watch. + */ + public async sendCatalog(catalog: WatchCatalog): Promise { + if (!Capacitor.isNativePlatform() || Capacitor.getPlatform() !== 'ios') { + return { ok: false, reachable: false, error: 'Apple Watch is only supported on iPhone.' }; + } + try { + const result = await WatchConnectivity.sendCatalog({ catalogJson: JSON.stringify(catalog) }); + return { ok: result.success === true, reachable: true, error: result.error }; + } catch (e) { + const message = e instanceof Error ? e.message : undefined; + return { ok: false, reachable: false, error: message ?? 'Could not reach the Apple Watch right now.' }; + } + } +}