diff --git a/.gitmodules b/.gitmodules index 1308e60c03..1be2f82bfb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,7 @@ [submodule "Loop"] path = Loop - url = https://github.com/LoopKit/Loop.git + url = https://github.com/TaylorJPatterson/Loop.git + branch = feat/AllFeatures [submodule "LoopKit"] path = LoopKit url = https://github.com/LoopKit/LoopKit.git diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000000..d809a17b7e --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,83 @@ +# Install FoodFinder + LoopInsights + AutoPresets + +Add AI-powered food analysis, therapy settings insights, and automatic preset management to your Loop app — compatible with all Loop & Learn customizations. + +## Quick Start + +### 1. Build Loop the normal way first + +Follow the standard LoopDocs build instructions through the cloning step: +https://loopkit.github.io/loopdocs/build/step4/ + +```bash +git clone --branch=main --recurse-submodules https://github.com/LoopKit/LoopWorkspace +cd LoopWorkspace +``` + +### 2. (Optional) Apply any Loop & Learn customizations you want + +https://www.loopandlearn.org/custom-code/ + +All L&L patches (Profiles, Basal Lock, Negative Insulin, etc.) are compatible. Apply them first — our installer adapts to whatever's already there. + +### 3. Run one command + +```bash +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/TaylorJPatterson/LoopWorkspace/feat/installer/Scripts/install_features.sh)" +``` + +That's it. The script downloads everything it needs, installs 77 new files, patches 11 existing files, updates the Xcode project, and validates the result. + +### 4. Build in Xcode + +1. Open `LoopWorkspace.xcworkspace` in Xcode +2. Select your signing team +3. Build and run (Cmd+R) + +### 5. Enable features in the app + +All features are **off by default**. Turn them on in Loop Settings: + +- **FoodFinder** — AI-powered & barcode food analysis +- **LoopInsights** — AI-powered therapy settings analysis +- **AutoPresets** — Automate presets during motion + +You'll need an AI API key (OpenAI, Anthropic, or Google) for the AI features. Enter it in FoodFinder Settings — LoopInsights shares the same key. + +--- + +## Uninstalling + +```bash +./Scripts/install_features.sh --rollback +``` + +Removes all feature files and restores Loop to its pre-install state (including any L&L patches you had applied). + +## Updating + +```bash +./Scripts/install_features.sh --rollback +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/TaylorJPatterson/LoopWorkspace/feat/installer/Scripts/install_features.sh)" +``` + +## L&L Compatibility + +| L&L Customization | Compatible | Notes | +|---|---|---| +| Profiles | Yes | Our features insert below Profiles in Settings | +| Basal Lock | Yes | Different code regions than our features | +| Negative Insulin | Yes | Different code regions than our features | +| Future Carbs 4h | Yes | Both modify CarbEntryView.swift in different regions; 3-way merge handles it | +| Override Insulin Needs Picker | Yes | No overlapping files | +| All other L&L patches | Yes | Our installer only modifies Loop submodule files | + +## Troubleshooting + +**"Anchor not found" error**: Your Loop version may be too old or too new. The installer targets Loop dev branch (v3.10.x+). Make sure you cloned the latest LoopWorkspace. + +**Merge conflicts during patching**: If the installer reports conflicts, check the affected file for `<<<<<<<` conflict markers and resolve them manually. + +**Xcode build errors after install**: Try a clean build (Cmd+Shift+K, then Cmd+R). If issues persist, run `--rollback` and re-install. + +**plutil validation failure**: The Xcode project file update failed. The installer automatically restores the backup. Try again — if it persists, file an issue. diff --git a/Loop b/Loop index db9cf70d72..2e139e56e9 160000 --- a/Loop +++ b/Loop @@ -1 +1 @@ -Subproject commit db9cf70d7292803308e0e7f3c5f1f7fe6d801c9e +Subproject commit 2e139e56e910a91401455150185443dc0b8b3cf1 diff --git a/LoopConfigOverride.xcconfig b/LoopConfigOverride.xcconfig index 2969db2882..3d1e467cf5 100644 --- a/LoopConfigOverride.xcconfig +++ b/LoopConfigOverride.xcconfig @@ -13,4 +13,4 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) EXPERIMENTAL_FEATURES_ENABLED SIMULATORS_ENABLED ALLOW_ALGORITHM_EXPERIMENTS DEBUG_FEATURES_ENABLED // Put your team id here for signing -//LOOP_DEVELOPMENT_TEAM = UY678SP37Q +LOOP_DEVELOPMENT_TEAM = 4S2EW2Q6ZW diff --git a/Scripts/install_features.sh b/Scripts/install_features.sh new file mode 100755 index 0000000000..735b2cd68a --- /dev/null +++ b/Scripts/install_features.sh @@ -0,0 +1,910 @@ +#!/usr/bin/env bash +# install_features.sh — Install FoodFinder + LoopInsights + AutoPresets into a standard Loop clone +# +# ONE-LINER INSTALL (run from your LoopWorkspace folder): +# /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/TaylorJPatterson/LoopWorkspace/feat/installer/Scripts/install_features.sh)" +# +# Or if you already have the scripts locally: +# ./Scripts/install_features.sh # Install features +# ./Scripts/install_features.sh --rollback # Remove features and restore prior state +# +# Concept & design by Taylor Patterson. Coded by Claude Code in February 2026. +# Copyright (c) 2025-2026 LoopKit Authors. All rights reserved. + +set -euo pipefail + +# ─── Constants ──────────────────────────────────────────────────────────────── + +FEATURE_REMOTE="_feature_src" +FEATURE_BRANCH="feat/installer" +FEATURE_LOOP_BRANCH="feat/AllFeatures" +FEATURE_REPO="https://github.com/TaylorJPatterson/Loop.git" +FEATURE_WORKSPACE_REPO="https://raw.githubusercontent.com/TaylorJPatterson/LoopWorkspace/${FEATURE_BRANCH}" +MARKER_FILE=".feature_install_marker" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# ─── New files (don't exist in standard Loop) ──────────────────────────────── + +NEW_FILES=( + # Documentation + "Documentation/FoodFinder/FoodFinder_README.md" + "Documentation/LoopInsights/LoopInsights_README.md" + + # AutoPresets — Managers + "Loop/Managers/ActivityDetectionManager.swift" + "Loop/Managers/AutoPresetsCoordinator.swift" + "Loop/Managers/AutoPresetsDelegate.swift" + "Loop/Managers/AutoPresetsLogger.swift" + "Loop/Managers/AutoPresetsStorage.swift" + + # LoopInsights — Managers + "Loop/Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift" + "Loop/Managers/LoopInsights/LoopInsights_Coordinator.swift" + + # AutoPresets — Models + "Loop/Models/AutoPresetsModels.swift" + + # FoodFinder — Models + "Loop/Models/FoodFinder/FoodFinder_AnalysisRecord.swift" + "Loop/Models/FoodFinder/FoodFinder_InputResults.swift" + "Loop/Models/FoodFinder/FoodFinder_Models.swift" + + # LoopInsights — Models + "Loop/Models/LoopInsights/LoopInsights_Models.swift" + "Loop/Models/LoopInsights/LoopInsights_Phase5Models.swift" + "Loop/Models/LoopInsights/LoopInsights_SuggestionRecord.swift" + + # FoodFinder — Resources + "Loop/Resources/FoodFinder/FoodFinder_FeatureFlags.swift" + + # LoopInsights — Resources + "Loop/Resources/LoopInsights/LoopInsights_FeatureFlags.swift" + "Loop/Resources/LoopInsights/TestData/tidepool_carb_entries.json" + "Loop/Resources/LoopInsights/TestData/tidepool_dose_entries.json" + "Loop/Resources/LoopInsights/TestData/tidepool_glucose_samples.json" + "Loop/Resources/LoopInsights/TestData/tidepool_therapy_settings.json" + + # FoodFinder — Services + "Loop/Services/FoodFinder/FoodFinder_AIAnalysis.swift" + "Loop/Services/FoodFinder/FoodFinder_AIProviderConfig.swift" + "Loop/Services/FoodFinder/FoodFinder_AIServiceAdapter.swift" + "Loop/Services/FoodFinder/FoodFinder_AIServiceManager.swift" + "Loop/Services/FoodFinder/FoodFinder_AnalysisHistoryStore.swift" + "Loop/Services/FoodFinder/FoodFinder_EmojiProvider.swift" + "Loop/Services/FoodFinder/FoodFinder_ImageDownloader.swift" + "Loop/Services/FoodFinder/FoodFinder_ImageStore.swift" + "Loop/Services/FoodFinder/FoodFinder_OpenFoodFactsService.swift" + "Loop/Services/FoodFinder/FoodFinder_ScannerService.swift" + "Loop/Services/FoodFinder/FoodFinder_SearchRouter.swift" + "Loop/Services/FoodFinder/FoodFinder_SecureStorage.swift" + "Loop/Services/FoodFinder/FoodFinder_VoiceService.swift" + + # LoopInsights — Services + "Loop/Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift" + "Loop/Services/LoopInsights/LoopInsights_AIAnalysis.swift" + "Loop/Services/LoopInsights/LoopInsights_AIServiceAdapter.swift" + "Loop/Services/LoopInsights/LoopInsights_CaffeineTracker.swift" + "Loop/Services/LoopInsights/LoopInsights_DataAggregator.swift" + "Loop/Services/LoopInsights/LoopInsights_FoodResponseAnalyzer.swift" + "Loop/Services/LoopInsights/LoopInsights_GoalStore.swift" + "Loop/Services/LoopInsights/LoopInsights_HealthKitManager.swift" + "Loop/Services/LoopInsights/LoopInsights_NightscoutImporter.swift" + "Loop/Services/LoopInsights/LoopInsights_ReportGenerator.swift" + "Loop/Services/LoopInsights/LoopInsights_SecureStorage.swift" + "Loop/Services/LoopInsights/LoopInsights_SuggestionStore.swift" + "Loop/Services/LoopInsights/LoopInsights_TestDataProvider.swift" + + # FoodFinder — View Models + "Loop/View Models/FoodFinder/FoodFinder_SearchViewModel.swift" + + # LoopInsights — View Models + "Loop/View Models/LoopInsights/LoopInsights_ChatViewModel.swift" + "Loop/View Models/LoopInsights/LoopInsights_DashboardViewModel.swift" + + # AutoPresets — Views + "Loop/Views/AutoPresetsSettingsView.swift" + + # FoodFinder — Views + "Loop/Views/FoodFinder/FoodFinder_AICameraView.swift" + "Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift" + "Loop/Views/FoodFinder/FoodFinder_FavoritesHelpers.swift" + "Loop/Views/FoodFinder/FoodFinder_ScannerView.swift" + "Loop/Views/FoodFinder/FoodFinder_SearchBar.swift" + "Loop/Views/FoodFinder/FoodFinder_SearchResultsView.swift" + "Loop/Views/FoodFinder/FoodFinder_SettingsView.swift" + "Loop/Views/FoodFinder/FoodFinder_VoiceSearchView.swift" + + # LoopInsights — Views + "Loop/Views/LoopInsights/LoopInsights_AGPChartView.swift" + "Loop/Views/LoopInsights/LoopInsights_CaffeineLogView.swift" + "Loop/Views/LoopInsights/LoopInsights_ChatView.swift" + "Loop/Views/LoopInsights/LoopInsights_DashboardView.swift" + "Loop/Views/LoopInsights/LoopInsights_GoalsView.swift" + "Loop/Views/LoopInsights/LoopInsights_MealInsightsView.swift" + "Loop/Views/LoopInsights/LoopInsights_MonitorSettingsView.swift" + "Loop/Views/LoopInsights/LoopInsights_SettingsView.swift" + "Loop/Views/LoopInsights/LoopInsights_SuggestionDetailView.swift" + "Loop/Views/LoopInsights/LoopInsights_SuggestionHistoryView.swift" + "Loop/Views/LoopInsights/LoopInsights_TrendsInsightsView.swift" + + # FoodFinder — Tests + "LoopTests/FoodFinder/FoodFinder_BarcodeScannerTests.swift" + "LoopTests/FoodFinder/FoodFinder_OpenFoodFactsTests.swift" + "LoopTests/FoodFinder/FoodFinder_VoiceSearchTests.swift" + + # LoopInsights — Tests + "LoopTests/LoopInsights/LoopInsights_DataAggregatorTests.swift" + "LoopTests/LoopInsights/LoopInsights_ModelsTests.swift" + "LoopTests/LoopInsights/LoopInsights_SuggestionStoreTests.swift" +) + +# Modified files to patch via git diff | git apply --3way +# Excludes: project.pbxproj (handled by Python script), SettingsView.swift (anchor-based), +# LoopDataManager.swift (anchor-based — L&L Customizations modify this file heavily), +# and Localizable.xcstrings (direct checkout — too large for 3-way merge on JSON) +PATCH_FILES=( + "Loop/View Controllers/StatusTableViewController.swift" + "Loop/View Models/AddEditFavoriteFoodViewModel.swift" + "Loop/View Models/CarbEntryViewModel.swift" + "Loop/View Models/SettingsViewModel.swift" + "Loop/Views/AddEditFavoriteFoodView.swift" + "Loop/Views/CarbEntryView.swift" + "Loop/Views/FavoriteFoodDetailView.swift" + "Loop/Views/FavoriteFoodsView.swift" +) + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERR]${NC} $*"; } +header() { echo -e "\n${BOLD}═══ $* ═══${NC}"; } + +die() { + error "$@" + exit 1 +} + +# ─── Phase 1: Validation ───────────────────────────────────────────────────── + +validate_environment() { + header "Phase 1: Validating environment" + + # Must run from LoopWorkspace root + if [[ ! -d "LoopWorkspace.xcworkspace" ]]; then + die "Must run from LoopWorkspace root directory (LoopWorkspace.xcworkspace not found). + cd into your LoopWorkspace folder and try again." + fi + success "Running from LoopWorkspace root" + + # Loop submodule must exist + if [[ ! -d "Loop/.git" ]] && [[ ! -f "Loop/.git" ]]; then + die "Loop submodule not found. Make sure you've cloned with --recurse-submodules." + fi + success "Loop submodule exists" + + # python3 available + if ! command -v python3 &>/dev/null; then + die "python3 is required but not found. Install Python 3 and try again." + fi + success "python3 available ($(python3 --version 2>&1))" + + # Check for existing feature files (idempotency) + if [[ -f "Loop/${MARKER_FILE}" ]]; then + die "Features are already installed (marker file found). + To reinstall, run: ./Scripts/install_features.sh --rollback first." + fi + + local sample_files=( + "Loop/Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift" + "Loop/Loop/Views/LoopInsights/LoopInsights_DashboardView.swift" + "Loop/Loop/Views/AutoPresetsSettingsView.swift" + ) + for f in "${sample_files[@]}"; do + if [[ -f "$f" ]]; then + die "Feature files already exist ($f found). + To reinstall, run: ./Scripts/install_features.sh --rollback first." + fi + done + success "No existing feature files found" + + # Verify SettingsView.swift anchors exist + local settings_file="Loop/Loop/Views/SettingsView.swift" + if [[ ! -f "$settings_file" ]]; then + die "SettingsView.swift not found at expected path." + fi + + if ! grep -q 'Diabetes Treatment' "$settings_file"; then + die "Anchor not found in SettingsView.swift: Diabetes Treatment + Your Loop version may be incompatible." + fi + + if ! grep -q 'private var cgmChoices' "$settings_file"; then + die "Anchor not found in SettingsView.swift: private var cgmChoices + Your Loop version may be incompatible." + fi + success "SettingsView.swift anchors verified" + + # Detect L&L patches (informational only) + detect_ll_patches +} + +detect_ll_patches() { + local settings_file="Loop/Loop/Views/SettingsView.swift" + local found_patches=() + + if grep -q "ProfileManager\|Profiles" "$settings_file" 2>/dev/null; then + found_patches+=("Profiles") + fi + + if grep -q "basalLock\|BasalLock\|basal_lock" "Loop/Loop/Managers/LoopDataManager.swift" 2>/dev/null; then + found_patches+=("Basal Lock") + fi + + if grep -q "negativeInsulin\|NegativeInsulin\|negative_insulin" "Loop/Loop/Managers/LoopDataManager.swift" 2>/dev/null; then + found_patches+=("Negative Insulin") + fi + + local carb_file="Loop/Loop/Views/CarbEntryView.swift" + if grep -q "futureCarb\|FutureCarb\|future_carb_4h\|absorptionTimeWasEdited" "$carb_file" 2>/dev/null; then + found_patches+=("Future Carbs 4h") + fi + + if [[ ${#found_patches[@]} -gt 0 ]]; then + info "Detected L&L patches: ${found_patches[*]}" + info "These are compatible — the installer will adapt to them." + else + info "No L&L patches detected (standard Loop)." + fi +} + +# ─── Phase 2: Backup ───────────────────────────────────────────────────────── + +create_backup() { + header "Phase 2: Creating backup" + + pushd Loop > /dev/null + + # Stash any uncommitted changes (including L&L patches) as a safety backup, + # then immediately restore them so L&L patches remain in the working tree + # during installation. The stash entry stays for rollback. + local stash_msg="pre-feature-install-$(date +%Y%m%d-%H%M%S)" + if ! git diff --quiet || ! git diff --cached --quiet; then + git stash push -m "$stash_msg" --include-untracked + git stash apply 2>/dev/null + success "Backed up working tree as: $stash_msg (L&L patches preserved)" + else + info "Working tree clean, no stash needed." + fi + + popd > /dev/null +} + +# ─── Phase 3: Fetch Source ──────────────────────────────────────────────────── + +setup_source_remote() { + header "Phase 3: Fetching feature source" + + pushd Loop > /dev/null + + # Remove stale remote if it exists + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" + fi + + git remote add "$FEATURE_REMOTE" "$FEATURE_REPO" + git fetch "$FEATURE_REMOTE" "$FEATURE_LOOP_BRANCH" --depth=1 + success "Fetched ${FEATURE_LOOP_BRANCH} from ${FEATURE_REPO}" + + # Also fetch dev — our feature branch was based on dev, so we need it as the diff base + # even when the user cloned main (which is the L&L-compatible path) + git fetch "$FEATURE_REMOTE" dev --depth=1 + success "Fetched dev ref for diff base" + + popd > /dev/null +} + +# ─── Phase 4: Install New Files ────────────────────────────────────────────── + +install_new_files() { + header "Phase 4: Installing ${#NEW_FILES[@]} new files" + + pushd Loop > /dev/null + + local installed=0 + local failed=0 + + for file in "${NEW_FILES[@]}"; do + if git checkout "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "$file" 2>/dev/null; then + ((installed++)) + else + warn "Failed to checkout: $file" + ((failed++)) + fi + done + + # Localizable.xcstrings: direct checkout instead of 3-way merge + # (71K-line JSON file — too large for reliable diff/apply) + # Only replace if the user already has it (dev branch uses xcstrings; + # main branch uses old-style .strings files and doesn't have xcstrings) + if [[ -f "Loop/Localizable.xcstrings" ]]; then + if git checkout "${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "Loop/Localizable.xcstrings" 2>/dev/null; then + ((installed++)) + success "Replaced Localizable.xcstrings (direct checkout)" + else + warn "Failed to checkout Localizable.xcstrings" + ((failed++)) + fi + else + info "Skipping Localizable.xcstrings (not present on this branch — features use NSLocalizedString fallback)" + fi + + popd > /dev/null + + success "Installed $installed files" + if [[ $failed -gt 0 ]]; then + warn "$failed files failed to install" + fi +} + +# ─── Phase 5: Patch Modified Files ─────────────────────────────────────────── + +patch_modified_files() { + header "Phase 5: Patching ${#PATCH_FILES[@]} modified files" + + pushd Loop > /dev/null + + # We need the dev branch as the diff base. feat/AllFeatures was branched from dev, + # so `git diff dev..feat/AllFeatures` isolates ONLY our feature changes. + # We fetched dev from our remote in Phase 3, so it's always available — + # even when the user cloned main (the L&L-compatible path). + local dev_ref + dev_ref=$(git rev-parse "${FEATURE_REMOTE}/dev" 2>/dev/null) + if [[ -z "$dev_ref" ]]; then + # Fallback to local dev branches + dev_ref=$(git rev-parse dev 2>/dev/null || git rev-parse origin/dev 2>/dev/null || git rev-parse upstream/dev 2>/dev/null) + fi + if [[ -z "$dev_ref" ]]; then + die "Cannot find dev branch reference. The feature remote fetch may have failed." + fi + + local patched=0 + local failed=0 + local skipped=0 + + for file in "${PATCH_FILES[@]}"; do + local diff_output + diff_output=$(git diff "$dev_ref".."${FEATURE_REMOTE}/${FEATURE_LOOP_BRANCH}" -- "$file" 2>/dev/null) + + if [[ -z "$diff_output" ]]; then + info "No changes for: $file (skipped)" + ((skipped++)) + continue + fi + + if echo "$diff_output" | git apply --3way 2>/dev/null; then + success "Patched: $file" + ((patched++)) + else + warn "3-way merge had conflicts for: $file" + warn " → Check for conflict markers and resolve manually." + ((failed++)) + fi + done + + popd > /dev/null + + info "Patched: $patched, Skipped: $skipped, Conflicts: $failed" + if [[ $failed -gt 0 ]]; then + warn "Some files had merge conflicts. Resolve them before building." + fi +} + +# ─── Phase 6: Patch SettingsView.swift (Anchor-Based) ──────────────────────── + +patch_settings_view() { + header "Phase 6: Patching SettingsView.swift (anchor-based)" + + local settings_file="Loop/Loop/Views/SettingsView.swift" + + # Use Python for reliable multi-line text insertion + python3 - "$settings_file" << 'PYTHON_SCRIPT' +import sys + +settings_path = sys.argv[1] + +with open(settings_path, "r") as f: + content = f.read() + +lines = content.split("\n") + +# ─── Anchor 1: Insert feature rows AFTER the Therapy Settings button ─── +# We anchor on "Diabetes Treatment" (the Therapy Settings descriptive text) so our +# features appear right after Therapy Settings. If L&L Profiles is installed, it +# inserts before the ForEach — so Profiles ends up BELOW our features. + +FEATURE_ROWS = """ + foodFinderSettingsRow + + loopInsightsSection + + NavigationLink(destination: AutoPresetsSettingsView()) { + LargeButton( + action: {}, + includeArrow: false, + imageView: AutoPresetsIconView(), + label: NSLocalizedString("AutoPresets", comment: "Title text for button to AutoPresets Settings"), + descriptiveText: NSLocalizedString("Automate your presets during motion", comment: "Descriptive text for Auto-Apply Presets") + ) + } +""" + +anchor1 = 'Diabetes Treatment' +anchor1_idx = None +for i, line in enumerate(lines): + if anchor1 in line: + anchor1_idx = i + break + +if anchor1_idx is None: + print(f"ERROR: Anchor 1 not found: {anchor1}", file=sys.stderr) + sys.exit(1) + +# Insert the feature rows AFTER the Therapy Settings descriptive text line +feature_lines = FEATURE_ROWS.rstrip("\n").split("\n") +insert_at = anchor1_idx + 2 # after the NavigationLink closing brace (line after "Diabetes Treatment") +for j, fl in enumerate(feature_lines): + lines.insert(insert_at + j, fl) +print(f" Inserted {len(feature_lines)} lines after Therapy Settings (line {anchor1_idx + 1})") + +# ─── Anchor 2: Insert computed properties BEFORE "private var cgmChoices:" ─── + +COMPUTED_PROPS = """ + // FoodFinder — single settings insertion point + private var foodFinderSettingsRow: some View { + NavigationLink(destination: AISettingsView()) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image(systemName: "fork.knife.circle.fill") + .foregroundColor(Color(red: 107/255, green: 47/255, blue: 160/255)) + .font(.system(size: 36)), + label: NSLocalizedString("FoodFinder", comment: "Title text for button to FoodFinder Settings"), + descriptiveText: NSLocalizedString("AI-powered & barcode food analysis", comment: "Descriptive text for FoodFinder Settings")) + } + } + + private var loopInsightsSection: some View { + NavigationLink(destination: LoopInsights_SettingsView(dataStoresProvider: viewModel.loopInsightsDataStores)) { + LargeButton(action: {}, + includeArrow: false, + imageView: Image(systemName: "brain.head.profile") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(Color(red: 26/255, green: 138/255, blue: 158/255)) + .frame(width: 30), + label: NSLocalizedString("LoopInsights", comment: "LoopInsights settings button"), + descriptiveText: NSLocalizedString("AI-powered therapy settings analysis", comment: "LoopInsights settings descriptive text")) + } + } + +""" + +anchor2 = "private var cgmChoices:" +anchor2_idx = None +# Re-scan from scratch since lines array was modified +for i, line in enumerate(lines): + if anchor2 in line: + anchor2_idx = i + break + +if anchor2_idx is None: + print(f"ERROR: Anchor 2 not found: {anchor2}", file=sys.stderr) + sys.exit(1) + +prop_lines = COMPUTED_PROPS.rstrip("\n").split("\n") +for j, pl in enumerate(prop_lines): + lines.insert(anchor2_idx + j, pl) +print(f" Inserted {len(prop_lines)} lines before cgmChoices anchor (line {anchor2_idx + 1})") + +# Write back +with open(settings_path, "w") as f: + f.write("\n".join(lines)) + +print(" SettingsView.swift patched successfully.") +PYTHON_SCRIPT + + if [[ $? -eq 0 ]]; then + success "SettingsView.swift patched with anchor-based insertion" + else + error "Failed to patch SettingsView.swift" + return 1 + fi +} + +# ─── Phase 6b: Patch LoopDataManager.swift (Anchor-Based) ──────────────────── +# +# L&L Customizations heavily modify LoopDataManager.swift (Negative Insulin Damper, +# function signature changes, etc.), so git apply --3way fails silently. +# Instead, we use anchor-based insertion like SettingsView.swift. + +patch_loop_data_manager() { + header "Phase 6b: Patching LoopDataManager.swift (anchor-based)" + + local ldm_file="Loop/Loop/Managers/LoopDataManager.swift" + + if [[ ! -f "$ldm_file" ]]; then + die "LoopDataManager.swift not found at: $ldm_file" + fi + + # Skip if already patched + if grep -q "AutoPresetsCoordinator" "$ldm_file"; then + info "LoopDataManager.swift already contains AutoPresets code — skipping." + return 0 + fi + + python3 - "$ldm_file" << 'PYTHON_SCRIPT' +import sys + +ldm_path = sys.argv[1] + +with open(ldm_path, "r") as f: + content = f.read() + +lines = content.split("\n") + +# ─── Anchor 1: Insert delegate setup after "self.trustedTimeOffset = trustedTimeOffset" ─── +# This is in the init method. The delegate line goes right after this assignment, +# before the LiveActivity setup. + +DELEGATE_SETUP = """\ + + // Set up AutoPresets coordinator delegate + AutoPresetsCoordinator.shared.delegate = self +""" + +anchor1 = "self.trustedTimeOffset = trustedTimeOffset" +anchor1_idx = None +for i, line in enumerate(lines): + if anchor1 in line: + anchor1_idx = i + break + +if anchor1_idx is None: + print(f"ERROR: Anchor not found: {anchor1}", file=sys.stderr) + sys.exit(1) + +delegate_lines = DELEGATE_SETUP.rstrip("\n").split("\n") +insert_at = anchor1_idx + 1 +for j, dl in enumerate(delegate_lines): + lines.insert(insert_at + j, dl) +print(f" Inserted delegate setup ({len(delegate_lines)} lines) after line {anchor1_idx + 1}") + +# ─── Anchor 2: Append AutoPresetsDelegate extension at end of file ─── +# We find the very last closing brace of the file and append after it. + +DELEGATE_EXTENSION = """ +// MARK: - AutoPresetsDelegate + +extension LoopDataManager: AutoPresetsDelegate { + + func autoPresets(_ coordinator: AutoPresetsCoordinator, + shouldActivatePreset preset: TemporaryScheduleOverridePreset) { + logger.default("AutoPresets activating preset: %{public}@", preset.name) + + mutateSettings { settings in + settings.scheduleOverride = preset.createOverride(enactTrigger: .local) + } + } + + func autoPresets(_ coordinator: AutoPresetsCoordinator, + shouldDeactivatePreset preset: TemporaryScheduleOverridePreset) { + guard let currentOverride = settings.scheduleOverride, + case let .preset(currentPreset) = currentOverride.context, + currentPreset.id == preset.id + else { + return + } + + logger.default("AutoPresets deactivating preset: %{public}@", preset.name) + + mutateSettings { settings in + settings.scheduleOverride = nil + } + } + + func autoPresetsAvailablePresets(_ coordinator: AutoPresetsCoordinator) -> [TemporaryScheduleOverridePreset] { + settings.overridePresets + } + + func autoPresetsCurrentOverride(_ coordinator: AutoPresetsCoordinator) -> TemporaryScheduleOverride? { + settings.scheduleOverride + } +} +""" + +extension_lines = DELEGATE_EXTENSION.split("\n") +lines.extend(extension_lines) +print(f" Appended AutoPresetsDelegate extension ({len(extension_lines)} lines) at end of file") + +# Write back +with open(ldm_path, "w") as f: + f.write("\n".join(lines)) + +print(" LoopDataManager.swift patched successfully.") +PYTHON_SCRIPT + + if [[ $? -eq 0 ]]; then + success "LoopDataManager.swift patched with AutoPresets delegate" + else + error "Failed to patch LoopDataManager.swift" + return 1 + fi +} + +# ─── Phase 7: Update project.pbxproj ───────────────────────────────────────── + +update_pbxproj() { + header "Phase 7: Updating project.pbxproj" + + local pbxproj="Loop/Loop.xcodeproj/project.pbxproj" + + if [[ ! -f "$pbxproj" ]]; then + die "project.pbxproj not found at: $pbxproj" + fi + + # Back up pbxproj + cp "$pbxproj" "${pbxproj}.backup" + + # Find the update script — alongside this script, or in Scripts/, or download it + local py_script="" + local script_dir + + # Try 1: alongside this script (normal local run) + if [[ -n "${BASH_SOURCE[0]:-}" ]] && [[ "${BASH_SOURCE[0]}" != "bash" ]]; then + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" + if [[ -f "${script_dir}/update_pbxproj.py" ]]; then + py_script="${script_dir}/update_pbxproj.py" + fi + fi + + # Try 2: in Scripts/ relative to cwd (LoopWorkspace root) + if [[ -z "$py_script" ]] && [[ -f "Scripts/update_pbxproj.py" ]]; then + py_script="Scripts/update_pbxproj.py" + fi + + # Try 3: download from GitHub + if [[ -z "$py_script" ]]; then + info "Downloading update_pbxproj.py..." + mkdir -p Scripts + if curl -fsSL "${FEATURE_WORKSPACE_REPO}/Scripts/update_pbxproj.py" -o Scripts/update_pbxproj.py; then + py_script="Scripts/update_pbxproj.py" + success "Downloaded update_pbxproj.py" + else + die "Failed to download update_pbxproj.py from GitHub." + fi + fi + + if python3 "$py_script" "$pbxproj"; then + success "project.pbxproj updated" + else + error "Failed to update project.pbxproj — restoring backup" + cp "${pbxproj}.backup" "$pbxproj" + return 1 + fi + + # Validate + if plutil -lint "$pbxproj" > /dev/null 2>&1; then + success "project.pbxproj passes plutil validation" + rm -f "${pbxproj}.backup" + else + error "project.pbxproj failed plutil validation — restoring backup" + cp "${pbxproj}.backup" "$pbxproj" + rm -f "${pbxproj}.backup" + return 1 + fi +} + +# ─── Phase 8: Validate & Cleanup ───────────────────────────────────────────── + +validate_installation() { + header "Phase 8: Validating installation" + + local missing=0 + + # Check a representative sample of files + local check_files=( + "Loop/Loop/Views/FoodFinder/FoodFinder_EntryPoint.swift" + "Loop/Loop/Views/LoopInsights/LoopInsights_DashboardView.swift" + "Loop/Loop/Views/AutoPresetsSettingsView.swift" + "Loop/Loop/Managers/AutoPresetsCoordinator.swift" + "Loop/Loop/Services/FoodFinder/FoodFinder_AIAnalysis.swift" + "Loop/Loop/Services/LoopInsights/LoopInsights_DataAggregator.swift" + "Loop/Loop/Resources/FoodFinder/FoodFinder_FeatureFlags.swift" + "Loop/Loop/Resources/LoopInsights/LoopInsights_FeatureFlags.swift" + ) + + for f in "${check_files[@]}"; do + if [[ ! -f "$f" ]]; then + warn "Missing: $f" + ((missing++)) + fi + done + + if [[ $missing -gt 0 ]]; then + warn "$missing expected files are missing" + else + success "All sample files verified" + fi + + # Verify SettingsView.swift has our insertions + local settings_file="Loop/Loop/Views/SettingsView.swift" + if grep -q "foodFinderSettingsRow" "$settings_file"; then + success "SettingsView.swift contains FoodFinder row" + else + warn "SettingsView.swift is missing FoodFinder row" + fi + + if grep -q "loopInsightsSection" "$settings_file"; then + success "SettingsView.swift contains LoopInsights section" + else + warn "SettingsView.swift is missing LoopInsights section" + fi + + if grep -q "AutoPresetsSettingsView" "$settings_file"; then + success "SettingsView.swift contains AutoPresets row" + else + warn "SettingsView.swift is missing AutoPresets row" + fi + + # Write marker file + echo "installed=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "Loop/${MARKER_FILE}" + success "Installation marker written" +} + +cleanup() { + header "Cleanup" + + pushd Loop > /dev/null + + # Remove temp remote + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" + success "Removed temporary remote: $FEATURE_REMOTE" + fi + + popd > /dev/null + + echo "" + echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════${NC}" + echo -e "${GREEN}${BOLD} Installation Complete!${NC}" + echo -e "${GREEN}${BOLD}════════════════════════════════════════════════════${NC}" + echo "" + echo -e " ${BOLD}Next steps:${NC}" + echo " 1. Open LoopWorkspace.xcworkspace in Xcode" + echo " 2. Build and run (Cmd+R)" + echo " 3. In Loop > Settings > Enable AutoPresets / FoodFinder / LoopInsights" + echo " 4. Enter your AI API key in FoodFinder Settings" + echo "" + echo -e " ${BOLD}To uninstall:${NC}" + echo " ./Scripts/install_features.sh --rollback" + echo "" +} + +# ─── Rollback ───────────────────────────────────────────────────────────────── + +rollback() { + header "Rolling back feature installation" + + if [[ ! -d "LoopWorkspace.xcworkspace" ]]; then + die "Must run from LoopWorkspace root directory." + fi + + pushd Loop > /dev/null + + # 1. Remove all new feature files + info "Removing new feature files..." + local removed=0 + for file in "${NEW_FILES[@]}"; do + if [[ -f "$file" ]]; then + rm -f "$file" + ((removed++)) + fi + done + success "Removed $removed feature files" + + # Clean up empty directories + local feature_dirs=( + "Loop/Views/FoodFinder" "Loop/Views/LoopInsights" + "Loop/Models/FoodFinder" "Loop/Models/LoopInsights" + "Loop/Services/FoodFinder" "Loop/Services/LoopInsights" + "Loop/Resources/FoodFinder" "Loop/Resources/LoopInsights/TestData" "Loop/Resources/LoopInsights" + "Loop/Managers/LoopInsights" + "Loop/View Models/FoodFinder" "Loop/View Models/LoopInsights" + "LoopTests/FoodFinder" "LoopTests/LoopInsights" + "Documentation/FoodFinder" "Documentation/LoopInsights" + "Loop/Services" "Loop/Resources" + ) + for dir in "${feature_dirs[@]}"; do + if [[ -d "$dir" ]] && [[ -z "$(ls -A "$dir" 2>/dev/null)" ]]; then + rmdir "$dir" 2>/dev/null || true + fi + done + success "Cleaned up empty directories" + + # 2. Reset all files to HEAD state (unstages new files, restores modified files) + info "Resetting all files to HEAD..." + git reset HEAD -- . 2>/dev/null || true + git checkout HEAD -- . 2>/dev/null || true + # Remove any remaining untracked feature files + git clean -fd -- Loop/Views/FoodFinder Loop/Views/LoopInsights \ + Loop/Models/FoodFinder Loop/Models/LoopInsights \ + Loop/Services/FoodFinder Loop/Services/LoopInsights \ + Loop/Resources/FoodFinder Loop/Resources/LoopInsights \ + Loop/Managers/LoopInsights \ + "Loop/View Models/FoodFinder" "Loop/View Models/LoopInsights" \ + LoopTests/FoodFinder LoopTests/LoopInsights \ + Documentation/FoodFinder Documentation/LoopInsights \ + 2>/dev/null || true + success "Reset all files to HEAD" + + # 3. Remove marker + rm -f "$MARKER_FILE" + + # 4. Pop stash if one exists from our install + local stash_list + stash_list=$(git stash list 2>/dev/null || true) + if echo "$stash_list" | grep -q "pre-feature-install"; then + info "Found pre-install stash, restoring..." + git stash pop 2>/dev/null || warn "Stash pop had conflicts — resolve manually." + success "Restored pre-install state" + fi + + # 5. Remove temp remote if still present + if git remote | grep -q "^${FEATURE_REMOTE}$"; then + git remote remove "$FEATURE_REMOTE" + fi + + popd > /dev/null + + echo "" + echo -e "${GREEN}${BOLD} Rollback complete. Your Loop is back to its previous state.${NC}" + echo "" +} + +# ─── Main ───────────────────────────────────────────────────────────────────── + +main() { + echo -e "${BOLD}" + echo "╔══════════════════════════════════════════════════════╗" + echo "║ Loop Feature Installer ║" + echo "║ FoodFinder + LoopInsights + AutoPresets ║" + echo "╚══════════════════════════════════════════════════════╝" + echo -e "${NC}" + + validate_environment + create_backup + setup_source_remote + install_new_files + patch_modified_files + patch_settings_view + patch_loop_data_manager + update_pbxproj + validate_installation + cleanup +} + +# ─── Entry Point ────────────────────────────────────────────────────────────── + +if [[ "${1:-}" == "--rollback" ]]; then + rollback +else + main +fi diff --git a/Scripts/update_pbxproj.py b/Scripts/update_pbxproj.py new file mode 100755 index 0000000000..7420a13ed9 --- /dev/null +++ b/Scripts/update_pbxproj.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +""" +Update Loop.xcodeproj/project.pbxproj to add FoodFinder + LoopInsights + AutoPresets files. + +Uses deterministic UUIDs via md5 hash for reproducibility across runs. +Finds parent group UUIDs dynamically by name for portability across Loop versions. + +Concept & design by Taylor Patterson. Coded by Claude Code in February 2026. +Copyright (c) 2025-2026 LoopKit Authors. All rights reserved. +""" + +import hashlib +import re +import subprocess +from typing import Optional +import sys + + +def make_uuid(name: str) -> str: + """Generate a deterministic 24-char hex UUID from a name.""" + return hashlib.md5(f"FeatureInstaller_{name}".encode()).hexdigest()[:24].upper() + + +# ─── File Manifest ──────────────────────────────────────────────────────────── +# Tuples of (relative_path_from_Loop/, filename, parent_group_key) +# parent_group_key maps to a group in the project that this file belongs under. + +SOURCE_FILES = [ + # ── AutoPresets ── + ("Managers/ActivityDetectionManager.swift", "ActivityDetectionManager.swift", "Managers"), + ("Managers/AutoPresetsCoordinator.swift", "AutoPresetsCoordinator.swift", "Managers"), + ("Managers/AutoPresetsDelegate.swift", "AutoPresetsDelegate.swift", "Managers"), + ("Managers/AutoPresetsLogger.swift", "AutoPresetsLogger.swift", "Managers"), + ("Managers/AutoPresetsStorage.swift", "AutoPresetsStorage.swift", "Managers"), + ("Models/AutoPresetsModels.swift", "AutoPresetsModels.swift", "Models"), + ("Views/AutoPresetsSettingsView.swift", "AutoPresetsSettingsView.swift", "Views"), + + # ── FoodFinder — Models ── + ("Models/FoodFinder/FoodFinder_AnalysisRecord.swift", "FoodFinder_AnalysisRecord.swift", "Models/FoodFinder"), + ("Models/FoodFinder/FoodFinder_InputResults.swift", "FoodFinder_InputResults.swift", "Models/FoodFinder"), + ("Models/FoodFinder/FoodFinder_Models.swift", "FoodFinder_Models.swift", "Models/FoodFinder"), + + # ── FoodFinder — Resources ── + ("Resources/FoodFinder/FoodFinder_FeatureFlags.swift", "FoodFinder_FeatureFlags.swift", "Resources/FoodFinder"), + + # ── FoodFinder — Services ── + ("Services/FoodFinder/FoodFinder_AIAnalysis.swift", "FoodFinder_AIAnalysis.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_AIProviderConfig.swift", "FoodFinder_AIProviderConfig.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_AIServiceAdapter.swift", "FoodFinder_AIServiceAdapter.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_AIServiceManager.swift", "FoodFinder_AIServiceManager.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_AnalysisHistoryStore.swift", "FoodFinder_AnalysisHistoryStore.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_EmojiProvider.swift", "FoodFinder_EmojiProvider.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_ImageDownloader.swift", "FoodFinder_ImageDownloader.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_ImageStore.swift", "FoodFinder_ImageStore.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_OpenFoodFactsService.swift", "FoodFinder_OpenFoodFactsService.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_ScannerService.swift", "FoodFinder_ScannerService.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_SearchRouter.swift", "FoodFinder_SearchRouter.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_SecureStorage.swift", "FoodFinder_SecureStorage.swift", "Services/FoodFinder"), + ("Services/FoodFinder/FoodFinder_VoiceService.swift", "FoodFinder_VoiceService.swift", "Services/FoodFinder"), + + # ── FoodFinder — View Models ── + ("View Models/FoodFinder/FoodFinder_SearchViewModel.swift", "FoodFinder_SearchViewModel.swift", "View Models/FoodFinder"), + + # ── FoodFinder — Views ── + ("Views/FoodFinder/FoodFinder_AICameraView.swift", "FoodFinder_AICameraView.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_EntryPoint.swift", "FoodFinder_EntryPoint.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_FavoritesHelpers.swift", "FoodFinder_FavoritesHelpers.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_ScannerView.swift", "FoodFinder_ScannerView.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_SearchBar.swift", "FoodFinder_SearchBar.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_SearchResultsView.swift", "FoodFinder_SearchResultsView.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_SettingsView.swift", "FoodFinder_SettingsView.swift", "Views/FoodFinder"), + ("Views/FoodFinder/FoodFinder_VoiceSearchView.swift", "FoodFinder_VoiceSearchView.swift", "Views/FoodFinder"), + + # ── LoopInsights — Managers ── + ("Managers/LoopInsights/LoopInsights_BackgroundMonitor.swift", "LoopInsights_BackgroundMonitor.swift", "Managers/LoopInsights"), + ("Managers/LoopInsights/LoopInsights_Coordinator.swift", "LoopInsights_Coordinator.swift", "Managers/LoopInsights"), + + # ── LoopInsights — Models ── + ("Models/LoopInsights/LoopInsights_Models.swift", "LoopInsights_Models.swift", "Models/LoopInsights"), + ("Models/LoopInsights/LoopInsights_Phase5Models.swift", "LoopInsights_Phase5Models.swift", "Models/LoopInsights"), + ("Models/LoopInsights/LoopInsights_SuggestionRecord.swift", "LoopInsights_SuggestionRecord.swift", "Models/LoopInsights"), + + # ── LoopInsights — Resources ── + ("Resources/LoopInsights/LoopInsights_FeatureFlags.swift", "LoopInsights_FeatureFlags.swift", "Resources/LoopInsights"), + + # ── LoopInsights — Services ── + ("Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift", "LoopInsights_AdvancedAnalyzers.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_AIAnalysis.swift", "LoopInsights_AIAnalysis.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_AIServiceAdapter.swift", "LoopInsights_AIServiceAdapter.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_CaffeineTracker.swift", "LoopInsights_CaffeineTracker.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_DataAggregator.swift", "LoopInsights_DataAggregator.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_FoodResponseAnalyzer.swift", "LoopInsights_FoodResponseAnalyzer.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_GoalStore.swift", "LoopInsights_GoalStore.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_HealthKitManager.swift", "LoopInsights_HealthKitManager.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_NightscoutImporter.swift", "LoopInsights_NightscoutImporter.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_ReportGenerator.swift", "LoopInsights_ReportGenerator.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_SecureStorage.swift", "LoopInsights_SecureStorage.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_SuggestionStore.swift", "LoopInsights_SuggestionStore.swift", "Services/LoopInsights"), + ("Services/LoopInsights/LoopInsights_TestDataProvider.swift", "LoopInsights_TestDataProvider.swift", "Services/LoopInsights"), + + # ── LoopInsights — View Models ── + ("View Models/LoopInsights/LoopInsights_ChatViewModel.swift", "LoopInsights_ChatViewModel.swift", "View Models/LoopInsights"), + ("View Models/LoopInsights/LoopInsights_DashboardViewModel.swift", "LoopInsights_DashboardViewModel.swift", "View Models/LoopInsights"), + + # ── LoopInsights — Views ── + ("Views/LoopInsights/LoopInsights_AGPChartView.swift", "LoopInsights_AGPChartView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_CaffeineLogView.swift", "LoopInsights_CaffeineLogView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_ChatView.swift", "LoopInsights_ChatView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_DashboardView.swift", "LoopInsights_DashboardView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_GoalsView.swift", "LoopInsights_GoalsView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_MealInsightsView.swift", "LoopInsights_MealInsightsView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_MonitorSettingsView.swift", "LoopInsights_MonitorSettingsView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_SettingsView.swift", "LoopInsights_SettingsView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_SuggestionDetailView.swift", "LoopInsights_SuggestionDetailView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_SuggestionHistoryView.swift", "LoopInsights_SuggestionHistoryView.swift", "Views/LoopInsights"), + ("Views/LoopInsights/LoopInsights_TrendsInsightsView.swift", "LoopInsights_TrendsInsightsView.swift", "Views/LoopInsights"), +] + +TEST_FILES = [ + ("FoodFinder/FoodFinder_BarcodeScannerTests.swift", "FoodFinder_BarcodeScannerTests.swift", "LoopTests/FoodFinder"), + ("FoodFinder/FoodFinder_OpenFoodFactsTests.swift", "FoodFinder_OpenFoodFactsTests.swift", "LoopTests/FoodFinder"), + ("FoodFinder/FoodFinder_VoiceSearchTests.swift", "FoodFinder_VoiceSearchTests.swift", "LoopTests/FoodFinder"), + ("LoopInsights/LoopInsights_DataAggregatorTests.swift", "LoopInsights_DataAggregatorTests.swift", "LoopTests/LoopInsights"), + ("LoopInsights/LoopInsights_ModelsTests.swift", "LoopInsights_ModelsTests.swift", "LoopTests/LoopInsights"), + ("LoopInsights/LoopInsights_SuggestionStoreTests.swift", "LoopInsights_SuggestionStoreTests.swift", "LoopTests/LoopInsights"), +] + +# ─── Subgroup definitions ──────────────────────────────────────────────────── +# (group_key, display_name, path, parent_group_key) +# parent_group_key is the existing group this should be added as a child of. + +SUBGROUPS = [ + # Feature subgroups under existing top-level groups + ("Models/FoodFinder", "FoodFinder", "FoodFinder", "Models"), + ("Models/LoopInsights", "LoopInsights", "LoopInsights", "Models"), + ("Views/FoodFinder", "FoodFinder", "FoodFinder", "Views"), + ("Views/LoopInsights", "LoopInsights", "LoopInsights", "Views"), + ("View Models/FoodFinder", "FoodFinder", "FoodFinder", "View Models"), + ("View Models/LoopInsights","LoopInsights", "LoopInsights", "View Models"), + ("Managers/LoopInsights", "LoopInsights", "LoopInsights", "Managers"), + + # Services and Resources are new top-level groups under Loop root + ("Services", "Services", "Services", "Loop"), + ("Services/FoodFinder", "FoodFinder", "FoodFinder", "Services"), + ("Services/LoopInsights", "LoopInsights", "LoopInsights", "Services"), + ("Resources", "Resources", "Resources", "Loop"), + ("Resources/FoodFinder", "FoodFinder", "FoodFinder", "Resources"), + ("Resources/LoopInsights", "LoopInsights", "LoopInsights", "Resources"), + + # Test subgroups + ("LoopTests/FoodFinder", "FoodFinder", "FoodFinder", "LoopTests"), + ("LoopTests/LoopInsights", "LoopInsights", "LoopInsights", "LoopTests"), +] + + +def fileref_uuid(filename: str) -> str: + return make_uuid(f"fileref_{filename}") + + +def buildfile_uuid(filename: str) -> str: + return make_uuid(f"buildfile_{filename}") + + +def group_uuid(group_key: str) -> str: + return make_uuid(f"group_{group_key}") + + +# ─── pbxproj Parsing Helpers ───────────────────────────────────────────────── + +def parse_all_groups(content: str) -> dict[str, dict]: + """Parse all PBXGroup definitions into a dict of uuid -> {name, path, children_uuids}.""" + group_section_match = re.search( + r'/\* Begin PBXGroup section \*/\n(.*?)\n/\* End PBXGroup section \*/', + content, re.DOTALL + ) + if not group_section_match: + return {} + + section = group_section_match.group(1) + groups = {} + + # Match each group definition block individually + # Some groups (like the root mainGroup) have no /* comment */, so make it optional + for m in re.finditer( + r'^\t\t([A-F0-9]{24})\s*(?:/\*[^\n]*?\*/)?\s*= \{\n(.*?)\n\t\t\};', + section, re.MULTILINE | re.DOTALL + ): + uuid = m.group(1) + body = m.group(2) + + if "isa = PBXGroup" not in body: + continue + + path_m = re.search(r'path = "(.*?)";|path = ([^;"\s]+);', body) + name_m = re.search(r'name = "(.*?)";|name = ([^;"\s]+);', body) + + path_val = (path_m.group(1) or path_m.group(2)) if path_m else None + name_val = (name_m.group(1) or name_m.group(2)) if name_m else None + display = name_val or path_val or "unknown" + + # Parse children + children = [] + children_m = re.search(r'children = \(\n(.*?)\n\t\t\t\);', body, re.DOTALL) + if children_m: + for c in re.finditer(r'([A-F0-9]{24})', children_m.group(1)): + children.append(c.group(1)) + + groups[uuid] = { + "name": display, + "path": path_val, + "children": children, + } + + return groups + + +def find_groups_by_hierarchy(content: str) -> dict[str, str]: + """Find the correct group UUIDs by walking the PBXProject → mainGroup → Loop hierarchy. + + Returns a dict of logical_name -> UUID for: Loop, Views, Models, View Models, Managers, LoopTests. + This avoids ambiguity from duplicate group names (e.g. Watch, Widget targets also have Models/Views). + """ + all_groups = parse_all_groups(content) + + # Step 1: Find the project's mainGroup from PBXProject section + main_group_match = re.search(r'mainGroup = ([A-F0-9]{24})', content) + if not main_group_match: + return {} + main_group_uuid = main_group_match.group(1) + + # Step 2: The mainGroup's children include "Loop" and "LoopTests" (among others) + main_group = all_groups.get(main_group_uuid, {}) + result = {} + + for child_uuid in main_group.get("children", []): + child = all_groups.get(child_uuid, {}) + child_path = child.get("path") + child_name = child.get("name") + + if child_path == "Loop" or child_name == "Loop": + result["Loop"] = child_uuid + elif child_path == "LoopTests" or child_name == "LoopTests": + result["LoopTests"] = child_uuid + + # Step 3: Walk Loop group's children to find Views, Models, View Models, Managers + loop_uuid = result.get("Loop") + if loop_uuid and loop_uuid in all_groups: + for child_uuid in all_groups[loop_uuid]["children"]: + child = all_groups.get(child_uuid, {}) + child_path = child.get("path") + child_name = child.get("name") + display = child_name or child_path + + if display in ("Views", "Models", "View Models", "Managers", "Services", "Resources"): + result[display] = child_uuid + + return result + + +def find_main_sources_phase(content: str) -> Optional[str]: + """Find the PBXSourcesBuildPhase UUID for the main app target (Loop).""" + target_section = re.search( + r'/\* Begin PBXNativeTarget section \*/\n(.*?)\n/\* End PBXNativeTarget section \*/', + content, re.DOTALL + ) + if not target_section: + return None + + # Match the Loop target specifically (not LoopTests, not LoopWidgetExtension, etc.) + # Use word boundary: "Loop" followed by space and asterisk, not "LoopTests" etc. + for m in re.finditer( + r'([A-F0-9]{24}) /\* (Loop[^*]*?)\*/ = \{(.*?)\n\t\t\};', + target_section.group(1), re.DOTALL + ): + target_name = m.group(2).strip() + if target_name == "Loop": + phases_match = re.search(r'buildPhases = \(\n(.*?)\n\t\t\t\);', m.group(3), re.DOTALL) + if phases_match: + sources_match = re.search(r'([A-F0-9]{24}) /\*[^\n]*?Sources[^\n]*?\*/', phases_match.group(1)) + if sources_match: + return sources_match.group(1) + return None + + +def find_test_sources_phase(content: str) -> Optional[str]: + """Find the PBXSourcesBuildPhase UUID for the LoopTests target.""" + target_section = re.search( + r'/\* Begin PBXNativeTarget section \*/\n(.*?)\n/\* End PBXNativeTarget section \*/', + content, re.DOTALL + ) + if not target_section: + return None + + for m in re.finditer( + r'([A-F0-9]{24}) /\* (LoopTests[^*]*?)\*/ = \{(.*?)\n\t\t\};', + target_section.group(1), re.DOTALL + ): + target_name = m.group(2).strip() + if target_name == "LoopTests": + phases_match = re.search(r'buildPhases = \(\n(.*?)\n\t\t\t\);', m.group(3), re.DOTALL) + if phases_match: + sources_match = re.search(r'([A-F0-9]{24}) /\*[^\n]*?Sources[^\n]*?\*/', phases_match.group(1)) + if sources_match: + return sources_match.group(1) + return None + + +def add_child_to_group(content: str, parent_uuid: str, child_uuid: str, child_name: str) -> str: + """Add a child reference to an existing PBXGroup's children list.""" + new_child = f"\t\t\t\t{child_uuid} /* {child_name} */," + + # Use [^\n]*? to prevent cross-line matching (critical lesson from prior work) + pattern = ( + f"({parent_uuid} /\\*[^\\n]*?\\*/ = \\{{\n" + f"\\t\\t\\tisa = PBXGroup;\n" + f"\\t\\t\\tchildren = \\(\n)" + f"(.*?)" + f"(\\n\\t\\t\\t\\);)" + ) + match = re.search(pattern, content, re.DOTALL) + if match: + before = match.group(1) + existing = match.group(2) + after = match.group(3) + content = content[:match.start()] + f"{before}{existing}\n{new_child}{after}" + content[match.end():] + else: + print(f" WARNING: Could not find group {parent_uuid} to add child {child_name}") + + return content + + +def add_to_build_phase(content: str, phase_uuid: str, entries_block: str) -> str: + """Add entries to a PBXSourcesBuildPhase's files list.""" + pattern = ( + f"({phase_uuid} /\\*[^\\n]*?\\*/ = \\{{\n" + f"\\t\\t\\tisa = PBXSourcesBuildPhase;\n" + f"\\t\\t\\tbuildActionMask = \\d+;\n" + f"\\t\\t\\tfiles = \\(\n)" + f"(.*?)" + f"(\\n\\t\\t\\t\\);\\n\\t\\t\\trunOnlyForDeploymentPostprocessing)" + ) + match = re.search(pattern, content, re.DOTALL) + if match: + before = match.group(1) + existing = match.group(2) + after = match.group(3) + content = content[:match.start()] + f"{before}{existing}\n{entries_block}{after}" + content[match.end():] + else: + print(f" WARNING: Could not find build phase {phase_uuid}") + + return content + + +def build_group_def(uuid: str, name: str, path: str, child_entries: list[tuple[str, str]]) -> str: + """Build a PBXGroup definition string.""" + children_lines = [] + for child_uuid, child_name in child_entries: + children_lines.append(f"\t\t\t\t{child_uuid} /* {child_name} */,") + children_str = "\n".join(children_lines) + + return ( + f"\t\t{uuid} /* {name} */ = {{\n" + f"\t\t\tisa = PBXGroup;\n" + f"\t\t\tchildren = (\n" + f"{children_str}\n" + f"\t\t\t);\n" + f"\t\t\tpath = {path};\n" + f"\t\t\tsourceTree = \"\";\n" + f"\t\t}};" + ) + + +# ─── Main ──────────────────────────────────────────────────────────────────── + +def main(): + if len(sys.argv) < 2: + print("Usage: update_pbxproj.py ") + sys.exit(1) + + pbxproj_path = sys.argv[1] + + with open(pbxproj_path, "r") as f: + content = f.read() + + # ── Discover existing group UUIDs by walking the project hierarchy ── + print(" Discovering existing group UUIDs...") + + known_groups = find_groups_by_hierarchy(content) + for name, uuid in sorted(known_groups.items()): + print(f" {name}: {uuid}") + + required = ["Loop", "Views", "Models", "View Models", "Managers", "LoopTests"] + for name in required: + if name not in known_groups: + print(f" WARNING: Could not find group '{name}'") + + main_sources_uuid = find_main_sources_phase(content) + test_sources_uuid = find_test_sources_phase(content) + + if main_sources_uuid: + print(f" Main Sources phase: {main_sources_uuid}") + else: + print(" WARNING: Could not find main Sources build phase") + + if test_sources_uuid: + print(f" Test Sources phase: {test_sources_uuid}") + else: + print(" WARNING: Could not find test Sources build phase") + + # ── Map group_key → UUID (generated or discovered) ── + group_uuids = {} + for gkey, gname, gpath, gparent in SUBGROUPS: + # If this group already exists in the project, use its UUID + if gkey in known_groups: + group_uuids[gkey] = known_groups[gkey] + else: + group_uuids[gkey] = group_uuid(gkey) + + # Also map parent groups from known_groups + for name, uuid in known_groups.items(): + if name not in group_uuids: + group_uuids[name] = uuid + + # ========================================================================= + # 1. Add PBXBuildFile entries + # ========================================================================= + print(" Adding PBXBuildFile entries...") + + build_entries = [] + for path, name, gkey in SOURCE_FILES: + bf = buildfile_uuid(name) + fr = fileref_uuid(name) + build_entries.append( + f"\t\t{bf} /* {name} in Sources */ = " + f"{{isa = PBXBuildFile; fileRef = {fr} /* {name} */; }};" + ) + for path, name, gkey in TEST_FILES: + bf = buildfile_uuid(name) + fr = fileref_uuid(name) + build_entries.append( + f"\t\t{bf} /* {name} in Sources */ = " + f"{{isa = PBXBuildFile; fileRef = {fr} /* {name} */; }};" + ) + + content = content.replace( + "/* End PBXBuildFile section */", + "\n".join(build_entries) + "\n/* End PBXBuildFile section */", + ) + + # ========================================================================= + # 2. Add PBXFileReference entries + # ========================================================================= + print(" Adding PBXFileReference entries...") + + ref_entries = [] + for path, name, gkey in SOURCE_FILES: + fr = fileref_uuid(name) + ref_entries.append( + f"\t\t{fr} /* {name} */ = " + f"{{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; " + f"path = {name}; sourceTree = \"\"; }};" + ) + for path, name, gkey in TEST_FILES: + fr = fileref_uuid(name) + ref_entries.append( + f"\t\t{fr} /* {name} */ = " + f"{{isa = PBXFileReference; lastKnownFileType = sourcecode.swift; " + f"path = {name}; sourceTree = \"\"; }};" + ) + + content = content.replace( + "/* End PBXFileReference section */", + "\n".join(ref_entries) + "\n/* End PBXFileReference section */", + ) + + # ========================================================================= + # 3. Create PBXGroup entries for new subgroups + # ========================================================================= + print(" Creating PBXGroup entries...") + + # Build child lists for each group + group_children: dict[str, list[tuple[str, str]]] = {gkey: [] for gkey, _, _, _ in SUBGROUPS} + + # Add source files to their groups + for path, name, gkey in SOURCE_FILES: + fr = fileref_uuid(name) + group_children.setdefault(gkey, []).append((fr, name)) + + for path, name, gkey in TEST_FILES: + fr = fileref_uuid(name) + group_children.setdefault(gkey, []).append((fr, name)) + + # Add subgroups as children of their parents + # Also add subgroups as children of parent subgroups + for gkey, gname, gpath, gparent in SUBGROUPS: + gu = group_uuids[gkey] + group_children.setdefault(gparent, []).append((gu, gname)) + + # Only create group defs for groups that don't already exist in the project + new_group_defs = [] + for gkey, gname, gpath, gparent in SUBGROUPS: + if gkey in known_groups: + continue # Already exists, don't re-create + gu = group_uuids[gkey] + children = group_children.get(gkey, []) + new_group_defs.append(build_group_def(gu, gname, gpath, children)) + + if new_group_defs: + groups_block = "\n".join(new_group_defs) + content = content.replace( + "/* End PBXGroup section */", + f"{groups_block}\n/* End PBXGroup section */", + ) + + # ========================================================================= + # 4. Add new subgroups as children of existing parent groups + # ========================================================================= + print(" Linking subgroups to parent groups...") + + for gkey, gname, gpath, gparent in SUBGROUPS: + parent_uuid = group_uuids.get(gparent) + child_uuid = group_uuids[gkey] + + if parent_uuid is None: + print(f" WARNING: Parent group '{gparent}' UUID unknown, skipping {gkey}") + continue + + # Only add if the parent is an existing group (not one we just created) + # For newly created parent groups, children were already added in step 3 + if gparent in known_groups: + content = add_child_to_group(content, parent_uuid, child_uuid, gname) + + # Also add AutoPresets files directly to their parent groups (no subgroup for these) + for path, name, gkey in SOURCE_FILES: + # Files in top-level groups (AutoPresets files go directly into Managers/Models/Views) + if gkey in known_groups and "/" not in gkey: + fr = fileref_uuid(name) + parent_uuid = known_groups[gkey] + content = add_child_to_group(content, parent_uuid, fr, name) + + # ========================================================================= + # 5. Add files to PBXSourcesBuildPhase + # ========================================================================= + print(" Adding files to build phases...") + + if main_sources_uuid: + main_entries = [] + for path, name, gkey in SOURCE_FILES: + bf = buildfile_uuid(name) + main_entries.append(f"\t\t\t\t{bf} /* {name} in Sources */,") + content = add_to_build_phase(content, main_sources_uuid, "\n".join(main_entries)) + + if test_sources_uuid: + test_entries = [] + for path, name, gkey in TEST_FILES: + bf = buildfile_uuid(name) + test_entries.append(f"\t\t\t\t{bf} /* {name} in Sources */,") + content = add_to_build_phase(content, test_sources_uuid, "\n".join(test_entries)) + + # ========================================================================= + # 6. Write output + # ========================================================================= + with open(pbxproj_path, "w") as f: + f.write(content) + + print(f"\n Updated {pbxproj_path}") + print(f" Added {len(SOURCE_FILES)} source files") + print(f" Added {len(TEST_FILES)} test files") + print(f" Created {len(new_group_defs)} new groups") + + +if __name__ == "__main__": + main() diff --git a/VersionOverride.xcconfig b/VersionOverride.xcconfig index 4e7b318af7..92a5315f1d 100644 --- a/VersionOverride.xcconfig +++ b/VersionOverride.xcconfig @@ -8,5 +8,5 @@ // Version [for DIY Loop] // configure the version number in LoopWorkspace -LOOP_MARKETING_VERSION = 3.10.0 +LOOP_MARKETING_VERSION = 3.11.0 CURRENT_PROJECT_VERSION = 57