diff --git a/app.json b/app.json index 2d973f5..9bdbb50 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "breathly", "version": "2.2.0", "orientation": "portrait", - "icon": "./assets/ios-icon.png", + "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", "updates": { "fallbackToCacheTimeout": 0 @@ -12,7 +12,7 @@ "assetBundlePatterns": ["**/*"], "ios": { "supportsTablet": true, - "icon": "./assets/ios-icon.png", + "icon": "./assets/icon.png", "buildNumber": "18", "bundleIdentifier": "com.mmazzarolo.breathly", "infoPlist": { @@ -32,7 +32,7 @@ "foregroundImage": "./assets/android-adaptive-icon-foreground.png", "backgroundColor": "#ffffff" }, - "icon": "./assets/android-icon.png", + "icon": "./assets/icon.png", "package": "com.mmazzarolo.breathly", "versionCode": 3145750 }, @@ -75,6 +75,11 @@ "image": "./assets/splash.png", "resizeMode": "cover", "backgroundColor": "#F2F2F1", + "enableFullScreenImage_legacy": true, + "dark": { + "image": "./assets/splash-dark.png", + "backgroundColor": "#0f172a" + }, "android": { "drawable": { "icon": "./assets/android-splash-screen-icon.xml" diff --git a/assets/audio/cuebell1.mp3 b/assets/audio/cuebell1.mp3 index c8cfef3..c3eb775 100644 Binary files a/assets/audio/cuebell1.mp3 and b/assets/audio/cuebell1.mp3 differ diff --git a/assets/audio/cuebell2.mp3 b/assets/audio/cuebell2.mp3 index aa7bbc8..e6ac254 100644 Binary files a/assets/audio/cuebell2.mp3 and b/assets/audio/cuebell2.mp3 differ diff --git a/assets/audio/endingbell1.mp3 b/assets/audio/endingbell1.mp3 index de8f686..32fd290 100644 Binary files a/assets/audio/endingbell1.mp3 and b/assets/audio/endingbell1.mp3 differ diff --git a/assets/audio/lauraexhale.mp3 b/assets/audio/lauraexhale.mp3 index 2837b24..a8fe61e 100644 Binary files a/assets/audio/lauraexhale.mp3 and b/assets/audio/lauraexhale.mp3 differ diff --git a/assets/audio/laurahold.mp3 b/assets/audio/laurahold.mp3 index f9d081d..23d555e 100644 Binary files a/assets/audio/laurahold.mp3 and b/assets/audio/laurahold.mp3 differ diff --git a/assets/audio/laurainhale.mp3 b/assets/audio/laurainhale.mp3 index 74d9547..97867d5 100644 Binary files a/assets/audio/laurainhale.mp3 and b/assets/audio/laurainhale.mp3 differ diff --git a/assets/audio/paulbreatheout.mp3 b/assets/audio/paulbreatheout.mp3 deleted file mode 100644 index 937cbb8..0000000 Binary files a/assets/audio/paulbreatheout.mp3 and /dev/null differ diff --git a/assets/audio/paulexhale.mp3 b/assets/audio/paulexhale.mp3 index f9d394e..4d48d38 100644 Binary files a/assets/audio/paulexhale.mp3 and b/assets/audio/paulexhale.mp3 differ diff --git a/assets/audio/paulhold.mp3 b/assets/audio/paulhold.mp3 index 2bf74ab..6756de1 100644 Binary files a/assets/audio/paulhold.mp3 and b/assets/audio/paulhold.mp3 differ diff --git a/assets/audio/paulinhale.mp3 b/assets/audio/paulinhale.mp3 index 763dc73..3167cd8 100644 Binary files a/assets/audio/paulinhale.mp3 and b/assets/audio/paulinhale.mp3 differ diff --git a/assets/audio/paulthein.mp3 b/assets/audio/paulthein.mp3 deleted file mode 100644 index 3e8951a..0000000 Binary files a/assets/audio/paulthein.mp3 and /dev/null differ diff --git a/assets/fonts/GeneralSans-Bold.otf b/assets/fonts/GeneralSans-Bold.otf deleted file mode 100644 index 697ce03..0000000 Binary files a/assets/fonts/GeneralSans-Bold.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-BoldItalic.otf b/assets/fonts/GeneralSans-BoldItalic.otf deleted file mode 100644 index c0f1cdd..0000000 Binary files a/assets/fonts/GeneralSans-BoldItalic.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-Extralight.otf b/assets/fonts/GeneralSans-Extralight.otf deleted file mode 100644 index 5a8cb07..0000000 Binary files a/assets/fonts/GeneralSans-Extralight.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-ExtralightItalic.otf b/assets/fonts/GeneralSans-ExtralightItalic.otf deleted file mode 100644 index 28bc23e..0000000 Binary files a/assets/fonts/GeneralSans-ExtralightItalic.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-Italic.otf b/assets/fonts/GeneralSans-Italic.otf deleted file mode 100644 index a489128..0000000 Binary files a/assets/fonts/GeneralSans-Italic.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-LightItalic.otf b/assets/fonts/GeneralSans-LightItalic.otf deleted file mode 100644 index 8637120..0000000 Binary files a/assets/fonts/GeneralSans-LightItalic.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-MediumItalic.otf b/assets/fonts/GeneralSans-MediumItalic.otf deleted file mode 100644 index 38dddc3..0000000 Binary files a/assets/fonts/GeneralSans-MediumItalic.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-Semibold.otf b/assets/fonts/GeneralSans-Semibold.otf deleted file mode 100644 index a436638..0000000 Binary files a/assets/fonts/GeneralSans-Semibold.otf and /dev/null differ diff --git a/assets/fonts/GeneralSans-SemiboldItalic.otf b/assets/fonts/GeneralSans-SemiboldItalic.otf deleted file mode 100644 index 8d4cd9b..0000000 Binary files a/assets/fonts/GeneralSans-SemiboldItalic.otf and /dev/null differ diff --git a/assets/fonts/Lora-Bold.otf b/assets/fonts/Lora-Bold.otf deleted file mode 100644 index ec8e92a..0000000 Binary files a/assets/fonts/Lora-Bold.otf and /dev/null differ diff --git a/assets/fonts/Lora-BoldItalic.otf b/assets/fonts/Lora-BoldItalic.otf deleted file mode 100644 index 634a530..0000000 Binary files a/assets/fonts/Lora-BoldItalic.otf and /dev/null differ diff --git a/assets/fonts/Lora-Italic.otf b/assets/fonts/Lora-Italic.otf deleted file mode 100644 index 803306d..0000000 Binary files a/assets/fonts/Lora-Italic.otf and /dev/null differ diff --git a/assets/fonts/Lora-MediumItalic.otf b/assets/fonts/Lora-MediumItalic.otf deleted file mode 100644 index a13000f..0000000 Binary files a/assets/fonts/Lora-MediumItalic.otf and /dev/null differ diff --git a/assets/fonts/Lora-Regular.otf b/assets/fonts/Lora-Regular.otf deleted file mode 100644 index de3e0b5..0000000 Binary files a/assets/fonts/Lora-Regular.otf and /dev/null differ diff --git a/assets/fonts/Lora-SemiBoldItalic.otf b/assets/fonts/Lora-SemiBoldItalic.otf deleted file mode 100644 index 881877f..0000000 Binary files a/assets/fonts/Lora-SemiBoldItalic.otf and /dev/null differ diff --git a/assets/android-icon.png b/assets/icon.png similarity index 100% rename from assets/android-icon.png rename to assets/icon.png diff --git a/assets/images/background-stars-horizontal-old.png b/assets/images/background-stars-horizontal-old.png deleted file mode 100755 index ee851f7..0000000 Binary files a/assets/images/background-stars-horizontal-old.png and /dev/null differ diff --git a/assets/ios-icon.png b/assets/ios-icon.png deleted file mode 100644 index db3a23b..0000000 Binary files a/assets/ios-icon.png and /dev/null differ diff --git a/assets/splash-dark.png b/assets/splash-dark.png new file mode 100644 index 0000000..e161f7a Binary files /dev/null and b/assets/splash-dark.png differ diff --git a/bun.lock b/bun.lock index ff4fd32..cdbe9e5 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ }, "devDependencies": { "@babel/core": "^7.19.3", + "@testing-library/react-native": "^14.0.1", "@types/color": "^3.0.3", "@types/jest": "29.5.14", "@types/ms": "^2.1.0", @@ -295,6 +296,8 @@ "@jest/create-cache-key-function": ["@jest/create-cache-key-function@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3" } }, "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA=="], + "@jest/diff-sequences": ["@jest/diff-sequences@30.4.0", "", {}, "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g=="], + "@jest/environment": ["@jest/environment@29.7.0", "", { "dependencies": { "@jest/fake-timers": "^29.7.0", "@jest/types": "^29.6.3", "@types/node": "*", "jest-mock": "^29.7.0" } }, "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw=="], "@jest/expect": ["@jest/expect@29.7.0", "", { "dependencies": { "expect": "^29.7.0", "jest-snapshot": "^29.7.0" } }, "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ=="], @@ -303,11 +306,13 @@ "@jest/fake-timers": ["@jest/fake-timers@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", "jest-message-util": "^29.7.0", "jest-mock": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ=="], + "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + "@jest/globals": ["@jest/globals@29.7.0", "", { "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", "@jest/types": "^29.6.3", "jest-mock": "^29.7.0" } }, "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ=="], "@jest/reporters": ["@jest/reporters@29.7.0", "", { "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^29.7.0", "@jest/test-result": "^29.7.0", "@jest/transform": "^29.7.0", "@jest/types": "^29.6.3", "@jridgewell/trace-mapping": "^0.3.18", "@types/node": "*", "chalk": "^4.0.0", "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", "glob": "^7.1.3", "graceful-fs": "^4.2.9", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", "istanbul-reports": "^3.1.3", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0", "jest-worker": "^29.7.0", "slash": "^3.0.0", "string-length": "^4.0.1", "strip-ansi": "^6.0.0", "v8-to-istanbul": "^9.0.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg=="], - "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "@jest/schemas": ["@jest/schemas@30.4.1", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q=="], "@jest/source-map": ["@jest/source-map@29.6.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.18", "callsites": "^3.0.0", "graceful-fs": "^4.2.9" } }, "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw=="], @@ -491,12 +496,14 @@ "@react-navigation/routers": ["@react-navigation/routers@7.6.4", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-GI7eJm8/KsZUQaYcXvEExikKurRZRgEsSzyZ7faENfi65yqJBCXjDMwyN1pF6pNW1MoLH1ErDwDivFxY6BzD3w=="], - "@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@sinclair/typebox": ["@sinclair/typebox@0.34.52", "", {}, "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw=="], "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], + "@testing-library/react-native": ["@testing-library/react-native@14.0.1", "", { "dependencies": { "jest-matcher-utils": "^30.4.1", "picocolors": "^1.1.1", "pretty-format": "^30.4.1", "redent": "^3.0.0" }, "peerDependencies": { "jest": ">=29.0.0", "react": ">=19.0.0", "react-native": ">=0.78", "test-renderer": "^1.0.0" }, "optionalPeers": ["jest"] }, "sha512-2r2e2y8SkUNRWKRXlUGqDYunB+AkbdXUPn49Bs5rpv9oxRb0K3USBVugbPbAKJjfj4w5Ba91NJ8LcQCZyopUZA=="], + "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -531,6 +538,8 @@ "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], + "@types/react-reconciler": ["@types/react-reconciler@0.33.0", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], @@ -979,6 +988,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -1029,7 +1040,7 @@ "jest-config": ["jest-config@29.7.0", "", { "dependencies": { "@babel/core": "^7.11.6", "@jest/test-sequencer": "^29.7.0", "@jest/types": "^29.6.3", "babel-jest": "^29.7.0", "chalk": "^4.0.0", "ci-info": "^3.2.0", "deepmerge": "^4.2.2", "glob": "^7.1.3", "graceful-fs": "^4.2.9", "jest-circus": "^29.7.0", "jest-environment-node": "^29.7.0", "jest-get-type": "^29.6.3", "jest-regex-util": "^29.6.3", "jest-resolve": "^29.7.0", "jest-runner": "^29.7.0", "jest-util": "^29.7.0", "jest-validate": "^29.7.0", "micromatch": "^4.0.4", "parse-json": "^5.2.0", "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "peerDependencies": { "@types/node": "*", "ts-node": ">=9.0.0" }, "optionalPeers": ["@types/node", "ts-node"] }, "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ=="], - "jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + "jest-diff": ["jest-diff@30.4.1", "", { "dependencies": { "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.4.1" } }, "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA=="], "jest-docblock": ["jest-docblock@29.7.0", "", { "dependencies": { "detect-newline": "^3.0.0" } }, "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g=="], @@ -1047,7 +1058,7 @@ "jest-leak-detector": ["jest-leak-detector@29.7.0", "", { "dependencies": { "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw=="], - "jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], + "jest-matcher-utils": ["jest-matcher-utils@30.4.1", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.4.1", "pretty-format": "30.4.1" } }, "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A=="], "jest-message-util": ["jest-message-util@29.7.0", "", { "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "micromatch": "^4.0.4", "pretty-format": "^29.7.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" } }, "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w=="], @@ -1195,6 +1206,8 @@ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], @@ -1295,7 +1308,7 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "pretty-format": ["pretty-format@30.4.1", "", { "dependencies": { "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", "react-is-18": "npm:react-is@^18.3.1", "react-is-19": "npm:react-is@^19.2.5" } }, "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw=="], "proc-log": ["proc-log@4.2.0", "", {}, "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA=="], @@ -1329,6 +1342,10 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-is-18": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-is-19": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="], + "react-native": ["react-native@0.86.2", "", { "dependencies": { "@react-native/assets-registry": "0.86.2", "@react-native/codegen": "0.86.2", "@react-native/community-cli-plugin": "0.86.2", "@react-native/gradle-plugin": "0.86.2", "@react-native/js-polyfills": "0.86.2", "@react-native/normalize-colors": "0.86.2", "@react-native/virtualized-lists": "0.86.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-plugin-syntax-hermes-parser": "0.36.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "hermes-compiler": "250829098.0.16", "invariant": "^2.2.4", "memoize-one": "^5.0.0", "metro-runtime": "^0.84.3", "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@react-native/jest-preset": "0.86.2", "@types/react": "^19.1.1", "react": "^19.2.3" }, "optionalPeers": ["@react-native/jest-preset", "@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-zbJXGZpwfZGA79Z9ob6Atvfx4nAQL8yJBa35s58E4Oo+khPykfQP2sTeumkKbjwajFYfVayg8pj7Il9nIfTk7A=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], @@ -1337,6 +1354,8 @@ "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1347,6 +1366,8 @@ "react-test-renderer": ["react-test-renderer@19.2.3", "", { "dependencies": { "react-is": "^19.2.3", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -1461,6 +1482,8 @@ "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "structured-headers": ["structured-headers@0.4.1", "", {}, "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg=="], @@ -1481,6 +1504,8 @@ "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], + "test-renderer": ["test-renderer@1.2.0", "", { "dependencies": { "@types/react-reconciler": "~0.33.0", "react-reconciler": "~0.33.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-JYiEGbgBGtmHAWX8Kf99gGRL1HtjcRVHLrmJNTZL4vFs9XrnWcuL45Iszw/pO4094+JR7havSrN+ds6YTOpSQA=="], + "throat": ["throat@5.0.0", "", {}, "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1607,6 +1632,8 @@ "@expo/cli/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "@expo/cli/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "@expo/cli/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@expo/cli/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], @@ -1643,16 +1670,22 @@ "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], + "@jest/core/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "@jest/reporters/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "@jest/reporters/string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + "@jest/types/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "@react-native/codegen/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], "@react-native/community-cli-plugin/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@react-navigation/core/react-is": ["react-is@19.2.8", "", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="], + "@types/jest/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -1675,6 +1708,10 @@ "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "expect/jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], + + "expo/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "expo-build-properties/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -1695,14 +1732,34 @@ "istanbul-lib-source-maps/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "jest-circus/jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], + + "jest-circus/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-config/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "jest-config/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-each/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-leak-detector/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "jest-message-util/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-runtime/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "jest-snapshot/jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + + "jest-snapshot/jest-matcher-utils": ["jest-matcher-utils@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "jest-diff": "^29.7.0", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g=="], + + "jest-snapshot/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-snapshot/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "jest-util/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "jest-validate/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "jest-watch-select-projects/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], "jest-watch-typeahead/ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], @@ -1761,6 +1818,8 @@ "react-native/babel-plugin-syntax-hermes-parser": ["babel-plugin-syntax-hermes-parser@0.36.0", "", { "dependencies": { "hermes-parser": "0.36.0" } }, "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q=="], + "react-native/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + "react-native/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "react-native-web/@react-native/normalize-colors": ["@react-native/normalize-colors@0.74.89", "", {}, "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg=="], @@ -1797,6 +1856,8 @@ "@expo/cli/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "@expo/cli/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "@expo/config-plugins/glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "@expo/config-plugins/glob/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], @@ -1827,22 +1888,50 @@ "@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "@jest/core/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "@jest/reporters/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@jest/reporters/string-length/char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + "@jest/types/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@react-native/codegen/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + "@types/jest/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "expect/jest-matcher-utils/jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + + "expect/jest-matcher-utils/pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "expo/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "jest-circus/jest-matcher-utils/jest-diff": ["jest-diff@29.7.0", "", { "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^29.6.3", "jest-get-type": "^29.6.3", "pretty-format": "^29.7.0" } }, "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw=="], + + "jest-circus/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "jest-config/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "jest-config/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-each/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-leak-detector/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-message-util/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "jest-runtime/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "jest-snapshot/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "jest-validate/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "jest-watch-select-projects/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "jest-watch-typeahead/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -1885,6 +1974,8 @@ "react-native/babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + "react-native/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + "restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1899,6 +1990,8 @@ "@expo/cli/glob/path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "@expo/cli/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@expo/config-plugins/glob/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "@expo/config-plugins/glob/path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], @@ -1917,12 +2010,34 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@jest/core/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@jest/reporters/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "@types/jest/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "expect/jest-matcher-utils/pretty-format/@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "expo/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "jest-circus/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "jest-config/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "jest-config/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "jest-each/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "jest-leak-detector/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "jest-message-util/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "jest-runtime/glob/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "jest-snapshot/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + + "jest-validate/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "log-symbols/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "log-symbols/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -1935,6 +2050,8 @@ "react-native/babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.36.0", "", {}, "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w=="], + "react-native/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "@expo/cli/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "@expo/config-plugins/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -1945,6 +2062,8 @@ "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "expect/jest-matcher-utils/pretty-format/@jest/schemas/@sinclair/typebox": ["@sinclair/typebox@0.27.12", "", {}, "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g=="], + "log-symbols/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], "ora/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], diff --git a/jest.config.js b/jest.config.js index 73c01d8..d374b80 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,6 @@ module.exports = { preset: "jest-expo", + setupFiles: ["/jest.setup.js"], moduleNameMapper: { "^@breathly/(.*)$": "/src/$1", }, diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..8b52329 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,5 @@ +// AsyncStorage is a native module, so anything that imports the settings store — which is +// most of the app — needs its Jest mock. The package ships one. +jest.mock("@react-native-async-storage/async-storage", () => + require("@react-native-async-storage/async-storage/jest/async-storage-mock"), +); diff --git a/package.json b/package.json index 6e7a8e2..7c8d755 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@babel/core": "^7.19.3", + "@testing-library/react-native": "^14.0.1", "@types/color": "^3.0.3", "@types/jest": "29.5.14", "@types/ms": "^2.1.0", diff --git a/src/assets/pattern-presets.ts b/src/assets/pattern-presets.ts index 450fe33..701623b 100644 --- a/src/assets/pattern-presets.ts +++ b/src/assets/pattern-presets.ts @@ -1,7 +1,19 @@ import ms from "ms"; -import { PatternPreset } from "../types/pattern-preset"; +import { PatternPreset } from "@breathly/types/pattern-preset"; -export const patternPresets: PatternPreset[] = [ +// Sorted by display name. The `id` of a preset is persisted in the settings store and +// validated by `normalizePersistedSettingsState`, so ids must never change: an id that +// no longer matches silently resets that user to the Square pattern. +// +// Typed as a non-empty tuple, so the first entry can serve as a total fallback without an +// assertion. +export const patternPresets: [PatternPreset, ...PatternPreset[]] = [ + { + id: "deep-calm", + name: "4-7-8 Deep Calm", + steps: [ms("4s"), ms("7s"), ms("8s"), 0], + description: "A natural tranquilizer for the nervous system. Do it at least twice a day.", + }, { id: "awake", name: "Awake", @@ -10,10 +22,18 @@ export const patternPresets: PatternPreset[] = [ "Use this technique first thing in the morning for quick burst of energy and alertness.", }, { - id: "deep-calm", - name: "Deep Calm", - steps: [ms("4s"), ms("7s"), ms("8s"), 0], - description: "A natural tranquilizer for the nervous system. Do it at least twice a day.", + id: "coherent", + name: "Coherent", + steps: [ms("5.5s"), 0, ms("5.5s"), 0], + description: + "Equal inhales and exhales at about five and a half breaths per minute. This pace brings the heart rate and the breath into step.", + }, + { + id: "extended-exhale", + name: "Extended Exhale", + steps: [ms("4s"), 0, ms("6s"), 0], + description: + "The exhale lasts longer than the inhale, which is the simplest way to settle. Use it when you have only a minute.", }, { id: "pranayama", diff --git a/src/core/splash-screen-manager.tsx b/src/core/splash-screen-manager.tsx index 3b8206f..1e68f71 100644 --- a/src/core/splash-screen-manager.tsx +++ b/src/core/splash-screen-manager.tsx @@ -1,6 +1,6 @@ import * as SplashScreen from "expo-splash-screen"; import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Animated, Easing, Platform, StyleSheet, View } from "react-native"; +import { Animated, Easing, Platform, StyleSheet, useColorScheme, View } from "react-native"; import { getRemainingSplashDurationMs, maximumSplashWaitMs, @@ -12,9 +12,18 @@ if (Platform.OS !== "web") { void SplashScreen.preventAutoHideAsync().catch(() => undefined); } -const splashImageAsset = require("../../assets/splash.png"); +// The same two assets the native splash uses, so the hand-off from the launch screen to +// this overlay draws exactly the same thing. +const splashImageAssets = { + light: require("../../assets/splash.png"), + dark: require("../../assets/splash-dark.png"), +}; -const splashBackgroundColor = "#F2F2F1"; +// Keep these in step with the `expo-splash-screen` plugin config in `app.json`. +const splashBackgroundColors = { + light: "#F2F2F1", + dark: "#0f172a", +}; export const SplashScreenManager: React.FC = ({ children }) => { if (Platform.OS === "web") return <>{children}; @@ -22,6 +31,10 @@ export const SplashScreenManager: React.FC = ({ children }) = }; const NativeSplashScreenManager: React.FC = ({ children }) => { + // The *system* appearance, not the app's resolved theme. The native launch screen can only + // follow the system, and the settings store has not hydrated yet at this point, so reading + // anything else here would make the two layers disagree. + const systemColorScheme = useColorScheme() === "dark" ? "dark" : "light"; const mountedAtMs = useRef(Date.now()).current; const opacity = useMemo(() => new Animated.Value(1), []); const isHomeScreenReady = useHomeScreenStatusStore((state) => state.isHomeScreenReady); @@ -79,13 +92,16 @@ const NativeSplashScreenManager: React.FC = ({ children }) => {!isSplashComplete && ( { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +}; + +const relativeLuminance = (hex: string) => { + const value = hex.replace("#", ""); + const red = Number.parseInt(value.slice(0, 2), 16); + const green = Number.parseInt(value.slice(2, 4), 16); + const blue = Number.parseInt(value.slice(4, 6), 16); + return ( + 0.2126 * channelLuminance(red) + + 0.7152 * channelLuminance(green) + + 0.0722 * channelLuminance(blue) + ); +}; + +const contrastRatio = (foreground: string, background: string) => { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + 0.05) / (darker + 0.05); +}; + +const bodyTextMinimum = 4.5; // WCAG 1.4.3 +const controlMinimum = 3; // WCAG 1.4.11 + +describe.each(["light", "dark"] as const)("the %s scheme", (scheme) => { + const theme = themeColors[scheme]; + + it("keeps primary text legible on the background", () => { + expect(contrastRatio(theme.text, theme.background)).toBeGreaterThanOrEqual(bodyTextMinimum); + }); + + it("keeps secondary text legible on the background", () => { + // The app used one secondary colour with no dark counterpart, so this failed in both + // schemes and worse in dark. The roles carry a value per scheme now. + expect(contrastRatio(theme.textSecondary, theme.background)).toBeGreaterThanOrEqual( + bodyTextMinimum, + ); + }); + + it("keeps secondary text legible on a surface", () => { + expect(contrastRatio(theme.textSecondary, theme.surface)).toBeGreaterThanOrEqual( + bodyTextMinimum, + ); + }); + + it("keeps controls visible against the background", () => { + // The exercise screen's close button is the only way out of a session, and it measured + // 1.37:1 in light mode. + expect(contrastRatio(theme.control, theme.background)).toBeGreaterThanOrEqual(controlMinimum); + }); +}); + +describe("the contrast helper", () => { + it("agrees with the known extremes", () => { + expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5); + expect(contrastRatio("#ffffff", "#ffffff")).toBeCloseTo(1, 5); + }); +}); diff --git a/src/design/colors.ts b/src/design/colors.ts index 4b865c7..adb4677 100644 --- a/src/design/colors.ts +++ b/src/design/colors.ts @@ -9,7 +9,8 @@ export const colors = { "stone-200": "#e7e5e4", // Borders in settings "stone-300": "#d6d3d1", // Disabled controls in settings "slate-300": "#cbd5e1", // Web settings section bottom border - "slate-500": "#64748b", // Secondary text in light mode, settings borders + "slate-400": "#94a3b8", // Secondary text in dark mode + "slate-500": "#64748b", // Settings borders, controls on the light background "slate-600": "#475569", // Settings stepper border in dark mode "slate-700": "#334155", // Settings bg border in dark mode "slate-800": "#1e293b", // Primary text in light mode @@ -24,3 +25,32 @@ export const colors = { "gray-light": "#E7E9E6", // Home screen button }, }; + +// What each colour is *for*, per scheme. The palette above says what a colour is; this says +// where it belongs. Components read these instead of pairing a light value with a hand-written +// `*Dark` override, which is how several elements ended up with no dark variant at all. +// +// Contrast against the scheme background, by the WCAG 2.x formula: +// light text 10.02:1 · textSecondary 6.95:1 · control 4.36:1 +// dark text 17.85:1 · textSecondary 6.97:1 · control 12.03:1 +// Body text needs 4.5:1 and a control outline needs 3:1, so every pair above has room. +export const themeColors = { + light: { + background: colors["stone-100"], + surface: colors.white, + text: colors["slate-800"], + textSecondary: colors["slate-600"], + border: colors["stone-200"], + control: colors["slate-500"], + }, + dark: { + background: colors["slate-900"], + surface: colors["slate-800"], + text: colors.white, + textSecondary: colors["slate-400"], + border: colors["slate-700"], + control: colors["slate-300"], + }, +} as const; + +export type ThemeColors = (typeof themeColors)["light"]; diff --git a/src/design/theme.ts b/src/design/theme.ts index c5128b7..b0b2aa0 100644 --- a/src/design/theme.ts +++ b/src/design/theme.ts @@ -1,8 +1,9 @@ import { useColorScheme as useSystemColorScheme } from "react-native"; import { resolveColorScheme, type ColorScheme } from "@breathly/design/color-scheme"; +import { themeColors, type ThemeColors } from "@breathly/design/colors"; import { useSettingsStore } from "@breathly/stores/settings"; -export { type ColorScheme }; +export { type ColorScheme, type ThemeColors }; // The color scheme that the app renders with. // @@ -15,3 +16,8 @@ export const useColorScheme = (): ColorScheme => { const theme = useSettingsStore((state) => state.theme); return resolveColorScheme(systemColorScheme, shouldFollowSystemDarkMode, theme); }; + +// The colours for the scheme the app is rendering with. Prefer this over reaching into the +// palette and writing a `*Dark` style beside every rule: a role that exists here cannot be +// left without a dark value by accident. +export const useThemeColors = (): ThemeColors => themeColors[useColorScheme()]; diff --git a/src/screens/exercise-screen/__tests__/accessibility-announcements.test.ts b/src/screens/exercise-screen/__tests__/accessibility-announcements.test.ts new file mode 100644 index 0000000..db6ad55 --- /dev/null +++ b/src/screens/exercise-screen/__tests__/accessibility-announcements.test.ts @@ -0,0 +1,26 @@ +import { + getInterludeAccessibilityLabel, + getStepAccessibilityLabel, +} from "../accessibility-announcements"; + +describe("exercise accessibility labels", () => { + it("reads the step and its duration", () => { + expect(getStepAccessibilityLabel("Inhale", 4_000)).toBe("Inhale, 4 seconds"); + expect(getStepAccessibilityLabel("Exhale", 8_000)).toBe("Exhale, 8 seconds"); + }); + + it("reads a duration of one second in the singular", () => { + expect(getStepAccessibilityLabel("Hold", 1_000)).toBe("Hold, 1 second"); + }); + + // The custom pattern of the settings screen moves in steps of half a second. + it("reads the half seconds of a custom pattern", () => { + expect(getStepAccessibilityLabel("Hold", 500)).toBe("Hold, 0.5 seconds"); + expect(getStepAccessibilityLabel("Inhale", 4_500)).toBe("Inhale, 4.5 seconds"); + expect(getStepAccessibilityLabel("Exhale", 99_000)).toBe("Exhale, 99 seconds"); + }); + + it("reads the countdown of the interlude", () => { + expect(getInterludeAccessibilityLabel(3)).toBe("Starting session in 3"); + }); +}); diff --git a/src/screens/exercise-screen/__tests__/exercise-session.test.ts b/src/screens/exercise-screen/__tests__/exercise-session.test.ts index e26b613..62bcf2e 100644 --- a/src/screens/exercise-screen/__tests__/exercise-session.test.ts +++ b/src/screens/exercise-screen/__tests__/exercise-session.test.ts @@ -30,13 +30,41 @@ describe("exercise session lifecycle", () => { expect(resumed).toMatchObject({ status: "running", activeElapsedMs: 5_000 }); }); - it("counts normal active heartbeats but discards suspension-sized gaps", () => { + it("counts normal heartbeats of the foreground", () => { expect(getActiveTickDeltaMs(1_000, 1_250, true, 1_000)).toBe(250); - expect(getActiveTickDeltaMs(1_000, 11_000, true, 1_000)).toBe(0); + expect(getActiveTickDeltaMs(1_000, 2_000, true, 1_000)).toBe(1_000); + }); + + it("counts no time in the background and no time that goes backwards", () => { expect(getActiveTickDeltaMs(1_000, 1_250, false, 1_000)).toBe(0); expect(getActiveTickDeltaMs(2_000, 1_000, true, 1_000)).toBe(0); }); + // A gap longer than the maximum comes from a stall of the JavaScript thread. + // The time of the stall must not disappear from the session: a slow device + // stalls many times, and the session then runs past the selected time limit. + it("limits a long gap but keeps the time that it can confirm", () => { + expect(getActiveTickDeltaMs(1_000, 11_000, true, 1_000)).toBe(1_000); + expect(getActiveTickDeltaMs(1_000, 1_001_000, true, 1_000)).toBe(1_000); + }); + + // A discarded gap counts 750 ms of these 8_750 ms, thus the session of the + // user runs 8 seconds longer than the selected time limit. + it("counts the limited part of every stall instead of nothing", () => { + const maximumGapMs = 1_000; + const stallsMs = [250, 3_000, 250, 5_000, 250]; + let clockMs = 0; + let elapsedMs = 0; + + stallsMs.forEach((stallMs) => { + elapsedMs += getActiveTickDeltaMs(clockMs, clockMs + stallMs, true, maximumGapMs); + clockMs += stallMs; + }); + + expect(clockMs).toBe(8_750); + expect(elapsedMs).toBe(2_750); + }); + it("restarts the interlude when it was interrupted before the exercise began", () => { const paused = exerciseSessionReducer(createExerciseSession(), { type: "pause", diff --git a/src/screens/exercise-screen/__tests__/step-animation.test.ts b/src/screens/exercise-screen/__tests__/step-animation.test.ts new file mode 100644 index 0000000..a2ee0a4 --- /dev/null +++ b/src/screens/exercise-screen/__tests__/step-animation.test.ts @@ -0,0 +1,106 @@ +import { Animated } from "react-native"; +import { createStepAnimation, getTextAnimDurationMs } from "../step-animation"; + +// The native driver does nothing under Jest: the animated values never move and +// every animation reports that it finished. The defect that these tests cover is +// in the composition of the animations and not in the driver, thus the tests run +// the same animations on the JavaScript driver. +jest.mock("@breathly/utils/animate", () => { + const { Animated: RNAnimated, Easing } = require("react-native"); + return { + animate: (value: Animated.Value, config: Animated.TimingAnimationConfig) => + RNAnimated.timing(value, { + ...config, + useNativeDriver: false, + easing: config.easing ?? Easing.inOut(Easing.quad), + }), + }; +}); + +const frameMs = 1; + +const readValue = (value: Animated.Value) => + (value as unknown as { __getValue: () => number }).__getValue(); + +const runStep = (durationMs: number) => { + const exerciseAnimVal = new Animated.Value(0); + const textAnimVal = new Animated.Value(0); + let finished: boolean | undefined; + let peakTextValue = 0; + let lastExerciseChangeAtMs = 0; + let lastExerciseValue = 0; + + createStepAnimation({ exerciseAnimVal, textAnimVal, toValue: 1, durationMs }).start((result) => { + finished = result.finished; + }); + + for (let elapsedMs = frameMs; elapsedMs <= durationMs + frameMs; elapsedMs += frameMs) { + jest.advanceTimersByTime(frameMs); + const exerciseValue = readValue(exerciseAnimVal); + if (exerciseValue !== lastExerciseValue) { + lastExerciseValue = exerciseValue; + lastExerciseChangeAtMs = elapsedMs; + } + peakTextValue = Math.max(peakTextValue, readValue(textAnimVal)); + } + + return { + finished, + exerciseValue: readValue(exerciseAnimVal), + lastExerciseChangeAtMs, + peakTextValue, + }; +}; + +describe("exercise step animation", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // A hold of half a second is available through the custom pattern settings. + it("completes the breathing animation of a half-second step", () => { + const run = runStep(500); + + expect(run.exerciseValue).toBe(1); + expect(run.finished).toBe(true); + expect(run.lastExerciseChangeAtMs).toBe(500); + }); + + it("shows the label of a half-second step at full opacity", () => { + const run = runStep(500); + + expect(run.peakTextValue).toBeGreaterThan(0.99); + }); + + // The label animation lasted 400 ms at every step length, thus a step of + // 800 ms or less started the fade out before the fade in ended. + it("completes the breathing animation of an eight-hundred-millisecond step", () => { + const run = runStep(800); + + expect(run.exerciseValue).toBe(1); + expect(run.finished).toBe(true); + expect(run.peakTextValue).toBeGreaterThan(0.99); + }); + + it("keeps the label animation of a normal step unchanged", () => { + expect(getTextAnimDurationMs(4000)).toBe(400); + + const run = runStep(4000); + + expect(run.exerciseValue).toBe(1); + expect(run.finished).toBe(true); + expect(run.peakTextValue).toBeGreaterThan(0.99); + }); + + it("keeps the two label fades apart at every available step length", () => { + const stepDurationsMs = [500, 800, 1_000, 2_000, 4_000, 99_000]; + + stepDurationsMs.forEach((stepDurationMs) => { + expect(getTextAnimDurationMs(stepDurationMs) * 2).toBeLessThanOrEqual(stepDurationMs); + }); + }); +}); diff --git a/src/screens/exercise-screen/accessibility-announcements.ts b/src/screens/exercise-screen/accessibility-announcements.ts new file mode 100644 index 0000000..75aab38 --- /dev/null +++ b/src/screens/exercise-screen/accessibility-announcements.ts @@ -0,0 +1,32 @@ +import { AccessibilityInfo, Platform } from "react-native"; + +// A screen reader says nothing when the text of an animation changes, thus a +// user without sight cannot follow the session. Android reads an +// `accessibilityLiveRegion` on its own, but iOS has no live regions: there the +// change needs an announcement. +export const announceLiveRegionUpdate = (message: string) => { + if (Platform.OS !== "ios") return; + AccessibilityInfo.announceForAccessibility(message); +}; + +// An announcement for a text that no live region covers. +export const announceForScreenReader = (message: string) => { + AccessibilityInfo.announceForAccessibility(message); +}; + +// The custom pattern allows steps of half a second, thus a step duration is not +// always a whole number of seconds. +const formatStepDuration = (durationMs: number) => { + const seconds = Math.round(Math.max(0, durationMs) / 100) / 10; + const value = Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1); + return `${value} ${value === "1" ? "second" : "seconds"}`; +}; + +// E.g.: "Inhale, 4 seconds". +export const getStepAccessibilityLabel = (label: string, durationMs: number) => + `${label}, ${formatStepDuration(durationMs)}`; + +export const getInterludeAccessibilityLabel = (secondsLeft: number) => + `Starting session in ${secondsLeft}`; + +export const sessionCompleteAnnouncement = "Session complete"; diff --git a/src/screens/exercise-screen/animated-dots.tsx b/src/screens/exercise-screen/animated-dots.tsx index d671b13..4f98a0f 100644 --- a/src/screens/exercise-screen/animated-dots.tsx +++ b/src/screens/exercise-screen/animated-dots.tsx @@ -5,6 +5,7 @@ import { useColorScheme } from "@breathly/design/theme"; import { animate } from "@breathly/utils/animate"; import { interpolateScale } from "@breathly/utils/interpolate"; import { times } from "@breathly/utils/times"; +import { useReduceMotion } from "@breathly/utils/use-accessibility-preferences"; const dotSize = Math.floor(4); const fadeInAnimDuration = 400; @@ -17,6 +18,7 @@ interface Props { export const AnimatedDots: FC = ({ visible = false, numberOfDots, totalDuration }) => { const isDarkMode = useColorScheme() === "dark"; + const reduceMotionEnabled = useReduceMotion(); const dotAnimVals = useMemo( () => times(numberOfDots).map(() => new Animated.Value(0)), [numberOfDots], @@ -52,12 +54,15 @@ export const AnimatedDots: FC = ({ visible = false, numberOfDots, totalDu inputRange: [0, 1], outputRange: [0, 1], }), - transform: [ - interpolateScale(val, { - inputRange: [0, 1], - outputRange: [0, 1], - }), - ], + // The dots only fade in when the user asked the system for less motion. + transform: reduceMotionEnabled + ? [] + : [ + interpolateScale(val, { + inputRange: [0, 1], + outputRange: [0, 1], + }), + ], })); return ( diff --git a/src/screens/exercise-screen/breathing-animation.tsx b/src/screens/exercise-screen/breathing-animation.tsx index 1fc488e..d748d49 100644 --- a/src/screens/exercise-screen/breathing-animation.tsx +++ b/src/screens/exercise-screen/breathing-animation.tsx @@ -6,6 +6,7 @@ import { shortestDeviceDimension } from "@breathly/design/metrics"; import { useColorScheme } from "@breathly/design/theme"; import { animate } from "@breathly/utils/animate"; import { times } from "@breathly/utils/times"; +import { useReduceMotion } from "@breathly/utils/use-accessibility-preferences"; const circleWidth = shortestDeviceDimension / 2; const MOUNT_ANIMATION_DURATION = 300; @@ -17,6 +18,7 @@ interface Props { export const BreathingAnimation: FC = ({ animationValue, color = colors.pastel.orange }) => { const colorScheme = useColorScheme(); + const reduceMotionEnabled = useReduceMotion(); const mountAnimationValue = useRef(new Animated.Value(0)).current; const innerOpacity = animationValue.interpolate({ inputRange: [0, 0.1, 1], @@ -35,6 +37,23 @@ export const BreathingAnimation: FC = ({ animationValue, color = colors.p return () => animation.stop(); }, [mountAnimationValue]); + if (reduceMotionEnabled) { + return ( + + + + ); + } + return ( = ({ animationValue, color = colors.p ); }; +interface SteadyCircleProps { + animationValue: Animated.Value; + color: string; +} + +// The variant of the animation for a user who asked the system for less motion: +// one circle in the centre of the screen that changes only its opacity and its +// size. Nothing rotates and nothing travels. The timing stays the same, thus the +// circle still shows the rhythm of the breath. +const SteadyCircle: FC = ({ animationValue, color }) => { + const opacity = animationValue.interpolate({ + inputRange: [0, 1], + outputRange: [0.35, 0.6], + }); + const scale = animationValue.interpolate({ + inputRange: [0, 1], + outputRange: [0.9, 1.05], + }); + + return ( + + + + ); +}; + interface RotatingCircleProps { animationValue: Animated.Value; opacity: number; diff --git a/src/screens/exercise-screen/complete.tsx b/src/screens/exercise-screen/complete.tsx index 573fcb8..d91cc72 100644 --- a/src/screens/exercise-screen/complete.tsx +++ b/src/screens/exercise-screen/complete.tsx @@ -3,6 +3,10 @@ import { Animated, StyleSheet, Text } from "react-native"; import { colors } from "@breathly/design/colors"; import { useColorScheme } from "@breathly/design/theme"; import { fontFamilies, fontSizes } from "@breathly/design/typography"; +import { + announceForScreenReader, + sessionCompleteAnnouncement, +} from "@breathly/screens/exercise-screen/accessibility-announcements"; import { animate } from "@breathly/utils/animate"; import { interpolateTranslateY } from "@breathly/utils/interpolate"; @@ -21,6 +25,12 @@ export const ExerciseComplete: FC = () => { return () => mountAnimation.stop(); }, [mountAnimVal]); + // The screen does not change: a screen reader reads nothing on its own when + // the exercise ends. + useEffect(() => { + announceForScreenReader(sessionCompleteAnnouncement); + }, []); + const containerAnimatedStyle = { opacity: mountAnimVal.interpolate({ inputRange: [0, 1], diff --git a/src/screens/exercise-screen/exercise-screen.tsx b/src/screens/exercise-screen/exercise-screen.tsx index 7a3c049..1e724b9 100644 --- a/src/screens/exercise-screen/exercise-screen.tsx +++ b/src/screens/exercise-screen/exercise-screen.tsx @@ -8,8 +8,12 @@ import { Pressable } from "@breathly/common/pressable"; import { RootStackParamList } from "@breathly/core/navigator"; import { colors } from "@breathly/design/colors"; import { widestDeviceDimension } from "@breathly/design/metrics"; -import { useColorScheme } from "@breathly/design/theme"; +import { useColorScheme, useThemeColors } from "@breathly/design/theme"; import { fontFamilies, fontSizes } from "@breathly/design/typography"; +import { + announceLiveRegionUpdate, + getStepAccessibilityLabel, +} from "@breathly/screens/exercise-screen/accessibility-announcements"; import { AnimatedDots } from "@breathly/screens/exercise-screen/animated-dots"; import { createExerciseSession, @@ -23,19 +27,34 @@ import { useExerciseHaptics } from "@breathly/screens/exercise-screen/use-exerci import { useExerciseLoop } from "@breathly/screens/exercise-screen/use-exercise-loop"; import { StarsBackground } from "@breathly/screens/home-screen/stars-background"; import { useSelectedPatternSteps, useSettingsStore } from "@breathly/stores/settings"; +import { GuidedBreathingMode } from "@breathly/types/guided-breathing-mode"; import { StepMetadata } from "@breathly/types/step-metadata"; import { animate } from "@breathly/utils/animate"; import { buildStepsMetadata } from "@breathly/utils/build-steps-metadata"; +import { useScreenReaderEnabled } from "@breathly/utils/use-accessibility-preferences"; import { useOnUpdate } from "@breathly/utils/use-on-update"; import { BreathingAnimation } from "./breathing-animation"; import { ExerciseComplete } from "./complete"; import { ExerciseInterlude } from "./interlude"; import { Timer } from "./timer"; +// The voice that the exercise uses for a user of a screen reader who disabled +// it. It is the default voice of the app. +const screenReaderFallbackVoice: GuidedBreathingMode = "paul"; + export const ExerciseScreen: FC> = ({ navigation, }) => { const { guidedBreathingVoice } = useSettingsStore(); + const screenReaderEnabled = useScreenReaderEnabled(); + // A user of a screen reader who disabled the voice has no channel that works + // without sight, because the visuals carry the whole exercise. The voice + // therefore starts, but the app does not write the setting: the user keeps + // the choice made in the settings screen. + const effectiveGuidedBreathingVoice = + screenReaderEnabled && guidedBreathingVoice === "disabled" + ? screenReaderFallbackVoice + : guidedBreathingVoice; const [session, dispatchSession] = useReducer( exerciseSessionReducer, undefined, @@ -44,13 +63,24 @@ export const ExerciseScreen: FC { const subscription = AppState.addEventListener("change", (nextAppState) => { - if (nextAppState !== "active") { + // iOS reports "inactive" for the Control Center, the Notification Center, + // the app switcher and the banner of an incoming call. The app stays on + // the screen and the user comes back to a live session, thus only a real + // background interrupts the exercise. + if (nextAppState === "background") { stopExerciseAudio(); dispatchSession({ type: "pause", activeElapsedMs: activeElapsedMs.current }); } @@ -123,13 +153,13 @@ export const ExerciseScreen: FC} - + @@ -169,8 +199,6 @@ const ExerciseRunningFragment: FC = ({ onStepIndexChange, ); - useKeepAwake(); - const playStepHaptic = useExerciseHaptics(vibrationEnabled); // The time limit does not stop the exercise on its own: it only arms the @@ -194,6 +222,10 @@ const ExerciseRunningFragment: FC = ({ useOnUpdate( (prevStepMetadata) => { + // An empty pattern would leave no current step. The duration limits stop that today, + // but nothing here should depend on that. + if (!currentStep) return; + const transition = getExerciseStepTransition( prevStepMetadata?.id, currentStep.id, @@ -204,6 +236,9 @@ const ExerciseRunningFragment: FC = ({ } else if (transition === "startStep") { onStepChange(currentStep); playStepHaptic(); + announceLiveRegionUpdate( + getStepAccessibilityLabel(currentStep.label, currentStep.duration), + ); } }, currentStep, @@ -229,7 +264,11 @@ const ExerciseRunningFragment: FC = ({ {currentStep && ( - + = ({ resumeStatus, onResume }) => { const isDarkMode = useColorScheme() === "dark"; + const theme = useThemeColors(); return ( Paused - + {resumeStatus === "interlude" ? "The starting countdown was interrupted." - : "Your session stopped while Breathly was in the background."} + : "The session paused while Breathly was in the background."} = ({ resumeStatus, onResume }) => const styles = StyleSheet.create({ closeButton: { alignItems: "center", - borderColor: colors["gray-300"], borderRadius: 9999, borderWidth: 2, height: 64, @@ -287,7 +326,6 @@ const styles = StyleSheet.create({ }, pausedDescription: { ...fontSizes.lg, - color: colors["slate-500"], fontFamily: fontFamilies.regular, marginBottom: 32, textAlign: "center", diff --git a/src/screens/exercise-screen/exercise-session.ts b/src/screens/exercise-screen/exercise-session.ts index ea5ff56..d98e8cc 100644 --- a/src/screens/exercise-screen/exercise-session.ts +++ b/src/screens/exercise-screen/exercise-session.ts @@ -25,15 +25,24 @@ export const createExerciseSession = (): ExerciseSession => ({ export const getSessionNowMs = () => globalThis.performance?.now() ?? Date.now(); +// The exercise keeps running while the app is inactive (the Control Center, the +// app switcher or a call banner), thus the clock must keep counting there too. +// Only a real background stops both. +// +// A gap that is longer than the maximum comes from a stall of the JavaScript +// thread or from a suspension that the app state did not report. The maximum +// limits the error of such a gap, but the gap must not be discarded: a slow +// device stalls many times, and each discarded gap makes the session run past +// the time limit that the user selected. export const getActiveTickDeltaMs = ( previousTickAtMs: number, currentTickAtMs: number, - appIsActive: boolean, + appIsForeground: boolean, maximumActiveTickGapMs: number, ) => { const tickDeltaMs = currentTickAtMs - previousTickAtMs; - if (!appIsActive || tickDeltaMs < 0 || tickDeltaMs > maximumActiveTickGapMs) return 0; - return tickDeltaMs; + if (!appIsForeground || tickDeltaMs < 0) return 0; + return Math.min(tickDeltaMs, maximumActiveTickGapMs); }; export type ExerciseStepTransition = "none" | "startStep" | "complete"; diff --git a/src/screens/exercise-screen/interlude.tsx b/src/screens/exercise-screen/interlude.tsx index bdbe6c1..e8020f7 100644 --- a/src/screens/exercise-screen/interlude.tsx +++ b/src/screens/exercise-screen/interlude.tsx @@ -1,11 +1,16 @@ import React, { FC, useRef, useState } from "react"; import { Animated, StyleSheet, Text } from "react-native"; import { colors } from "@breathly/design/colors"; -import { useColorScheme } from "@breathly/design/theme"; +import { useColorScheme, useThemeColors } from "@breathly/design/theme"; import { fontFamilies, fontSizes } from "@breathly/design/typography"; +import { + announceLiveRegionUpdate, + getInterludeAccessibilityLabel, +} from "@breathly/screens/exercise-screen/accessibility-announcements"; import { animate } from "@breathly/utils/animate"; import { delay } from "@breathly/utils/delay"; import { interpolateTranslateY } from "@breathly/utils/interpolate"; +import { useReduceMotion } from "@breathly/utils/use-accessibility-preferences"; import { useOnMount } from "@breathly/utils/use-on-mount"; interface Props { @@ -14,13 +19,21 @@ interface Props { const interludeInitialDelay = 600; const interludeAnimDuration = 400; +const interludeInitialStep = 3; export const ExerciseInterlude: FC = ({ onComplete }) => { const isDarkMode = useColorScheme() === "dark"; + const theme = useThemeColors(); + const reduceMotionEnabled = useReduceMotion(); const isMountedRef = useRef(true); const containerAnimVal = useRef(new Animated.Value(1)).current; const subtitleAnimVal = useRef(new Animated.Value(0)).current; - const [step, setStep] = useState(3); + const [step, setStep] = useState(interludeInitialStep); + + const goToStep = (nextStep: number) => { + setStep(nextStep); + announceLiveRegionUpdate(getInterludeAccessibilityLabel(nextStep)); + }; const showSubtitleAnimation = animate(subtitleAnimVal, { toValue: 1, @@ -35,10 +48,10 @@ export const ExerciseInterlude: FC = ({ onComplete }) => { const countDownAndHide = async () => { await delay(1000); if (!isMountedRef.current) return; - setStep(2); + goToStep(2); await delay(1000); if (!isMountedRef.current) return; - setStep(1); + goToStep(1); await delay(1000); if (!isMountedRef.current) return; hideContainerAnimation.start((done) => done && onComplete()); @@ -48,6 +61,7 @@ export const ExerciseInterlude: FC = ({ onComplete }) => { await delay(interludeInitialDelay); showSubtitleAnimation.start(({ finished }) => { if (!finished) return; + announceLiveRegionUpdate(getInterludeAccessibilityLabel(interludeInitialStep)); void countDownAndHide(); }); }; @@ -61,36 +75,47 @@ export const ExerciseInterlude: FC = ({ onComplete }) => { }; }); + // The countdown only fades when the user asked the system for less motion. const containerAnimatedStyle = { opacity: containerAnimVal.interpolate({ inputRange: [0, 1], outputRange: [0, 1], }), - transform: [ - interpolateTranslateY(containerAnimVal, { - inputRange: [0, 1], - outputRange: [0, 8], - }), - ], + transform: reduceMotionEnabled + ? [] + : [ + interpolateTranslateY(containerAnimVal, { + inputRange: [0, 1], + outputRange: [0, 8], + }), + ], }; const subtitleAnimatedStyle = { opacity: subtitleAnimVal.interpolate({ inputRange: [0, 1], outputRange: [0, 1], }), - transform: [ - interpolateTranslateY(subtitleAnimVal, { - inputRange: [0, 1], - outputRange: [0, -8], - }), - ], + transform: reduceMotionEnabled + ? [] + : [ + interpolateTranslateY(subtitleAnimVal, { + inputRange: [0, 1], + outputRange: [0, -8], + }), + ], }; return ( Relax - {`Starting session in \n${step}`} + {`Starting session in \n${step}`} ); @@ -104,7 +129,6 @@ const styles = StyleSheet.create({ }, subtitle: { ...fontSizes.xl, - color: colors["slate-500"], fontFamily: fontFamilies.regular, textAlign: "center", }, diff --git a/src/screens/exercise-screen/step-animation.ts b/src/screens/exercise-screen/step-animation.ts new file mode 100644 index 0000000..5d5cacc --- /dev/null +++ b/src/screens/exercise-screen/step-animation.ts @@ -0,0 +1,49 @@ +import { Animated } from "react-native"; +import { animate } from "@breathly/utils/animate"; + +const maximumTextAnimDurationMs = 400; +// The label fades in at the start of the step and out at its end, and both fades +// use the same animated value. A fade out that starts before the fade in ends +// stops the fade in, thus the label never becomes readable. The two fades +// together must therefore stay inside the step. +const maximumTextAnimStepRatio = 0.35; + +export const getTextAnimDurationMs = (stepDurationMs: number) => + Math.max(0, Math.min(maximumTextAnimDurationMs, stepDurationMs * maximumTextAnimStepRatio)); + +interface StepAnimationOptions { + exerciseAnimVal: Animated.Value; + textAnimVal: Animated.Value; + toValue: number; + durationMs: number; +} + +export const createStepAnimation = ({ + exerciseAnimVal, + textAnimVal, + toValue, + durationMs, +}: StepAnimationOptions): Animated.CompositeAnimation => { + const textAnimDurationMs = getTextAnimDurationMs(durationMs); + return Animated.stagger(Math.max(0, durationMs - textAnimDurationMs), [ + Animated.parallel( + [ + animate(exerciseAnimVal, { + toValue: toValue, + duration: durationMs, + }), + animate(textAnimVal, { + toValue: 1, + duration: textAnimDurationMs, + }), + ], + // The breathing circle carries the rhythm of the exercise: it must + // continue even if the label animation stops. + { stopTogether: false }, + ), + animate(textAnimVal, { + toValue: 0, + duration: textAnimDurationMs, + }), + ]); +}; diff --git a/src/screens/exercise-screen/step-description.tsx b/src/screens/exercise-screen/step-description.tsx index 71cb3dc..6e78e33 100644 --- a/src/screens/exercise-screen/step-description.tsx +++ b/src/screens/exercise-screen/step-description.tsx @@ -3,32 +3,43 @@ import { Animated, StyleSheet } from "react-native"; import { colors } from "@breathly/design/colors"; import { useColorScheme } from "@breathly/design/theme"; import { fontFamilies, fontSizes } from "@breathly/design/typography"; +import { getStepAccessibilityLabel } from "@breathly/screens/exercise-screen/accessibility-announcements"; import { interpolateTranslateY } from "@breathly/utils/interpolate"; +import { useReduceMotion } from "@breathly/utils/use-accessibility-preferences"; interface Props { label: string; + durationMs: number; animationValue: Animated.Value; } -export const StepDescription: FC = ({ label, animationValue }) => { +export const StepDescription: FC = ({ label, durationMs, animationValue }) => { const isDarkMode = useColorScheme() === "dark"; + const reduceMotionEnabled = useReduceMotion(); const textAnimatedStyle = { opacity: animationValue.interpolate({ inputRange: [0, 1], outputRange: [0, 1], }), - transform: [ - interpolateTranslateY(animationValue, { - inputRange: [0, 1], - outputRange: [0, -8], - }), - ], + // The label only fades when the user asked the system for less motion. + transform: reduceMotionEnabled + ? [] + : [ + interpolateTranslateY(animationValue, { + inputRange: [0, 1], + outputRange: [0, -8], + }), + ], }; return ( {label} diff --git a/src/screens/exercise-screen/timer.tsx b/src/screens/exercise-screen/timer.tsx index 72b8e47..b0d3544 100644 --- a/src/screens/exercise-screen/timer.tsx +++ b/src/screens/exercise-screen/timer.tsx @@ -3,6 +3,7 @@ import { Animated, AppState, StyleSheet } from "react-native"; import { colors } from "@breathly/design/colors"; import { useColorScheme } from "@breathly/design/theme"; import { fontSizes } from "@breathly/design/typography"; +import { announceForScreenReader } from "@breathly/screens/exercise-screen/accessibility-announcements"; import { getActiveTickDeltaMs, getSessionNowMs, @@ -22,6 +23,7 @@ const timerRefreshIntervalMs = 250; const maximumActiveTickGapMs = timerRefreshIntervalMs * 4; const showAnimDuration = 500; const hideAnimDuration = 400; +export const lastBreathLabel = "Last breath"; export const Timer: FC = ({ limit, @@ -35,13 +37,14 @@ export const Timer: FC = ({ const previousTickAtMs = useRef(getSessionNowMs()); const opacityAnimVal = useRef(new Animated.Value(0)).current; const limitReachedRef = useRef(false); + const [lastBreathVisible, setLastBreathVisible] = useState(false); useInterval(() => { const currentTickAtMs = getSessionNowMs(); const activeTickDeltaMs = getActiveTickDeltaMs( previousTickAtMs.current, currentTickAtMs, - AppState.currentState === "active", + AppState.currentState !== "background", maximumActiveTickGapMs, ); previousTickAtMs.current = currentTickAtMs; @@ -58,17 +61,41 @@ export const Timer: FC = ({ const remainingTimeMs = limit ? Math.max(0, limit - elapsedTimeMs) : undefined; const limitReached = remainingTimeMs === 0; - // The exercise continues until the end of the current exhale, thus the timer - // stays at 00:00 for some seconds. It fades away instead: the clock is - // complete, and only the last breath remains. + // The exercise continues until the end of the current exhale, thus the clock + // stays at 00:00 for some seconds. The clock is complete and only the last + // breath remains: the timer crossfades to that message. It must not fade + // away, because a blank screen tells the user nothing about the time that the + // exercise still needs. useEffect(() => { - const containerAnimation = animate(opacityAnimVal, { - toValue: limitReached ? 0 : 1, - duration: limitReached ? hideAnimDuration : showAnimDuration, + if (!limitReached) { + const showAnimation = animate(opacityAnimVal, { + toValue: 1, + duration: showAnimDuration, + }); + showAnimation.start(); + return () => { + showAnimation.stop(); + }; + } + const hideAnimation = animate(opacityAnimVal, { + toValue: 0, + duration: hideAnimDuration, + }); + const showAnimation = animate(opacityAnimVal, { + toValue: 1, + duration: showAnimDuration, + }); + hideAnimation.start(({ finished }) => { + if (!finished) return; + setLastBreathVisible(true); + // The clock is not a live region: it changes every second, and a live + // region would read every change. + announceForScreenReader(lastBreathLabel); + showAnimation.start(); }); - containerAnimation.start(); return () => { - containerAnimation.stop(); + hideAnimation.stop(); + showAnimation.stop(); }; }, [limitReached, opacityAnimVal]); @@ -83,19 +110,14 @@ export const Timer: FC = ({ opacity: opacityAnimVal, }; - const timerText = + const clockText = remainingTimeMs == null ? formatTimer(Math.floor(elapsedTimeMs / 1000)) : formatTimer(Math.ceil(remainingTimeMs / 1000)); + const timerText = lastBreathVisible ? lastBreathLabel : clockText; return ( - + { - return Animated.stagger(duration - textAnimDuration, [ - Animated.parallel([ - animate(exerciseAnimVal, { - toValue: toValue, - duration: duration, - }), - animate(textAnimVal, { - toValue: 1, - duration: textAnimDuration, - }), - ]), - animate(textAnimVal, { - toValue: 0, - duration: textAnimDuration, - }), - ]); - }, + (toValue: number, durationMs: number) => + createStepAnimation({ exerciseAnimVal, textAnimVal, toValue, durationMs }), [exerciseAnimVal, textAnimVal], ); diff --git a/src/screens/home-screen/home-screen.tsx b/src/screens/home-screen/home-screen.tsx index 9e72801..8731e45 100644 --- a/src/screens/home-screen/home-screen.tsx +++ b/src/screens/home-screen/home-screen.tsx @@ -6,7 +6,7 @@ import { create } from "zustand"; import { Pressable } from "@breathly/common/pressable"; import { RootStackParamList } from "@breathly/core/navigator"; import { colors } from "@breathly/design/colors"; -import { useColorScheme } from "@breathly/design/theme"; +import { useColorScheme, useThemeColors } from "@breathly/design/theme"; import { fontFamilies, fontSizes } from "@breathly/design/typography"; import { PlanetsBackground } from "@breathly/screens/home-screen/planets-background"; import { StarsBackground } from "@breathly/screens/home-screen/stars-background"; @@ -23,6 +23,7 @@ export const HomeScreen: FC> navigation, }) => { const colorScheme = useColorScheme(); + const theme = useThemeColors(); const { isHomeScreenReady, markHomeScreenAsReady } = useHomeScreenStatusStore(); const insets = useSafeAreaInsets(); const handleStartButtonPress = () => { @@ -71,7 +72,7 @@ export const HomeScreen: FC> Breathly - + Relax, focus on your breath, and find your inner peace. @@ -91,7 +92,7 @@ export const HomeScreen: FC> Start a new session - or + or = ({ onImageLoaded, size = widestDeviceDimension * 0.6, }) => { + const reduceMotionEnabled = useReduceMotion(); const backgroundAnimValue = useRef(new Animated.Value(0)).current; const fadeInAnimValue = useRef(new Animated.Value(fadeIn ? 0 : 1)).current; useEffect(() => { + // The sky drifts for as long as it is on screen, which makes it the one piece of + // continuous motion left when the user asks the system for less of it. Hold it still; + // the star field itself is the point, not the drift. + if (reduceMotionEnabled) return; + const animation = Animated.loop( Animated.sequence([ Animated.timing(backgroundAnimValue, { @@ -42,7 +49,7 @@ export const StarsBackground: FC = ({ ); animation.start(); return () => animation.stop(); - }, [backgroundAnimValue]); + }, [backgroundAnimValue, reduceMotionEnabled]); const backgroundTransform = [ { diff --git a/src/screens/home-screen/stars-background.web.tsx b/src/screens/home-screen/stars-background.web.tsx index d0fc765..baec46d 100644 --- a/src/screens/home-screen/stars-background.web.tsx +++ b/src/screens/home-screen/stars-background.web.tsx @@ -3,6 +3,7 @@ import React, { FC, useEffect, useRef } from "react"; import { Animated, Easing, Image, StyleSheet, ViewStyle } from "react-native"; import { images } from "@breathly/assets/images"; import { widestDeviceDimension } from "@breathly/design/metrics"; +import { useReduceMotion } from "@breathly/utils/use-accessibility-preferences"; const BACKGROUND_ANIM_DURATION = ms("2 min"); @@ -26,10 +27,14 @@ export const StarsBackground: FC = ({ onImageLoaded, size = widestDeviceDimension * 0.6, }) => { + const reduceMotionEnabled = useReduceMotion(); const backgroundAnimValue = useRef(new Animated.Value(0)).current; const fadeInAnimValue = useRef(new Animated.Value(fadeIn ? 0 : 1)).current; useEffect(() => { + // Hold the sky still when the user asks the system for less motion. + if (reduceMotionEnabled) return; + const animation = Animated.loop( Animated.sequence([ Animated.timing(backgroundAnimValue, { @@ -52,7 +57,7 @@ export const StarsBackground: FC = ({ return () => { animation.stop(); }; - }, [backgroundAnimValue]); + }, [backgroundAnimValue, reduceMotionEnabled]); const backgroundTransform = [ { diff --git a/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx b/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx new file mode 100644 index 0000000..290cd30 --- /dev/null +++ b/src/screens/settings-screen/__tests__/settings-ui-parity.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react-native"; +import React from "react"; +import { SettingsUI as IosSettingsUI } from "../settings-ui.ios"; +import { SettingsUI as WebSettingsUI } from "../settings-ui.web"; +import type { SettingsUIModule } from "../settings-ui.types"; + +// The three platform implementations are written separately on purpose — Android uses real +// Jetpack Compose components — but they have to behave the same where it matters. Nothing +// compared them before, and three behaviours had already drifted apart. +// +// Android is not rendered here: its components come from `@expo/ui/jetpack-compose` and need +// the native runtime. Its conformance is checked by the compiler through `SettingsUIModule`. +const implementations: [string, SettingsUIModule][] = [ + ["ios", IosSettingsUI], + ["web", WebSettingsUI], +]; + +describe.each(implementations)("the %s settings UI", (_platform, SettingsUI) => { + it("provides every component the contract names", () => { + const required: (keyof SettingsUIModule)[] = [ + "Section", + "Header", + "LinkItem", + "PickerItem", + "SwitchItem", + "StepperItem", + "RadioButtonItem", + ]; + + for (const component of required) { + expect(SettingsUI[component]).toBeDefined(); + } + }); + + it("shows the field name on a picker, not only its options", async () => { + // The web implementation spread the shared props and then overrode `label` with each + // option's own label, so the user saw unnamed rows with no "Voice" anywhere on screen. + await render( + undefined} + testID="settings.voice" + />, + ); + + expect(screen.queryByText("Voice")).not.toBeNull(); + }); + + it("names both stepper buttons for a screen reader", async () => { + await render( + undefined} + onDecrease={() => undefined} + testID="settings.timer" + />, + ); + + // Android's Compose buttons hold only "−" and "+", so TalkBack reads two unnamed symbols + // with no clue what they change. iOS and web name them; the contract should require it. + expect(screen.queryByLabelText(/increase/i)).not.toBeNull(); + expect(screen.queryByLabelText(/decrease/i)).not.toBeNull(); + }); + + it("exposes the stepper controls under their test ids", async () => { + await render( + undefined} + onDecrease={() => undefined} + testID="settings.timer" + />, + ); + + expect(screen.queryByTestId("settings.timer.value")).not.toBeNull(); + expect(screen.queryByTestId("settings.timer.increase")).not.toBeNull(); + expect(screen.queryByTestId("settings.timer.decrease")).not.toBeNull(); + }); + + it("tells a screen reader when a radio row is disabled", async () => { + await render( + undefined} + testID="settings.pattern.square" + />, + ); + + const row = screen.getByTestId("settings.pattern.square"); + expect(row.props.accessibilityState?.disabled).toBe(true); + }); +}); diff --git a/src/screens/settings-screen/settings-screen.tsx b/src/screens/settings-screen/settings-screen.tsx index 44c4baa..9533e77 100644 --- a/src/screens/settings-screen/settings-screen.tsx +++ b/src/screens/settings-screen/settings-screen.tsx @@ -4,6 +4,7 @@ import React, { FC } from "react"; import { Animated, ScrollView, LayoutAnimation, Button, Platform, StyleSheet } from "react-native"; import { patternPresets } from "@breathly/assets/pattern-presets"; import { SettingsStackParamList } from "@breathly/core/navigator"; +import { useColorScheme } from "@breathly/design/theme"; import { SettingsUI } from "@breathly/screens/settings-screen/settings-ui"; import { useSelectedPatternSteps, @@ -33,6 +34,7 @@ export const SettingsRootScreen: FC< (state) => state.setShouldFollowSystemDarkMode, ); const theme = useSettingsStore((state) => state.theme); + const resolvedColorScheme = useColorScheme(); const setTheme = useSettingsStore((state) => state.setTheme); const vibrationEnabled = useSettingsStore((state) => state.vibrationEnabled); const setVibrationEnabled = useSettingsStore((state) => state.setVibrationEnabled); @@ -97,7 +99,13 @@ export const SettingsRootScreen: FC< iconName="moon" iconBackgroundColor="#a5b4fc" value={shouldFollowSystemDarkMode} - onValueChange={setShouldFollowSystemDarkMode} + onValueChange={(shouldFollow) => { + // Turning this off must keep the appearance the user is looking at. The + // stored theme defaults to "light", so a user on a dark phone who switched + // this off to *keep* dark was flipped to light in front of them. + if (!shouldFollow) setTheme(resolvedColorScheme); + setShouldFollowSystemDarkMode(shouldFollow); + }} testID="settings.system-theme" /> {!shouldFollowSystemDarkMode && ( @@ -126,7 +134,7 @@ export const SettingsRootScreen: FC< testID="settings.vibration" /> - + {customPatternEnabled && customPatternDurations.map((stepValue, stepIndex) => { - const [lowerLimit, upperLimit] = customPatternDurationLimits[stepIndex]; - const stepLabel = ["Inhale", "Hold", "Exhale", "Hold"][stepIndex]; + const limits = customPatternDurationLimits[stepIndex]; + if (!limits) return null; + + const [lowerLimit, upperLimit] = limits; + const stepLabel = ["Inhale", "Hold after inhale", "Exhale", "Hold after exhale"][ + stepIndex + ]; return ( - + {patternPresets.map((patternPreset) => { return ( ; @@ -388,6 +389,12 @@ const StepperItem: FC = ({ testID={testID} groupPosition={groupPosition} trailing={ + // KNOWN GAP: these two buttons have no accessible name, so TalkBack reads "−" and + // "+" with no clue what they change. iOS and web set "Increase