diff --git a/.github/workflows/bootstrap_glucose_guard.yml b/.github/workflows/bootstrap_glucose_guard.yml new file mode 100644 index 0000000000..9341c5a09b --- /dev/null +++ b/.github/workflows/bootstrap_glucose_guard.yml @@ -0,0 +1,47 @@ +name: Bootstrap Glucose Guard +run-name: Bootstrap Glucose Guard (${{ github.ref_name }}) +on: + workflow_dispatch: + push: + branches: + - main + - cursor/glucose-guard-branding-05a7 + paths: + - "glucose-guard-design/**" + - "Scripts/publish_glucose_guard_design.sh" + - "Scripts/apply_glucose_guard_branding.sh" + - ".github/workflows/bootstrap_glucose_guard.yml" + +permissions: + contents: read + actions: write + +jobs: + publish-design-and-build: + runs-on: ubuntu-latest + env: + GH_PAT: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ secrets.GH_PAT }} + steps: + - name: Checkout Loop fork + uses: actions/checkout@v5 + + - name: Require GH_PAT + run: | + if [ -z "${GH_PAT}" ]; then + echo "Secret GH_PAT is missing. Add the same PAT used for Loop browser builds." >&2 + exit 1 + fi + + - name: Publish iDustbin/glucoseguard + env: + GLUCOSE_GUARD_DESIGN_REPO: glucoseguard + run: ./Scripts/publish_glucose_guard_design.sh + + - name: Enable and start 4. Build Loop + run: | + set -euo pipefail + gh workflow enable "4. Build Loop" || true + gh workflow run "4. Build Loop" --ref "${GITHUB_REF_NAME}" + echo "Dispatched 4. Build Loop on ${GITHUB_REF_NAME}" + echo "That job applies icons, GlucoseGuard name, and the Figma mobile theme from iDustbin/glucoseguard, then Fastlane/TestFlight." diff --git a/.github/workflows/build_loop.yml b/.github/workflows/build_loop.yml index 6e6a68da58..4a3ca40fae 100644 --- a/.github/workflows/build_loop.yml +++ b/.github/workflows/build_loop.yml @@ -96,9 +96,10 @@ jobs: # This syncs any target branch to upstream branch of the same name - name: Sync upstream changes - if: | # do not run the upstream sync action on the upstream repository + if: | # skip on feature branches so a TestFlight test build is not reset to LoopKit steps.workflow-permission.outputs.has_permission == 'true' && - vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'LoopKit' + vars.SCHEDULED_SYNC != 'false' && github.repository_owner != 'LoopKit' && + (github.ref_name == 'main' || github.ref_name == 'dev') id: sync uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.2 with: @@ -190,8 +191,8 @@ jobs: # LoopWorkspace patches # -applies any patches located in the LoopWorkspace/patches/ directory - if $(ls ./patches/* &> /dev/null); then - git apply ./patches/* --allow-empty -v --whitespace=fix + if $(ls ./patches/*.patch &> /dev/null); then + git apply ./patches/*.patch --allow-empty -v --whitespace=fix fi # Submodule Loop patches: @@ -215,7 +216,7 @@ jobs: env: GH_PAT: ${{ secrets.GH_PAT }} GLUCOSE_GUARD_DESIGN_OWNER: iDustbin - GLUCOSE_GUARD_DESIGN_REPO: glucose-guard-design + GLUCOSE_GUARD_DESIGN_REPO: glucoseguard GLUCOSE_GUARD_DESIGN_REF: main run: ./Scripts/apply_glucose_guard_branding.sh diff --git a/LoopConfigOverride.xcconfig b/LoopConfigOverride.xcconfig index b3bbbc9986..0af7136341 100644 --- a/LoopConfigOverride.xcconfig +++ b/LoopConfigOverride.xcconfig @@ -3,7 +3,8 @@ // Override this if you don't want the default com.${DEVELOPMENT_TEAM}.loopkit that loop uses // MAIN_APP_BUNDLE_IDENTIFIER = com.myname.loop -// Customize this to change the app name displayed +// Home-screen name. Source of truth is also +// glucose-guard-design/ios/display_name.xcconfig (published to iDustbin/glucoseguard). MAIN_APP_DISPLAY_NAME = GlucoseGuard // Customize this to change the URL to open Loop to something other than the display name diff --git a/README.md b/README.md index 3e81f6b882..f4e2cf3f0b 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ This fork builds **Glucose Guard** on top of [LoopKit/LoopWorkspace](https://github.com/LoopKit/LoopWorkspace). -This step is **iOS only**: GitHub Actions sync from upstream Loop, apply Glucose -Guard icons and the display name, then publish to the existing TestFlight -account. Branding lives in `glucose-guard-design/` (and later in -[iDustbin/glucose-guard-design](https://github.com/iDustbin/glucose-guard-design) -when that repo exists). There is no web or Kubernetes app in this step. +Two GitHub pieces, then one iOS build: + +1. This repo is already the fork of [LoopKit/LoopWorkspace](https://github.com/LoopKit/LoopWorkspace). +2. [iDustbin/glucoseguard](https://github.com/iDustbin/glucoseguard) holds logo, icons, the **GlucoseGuard** display name, and the Figma mobile theme. The **Bootstrap Glucose Guard** Action publishes that repo (via `GH_PAT`) and then starts **4. Build Loop**. +3. Build Loop clones the design repo, applies branding plus native Xcode/SwiftUI Figma screens, and uploads to the existing TestFlight app (`com.TEAMID.loopkit.Loop`). No new Apple App ID. + +Run **Bootstrap Glucose Guard** (or push to this branch) after `GH_PAT` is set. Then install the new TestFlight build to see icon, name, and the Glucose Guard home chrome. For a local Xcode icon/name refresh after an upstream pull: diff --git a/Scripts/apply_glucose_guard_branding.sh b/Scripts/apply_glucose_guard_branding.sh index fbf40a3bcf..1f7de7de8a 100755 --- a/Scripts/apply_glucose_guard_branding.sh +++ b/Scripts/apply_glucose_guard_branding.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# Pull Glucose Guard branding from iDustbin/glucose-guard-design and apply +# Pull Glucose Guard branding from iDustbin/glucoseguard and apply # it to this LoopWorkspace checkout. Used after upstream LoopKit sync so # icons and the display name survive a fork reset. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DESIGN_OWNER="${GLUCOSE_GUARD_DESIGN_OWNER:-iDustbin}" -DESIGN_REPO="${GLUCOSE_GUARD_DESIGN_REPO:-glucose-guard-design}" +DESIGN_REPO="${GLUCOSE_GUARD_DESIGN_REPO:-glucoseguard}" DESIGN_REF="${GLUCOSE_GUARD_DESIGN_REF:-main}" LOCAL_DESIGN="${GLUCOSE_GUARD_DESIGN_PATH:-}" CLONE_DIR="" @@ -29,25 +29,35 @@ resolve_source() { fi local bundled="${ROOT}/glucose-guard-design" - local url="https://github.com/${DESIGN_OWNER}/${DESIGN_REPO}.git" CLONE_DIR="$(mktemp -d)" + export GIT_TERMINAL_PROMPT=0 echo "Cloning ${DESIGN_OWNER}/${DESIGN_REPO}@${DESIGN_REF}" >&2 set +e if [[ -n "${GH_PAT:-}" ]]; then - git -c "http.extraHeader=Authorization: Bearer ${GH_PAT}" \ - clone --depth 1 --branch "${DESIGN_REF}" "${url}" "${CLONE_DIR}" + git clone --depth 1 --branch "${DESIGN_REF}" \ + "https://x-access-token:${GH_PAT}@github.com/${DESIGN_OWNER}/${DESIGN_REPO}.git" \ + "${CLONE_DIR}" else - git clone --depth 1 --branch "${DESIGN_REF}" "${url}" "${CLONE_DIR}" + git clone --depth 1 --branch "${DESIGN_REF}" \ + "https://github.com/${DESIGN_OWNER}/${DESIGN_REPO}.git" \ + "${CLONE_DIR}" fi local clone_status=$? set -e if [[ "${clone_status}" -eq 0 && -d "${CLONE_DIR}/ios" ]]; then + echo "Cloned https://github.com/${DESIGN_OWNER}/${DESIGN_REPO} @ $(git -C "${CLONE_DIR}" rev-parse --short HEAD)" >&2 printf '%s\n' "${CLONE_DIR}" return fi + # In CI the design repo must be used. Local Xcode can fall back to the bundle. + if [[ -n "${GH_PAT:-}" ]]; then + echo "Failed to clone https://github.com/${DESIGN_OWNER}/${DESIGN_REPO}. Not using the bundled fallback in CI." >&2 + exit 1 + fi + echo "Remote ${DESIGN_OWNER}/${DESIGN_REPO} is not available; using bundled glucose-guard-design." >&2 rm -rf "${CLONE_DIR}" CLONE_DIR="" @@ -56,7 +66,7 @@ resolve_source() { return fi - echo "No Glucose Guard design source found. Create iDustbin/glucose-guard-design or set GLUCOSE_GUARD_DESIGN_PATH." >&2 + echo "No Glucose Guard design source found. Create iDustbin/glucoseguard or set GLUCOSE_GUARD_DESIGN_PATH." >&2 exit 1 } @@ -107,6 +117,58 @@ print(f"Set {replacement}") PY } +apply_localized_display_name() { + local name="$1" + python3 - "${ROOT}" "${name}" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +name = sys.argv[2] +keys = ("CFBundleDisplayName", "CFBundleName") +updated = 0 + +loop_root = root / "Loop" +if not loop_root.is_dir(): + print("Loop submodule is missing; cannot rewrite InfoPlist.xcstrings", file=sys.stderr) + sys.exit(1) + +for path in loop_root.rglob("InfoPlist.xcstrings"): + data = json.loads(path.read_text(encoding="utf-8")) + strings = data.get("strings") + if not isinstance(strings, dict): + continue + changed = False + for key in keys: + entry = strings.get(key) + if not isinstance(entry, dict): + continue + locs = entry.get("localizations") + if not isinstance(locs, dict): + continue + for loc in locs.values(): + if not isinstance(loc, dict): + continue + unit = loc.get("stringUnit") + if isinstance(unit, dict) and "value" in unit: + unit["value"] = name + changed = True + if changed: + path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + updated += 1 + print(f"Set {name} in {path.relative_to(root)}") + +if updated == 0: + print("No Loop InfoPlist.xcstrings files were updated", file=sys.stderr) + sys.exit(1) +print(f"Updated CFBundleDisplayName in {updated} string catalog(s)") +PY +} + SRC="$(resolve_source)" copy_catalog \ "${SRC}/ios/OverrideAssetsLoop.xcassets" \ @@ -115,4 +177,39 @@ copy_catalog \ "${SRC}/ios/OverrideAssetsWatchApp.xcassets" \ "${ROOT}/OverrideAssetsWatchApp.xcassets" apply_display_name "${SRC}/ios/display_name.xcconfig" +DISPLAY_NAME="$(python3 - "${SRC}/ios/display_name.xcconfig" <<'PY' +from pathlib import Path +import sys +name = "GlucoseGuard" +p = Path(sys.argv[1]) +if p.is_file(): + for line in p.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped.startswith("MAIN_APP_DISPLAY_NAME"): + _, _, value = stripped.partition("=") + if value.strip(): + name = value.strip() + break +print(name) +PY +)" +apply_localized_display_name "${DISPLAY_NAME}" + +# Loop.xcconfig includes this file after the default MAIN_APP_DISPLAY_NAME = Loop. +printf '%s\n' \ + "// Generated from glucose-guard-design/ios/display_name.xcconfig" \ + "MAIN_APP_DISPLAY_NAME = ${DISPLAY_NAME}" \ + > "${ROOT}/Loop/LoopOverride.xcconfig" +echo "Wrote Loop/LoopOverride.xcconfig (${DISPLAY_NAME})" + +THEME_SCRIPT="${SRC}/ios/apply_theme.py" +if [[ ! -f "${THEME_SCRIPT}" ]]; then + THEME_SCRIPT="${ROOT}/glucose-guard-design/ios/apply_theme.py" +fi +if [[ ! -d "${ROOT}/Loop/LoopUI" ]]; then + echo "Loop submodule is missing; cannot apply Glucose Guard theme" >&2 + exit 1 +fi +python3 "${THEME_SCRIPT}" --loop-root "${ROOT}/Loop" --design-root "${SRC}" + echo "Glucose Guard branding applied from ${SRC}" diff --git a/Scripts/publish_glucose_guard_design.sh b/Scripts/publish_glucose_guard_design.sh new file mode 100755 index 0000000000..c2a7e72327 --- /dev/null +++ b/Scripts/publish_glucose_guard_design.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Create or update iDustbin/glucoseguard from the bundled branding tree. +# Uses GH_PAT (never printed). Intended for GitHub Actions. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OWNER="${GLUCOSE_GUARD_DESIGN_OWNER:-iDustbin}" +REPO="${GLUCOSE_GUARD_DESIGN_REPO:-glucoseguard}" +SRC="${ROOT}/glucose-guard-design" + +if [[ -z "${GH_PAT:-}" ]]; then + echo "GH_PAT is unset. Add the Loop browser-build PAT as a repo secret." >&2 + exit 1 +fi + +if [[ ! -d "${SRC}/ios" ]]; then + echo "Missing ${SRC}/ios" >&2 + exit 1 +fi + +export GH_TOKEN="${GH_PAT}" +export GH_PROMPT_DISABLED=1 +export GIT_TERMINAL_PROMPT=0 + +if ! gh repo view "${OWNER}/${REPO}" >/dev/null 2>&1; then + echo "Creating ${OWNER}/${REPO}" + gh repo create "${OWNER}/${REPO}" \ + --public \ + --description "Glucose Guard iOS branding: logo, icons, display name" \ + --disable-wiki +else + echo "${OWNER}/${REPO} already exists" +fi + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT +cp -R "${SRC}/." "${work}/" +cd "${work}" +git init -b main +git config user.name "github-actions[bot]" +git config user.email "41898282+github-actions[bot]@users.noreply.github.com" +git add -A +git commit -m "sync iOS branding from LoopWorkspace" + +# Token only in the remote URL for this process; do not echo the URL. +git remote add origin "https://x-access-token:${GH_PAT}@github.com/${OWNER}/${REPO}.git" +git push --force origin main + +echo "Published branding to https://github.com/${OWNER}/${REPO}" diff --git a/glucose-guard-design/.gitignore b/glucose-guard-design/.gitignore index 1a7cd0f3a9..1f719963bb 100644 --- a/glucose-guard-design/.gitignore +++ b/glucose-guard-design/.gitignore @@ -2,3 +2,5 @@ .idea/ .vscode/ *.log +__pycache__/ +*.py[cod] diff --git a/glucose-guard-design/README.md b/glucose-guard-design/README.md index c97f3bf54d..ae9c200109 100644 --- a/glucose-guard-design/README.md +++ b/glucose-guard-design/README.md @@ -1,12 +1,13 @@ # Glucose Guard Design -iOS branding for **Glucose Guard**: logo, color tokens, and Loop override app icons. +iOS branding for **Glucose Guard**: logo, color tokens, Loop override app icons, +and native Xcode/SwiftUI screens from the Figma mobile theme +(Today / Learning / Healthway / +, CGM and pump detail, A1C). Step 1 is **iOS only**. The Loop fork [iDustbin/LoopWorkspace](https://github.com/iDustbin/LoopWorkspace) syncs from [LoopKit/LoopWorkspace](https://github.com/LoopKit/LoopWorkspace). GitHub Actions -then apply `ios/` (icons + display name) and publish to the existing TestFlight -account. +then apply `ios/` and publish to the existing TestFlight account. ## Layout @@ -29,5 +30,6 @@ GLUCOSE_GUARD_DESIGN_PATH=/path/to/glucose-guard-design \ ./Scripts/apply_glucose_guard_branding.sh ``` -Until `iDustbin/glucose-guard-design` exists as its own GitHub repo, this tree -stays bundled inside the Loop fork so TestFlight still gets the Glucose Guard icon. +The GitHub source of truth is [iDustbin/glucoseguard](https://github.com/iDustbin/glucoseguard). +This folder is the copy that Bootstrap publishes there, and the local fallback +if the remote is not cloned yet. diff --git a/glucose-guard-design/branding/tokens.json b/glucose-guard-design/branding/tokens.json index 49e4e8628e..99e5ba1c8e 100644 --- a/glucose-guard-design/branding/tokens.json +++ b/glucose-guard-design/branding/tokens.json @@ -10,7 +10,7 @@ }, "idle": { "color": "#F4F4F5", - "meaning": "White ring — awaiting a live connection (placeholder in step 1)" + "meaning": "White/aging ring — waiting for a fresh closed loop" }, "disconnected": { "color": "#F4333C", diff --git a/glucose-guard-design/docs/step-1.md b/glucose-guard-design/docs/step-1.md index 35d3dc8b73..737a4fcb68 100644 --- a/glucose-guard-design/docs/step-1.md +++ b/glucose-guard-design/docs/step-1.md @@ -4,28 +4,26 @@ Branding source of truth for the Loop iOS fork (`iDustbin/LoopWorkspace`). ## Implemented now -- App name: **GlucoseGuard** +- App name: **GlucoseGuard** on the Home Screen (rewrites Loop `InfoPlist.xcstrings`) - Robot-cross logo on brand red (`#F4333C`) - iOS and watchOS override app icons -- GitHub Actions on the Loop fork pull this tree **after** syncing - `LoopKit/LoopWorkspace`, then Fastlane publishes to the existing TestFlight - account +- Native **Xcode/SwiftUI** screens from the Figma mobile theme: + - Accent / glucose charts in brand red, closed-loop ring in green (`#22C55E`) + - Logo inside the loop status ring + - SwiftUI tab bar: **Today · Learning · Healthway · +** + - CGM detail: 14-day sensor bar, last reading, AVG + - Pump detail: 3-day pod bar, basal, change bolus + - Learning: 3 / 7 / 30 / 90 day AVG, SD, **A1C**, TIR / TBR / TAR + - Healthway: Glucose Values dashboard + - Bolus: current recommended amount plus **Change Bolus** + - Add CGM overlay lists LibreLinkUp and Dexcom Share +- GitHub Actions pull this tree after `LoopKit/LoopWorkspace` sync, then Fastlane uploads to the existing TestFlight app ## Not in this step - Kubernetes / web dashboard -- Live CGM, pump, or Nightscout values -- Withings, Rex.fit, or other third-party health APIs -- Medical-person search and dataset access requests -- Food overview (owned by another company / designer) -- Custom Omnipod DASH communication patches +- Withings, Rex.fit, or clinician search +- Food overview / restaurant favorites (owned by another company) +- Custom Omnipod DASH radio patches (stay synced with LoopKit) -Omnipod DASH drops on newer InPlay/Atlas pods (especially iPhone 16 / 17e) are -handled upstream. Stay synced with LoopKit so Pod Keep Alive (Loop 3.14+) -arrives through the fork-sync workflow. - -## Later - -- Separate design repo on GitHub -- Web/Kubernetes shell -- CGM detail, A1C/GMI, averages +Omnipod DASH drops on newer InPlay/Atlas pods are handled upstream. Stay synced with LoopKit so Pod Keep Alive arrives through the fork-sync workflow. diff --git a/glucose-guard-design/ios/apply_theme.py b/glucose-guard-design/ios/apply_theme.py new file mode 100755 index 0000000000..95e3da0fe1 --- /dev/null +++ b/glucose-guard-design/ios/apply_theme.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +"""Apply Glucose Guard mobile theme onto a checked-out Loop submodule.""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +MARKER = "GLUCOSE_GUARD_THEME" + + +def die(message: str) -> None: + print(message, file=sys.stderr) + raise SystemExit(1) + + +def replace_once(path: Path, old: str, new: str, *, required: bool = True) -> bool: + text = path.read_text(encoding="utf-8") + if old not in text: + if new.strip()[:40] in text or MARKER in text and required is False: + return False + if required: + die(f"Could not find expected text in {path}") + return False + path.write_text(text.replace(old, new, 1), encoding="utf-8") + print(f"Patched {path}") + return True + + +def copy_colorset(src: Path, dest_catalog: Path, name: str) -> None: + source = src / f"{name}.colorset" + if not source.is_dir(): + die(f"Missing colorset {source}") + target = dest_catalog / f"{name}.colorset" + if target.exists(): + shutil.rmtree(target) + shutil.copytree(source, target) + print(f"Wrote {target}") + + +def apply_colors(design: Path, loop: Path) -> None: + theme = design / "ios" / "theme" + catalogs = [ + loop / "Loop" / "DerivedAssets.xcassets", + loop / "WatchApp" / "DerivedAssets.xcassets", + loop / "Loop Widget Extension" / "DerivedAssets.xcassets", + ] + for catalog in catalogs: + if not catalog.is_dir(): + print(f"Skip missing asset catalog {catalog}") + continue + for name in ("accent", "fresh", "glucose"): + copy_colorset(theme, catalog, name) + + +def apply_hud_mark(design: Path, loop: Path) -> None: + source = design / "ios" / "overlays" / "glucose_guard_mark.imageset" + dest = loop / "LoopUI" / "HUDAssets.xcassets" / "glucose_guard_mark.imageset" + if not source.is_dir(): + die(f"Missing HUD mark {source}") + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(source, dest) + print(f"Wrote {dest}") + + overlay = design / "ios" / "overlays" / "LoopStateView.swift" + target = loop / "LoopUI" / "Views" / "LoopStateView.swift" + if not overlay.is_file(): + die(f"Missing {overlay}") + shutil.copyfile(overlay, target) + print(f"Wrote {target}") + + +def apply_color_fallbacks(loop: Path) -> None: + uicolor = loop / "LoopUI" / "Extensions" / "UIColor.swift" + replace_once( + uicolor, + "@nonobjc static let fresh = UIColor(named: \"fresh\") ?? HIGGreenColor()", + "@nonobjc static let fresh = UIColor(named: \"fresh\") ?? UIColor(red: 0.133, green: 0.773, blue: 0.369, alpha: 1)", + ) + replace_once( + uicolor, + "@nonobjc static let glucose = UIColor(named: \"glucose\") ?? systemTeal", + "@nonobjc static let glucose = UIColor(named: \"glucose\") ?? UIColor(red: 0.957, green: 0.200, blue: 0.235, alpha: 1)", + ) + replace_once( + uicolor, + "@nonobjc public static let loopAccent = UIColor(named: \"accent\") ?? systemBlue", + "@nonobjc public static let loopAccent = UIColor(named: \"accent\") ?? UIColor(red: 0.957, green: 0.200, blue: 0.235, alpha: 1)", + ) + replace_once( + uicolor, + "@nonobjc public static let critical = systemRed", + "@nonobjc public static let critical = UIColor(red: 0.957, green: 0.200, blue: 0.235, alpha: 1)", + ) + + swift_color = loop / "LoopUI" / "Extensions" / "Color.swift" + replace_once( + swift_color, + " public static let critical = red", + " public static let critical = Color(red: 0.957, green: 0.200, blue: 0.235)", + ) + + +def apply_hud_chrome(loop: Path) -> None: + device = loop / "LoopUI" / "Views" / "DeviceStatusHUDView.swift" + replace_once( + device, + " progressView.tintColor = .systemGray", + " progressView.tintColor = UIColor(red: 0.957, green: 0.200, blue: 0.235, alpha: 1)", + ) + replace_once( + device, + " backgroundView.backgroundColor = .systemBackground\n backgroundView.layer.cornerRadius = 23", + " backgroundView.backgroundColor = .systemBackground\n backgroundView.layer.cornerRadius = 18\n backgroundView.layer.borderWidth = 1\n backgroundView.layer.borderColor = UIColor.separator.cgColor", + ) + + status_bar = loop / "LoopUI" / "Views" / "StatusBarHUDView.swift" + replace_once( + status_bar, + " self.backgroundColor = UIColor.secondarySystemBackground", + " self.backgroundColor = UIColor.systemBackground", + ) + + completion = loop / "LoopUI" / "Views" / "LoopCompletionHUDView.swift" + replace_once( + completion, + " return (title: LocalizedString(\"Loop Warning\", comment: \"Title of yellow loop message\"),", + " return (title: LocalizedString(\"Glucose Guard Warning\", comment: \"Title of yellow loop message\"),", + ) + replace_once( + completion, + " return (title: LocalizedString(\"Loop Failure\", comment: \"Title of red loop message\"),", + " return (title: LocalizedString(\"Glucose Guard Failure\", comment: \"Title of red loop message\"),", + ) + + +def apply_toolbar(loop: Path) -> None: + path = loop / "Loop" / "View Controllers" / "StatusTableViewController.swift" + text = path.read_text(encoding="utf-8") + if "presentGlucoseGuardLogSheet" in text: + print(f"Home chrome already applied in {path}") + return + + old_setup = """ private func setupToolbarItems() { + let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) + let carbs = UIBarButtonItem(image: UIImage(named: "carbs"), style: .plain, target: self, action: #selector(userTappedAddCarbs)) + let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) + let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) + + let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) + let workout = createWorkoutButtonItem(selected: false, isEnabled: true) + toolbarItems = [ + carbs, + space, + preMeal, + space, + bolus, + space, + workout, + space, + settings + ] + }""" + new_setup = """ private func setupToolbarItems() { + let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) + let symbol = UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold) + let today = UIBarButtonItem(image: UIImage(systemName: "sun.max.fill", withConfiguration: symbol), style: .plain, target: self, action: #selector(presentGlucoseGuardToday)) + let stats = UIBarButtonItem(image: UIImage(systemName: "chart.bar.fill", withConfiguration: symbol), style: .plain, target: self, action: #selector(presentGlucoseGuardStatistics)) + let add = UIBarButtonItem(image: UIImage(systemName: "plus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .bold)), style: .plain, target: self, action: #selector(presentGlucoseGuardLogSheet)) + let settings = UIBarButtonItem(image: UIImage(systemName: "person.crop.circle", withConfiguration: symbol), style: .plain, target: self, action: #selector(onSettingsTapped)) + toolbarItems = [ + today, + space, + stats, + space, + add, + space, + settings + ] + }""" + replace_once(path, old_setup, new_setup) + + old_update = """ private func updateToolbarItems() { + let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true + + toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") + toolbarItems![0].isEnabled = isPumpOnboarded + toolbarItems![0].tintColor = UIColor.carbTintColor + toolbarItems![4].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") + toolbarItems![4].isEnabled = isPumpOnboarded + toolbarItems![4].tintColor = UIColor.insulinTintColor + toolbarItems![8].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") + toolbarItems![8].tintColor = UIColor.secondaryLabel + + toolbarItems![2] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) + toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) + }""" + new_update = """ private func updateToolbarItems() { + let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true + + toolbarItems![0].accessibilityLabel = NSLocalizedString("Today", comment: "The label of the Today toolbar item") + toolbarItems![0].isEnabled = true + toolbarItems![0].tintColor = UIColor.loopAccent + toolbarItems![2].accessibilityLabel = NSLocalizedString("Statistics", comment: "The label of the statistics toolbar item") + toolbarItems![2].isEnabled = true + toolbarItems![2].tintColor = UIColor.secondaryLabel + toolbarItems![4].accessibilityLabel = NSLocalizedString("Add", comment: "The label of the add toolbar item") + toolbarItems![4].isEnabled = isPumpOnboarded || deviceManager.cgmManager != nil + toolbarItems![4].tintColor = UIColor.loopAccent + toolbarItems![6].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") + toolbarItems![6].tintColor = UIColor.secondaryLabel + }""" + replace_once(path, old_update, new_update) + + replace_once( + path, + " tableView.backgroundColor = .secondarySystemBackground", + " tableView.backgroundColor = .systemBackground", + ) + + insert_after = """ present(navigationWrapper, animated: true) + deviceManager.analyticsServicesManager.didDisplayBolusScreen() + } +""" + chrome = Path(__file__).with_name("overlays") / "GlucoseGuardHomeChrome.swift.txt" + stats = Path(__file__).with_name("overlays") / "GlucoseGuardStatistics.swift.txt" + chrome_text = chrome.read_text(encoding="utf-8") + stats_text = stats.read_text(encoding="utf-8") + text = path.read_text(encoding="utf-8") + if insert_after not in text: + die(f"Could not insert home chrome into {path}") + text = text.replace(insert_after, insert_after + "\n" + chrome_text + "\n", 1) + if not text.endswith("\n"): + text += "\n" + text += "\n" + stats_text + if not text.endswith("\n"): + text += "\n" + path.write_text(text, encoding="utf-8") + print(f"Inserted Glucose Guard home chrome into {path}") + + +def apply_xcode_design(loop: Path) -> None: + path = loop / "Loop" / "View Controllers" / "StatusTableViewController.swift" + replace_once( + path, + " navigationController?.setToolbarHidden(false, animated: animated)", + " navigationController?.setToolbarHidden(true, animated: animated)", + ) + status_text = path.read_text(encoding="utf-8") + if "installGlucoseGuardTabBar()" not in status_text: + replace_once( + path, + " setupToolbarItems()\n", + " setupToolbarItems()\n installGlucoseGuardTabBar()\n", + ) + replace_once( + path, + """ @objc private func pumpStatusTapped(_ sender: UIGestureRecognizer) { + if let pumpStatusView = sender.view as? PumpStatusHUDView { + executeHUDTapAction(deviceManager.didTapOnPumpStatus(pumpStatusView.pumpManagerProvidedHUD)) + } + }""", + """ @objc private func pumpStatusTapped(_ sender: UIGestureRecognizer) { + presentGlucoseGuardPumpDetail() + }""", + ) + replace_once( + path, + """ @objc private func cgmStatusTapped( _ sender: UIGestureRecognizer) { + executeHUDTapAction(deviceManager.didTapOnCGMStatus()) + }""", + """ @objc private func cgmStatusTapped( _ sender: UIGestureRecognizer) { + presentGlucoseGuardCGMDetail() + }""", + ) + + text = path.read_text(encoding="utf-8") + if "installGlucoseGuardTabBar" in text and "GLUCOSE_GUARD_XCODE_DESIGN host" not in text: + host = Path(__file__).with_name("overlays") / "GlucoseGuardTabBarHost.swift.txt" + insert_after = """ present(navigationWrapper, animated: true) + deviceManager.analyticsServicesManager.didDisplayBolusScreen() + } +""" + if insert_after not in text: + die(f"Could not insert Xcode tab bar host into {path}") + text = text.replace(insert_after, insert_after + "\n" + host.read_text(encoding="utf-8") + "\n", 1) + path.write_text(text, encoding="utf-8") + print(f"Inserted Glucose Guard Xcode tab bar host into {path}") + + text = path.read_text(encoding="utf-8") + if "GLUCOSE_GUARD_XCODE_DESIGN\n" not in text: + design = Path(__file__).with_name("overlays") / "GlucoseGuardXcodeDesign.swift.txt" + if not text.endswith("\n"): + text += "\n" + path.write_text(text + "\n" + design.read_text(encoding="utf-8"), encoding="utf-8") + print(f"Appended Glucose Guard Xcode design screens to {path}") + + +def apply_bolus_and_settings(loop: Path) -> None: + bolus = loop / "Loop" / "Views" / "BolusEntryView.swift" + replace_once( + bolus, + " Text(\"Bolus\", comment: \"Label for bolus entry row on bolus screen\")", + " Text(\"Change Bolus\", comment: \"Label for bolus entry row on bolus screen\")", + ) + replace_once( + bolus, + " bolusEntryRow\n }", + " bolusEntryRow\n Text(\"Current recommended bolus is above. Enter a new amount to change it.\", comment: \"Help text for changing the current bolus\")\n .font(.footnote)\n .foregroundColor(.secondary)\n }", + required=False, + ) + + settings = loop / "Loop" / "Views" / "SettingsView.swift" + replace_once( + settings, + """ case .cgmPicker: + return ActionSheet( + title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), + buttons: cgmChoices + )""", + """ case .cgmPicker: + return ActionSheet( + title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), + message: Text("LibreLinkUp, Dexcom Share, or another CGM source.", comment: "CGM chooser subtitle listing required sources"), + buttons: cgmChoices + )""", + ) + replace_once( + settings, + " descriptiveText: NSLocalizedString(\"Tap here to set up a CGM\", comment: \"Descriptive text for button to add CGM device\"))", + " descriptiveText: NSLocalizedString(\"LibreLinkUp, Dexcom Share, or another CGM. Sensor expiry is in CGM detail.\", comment: \"Descriptive text for button to add CGM device\"))", + ) + replace_once( + settings, + " .accentColor(Color(.systemGray))", + " .accentColor(Color(red: 0.957, green: 0.200, blue: 0.235))", + ) + settings_text = settings.read_text(encoding="utf-8") + if "glucoseGuardProfileSection" not in settings_text: + replace_once( + settings, + " loopSection", + " loopSection\n glucoseGuardProfileSection", + ) + + profile = ''' + private var glucoseGuardProfileSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Profile", comment: "Settings profile section"))) { + VStack(alignment: .leading, spacing: 6) { + Text("A1C, AVG, SD, TIR, TBR, and TAR") + .font(.headline) + Text("Open Statistics from the Today toolbar to switch 3, 7, 30, or 90 days.") + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + } +''' + text = settings.read_text(encoding="utf-8") + if "glucoseGuardProfileSection" in text and "private var glucoseGuardProfileSection" not in text: + # referenced but not defined — fall through to insert + pass + if "private var glucoseGuardProfileSection" not in text: + needle = " private var loopSection: some View {" + if needle not in text: + die(f"Could not insert profile section into {settings}") + settings.write_text(text.replace(needle, profile + "\n" + needle, 1), encoding="utf-8") + print(f"Inserted profile section into {settings}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--loop-root", required=True) + parser.add_argument("--design-root", required=True) + args = parser.parse_args() + loop = Path(args.loop_root) + design = Path(args.design_root) + if not (loop / "LoopUI").is_dir(): + die(f"Loop checkout is missing LoopUI: {loop}") + apply_colors(design, loop) + apply_hud_mark(design, loop) + apply_color_fallbacks(loop) + apply_hud_chrome(loop) + apply_toolbar(loop) + apply_xcode_design(loop) + apply_bolus_and_settings(loop) + print("Glucose Guard mobile theme applied") + + +if __name__ == "__main__": + main() diff --git a/glucose-guard-design/ios/display_name.xcconfig b/glucose-guard-design/ios/display_name.xcconfig index bb2416fa62..2e7f162671 100644 --- a/glucose-guard-design/ios/display_name.xcconfig +++ b/glucose-guard-design/ios/display_name.xcconfig @@ -1,3 +1,4 @@ // Applied by Scripts/apply_glucose_guard_branding.sh in LoopWorkspace. +// This is the Home Screen name (not the TestFlight / App Store Connect listing). // Do not put team IDs, API keys, or signing secrets in this file. MAIN_APP_DISPLAY_NAME = GlucoseGuard diff --git a/glucose-guard-design/ios/overlays/GlucoseGuardHomeChrome.swift.txt b/glucose-guard-design/ios/overlays/GlucoseGuardHomeChrome.swift.txt new file mode 100644 index 0000000000..c673c56001 --- /dev/null +++ b/glucose-guard-design/ios/overlays/GlucoseGuardHomeChrome.swift.txt @@ -0,0 +1,71 @@ + // GLUCOSE_GUARD_THEME home chrome + + @objc private func presentGlucoseGuardToday() { + tableView.setContentOffset(.zero, animated: true) + } + + @objc private func presentGlucoseGuardLogSheet() { + let sheet = UIAlertController( + title: NSLocalizedString("Add", comment: "Title of Glucose Guard add sheet"), + message: NSLocalizedString("Insulin bolus, measured glucose, or carbohydrates. The bolus screen shows the current recommended amount and lets you change it.", comment: "Subtitle of Glucose Guard add sheet"), + preferredStyle: .actionSheet + ) + sheet.addAction(UIAlertAction( + title: NSLocalizedString("Insulin Bolus", comment: "Add-sheet action to change bolus"), + style: .default, + handler: { [weak self] _ in self?.presentBolusScreen() } + )) + sheet.addAction(UIAlertAction( + title: NSLocalizedString("Glucose Value", comment: "Add-sheet action to enter glucose"), + style: .default, + handler: { [weak self] _ in self?.presentBolusEntryView(enableManualGlucoseEntry: true) } + )) + sheet.addAction(UIAlertAction( + title: NSLocalizedString("Carbohydrates", comment: "Add-sheet action to enter carbs"), + style: .default, + handler: { [weak self] _ in self?.userTappedAddCarbs() } + )) + let preMealAction = UIAlertAction( + title: NSLocalizedString("Pre-Meal Targets", comment: "Add-sheet action for pre-meal"), + style: .default, + handler: { [weak self] _ in self?.togglePreMealMode(confirm: false) } + ) + preMealAction.isEnabled = preMealModeAllowed + sheet.addAction(preMealAction) + let workoutAction = UIAlertAction( + title: NSLocalizedString("Workout Targets", comment: "Add-sheet action for workout"), + style: .default, + handler: { [weak self] _ in self?.presentCustomPresets(confirm: false) } + ) + workoutAction.isEnabled = workoutModeAllowed + sheet.addAction(workoutAction) + sheet.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel add sheet"), style: .cancel)) + present(sheet, animated: true) + } + + @objc private func presentGlucoseGuardStatistics() { + let unit = deviceManager.displayGlucosePreference.unit + let root = GlucoseGuardStatisticsView(displayUnit: unit) { start, completion in + self.deviceManager.glucoseStore.getGlucoseSamples(start: start, end: nil) { result in + let values: [Double] + switch result { + case .success(let samples): + values = samples.map { $0.quantity.doubleValue(for: .milligramsPerDeciliter) } + case .failure: + values = [] + } + DispatchQueue.main.async { + completion(values) + } + } + } + let hosting = UIHostingController(rootView: root) + hosting.title = NSLocalizedString("Statistics", comment: "Title of Glucose Guard statistics") + let navigation = UINavigationController(rootViewController: hosting) + hosting.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: navigation, + action: #selector(UIViewController.dismissWithAnimation) + ) + present(navigation, animated: true) + } diff --git a/glucose-guard-design/ios/overlays/GlucoseGuardStatistics.swift.txt b/glucose-guard-design/ios/overlays/GlucoseGuardStatistics.swift.txt new file mode 100644 index 0000000000..a28ffdf578 --- /dev/null +++ b/glucose-guard-design/ios/overlays/GlucoseGuardStatistics.swift.txt @@ -0,0 +1,126 @@ +// GLUCOSE_GUARD_THEME statistics + +private struct GlucoseGuardStatisticsView: View { + let displayUnit: HKUnit + let loader: (Date, @escaping ([Double]) -> Void) -> Void + + @State private var periodDays = 7 + @State private var valuesMgdl: [Double] = [] + @State private var loading = true + + private let periods = [3, 7, 30, 90] + + var body: some View { + List { + Section(header: Text(NSLocalizedString("Period", comment: "Statistics period header"))) { + Picker(NSLocalizedString("Period", comment: "Statistics period picker"), selection: $periodDays) { + ForEach(periods, id: \.self) { days in + Text("\(days)d").tag(days) + } + } + .pickerStyle(.segmented) + .onChange(of: periodDays) { _ in + reload() + } + } + + Section(header: Text(NSLocalizedString("Glucose", comment: "Statistics glucose header"))) { + metricRow( + title: "AVG", + value: formattedAverage, + detail: NSLocalizedString("Average glucose for the selected days.", comment: "AVG help") + ) + metricRow( + title: "SD", + value: formattedSD, + detail: NSLocalizedString("Standard deviation of glucose.", comment: "SD help") + ) + metricRow( + title: "A1C", + value: formattedA1C, + detail: NSLocalizedString("Estimated A1C from mean glucose (GMI formula).", comment: "A1C help") + ) + } + + Section(header: Text(NSLocalizedString("Time in range", comment: "TIR section header"))) { + metricRow(title: "TIR", value: formattedPercent(inRange), detail: NSLocalizedString("70–180 mg/dL.", comment: "TIR help")) + metricRow(title: "TBR", value: formattedPercent(belowRange), detail: NSLocalizedString("Below 70 mg/dL.", comment: "TBR help")) + metricRow(title: "TAR", value: formattedPercent(aboveRange), detail: NSLocalizedString("Above 180 mg/dL.", comment: "TAR help")) + } + } + .listStyle(.insetGrouped) + .overlay { + if loading { + ProgressView() + } else if valuesMgdl.isEmpty { + Text(NSLocalizedString("No glucose values in this period.", comment: "Empty statistics")) + .foregroundColor(.secondary) + } + } + .onAppear(perform: reload) + } + + private func metricRow(title: String, value: String, detail: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + .font(.headline) + Spacer() + Text(value) + .font(.title2.monospacedDigit()) + .foregroundColor(Color(red: 0.957, green: 0.200, blue: 0.235)) + } + Text(detail) + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + private var averageMgdl: Double? { + guard !valuesMgdl.isEmpty else { return nil } + return valuesMgdl.reduce(0, +) / Double(valuesMgdl.count) + } + + private var formattedAverage: String { + guard let averageMgdl else { return "—" } + let quantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: averageMgdl) + return String(format: "%.1f %@", quantity.doubleValue(for: displayUnit), displayUnit.unitString) + } + + private var formattedSD: String { + guard let averageMgdl, valuesMgdl.count > 1 else { return "—" } + let variance = valuesMgdl.reduce(0) { $0 + pow($1 - averageMgdl, 2) } / Double(valuesMgdl.count) + let quantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: sqrt(variance)) + return String(format: "%.1f %@", quantity.doubleValue(for: displayUnit), displayUnit.unitString) + } + + private var formattedA1C: String { + guard let averageMgdl else { return "—" } + let a1c = 3.31 + 0.02392 * averageMgdl + return String(format: "%.1f%%", a1c) + } + + private var inRange: Double? { ratio { $0 >= 70 && $0 <= 180 } } + private var belowRange: Double? { ratio { $0 < 70 } } + private var aboveRange: Double? { ratio { $0 > 180 } } + + private func ratio(_ predicate: (Double) -> Bool) -> Double? { + guard !valuesMgdl.isEmpty else { return nil } + return Double(valuesMgdl.filter(predicate).count) / Double(valuesMgdl.count) + } + + private func formattedPercent(_ value: Double?) -> String { + guard let value else { return "—" } + return String(format: "%.0f%%", value * 100) + } + + private func reload() { + loading = true + let start = Calendar.current.date(byAdding: .day, value: -periodDays, to: Date()) ?? Date() + loader(start) { samples in + valuesMgdl = samples + loading = false + } + } +} diff --git a/glucose-guard-design/ios/overlays/GlucoseGuardTabBarHost.swift.txt b/glucose-guard-design/ios/overlays/GlucoseGuardTabBarHost.swift.txt new file mode 100644 index 0000000000..4ae10fb22e --- /dev/null +++ b/glucose-guard-design/ios/overlays/GlucoseGuardTabBarHost.swift.txt @@ -0,0 +1,188 @@ + // GLUCOSE_GUARD_XCODE_DESIGN host + + private var glucoseGuardTabBarHost: UIHostingController? + + private func installGlucoseGuardTabBar() { + guard glucoseGuardTabBarHost == nil else { return } + navigationController?.setToolbarHidden(true, animated: false) + let bar = GlucoseGuardTabBar( + onToday: { [weak self] in self?.presentGlucoseGuardToday() }, + onLearning: { [weak self] in self?.presentGlucoseGuardLearning() }, + onHealthway: { [weak self] in self?.presentGlucoseGuardHealthway() }, + onAdd: { [weak self] in self?.presentGlucoseGuardLogSheet() } + ) + let host = UIHostingController(rootView: bar) + host.view.backgroundColor = .clear + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + host.didMove(toParent: self) + glucoseGuardTabBarHost = host + tableView.contentInset.bottom = 96 + tableView.verticalScrollIndicatorInsets.bottom = 96 + } + + private func presentGlucoseGuardDesign(_ root: Content, title: String) { + let hosting = UIHostingController(rootView: root) + hosting.title = title + hosting.view.backgroundColor = UIColor(red: 0.039, green: 0.039, blue: 0.039, alpha: 1) + let navigation = UINavigationController(rootViewController: hosting) + hosting.navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: navigation, + action: #selector(UIViewController.dismissWithAnimation) + ) + present(navigation, animated: true) + } + + @objc private func presentGlucoseGuardLearning() { + let unit = deviceManager.displayGlucosePreference.unit + presentGlucoseGuardDesign( + GlucoseGuardLearningView(displayUnit: unit) { start, completion in + self.deviceManager.glucoseStore.getGlucoseSamples(start: start, end: nil) { result in + let values: [Double] + switch result { + case .success(let samples): + values = samples.map { $0.quantity.doubleValue(for: .milligramsPerDeciliter) } + case .failure: + values = [] + } + DispatchQueue.main.async { completion(values) } + } + }, + title: NSLocalizedString("Learning", comment: "Learning tab title") + ) + } + + @objc private func presentGlucoseGuardHealthway() { + let unit = deviceManager.displayGlucosePreference.unit + let latest = deviceManager.glucoseStore.latestGlucose + let current = latest.map { deviceManager.displayGlucosePreference.format($0.quantity) } ?? "—" + let start = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + deviceManager.glucoseStore.getGlucoseSamples(start: start, end: nil) { result in + let values: [Double] + switch result { + case .success(let samples): + values = samples.map { $0.quantity.doubleValue(for: .milligramsPerDeciliter) } + case .failure: + values = [] + } + let average = values.isEmpty ? nil : values.reduce(0, +) / Double(values.count) + let a1c = average.map { String(format: "%.1f%%", 3.31 + 0.02392 * $0) } ?? "—" + let avgText: String + if let average { + let quantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: average) + avgText = String(format: "%.1f %@", quantity.doubleValue(for: unit), unit.unitString) + } else { + avgText = "—" + } + let tir: String + if values.isEmpty { + tir = "—" + } else { + let inRange = values.filter { $0 >= 70 && $0 <= 180 }.count + tir = String(format: "%.0f%%", (Double(inRange) / Double(values.count)) * 100) + } + let spark = Array(values.suffix(48)) + DispatchQueue.main.async { + self.presentGlucoseGuardDesign( + GlucoseGuardHealthwayView( + currentGlucose: current, + a1c: a1c, + average: avgText, + tir: tir, + points: spark + ), + title: NSLocalizedString("Healthway", comment: "Healthway tab title") + ) + } + } + } + + private func presentGlucoseGuardCGMDetail() { + let unit = deviceManager.displayGlucosePreference.unit + let latest = deviceManager.glucoseStore.latestGlucose + let reading = latest.map { deviceManager.displayGlucosePreference.format($0.quantity) } ?? "— — —" + let minutes: String + if let date = latest?.startDate { + minutes = String(format: "%.0f min", abs(date.timeIntervalSinceNow) / 60) + } else { + minutes = "0 min" + } + let used = deviceManager.cgmLifecycleProgress?.percentComplete ?? 0 + let remaining = max(0, (1 - used) * 14) + let start = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + deviceManager.glucoseStore.getGlucoseSamples(start: start, end: nil) { result in + let values: [Double] + switch result { + case .success(let samples): + values = samples.map { $0.quantity.doubleValue(for: .milligramsPerDeciliter) } + case .failure: + values = [] + } + let average: String + if values.isEmpty { + average = "—" + } else { + let mean = values.reduce(0, +) / Double(values.count) + let quantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: mean) + average = String(format: "%.1f %@", quantity.doubleValue(for: unit), unit.unitString) + } + DispatchQueue.main.async { + self.presentGlucoseGuardDesign( + GlucoseGuardCGMDetailView( + lastReading: reading, + minutesAgo: minutes, + remainingDays: remaining, + sensorLifeDays: 14, + average: average, + onOpenCGM: { + self.dismiss(animated: true) { + self.executeHUDTapAction(self.deviceManager.didTapOnCGMStatus()) + } + } + ), + title: "CGM" + ) + } + } + } + + private func presentGlucoseGuardPumpDetail() { + let used = deviceManager.pumpLifecycleProgress?.percentComplete ?? 0 + let remaining = max(0, (1 - used) * 3) + let name: String + if let pump = deviceManager.pumpManager { + name = type(of: pump).localizedTitle + } else { + name = "Insulin Pump" + } + let basal = hudView?.pumpStatusHUD.basalRateHUD.accessibilityValue ?? "—" + presentGlucoseGuardDesign( + GlucoseGuardPumpDetailView( + pumpName: name, + remainingDays: remaining, + podLifeDays: 3, + basal: basal, + remainingInsulin: "—", + onChangeBolus: { + self.dismiss(animated: true) { + self.presentBolusScreen() + } + }, + onOpenPump: { + self.dismiss(animated: true) { + if let pumpView = self.hudView?.pumpStatusHUD { + self.executeHUDTapAction(self.deviceManager.didTapOnPumpStatus(pumpView.pumpManagerProvidedHUD)) + } + } + } + ), + title: name + ) + } diff --git a/glucose-guard-design/ios/overlays/GlucoseGuardXcodeDesign.swift.txt b/glucose-guard-design/ios/overlays/GlucoseGuardXcodeDesign.swift.txt new file mode 100644 index 0000000000..cc35bc642d --- /dev/null +++ b/glucose-guard-design/ios/overlays/GlucoseGuardXcodeDesign.swift.txt @@ -0,0 +1,362 @@ +// GLUCOSE_GUARD_XCODE_DESIGN +// Native SwiftUI screens matching the Figma mobile theme. + +private enum GlucoseGuardTheme { + static let red = Color(red: 0.957, green: 0.200, blue: 0.235) + static let green = Color(red: 0.133, green: 0.773, blue: 0.369) + static let ink = Color(red: 0.039, green: 0.039, blue: 0.039) + static let surface = Color(red: 0.090, green: 0.090, blue: 0.090) + static let muted = Color(red: 0.639, green: 0.639, blue: 0.639) + static let cardRadius: CGFloat = 18 +} + +struct GlucoseGuardTabBar: View { + var onToday: () -> Void + var onLearning: () -> Void + var onHealthway: () -> Void + var onAdd: () -> Void + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + tabButton("Today", systemName: "sun.max.fill", action: onToday) + tabButton("Learning", systemName: "rectangle.stack.fill", action: onLearning) + tabButton("Healthway", systemName: "heart.text.square.fill", action: onHealthway) + Button(action: onAdd) { + ZStack { + Circle() + .fill(GlucoseGuardTheme.red) + .frame(width: 58, height: 58) + .shadow(color: GlucoseGuardTheme.red.opacity(0.35), radius: 8, y: 3) + Image(systemName: "plus") + .font(.system(size: 26, weight: .bold)) + .foregroundColor(.white) + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Add")) + .padding(.bottom, 2) + } + .padding(.horizontal, 10) + .padding(.top, 10) + .padding(.bottom, 14) + .background(GlucoseGuardTheme.ink.edgesIgnoringSafeArea(.bottom)) + } + + private func tabButton(_ title: String, systemName: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 4) { + Image(systemName: systemName) + .font(.system(size: 18, weight: .semibold)) + Text(title) + .font(.caption2.weight(.semibold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } +} + +struct GlucoseGuardScreenChrome: View { + let title: String + let content: Content + + init(_ title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + ZStack { + GlucoseGuardTheme.ink.ignoresSafeArea() + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image("glucose_guard_mark", bundle: Bundle(for: StatusBarHUDView.self)) + .resizable() + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text(title) + .font(.title2.bold()) + .foregroundColor(.white) + Spacer() + } + content + Spacer(minLength: 0) + } + .padding(20) + } + } +} + +struct GlucoseGuardCard: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(GlucoseGuardTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: GlucoseGuardTheme.cardRadius, style: .continuous)) + } +} + +struct GlucoseGuardProgressBar: View { + let progress: Double + var tint: Color = GlucoseGuardTheme.red + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.12)) + Capsule() + .fill(tint) + .frame(width: max(8, geo.size.width * CGFloat(min(max(progress, 0), 1)))) + } + } + .frame(height: 8) + } +} + +struct GlucoseGuardCGMDetailView: View { + let lastReading: String + let minutesAgo: String + let remainingDays: Double + let sensorLifeDays: Double + let average: String + let onOpenCGM: () -> Void + + var body: some View { + GlucoseGuardScreenChrome("CGM") { + GlucoseGuardCard { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "waveform.path.ecg") + .foregroundColor(GlucoseGuardTheme.red) + Text(String(format: NSLocalizedString("Sensor expires in %.0f days", comment: "CGM remaining life"), remainingDays)) + .font(.headline) + .foregroundColor(.white) + } + GlucoseGuardProgressBar(progress: 1 - (remainingDays / max(sensorLifeDays, 1))) + Text(String(format: NSLocalizedString("%.0f of %.0f days remaining", comment: "CGM life caption"), remainingDays, sensorLifeDays)) + .font(.footnote) + .foregroundColor(GlucoseGuardTheme.muted) + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Last Reading") + .font(.caption) + .foregroundColor(GlucoseGuardTheme.muted) + Text(lastReading) + .font(.title.bold()) + .foregroundColor(.white) + } + Spacer() + Label(minutesAgo, systemImage: "clock") + .foregroundColor(GlucoseGuardTheme.red) + } + HStack { + Text("AVG") + .foregroundColor(GlucoseGuardTheme.muted) + Spacer() + Text(average) + .foregroundColor(.white) + .font(.headline) + } + } + } + Button(action: onOpenCGM) { + Text("LibreLinkUp, Dexcom Share, or CGM settings") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(GlucoseGuardTheme.red) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .padding(.top, 8) + } + } +} + +struct GlucoseGuardPumpDetailView: View { + let pumpName: String + let remainingDays: Double + let podLifeDays: Double + let basal: String + let remainingInsulin: String + let onChangeBolus: () -> Void + let onOpenPump: () -> Void + + var body: some View { + GlucoseGuardScreenChrome(pumpName) { + GlucoseGuardCard { + VStack(alignment: .leading, spacing: 12) { + Text(String(format: NSLocalizedString("Pod expires in %.1f days", comment: "Pump remaining life"), remainingDays)) + .font(.headline) + .foregroundColor(.white) + GlucoseGuardProgressBar(progress: 1 - (remainingDays / max(podLifeDays, 1)), tint: .orange) + Text(String(format: NSLocalizedString("%.1f of %.0f days remaining", comment: "Pump life caption"), remainingDays, podLifeDays)) + .font(.footnote) + .foregroundColor(GlucoseGuardTheme.muted) + HStack { + VStack(alignment: .leading) { + Text("Planned basal") + .font(.caption) + .foregroundColor(GlucoseGuardTheme.muted) + Text(basal) + .font(.headline) + .foregroundColor(.white) + } + Spacer() + VStack(alignment: .trailing) { + Text("Remaining insulin") + .font(.caption) + .foregroundColor(GlucoseGuardTheme.muted) + Text(remainingInsulin) + .font(.headline) + .foregroundColor(.orange) + } + } + } + } + Button(action: onChangeBolus) { + Text("Current bolus — change") + .font(.headline) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(GlucoseGuardTheme.red) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + Button(action: onOpenPump) { + Text("Open pump settings") + .font(.subheadline.weight(.semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + } +} + +struct GlucoseGuardLearningView: View { + let displayUnit: HKUnit + let loader: (Date, @escaping ([Double]) -> Void) -> Void + + var body: some View { + GlucoseGuardStatisticsView(displayUnit: displayUnit, loader: loader) + .background(GlucoseGuardTheme.ink.ignoresSafeArea()) + } +} + +struct GlucoseGuardHealthwayView: View { + let currentGlucose: String + let a1c: String + let average: String + let tir: String + let points: [Double] + + @State private var range = 1 + + var body: some View { + ZStack { + Color(red: 0.980, green: 0.980, blue: 0.980).ignoresSafeArea() + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image("glucose_guard_mark", bundle: Bundle(for: StatusBarHUDView.self)) + .resizable() + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text("Glucose Values") + .font(.title2.bold()) + Spacer() + } + Picker("Range", selection: $range) { + Text("Day").tag(1) + Text("Week").tag(7) + Text("Month").tag(30) + Text("Year").tag(365) + } + .pickerStyle(.segmented) + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Glucose") + .foregroundColor(GlucoseGuardTheme.muted) + Spacer() + Text(currentGlucose) + .foregroundColor(GlucoseGuardTheme.green) + .font(.headline) + } + GlucoseGuardSparkline(points: points) + .frame(height: 160) + HStack { + metric("A1C", a1c) + metric("AVG", average) + metric("TIR", tir) + } + } + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: Color.black.opacity(0.06), radius: 8, y: 3) + Spacer() + } + .padding(20) + } + } + + private func metric(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundColor(GlucoseGuardTheme.muted) + Text(value) + .font(.headline) + .foregroundColor(GlucoseGuardTheme.red) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct GlucoseGuardSparkline: View { + let points: [Double] + + var body: some View { + GeometryReader { geo in + let values = points.isEmpty ? [90, 110, 95, 130, 100] : points + let minV = (values.min() ?? 0) - 10 + let maxV = (values.max() ?? 1) + 10 + let span = max(maxV - minV, 1) + Path { path in + for (index, value) in values.enumerated() { + let x = geo.size.width * CGFloat(index) / CGFloat(max(values.count - 1, 1)) + let y = geo.size.height * (1 - CGFloat((value - minV) / span)) + if index == 0 { + path.move(to: CGPoint(x: x, y: geo.size.height)) + path.addLine(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + path.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) + path.closeSubpath() + } + .fill(GlucoseGuardTheme.red.opacity(0.28)) + Path { path in + for (index, value) in values.enumerated() { + let x = geo.size.width * CGFloat(index) / CGFloat(max(values.count - 1, 1)) + let y = geo.size.height * (1 - CGFloat((value - minV) / span)) + if index == 0 { + path.move(to: CGPoint(x: x, y: y)) + } else { + path.addLine(to: CGPoint(x: x, y: y)) + } + } + } + .stroke(GlucoseGuardTheme.red, lineWidth: 2) + } + } +} diff --git a/glucose-guard-design/ios/overlays/LoopStateView.swift b/glucose-guard-design/ios/overlays/LoopStateView.swift new file mode 100644 index 0000000000..0dc6bc5d13 --- /dev/null +++ b/glucose-guard-design/ios/overlays/LoopStateView.swift @@ -0,0 +1,132 @@ +// +// LoopStateView.swift +// LoopUI +// +// Glucose Guard theme: brand mark inside the closed-loop status ring. +// GLUCOSE_GUARD_THEME +// + +import UIKit + +final class LoopStateView: UIView { + var firstDataUpdate = true + + private let markView: UIImageView = { + let view = UIImageView() + view.contentMode = .scaleAspectFit + view.clipsToBounds = true + view.isUserInteractionEnabled = false + view.image = UIImage(named: "glucose_guard_mark", in: Bundle(for: LoopStateView.self), compatibleWith: nil) + return view + }() + + override func tintColorDidChange() { + super.tintColorDidChange() + + updateTintColor() + } + + private func updateTintColor() { + shapeLayer.strokeColor = tintColor.cgColor + } + + var open = false { + didSet { + if open != oldValue { + shapeLayer.path = drawPath() + } + } + } + + override class var layerClass: AnyClass { + return CAShapeLayer.self + } + + private var shapeLayer: CAShapeLayer { + return layer as! CAShapeLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + configureLayer() + embedMark() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + configureLayer() + embedMark() + } + + private func configureLayer() { + shapeLayer.lineWidth = 6 + shapeLayer.fillColor = UIColor.clear.cgColor + shapeLayer.lineCap = .round + updateTintColor() + shapeLayer.path = drawPath() + } + + private func embedMark() { + if markView.superview == nil { + addSubview(markView) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + shapeLayer.path = drawPath() + let inset = max(8, shapeLayer.lineWidth + 3) + markView.frame = bounds.insetBy(dx: inset, dy: inset) + markView.layer.cornerRadius = markView.bounds.width * 0.22 + } + + private func drawPath(lineWidth: CGFloat? = nil) -> CGPath { + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let lineWidth = lineWidth ?? shapeLayer.lineWidth + let radius = min(bounds.width / 2, bounds.height / 2) - lineWidth / 2 + + let startAngle = open ? -CGFloat.pi / 4 : 0 + let endAngle = open ? 5 * CGFloat.pi / 4 : 2 * CGFloat.pi + + let path = UIBezierPath( + arcCenter: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: true + ) + + return path.cgPath + } + + private static let AnimationKey = "com.loudnate.Naterade.breatheAnimation" + + var animated: Bool = false { + didSet { + if animated != oldValue { + if animated { + let path = CABasicAnimation(keyPath: "path") + path.fromValue = shapeLayer.path ?? drawPath() + path.toValue = drawPath(lineWidth: 12) + + let width = CABasicAnimation(keyPath: "lineWidth") + width.fromValue = shapeLayer.lineWidth + width.toValue = 8 + + let group = CAAnimationGroup() + group.animations = [path, width] + group.duration = firstDataUpdate ? 0 : 1 + group.repeatCount = HUGE + group.autoreverses = true + group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + + shapeLayer.add(group, forKey: type(of: self).AnimationKey) + } else { + shapeLayer.removeAnimation(forKey: type(of: self).AnimationKey) + } + } + firstDataUpdate = false + } + } +} diff --git a/glucose-guard-design/ios/overlays/glucose_guard_mark.imageset/Contents.json b/glucose-guard-design/ios/overlays/glucose_guard_mark.imageset/Contents.json new file mode 100644 index 0000000000..83c81ad630 --- /dev/null +++ b/glucose-guard-design/ios/overlays/glucose_guard_mark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "mark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/glucose-guard-design/ios/overlays/glucose_guard_mark.imageset/mark.png b/glucose-guard-design/ios/overlays/glucose_guard_mark.imageset/mark.png new file mode 100644 index 0000000000..b46d6f9f4e Binary files /dev/null and b/glucose-guard-design/ios/overlays/glucose_guard_mark.imageset/mark.png differ diff --git a/glucose-guard-design/ios/theme/accent.colorset/Contents.json b/glucose-guard-design/ios/theme/accent.colorset/Contents.json new file mode 100644 index 0000000000..a748405c98 --- /dev/null +++ b/glucose-guard-design/ios/theme/accent.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "red" : "0.957", + "green" : "0.200", + "blue" : "0.235" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/glucose-guard-design/ios/theme/fresh.colorset/Contents.json b/glucose-guard-design/ios/theme/fresh.colorset/Contents.json new file mode 100644 index 0000000000..04ce722bc5 --- /dev/null +++ b/glucose-guard-design/ios/theme/fresh.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "red" : "0.133", + "green" : "0.773", + "blue" : "0.369" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/glucose-guard-design/ios/theme/glucose.colorset/Contents.json b/glucose-guard-design/ios/theme/glucose.colorset/Contents.json new file mode 100644 index 0000000000..e663b376fa --- /dev/null +++ b/glucose-guard-design/ios/theme/glucose.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "red" : "0.957", + "green" : "0.200", + "blue" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "red" : "1.000", + "green" : "0.353", + "blue" : "0.380" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +}