Utilities and UI components for cross-platform React Native apps
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add components, theme/settings helpers, example app

Extracted from https://github.com/thehale/HabitSync-for-Todoist and
modified slightly to provide more flexibility for other apps I'm
upgrading as well.

Joseph Hale (Nov 11, 2025, 3:12 PM -0700) be2b0093 f06835bb

+3048 -16
+9
package.json
··· 58 58 "publishConfig": { 59 59 "registry": "https://registry.npmjs.org/" 60 60 }, 61 + "dependencies": { 62 + "@ungap/structured-clone": "^1.3.0", 63 + "color": "^5.0.0", 64 + "lodash": "^4.17.21" 65 + }, 61 66 "devDependencies": { 62 67 "@commitlint/config-conventional": "^19.8.1", 63 68 "@eslint/compat": "^1.3.2", ··· 67 72 "@react-native/babel-preset": "0.81.1", 68 73 "@react-native/eslint-config": "^0.81.1", 69 74 "@release-it/conventional-changelog": "^10.0.1", 75 + "@testing-library/react-native": "^13.3.3", 70 76 "@types/jest": "^29.5.14", 77 + "@types/lodash": "^4.17.20", 71 78 "@types/react": "^19.1.12", 79 + "@types/ungap__structured-clone": "^1.2.0", 72 80 "commitlint": "^19.8.1", 73 81 "del-cli": "^6.0.0", 74 82 "eslint": "^9.35.0", ··· 79 87 "react": "19.1.0", 80 88 "react-native": "0.81.5", 81 89 "react-native-builder-bob": "^0.40.14", 90 + "react-test-renderer": "19.1.0", 82 91 "release-it": "^19.0.4", 83 92 "typescript": "^5.9.2" 84 93 },
+188 -2
yarn.lock
··· 2740 2740 languageName: node 2741 2741 linkType: hard 2742 2742 2743 + "@jest/diff-sequences@npm:30.0.1": 2744 + version: 30.0.1 2745 + resolution: "@jest/diff-sequences@npm:30.0.1" 2746 + checksum: e5f931ca69c15a9b3a9b23b723f51ffc97f031b2f3ca37f901333dab99bd4dfa1ad4192a5cd893cd1272f7602eb09b9cfb5fc6bb62a0232c96fb8b5e96094970 2747 + languageName: node 2748 + linkType: hard 2749 + 2743 2750 "@jest/environment@npm:^29.7.0": 2744 2751 version: 29.7.0 2745 2752 resolution: "@jest/environment@npm:29.7.0" ··· 2782 2789 jest-mock: ^29.7.0 2783 2790 jest-util: ^29.7.0 2784 2791 checksum: caf2bbd11f71c9241b458d1b5a66cbe95debc5a15d96442444b5d5c7ba774f523c76627c6931cca5e10e76f0d08761f6f1f01a608898f4751a0eee54fc3d8d00 2792 + languageName: node 2793 + linkType: hard 2794 + 2795 + "@jest/get-type@npm:30.1.0": 2796 + version: 30.1.0 2797 + resolution: "@jest/get-type@npm:30.1.0" 2798 + checksum: e2a95fbb49ce2d15547db8af5602626caf9b05f62a5e583b4a2de9bd93a2bfe7175f9bbb2b8a5c3909ce261d467b6991d7265bb1d547cb60e7e97f571f361a70 2785 2799 languageName: node 2786 2800 linkType: hard 2787 2801 ··· 2831 2845 node-notifier: 2832 2846 optional: true 2833 2847 checksum: 7eadabd62cc344f629024b8a268ecc8367dba756152b761bdcb7b7e570a3864fc51b2a9810cd310d85e0a0173ef002ba4528d5ea0329fbf66ee2a3ada9c40455 2848 + languageName: node 2849 + linkType: hard 2850 + 2851 + "@jest/schemas@npm:30.0.5": 2852 + version: 30.0.5 2853 + resolution: "@jest/schemas@npm:30.0.5" 2854 + dependencies: 2855 + "@sinclair/typebox": ^0.34.0 2856 + checksum: 7a4fc4166f688947c22d81e61aaf2cb22f178dbf6ee806b0931b75136899d426a72a8330762f27f0cf6f79da0d2a56f49a22fe09f5f80df95a683ed237a0f3b0 2834 2857 languageName: node 2835 2858 linkType: hard 2836 2859 ··· 3514 3537 languageName: node 3515 3538 linkType: hard 3516 3539 3540 + "@sinclair/typebox@npm:^0.34.0": 3541 + version: 0.34.41 3542 + resolution: "@sinclair/typebox@npm:0.34.41" 3543 + checksum: dbcfdc55caef47ef5b728c2bc6979e50d00ee943b63eaaf604551be9a039187cdd256d810b790e61fdf63131df54b236149aef739d83bfe9a594a9863ac28115 3544 + languageName: node 3545 + linkType: hard 3546 + 3517 3547 "@sindresorhus/merge-streams@npm:^2.1.0": 3518 3548 version: 2.3.0 3519 3549 resolution: "@sindresorhus/merge-streams@npm:2.3.0" ··· 3536 3566 dependencies: 3537 3567 "@sinonjs/commons": ^3.0.0 3538 3568 checksum: 614d30cb4d5201550c940945d44c9e0b6d64a888ff2cd5b357f95ad6721070d6b8839cd10e15b76bf5e14af0bcc1d8f9ec00d49a46318f1f669a4bec1d7f3148 3569 + languageName: node 3570 + linkType: hard 3571 + 3572 + "@testing-library/react-native@npm:^13.3.3": 3573 + version: 13.3.3 3574 + resolution: "@testing-library/react-native@npm:13.3.3" 3575 + dependencies: 3576 + jest-matcher-utils: ^30.0.5 3577 + picocolors: ^1.1.1 3578 + pretty-format: ^30.0.5 3579 + redent: ^3.0.0 3580 + peerDependencies: 3581 + jest: ">=29.0.0" 3582 + react: ">=18.2.0" 3583 + react-native: ">=0.71" 3584 + react-test-renderer: ">=18.2.0" 3585 + peerDependenciesMeta: 3586 + jest: 3587 + optional: true 3588 + checksum: 5688918384ce834e3667a56b72c8b776a2f9a5afae0a2738e7d0077f342b3ade7eca628cbe122943201caee75f3718379ef7b3ca00cd50c4ee607b4131d09505 3539 3589 languageName: node 3540 3590 linkType: hard 3541 3591 ··· 3654 3704 languageName: node 3655 3705 linkType: hard 3656 3706 3707 + "@types/lodash@npm:^4.17.20": 3708 + version: 4.17.20 3709 + resolution: "@types/lodash@npm:4.17.20" 3710 + checksum: dc7bb4653514dd91117a4c4cec2c37e2b5a163d7643445e4757d76a360fabe064422ec7a42dde7450c5e7e0e7e678d5e6eae6d2a919abcddf581d81e63e63839 3711 + languageName: node 3712 + linkType: hard 3713 + 3657 3714 "@types/node@npm:*": 3658 3715 version: 24.10.0 3659 3716 resolution: "@types/node@npm:24.10.0" ··· 3697 3754 version: 2.0.3 3698 3755 resolution: "@types/stack-utils@npm:2.0.3" 3699 3756 checksum: 72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 3757 + languageName: node 3758 + linkType: hard 3759 + 3760 + "@types/ungap__structured-clone@npm:^1.2.0": 3761 + version: 1.2.0 3762 + resolution: "@types/ungap__structured-clone@npm:1.2.0" 3763 + checksum: 5399cfc129ecae2986fbe223fdf8f443c27a628e0d24e1be0fd3d041d643e9a041be54d9b9511fdf36279e7a3cb4c9d72493f30d75fdcde52efa3ac9dc0ebb1e 3700 3764 languageName: node 3701 3765 linkType: hard 3702 3766 ··· 4092 4156 languageName: node 4093 4157 linkType: hard 4094 4158 4095 - "ansi-styles@npm:^5.0.0": 4159 + "ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": 4096 4160 version: 5.2.0 4097 4161 resolution: "ansi-styles@npm:5.2.0" 4098 4162 checksum: d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 ··· 5013 5077 languageName: node 5014 5078 linkType: hard 5015 5079 5080 + "color-convert@npm:^3.0.1": 5081 + version: 3.1.2 5082 + resolution: "color-convert@npm:3.1.2" 5083 + dependencies: 5084 + color-name: ^2.0.0 5085 + checksum: 6817f4de27dd847129d84aa3695978c93a8eac8b67c496b9be1de78b33e1c96318edb4b814da25d7fe15fcedc62ede48a940ce24e258cde71e56eb3bb6e077de 5086 + languageName: node 5087 + linkType: hard 5088 + 5016 5089 "color-name@npm:1.1.3": 5017 5090 version: 1.1.3 5018 5091 resolution: "color-name@npm:1.1.3" ··· 5020 5093 languageName: node 5021 5094 linkType: hard 5022 5095 5096 + "color-name@npm:^2.0.0": 5097 + version: 2.0.2 5098 + resolution: "color-name@npm:2.0.2" 5099 + checksum: 58e5fa3853a0dac813179e75a1fe07ff362abacb9fd456fcaae702b74d4ed5f6de2cbaee07ff2660f3495c7a6ceabc4ef0420165db0018e7150a6d4045f6539e 5100 + languageName: node 5101 + linkType: hard 5102 + 5023 5103 "color-name@npm:~1.1.4": 5024 5104 version: 1.1.4 5025 5105 resolution: "color-name@npm:1.1.4" 5026 5106 checksum: b0445859521eb4021cd0fb0cc1a75cecf67fceecae89b63f62b201cca8d345baf8b952c966862a9d9a2632987d4f6581f0ec8d957dfacece86f0a7919316f610 5107 + languageName: node 5108 + linkType: hard 5109 + 5110 + "color-string@npm:^2.0.0": 5111 + version: 2.1.2 5112 + resolution: "color-string@npm:2.1.2" 5113 + dependencies: 5114 + color-name: ^2.0.0 5115 + checksum: 8468bf79a56da7b4d75628d7fd36b6c214f2013bcafd618c1d92194960f202e656add3395eb82e208ac6038707b882c01dea9bbe182e325ca090f5ea1695d48e 5116 + languageName: node 5117 + linkType: hard 5118 + 5119 + "color@npm:^5.0.0": 5120 + version: 5.0.2 5121 + resolution: "color@npm:5.0.2" 5122 + dependencies: 5123 + color-convert: ^3.0.1 5124 + color-string: ^2.0.0 5125 + checksum: 59f343e54652c4589e3f6517cda56fb53637cbb2e468d85024ea2634ce530f6b9cf772e069beae680d716bbbee4c980bfd013a47baa2a75d75552176fc5a3529 5027 5126 languageName: node 5028 5127 linkType: hard 5029 5128 ··· 8382 8481 languageName: node 8383 8482 linkType: hard 8384 8483 8484 + "jest-diff@npm:30.2.0": 8485 + version: 30.2.0 8486 + resolution: "jest-diff@npm:30.2.0" 8487 + dependencies: 8488 + "@jest/diff-sequences": 30.0.1 8489 + "@jest/get-type": 30.1.0 8490 + chalk: ^4.1.2 8491 + pretty-format: 30.2.0 8492 + checksum: 62fd17d3174316bf0140c2d342ac5ad84574763fa78fc4dd4e5ee605f121699033c9bfb7507ba8f1c5cc7fa95539a19abab13d3909a5aec1b447ab14d03c5386 8493 + languageName: node 8494 + linkType: hard 8495 + 8385 8496 "jest-diff@npm:^29.7.0": 8386 8497 version: 29.7.0 8387 8498 resolution: "jest-diff@npm:29.7.0" ··· 8479 8590 jest-get-type: ^29.6.3 8480 8591 pretty-format: ^29.7.0 8481 8592 checksum: d7259e5f995d915e8a37a8fd494cb7d6af24cd2a287b200f831717ba0d015190375f9f5dc35393b8ba2aae9b2ebd60984635269c7f8cff7d85b077543b7744cd 8593 + languageName: node 8594 + linkType: hard 8595 + 8596 + "jest-matcher-utils@npm:^30.0.5": 8597 + version: 30.2.0 8598 + resolution: "jest-matcher-utils@npm:30.2.0" 8599 + dependencies: 8600 + "@jest/get-type": 30.1.0 8601 + chalk: ^4.1.2 8602 + jest-diff: 30.2.0 8603 + pretty-format: 30.2.0 8604 + checksum: 33154f3fc10b19608af7f8bc91eec129f9aba0a3d89f74ffbae659159c8e2dea69c85ef1d742b1d5dd6a8be57503d77d37351edc86ce9ef3f57ecc8585e0b154 8482 8605 languageName: node 8483 8606 linkType: hard 8484 8607 ··· 9863 9986 languageName: node 9864 9987 linkType: hard 9865 9988 9989 + "min-indent@npm:^1.0.0": 9990 + version: 1.0.1 9991 + resolution: "min-indent@npm:1.0.1" 9992 + checksum: bfc6dd03c5eaf623a4963ebd94d087f6f4bbbfd8c41329a7f09706b0cb66969c4ddd336abeb587bc44bc6f08e13bf90f0b374f9d71f9f01e04adc2cd6f083ef1 9993 + languageName: node 9994 + linkType: hard 9995 + 9866 9996 "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": 9867 9997 version: 3.1.2 9868 9998 resolution: "minimatch@npm:3.1.2" ··· 10890 11020 languageName: node 10891 11021 linkType: hard 10892 11022 11023 + "pretty-format@npm:30.2.0, pretty-format@npm:^30.0.5": 11024 + version: 30.2.0 11025 + resolution: "pretty-format@npm:30.2.0" 11026 + dependencies: 11027 + "@jest/schemas": 30.0.5 11028 + ansi-styles: ^5.2.0 11029 + react-is: ^18.3.1 11030 + checksum: 4c54f5ed8bcf450df9d5d70726c3373f26896845a9704f5a4a835913dacea794fabb5de4ab19fabb0d867de496f9fc8bf854ccdb661c45af334026308557d622 11031 + languageName: node 11032 + linkType: hard 11033 + 10893 11034 "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": 10894 11035 version: 29.7.0 10895 11036 resolution: "pretty-format@npm:29.7.0" ··· 11109 11250 languageName: node 11110 11251 linkType: hard 11111 11252 11112 - "react-is@npm:^18.0.0": 11253 + "react-is@npm:^18.0.0, react-is@npm:^18.3.1": 11113 11254 version: 18.3.1 11114 11255 resolution: "react-is@npm:18.3.1" 11115 11256 checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21 11257 + languageName: node 11258 + linkType: hard 11259 + 11260 + "react-is@npm:^19.1.0": 11261 + version: 19.2.0 11262 + resolution: "react-is@npm:19.2.0" 11263 + checksum: 9a23e1c2d0bbc13b383bc59a05f54e6eb95dd87e01aec8aa92a88618364b7b0ee8a5b057ad813cf61e2f7ae7d24503b624706acb609d07c54754e5ad2c522568 11116 11264 languageName: node 11117 11265 linkType: hard 11118 11266 ··· 11176 11324 "@react-native/babel-preset": 0.81.1 11177 11325 "@react-native/eslint-config": ^0.81.1 11178 11326 "@release-it/conventional-changelog": ^10.0.1 11327 + "@testing-library/react-native": ^13.3.3 11179 11328 "@types/jest": ^29.5.14 11329 + "@types/lodash": ^4.17.20 11180 11330 "@types/react": ^19.1.12 11331 + "@types/ungap__structured-clone": ^1.2.0 11332 + "@ungap/structured-clone": ^1.3.0 11333 + color: ^5.0.0 11181 11334 commitlint: ^19.8.1 11182 11335 del-cli: ^6.0.0 11183 11336 eslint: ^9.35.0 11184 11337 eslint-config-prettier: ^10.1.8 11185 11338 eslint-plugin-prettier: ^5.5.4 11186 11339 jest: ^29.7.0 11340 + lodash: ^4.17.21 11187 11341 prettier: ^3.6.2 11188 11342 react: 19.1.0 11189 11343 react-native: 0.81.5 11190 11344 react-native-builder-bob: ^0.40.14 11345 + react-test-renderer: 19.1.0 11191 11346 release-it: ^19.0.4 11192 11347 typescript: ^5.9.2 11193 11348 peerDependencies: ··· 11292 11447 languageName: node 11293 11448 linkType: hard 11294 11449 11450 + "react-test-renderer@npm:19.1.0": 11451 + version: 19.1.0 11452 + resolution: "react-test-renderer@npm:19.1.0" 11453 + dependencies: 11454 + react-is: ^19.1.0 11455 + scheduler: ^0.26.0 11456 + peerDependencies: 11457 + react: ^19.1.0 11458 + checksum: e060239f8fbfbe54c2fa0e1d7a9eb1ee77787b0b29fd75ba6170dc22cd637c3a9052a82285db6be5a269309a65b8e8c40ddc8103620fbe1652cce693fd608acc 11459 + languageName: node 11460 + linkType: hard 11461 + 11295 11462 "react@npm:19.1.0": 11296 11463 version: 19.1.0 11297 11464 resolution: "react@npm:19.1.0" ··· 11338 11505 version: 4.1.2 11339 11506 resolution: "readdirp@npm:4.1.2" 11340 11507 checksum: 3242ee125422cb7c0e12d51452e993f507e6ed3d8c490bc8bf3366c5cdd09167562224e429b13e9cb2b98d4b8b2b11dc100d3c73883aa92d657ade5a21ded004 11508 + languageName: node 11509 + linkType: hard 11510 + 11511 + "redent@npm:^3.0.0": 11512 + version: 3.0.0 11513 + resolution: "redent@npm:3.0.0" 11514 + dependencies: 11515 + indent-string: ^4.0.0 11516 + strip-indent: ^3.0.0 11517 + checksum: fa1ef20404a2d399235e83cc80bd55a956642e37dd197b4b612ba7327bf87fa32745aeb4a1634b2bab25467164ab4ed9c15be2c307923dd08b0fe7c52431ae6b 11341 11518 languageName: node 11342 11519 linkType: hard 11343 11520 ··· 12370 12547 version: 3.0.0 12371 12548 resolution: "strip-final-newline@npm:3.0.0" 12372 12549 checksum: 23ee263adfa2070cd0f23d1ac14e2ed2f000c9b44229aec9c799f1367ec001478469560abefd00c5c99ee6f0b31c137d53ec6029c53e9f32a93804e18c201050 12550 + languageName: node 12551 + linkType: hard 12552 + 12553 + "strip-indent@npm:^3.0.0": 12554 + version: 3.0.0 12555 + resolution: "strip-indent@npm:3.0.0" 12556 + dependencies: 12557 + min-indent: ^1.0.0 12558 + checksum: 18f045d57d9d0d90cd16f72b2313d6364fd2cb4bf85b9f593523ad431c8720011a4d5f08b6591c9d580f446e78855c5334a30fb91aa1560f5d9f95ed1b4a0530 12373 12559 languageName: node 12374 12560 linkType: hard 12375 12561
+1 -1
example/app.json
··· 5 5 "version": "1.0.0", 6 6 "orientation": "portrait", 7 7 "icon": "./assets/icon.png", 8 - "userInterfaceStyle": "light", 8 + "userInterfaceStyle": "automatic", 9 9 "newArchEnabled": true, 10 10 "splash": { 11 11 "image": "./assets/splash-icon.png",
+15 -3
src/index.tsx
··· 1 - export function multiply(a: number, b: number): number { 2 - return a * b; 3 - } 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + export { default as Button } from './components/Button'; 8 + export { default as Card } from './components/Card'; 9 + export { default as Dialog } from './components/Dialog'; 10 + export { default as Divider } from './components/Divider'; 11 + export { default as Row } from './components/Row'; 12 + export { default as Text } from './components/Text'; 13 + export { default as TextInput } from './components/TextInput'; 14 + 15 + export * from './theme';
+17 -9
example/src/App.tsx
··· 1 - import { Text, View, StyleSheet } from 'react-native'; 2 - import { multiply } from 'react-native-expressive'; 3 - 4 - const result = multiply(3, 7); 1 + import { StyleSheet, ScrollView } from 'react-native'; 2 + import Buttons from './Buttons'; 3 + import Typography from './Typography'; 4 + import Themes from './Themes'; 5 + import { useMaterialTheme } from 'react-native-expressive'; 6 + import { Divider } from 'react-native-expressive'; 5 7 6 8 export default function App() { 9 + const { theme } = useMaterialTheme(); 7 10 return ( 8 - <View style={styles.container}> 9 - <Text>Result: {result}</Text> 10 - </View> 11 + <ScrollView 12 + style={[styles.container, { backgroundColor: theme.colors.background }]} 13 + > 14 + <Themes /> 15 + <Divider /> 16 + <Buttons /> 17 + <Divider /> 18 + <Typography /> 19 + </ScrollView> 11 20 ); 12 21 } 13 22 14 23 const styles = StyleSheet.create({ 15 24 container: { 16 25 flex: 1, 17 - alignItems: 'center', 18 - justifyContent: 'center', 26 + padding: 16, 19 27 }, 20 28 });
+50
example/src/Buttons.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { StyleSheet, View } from 'react-native'; 8 + import { Text, Button, Row } from 'react-native-expressive'; 9 + 10 + export default function Buttons() { 11 + const modes = ['text', 'contained', 'outlined', 'tonal', 'elevated'] as const; 12 + return ( 13 + <View style={styles.container}> 14 + <View style={styles.header}> 15 + <Text variant="title" style={styles.mode}> 16 + Mode 17 + </Text> 18 + <Text variant="title" style={styles.intent}> 19 + Normal 20 + </Text> 21 + <Text variant="title" style={styles.intent}> 22 + Danger 23 + </Text> 24 + </View> 25 + {modes.map((mode) => ( 26 + <Row key={mode} itemStyle={styles.button}> 27 + <Text variant="body">{mode}</Text> 28 + <Button mode={mode}>Button</Button> 29 + <Button mode={mode} disabled> 30 + Button 31 + </Button> 32 + <Button mode={mode} intent="danger"> 33 + Button 34 + </Button> 35 + <Button mode={mode} intent="danger" disabled> 36 + Button 37 + </Button> 38 + </Row> 39 + ))} 40 + </View> 41 + ); 42 + } 43 + 44 + const styles = StyleSheet.create({ 45 + container: { flex: 1 }, 46 + header: { flexDirection: 'row', marginVertical: 8 }, 47 + mode: { flex: 1, textAlign: 'left' }, 48 + intent: { flex: 2, textAlign: 'center' }, 49 + button: { flex: 1 }, 50 + });
+69
example/src/Themes.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { StyleSheet, View } from 'react-native'; 8 + import { Button, Text } from 'react-native-expressive'; 9 + import { 10 + useMaterialTheme, 11 + MaterialBlue, 12 + MaterialCyan, 13 + MaterialGreen, 14 + MaterialOrange, 15 + MaterialPink, 16 + MaterialRed, 17 + MaterialYellow, 18 + } from 'react-native-expressive'; 19 + 20 + export default function Themes() { 21 + const { setTheme, setScheme, resetTheme } = useMaterialTheme(); 22 + return ( 23 + <View style={styles.container}> 24 + <Text variant="headline">Theme Picker</Text> 25 + <View style={styles.section}> 26 + <Button mode="contained" onPress={() => setTheme(MaterialBlue)}> 27 + Blue 28 + </Button> 29 + <Button mode="contained" onPress={() => setTheme(MaterialCyan)}> 30 + Cyan 31 + </Button> 32 + <Button mode="contained" onPress={() => setTheme(MaterialGreen)}> 33 + Green 34 + </Button> 35 + <Button mode="contained" onPress={() => setTheme(MaterialOrange)}> 36 + Orange 37 + </Button> 38 + <Button mode="contained" onPress={() => setTheme(MaterialPink)}> 39 + Pink 40 + </Button> 41 + <Button mode="contained" onPress={() => setTheme(MaterialRed)}> 42 + Red 43 + </Button> 44 + <Button mode="contained" onPress={() => setTheme(MaterialYellow)}> 45 + Yellow 46 + </Button> 47 + </View> 48 + <View style={styles.section}> 49 + <Button mode="outlined" onPress={() => setScheme('light')}> 50 + Light 51 + </Button> 52 + <Button mode="outlined" onPress={() => setScheme('dark')}> 53 + Dark 54 + </Button> 55 + <Button mode="outlined" onPress={() => setScheme('system')}> 56 + System 57 + </Button> 58 + <Button mode="text" onPress={() => resetTheme()}> 59 + Reset 60 + </Button> 61 + </View> 62 + </View> 63 + ); 64 + } 65 + 66 + const styles = StyleSheet.create({ 67 + container: { gap: 16, marginBlock: 16 }, 68 + section: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, 69 + });
+30
example/src/Typography.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { View } from 'react-native'; 8 + import { Text } from 'react-native-expressive'; 9 + 10 + export default function Typography() { 11 + return ( 12 + <View> 13 + <Text variant="display large">Display Large</Text> 14 + <Text variant="display medium">Display Medium</Text> 15 + <Text variant="display small">Display Small</Text> 16 + <Text variant="headline large">Headline Large</Text> 17 + <Text variant="headline medium">Headline Medium</Text> 18 + <Text variant="headline small">Headline Small</Text> 19 + <Text variant="title large">Title Large</Text> 20 + <Text variant="title medium">Title Medium</Text> 21 + <Text variant="title small">Title Small</Text> 22 + <Text variant="label large">Label Large</Text> 23 + <Text variant="label medium">Label Medium</Text> 24 + <Text variant="label small">Label Small</Text> 25 + <Text variant="body large">Body Large</Text> 26 + <Text variant="body medium">Body Medium</Text> 27 + <Text variant="body small">Body Small</Text> 28 + </View> 29 + ); 30 + }
-1
src/__tests__/index.test.tsx
··· 1 - it.todo('write a test');
+205
src/components/Button.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + /* 8 + * KNOWN BUGS: 9 + * `mode="elevated" disabled`: The text is highlighted lighter than the rest of the button 10 + */ 11 + 12 + import { Pressable, StyleSheet, View, type ViewStyle } from 'react-native'; 13 + 14 + import type { Optional } from '../utils/typing'; 15 + import Text from './Text'; 16 + import type { MaterialTheme } from '../theme/material/types'; 17 + import { useMaterialTheme } from '../theme/material/useMaterialTheme'; 18 + 19 + type ButtonMode = 'text' | 'contained' | 'outlined' | 'tonal' | 'elevated'; 20 + type ButtonIntent = 'normal' | 'danger'; 21 + 22 + export interface ButtonProps { 23 + children: string; 24 + mode: ButtonMode; 25 + intent: ButtonIntent; 26 + disabled: boolean; 27 + onPress: () => void; 28 + before?: React.ReactNode; 29 + after?: React.ReactNode; 30 + } 31 + 32 + type PropsWithDefaults = 'mode' | 'intent' | 'disabled' | 'onPress'; 33 + 34 + const propDefaults: Pick<ButtonProps, PropsWithDefaults> = { 35 + mode: 'text', 36 + intent: 'normal', 37 + disabled: false, 38 + onPress: () => { 39 + /* no-op */ 40 + }, 41 + }; 42 + 43 + export default function Button(args: Optional<ButtonProps, PropsWithDefaults>) { 44 + const { theme } = useMaterialTheme(); 45 + const props: ButtonProps = { ...propDefaults, ...args }; 46 + const modeColors = buttonModeColors(theme)[props.mode]; 47 + const dangerColors = buttonDangerColors(theme)[props.mode]; 48 + const disabledColors = buttonDisabledColors(theme)[props.mode]; 49 + return ( 50 + <Pressable 51 + onPress={props.onPress} 52 + disabled={props.disabled} 53 + style={[ 54 + styles.container, 55 + modeColors, 56 + props.intent === 'danger' && dangerColors, 57 + props.disabled && disabledColors, 58 + ]} 59 + > 60 + <View style={styles.contents}> 61 + {props.before} 62 + <Text 63 + style={[ 64 + { color: modeColors.textColor }, 65 + props.intent === 'danger' && { color: dangerColors.textColor }, 66 + props.disabled && { color: disabledColors.textColor }, 67 + ]} 68 + > 69 + {props.children} 70 + </Text> 71 + {props.after} 72 + </View> 73 + </Pressable> 74 + ); 75 + } 76 + 77 + interface ButtonColors extends ViewStyle { 78 + backgroundColor: string; 79 + textColor: string; 80 + borderColor: string; 81 + borderWidth: number; 82 + } 83 + 84 + function buttonModeColors( 85 + theme: MaterialTheme 86 + ): Record<ButtonMode, ButtonColors> { 87 + return { 88 + text: { 89 + backgroundColor: 'transparent', 90 + textColor: theme.colors.primary, 91 + borderColor: 'transparent', 92 + borderWidth: 0, 93 + }, 94 + contained: { 95 + backgroundColor: theme.colors.primary, 96 + textColor: theme.colors.onPrimary, 97 + borderColor: 'transparent', 98 + borderWidth: 0, 99 + }, 100 + outlined: { 101 + backgroundColor: 'transparent', 102 + textColor: theme.colors.primary, 103 + borderColor: theme.colors.outline, 104 + borderWidth: 1, 105 + }, 106 + tonal: { 107 + backgroundColor: theme.colors.secondaryContainer, 108 + textColor: theme.colors.onSecondaryContainer, 109 + borderColor: 'transparent', 110 + borderWidth: 0, 111 + }, 112 + elevated: { 113 + backgroundColor: theme.colors.elevation.level2, 114 + textColor: theme.colors.primary, 115 + borderColor: 'transparent', 116 + borderWidth: 0, 117 + elevation: 2, 118 + }, 119 + }; 120 + } 121 + 122 + function buttonDangerColors( 123 + theme: MaterialTheme 124 + ): Record<ButtonMode, ButtonColors> { 125 + return { 126 + text: { 127 + backgroundColor: 'transparent', 128 + textColor: theme.colors.error, 129 + borderColor: 'transparent', 130 + borderWidth: 0, 131 + }, 132 + contained: { 133 + backgroundColor: theme.colors.error, 134 + textColor: theme.colors.onError, 135 + borderColor: 'transparent', 136 + borderWidth: 0, 137 + }, 138 + outlined: { 139 + backgroundColor: 'transparent', 140 + textColor: theme.colors.error, 141 + borderColor: theme.colors.error, 142 + borderWidth: 1, 143 + }, 144 + tonal: { 145 + backgroundColor: theme.colors.errorContainer, 146 + textColor: theme.colors.onErrorContainer, 147 + borderColor: 'transparent', 148 + borderWidth: 0, 149 + }, 150 + elevated: { 151 + backgroundColor: theme.colors.errorContainer, 152 + textColor: theme.colors.onErrorContainer, 153 + borderColor: 'transparent', 154 + borderWidth: 0, 155 + elevation: 2, 156 + }, 157 + }; 158 + } 159 + 160 + function buttonDisabledColors( 161 + theme: MaterialTheme 162 + ): Record<ButtonMode, ButtonColors> { 163 + return { 164 + text: { 165 + backgroundColor: 'transparent', 166 + textColor: theme.colors.onSurfaceDisabled, 167 + borderColor: 'transparent', 168 + borderWidth: 0, 169 + }, 170 + contained: { 171 + backgroundColor: theme.colors.surfaceDisabled, 172 + textColor: theme.colors.onSurfaceDisabled, 173 + borderColor: 'transparent', 174 + borderWidth: 0, 175 + }, 176 + outlined: { 177 + backgroundColor: 'transparent', 178 + textColor: theme.colors.onSurfaceDisabled, 179 + borderColor: theme.colors.surfaceDisabled, 180 + borderWidth: 1, 181 + }, 182 + tonal: { 183 + backgroundColor: theme.colors.surfaceDisabled, 184 + textColor: theme.colors.onSurfaceDisabled, 185 + borderColor: 'transparent', 186 + borderWidth: 0, 187 + }, 188 + elevated: { 189 + backgroundColor: theme.colors.surfaceDisabled, 190 + textColor: theme.colors.onSurfaceDisabled, 191 + borderColor: 'transparent', 192 + borderWidth: 0, 193 + }, 194 + }; 195 + } 196 + 197 + const styles = StyleSheet.create({ 198 + container: { margin: 2, padding: 8, borderRadius: 8 }, 199 + contents: { 200 + flexDirection: 'row', 201 + alignItems: 'center', 202 + justifyContent: 'space-evenly', 203 + gap: 8, 204 + }, 205 + });
+119
src/components/Card.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { StyleSheet, View } from 'react-native'; 8 + import type { StyleProp, ViewStyle } from 'react-native'; 9 + import Text, { type TextProps } from './Text'; 10 + 11 + import React from 'react'; 12 + import { useMaterialTheme } from '../theme/material/useMaterialTheme'; 13 + 14 + export interface CardProps { 15 + title?: string | React.ReactNode; 16 + subtitle?: string | React.ReactNode; 17 + content?: string | React.ReactNode; 18 + actions?: React.ReactNode; 19 + } 20 + 21 + export default function Card(props: CardProps) { 22 + const { theme } = useMaterialTheme(); 23 + const colors: ViewStyle = { 24 + backgroundColor: theme.colors.surface, 25 + borderColor: theme.colors.outlineVariant, 26 + borderWidth: 1, 27 + elevation: 5, 28 + }; 29 + return ( 30 + <View style={[styles.container, colors]}> 31 + <Title>{props.title}</Title> 32 + <Subtitle>{props.subtitle}</Subtitle> 33 + <Content>{props.content}</Content> 34 + {props.actions instanceof Actions ? ( 35 + props.actions 36 + ) : ( 37 + <Actions>{props.actions}</Actions> 38 + )} 39 + </View> 40 + ); 41 + } 42 + 43 + function Title(props: { children: string | React.ReactNode }) { 44 + return ( 45 + <_Section containerStyle={styles.title} textVariant="title"> 46 + {props.children} 47 + </_Section> 48 + ); 49 + } 50 + 51 + function Subtitle(props: { children: string | React.ReactNode }) { 52 + return ( 53 + <_Section containerStyle={styles.subtitle} textVariant="title small"> 54 + {props.children} 55 + </_Section> 56 + ); 57 + } 58 + 59 + function Content(props: { children: string | React.ReactNode }) { 60 + return <_Section containerStyle={styles.content}>{props.children}</_Section>; 61 + } 62 + 63 + function Actions(props: { children: React.ReactNode }) { 64 + return ( 65 + <_Section containerStyle={styles.actions} textVariant="label"> 66 + {props.children} 67 + </_Section> 68 + ); 69 + } 70 + 71 + Card.Actions = Actions; 72 + 73 + function _Section(props: { 74 + children: string | React.ReactNode; 75 + containerStyle?: StyleProp<ViewStyle>; 76 + textVariant?: TextProps['variant']; 77 + }) { 78 + if (!props.children) return null; 79 + else { 80 + const children = Array.isArray(props.children) 81 + ? props.children 82 + : [props.children]; 83 + const inners = children 84 + .filter((i) => !!i) 85 + .map((child, index) => 86 + typeof child === 'string' ? ( 87 + <Text key={index} variant={props.textVariant}> 88 + {child} 89 + </Text> 90 + ) : ( 91 + <View key={index}>{child}</View> 92 + ) 93 + ); 94 + return <View style={props.containerStyle}>{inners}</View>; 95 + } 96 + } 97 + 98 + const styles = StyleSheet.create({ 99 + container: { 100 + width: '100%', 101 + padding: 16, 102 + borderRadius: 8, 103 + shadowOffset: { 104 + width: 0, 105 + height: 2, 106 + }, 107 + shadowOpacity: 0.25, 108 + shadowRadius: 3.84, 109 + elevation: 5, 110 + }, 111 + title: { marginBottom: 8 }, 112 + subtitle: { marginBottom: 8 }, 113 + content: { marginBottom: 8 }, 114 + actions: { 115 + flexDirection: 'row', 116 + justifyContent: 'flex-end', 117 + gap: 8, 118 + }, 119 + });
+53
src/components/Dialog.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import Card, { type CardProps } from './Card'; 8 + import { Modal, Pressable, StyleSheet, View } from 'react-native'; 9 + 10 + import { useMaterialTheme } from '../theme/material/useMaterialTheme'; 11 + 12 + export interface DialogProps extends CardProps { 13 + visible: boolean; 14 + onDismiss: () => void; 15 + } 16 + 17 + export default function Dialog(props: DialogProps) { 18 + const { theme } = useMaterialTheme(); 19 + return ( 20 + <Modal 21 + visible={props.visible} 22 + animationType="fade" 23 + transparent={true} 24 + onRequestClose={props.onDismiss} 25 + > 26 + <Pressable 27 + style={[styles.centered, { backgroundColor: theme.colors.backdrop }]} 28 + onPress={props.onDismiss} 29 + > 30 + <View style={[styles.centered, styles.container]}> 31 + <Card {...props} /> 32 + </View> 33 + </Pressable> 34 + </Modal> 35 + ); 36 + } 37 + 38 + Dialog.Actions = Card.Actions; 39 + 40 + const styles = StyleSheet.create({ 41 + centered: { 42 + flex: 1, 43 + justifyContent: 'center', 44 + alignItems: 'center', 45 + }, 46 + background: { 47 + backgroundColor: 'rgba(0, 0, 0, 0.25)', 48 + }, 49 + container: { 50 + width: '80%', 51 + height: 'auto', 52 + }, 53 + });
+25
src/components/Divider.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { StyleSheet, View } from 'react-native'; 8 + 9 + import { useMaterialTheme } from '../theme/material/useMaterialTheme'; 10 + 11 + export default function Divider() { 12 + const { theme } = useMaterialTheme(); 13 + return ( 14 + <View 15 + style={[styles.container, { backgroundColor: theme.colors.outline }]} 16 + /> 17 + ); 18 + } 19 + 20 + const styles = StyleSheet.create({ 21 + container: { 22 + width: '100%', 23 + height: 1, 24 + }, 25 + });
+35
src/components/Row.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import React from 'react'; 8 + import { StyleSheet, View } from 'react-native'; 9 + import type { StyleProp, ViewStyle } from 'react-native'; 10 + 11 + interface RowProps { 12 + children: React.ReactNode; 13 + style?: StyleProp<ViewStyle>; 14 + itemStyle?: StyleProp<ViewStyle>; 15 + } 16 + 17 + export default function Row(props: RowProps) { 18 + return ( 19 + <View style={[styles.container, props.style]}> 20 + {React.Children.map(props.children, (child, index) => ( 21 + <View key={index} style={props.itemStyle}> 22 + {child} 23 + </View> 24 + ))} 25 + </View> 26 + ); 27 + } 28 + 29 + const styles = StyleSheet.create({ 30 + container: { 31 + flexDirection: 'row', 32 + alignItems: 'center', 33 + justifyContent: 'space-between', 34 + }, 35 + });
+36
src/components/Text.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { Text as NativeText } from 'react-native'; 8 + import type { StyleProp, TextStyle } from 'react-native'; 9 + 10 + import type { MaterialTheme } from '../theme/material/types'; 11 + import { useMaterialTheme } from '../theme/material/useMaterialTheme'; 12 + 13 + type TextVariant = keyof MaterialTheme['fonts']; 14 + type TextSize = keyof MaterialTheme['fonts'][TextVariant]; 15 + 16 + export interface TextProps { 17 + variant?: TextVariant | `${TextVariant} ${TextSize}`; 18 + children: string | React.ReactNode; 19 + style?: StyleProp<TextStyle>; 20 + } 21 + 22 + export default function Text(props: TextProps) { 23 + const { theme } = useMaterialTheme(); 24 + const colors: TextStyle = { color: theme.colors.onSurface }; 25 + const [variant, size] = (props.variant ?? 'body').split(' ') as [ 26 + TextVariant, 27 + TextSize | undefined, 28 + ]; 29 + return ( 30 + <NativeText 31 + style={[theme.fonts[variant][size ?? 'medium'], colors, props.style]} 32 + > 33 + {props.children} 34 + </NativeText> 35 + ); 36 + }
+64
src/components/TextInput.tsx
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { TextInput as NativeTextInput, StyleSheet, View } from 'react-native'; 8 + 9 + import Color from 'color'; 10 + import { useMaterialTheme } from '../theme/material/useMaterialTheme'; 11 + 12 + interface TextInputProps { 13 + value: string; 14 + onChangeText: (text: string) => void; 15 + placeholder?: string; 16 + before?: React.ReactNode; 17 + after?: React.ReactNode; 18 + } 19 + 20 + export default function TextInput(props: TextInputProps) { 21 + const { theme } = useMaterialTheme(); 22 + return ( 23 + <View 24 + style={[ 25 + styles.container, 26 + { 27 + backgroundColor: theme.colors.secondaryContainer, 28 + borderColor: theme.colors.primary, 29 + }, 30 + ]} 31 + > 32 + {props.before} 33 + <NativeTextInput 34 + style={[styles.input, { color: theme.colors.onSecondaryContainer }]} 35 + placeholder={`${props.placeholder}...`} 36 + placeholderTextColor={Color(theme.colors.onSecondaryContainer) 37 + .fade(0.5) 38 + .string()} 39 + value={props.value} 40 + onChangeText={props.onChangeText} 41 + autoCapitalize="none" 42 + autoCorrect={false} 43 + underlineColorAndroid={'transparent'} 44 + /> 45 + {props.after} 46 + </View> 47 + ); 48 + } 49 + 50 + const styles = StyleSheet.create({ 51 + container: { 52 + flexDirection: 'row', 53 + justifyContent: 'space-between', 54 + alignItems: 'center', 55 + borderRadius: 8, 56 + gap: 8, 57 + paddingHorizontal: 8, 58 + borderBottomWidth: 1, 59 + }, 60 + input: { 61 + borderRadius: 8, 62 + flex: 1, 63 + }, 64 + });
+41
src/settings/SettingsStore.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { NamedSubscriberStore } from './_utils/NamedSubscriberStore'; 8 + import type { Settings } from './types'; 9 + 10 + export class SettingsStore<S extends Settings> extends NamedSubscriberStore<{ 11 + [K in keyof S]: S[K]['default']; 12 + }> { 13 + settings: S; 14 + constructor(settings: S) { 15 + super(toDefaults(settings)); 16 + this.settings = settings; 17 + } 18 + 19 + reset() { 20 + this.update(toDefaults(this.settings)); 21 + } 22 + 23 + protected isValidUpdate<K extends keyof S>( 24 + key: K, 25 + value: S[K]['default'] 26 + ): boolean { 27 + return this.settings[key]!.validate(value); 28 + } 29 + protected onUpdate<K extends keyof S>(key: K, value: S[K]['default']): void { 30 + this.settings[key]!.update(value); 31 + } 32 + } 33 + 34 + function toDefaults<S extends Settings>( 35 + settings: S 36 + ): { [K in keyof S]: S[K]['default'] } { 37 + // @ts-expect-error Object.fromEntries is type-lossy 38 + return Object.fromEntries( 39 + Object.entries(settings).map(([key, value]) => [key, value.default]) 40 + ); 41 + }
+43
src/settings/createSettings.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { useCallback, useSyncExternalStore } from 'react'; 8 + 9 + import type { Settings } from './types'; 10 + import { SettingsStore } from './SettingsStore'; 11 + 12 + export function createSettings<S extends Settings>(definitions: S) { 13 + const store = new SettingsStore(definitions); 14 + 15 + async function initSettings() { 16 + const entries = await Promise.all( 17 + Object.entries(definitions).map(async ([key, setting]) => [ 18 + key, 19 + await setting.read(), 20 + ]) 21 + ); 22 + store.update(Object.fromEntries(entries)); 23 + } 24 + 25 + function useSettings(callerName: string = 'settings') { 26 + const subscribe = useCallback<Parameters<typeof useSyncExternalStore>[0]>( 27 + (listener) => store.subscribe(listener, callerName), 28 + [callerName] 29 + ); 30 + const getSnapshot = useCallback(() => store.getSnapshot(), []); 31 + 32 + const snapshot = useSyncExternalStore(subscribe, getSnapshot); 33 + const update = useCallback( 34 + (updates: Partial<typeof snapshot>) => store.update(updates), 35 + [] 36 + ); 37 + const resetToDefaults = useCallback(() => store.reset(), []); 38 + 39 + return [snapshot, update, resetToDefaults] as const; 40 + } 41 + 42 + return { initSettings, useSettings, settingsStore: store }; 43 + }
+10
src/settings/index.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + export type { Setting, Settings } from './types'; 8 + 9 + export { SettingsStore } from './SettingsStore'; 10 + export { createSettings } from './createSettings';
+14
src/settings/types.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + export interface Setting<Value> { 8 + default: Value; 9 + validate: (value: Value) => boolean; 10 + update: (value: Value) => Promise<void>; 11 + read: () => Promise<Value>; 12 + } 13 + 14 + export type Settings = Record<string, Setting<any>>;
+52
src/stores/KeyValueStore.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { debounce, type DebouncedFunc } from 'lodash'; 8 + 9 + export interface ExternalKeyValueStore { 10 + get: (key: string) => Promise<string | null>; 11 + set: (key: string, value: string) => Promise<void>; 12 + } 13 + 14 + export default class KeyValueStore { 15 + private external: ExternalKeyValueStore; 16 + private setters = new Map< 17 + string, 18 + DebouncedFunc<(value: string) => Promise<void>> 19 + >(); 20 + 21 + constructor(external?: ExternalKeyValueStore) { 22 + this.external = external ?? new InMemoryKeyValueStore(); 23 + } 24 + 25 + async put(key: string, value: string): Promise<void> { 26 + if (!this.setters.has(key)) { 27 + const setter = async (newVal: string) => 28 + await this.external.set(key, newVal); 29 + this.setters.set(key, debounce(setter, 1000, { trailing: true })); 30 + return setter(value); 31 + } else { 32 + return this.setters.get(key)!(value); 33 + } 34 + } 35 + 36 + async read(key: string, defaultValue: string = ''): Promise<string> { 37 + const value = await this.external.get(key); 38 + return value ?? defaultValue; 39 + } 40 + } 41 + 42 + class InMemoryKeyValueStore implements ExternalKeyValueStore { 43 + private store = new Map<string, string>(); 44 + 45 + async get(key: string): Promise<string | null> { 46 + return this.store.get(key) ?? null; 47 + } 48 + 49 + async set(key: string, value: string): Promise<void> { 50 + this.store.set(key, value); 51 + } 52 + }
+77
src/theme/createTheme.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { createSettings } from '../settings/createSettings'; 8 + import type { Settings, Setting } from '../settings/types'; 9 + import KeyValueStore from '../stores/KeyValueStore'; 10 + import { useColorScheme as useSystemColorScheme } from 'react-native'; 11 + import type { Theme, ThemeColors, ThemeDefinition } from './types'; 12 + 13 + type ColorScheme = 'light' | 'dark' | 'system'; 14 + const COLOR_SCHEMES = ['light', 'dark', 'system'] as const; 15 + const DEFAULT_COLOR_SCHEME = 'system'; 16 + const DEFAULT_SYSTEM_COLOR_SCHEME = 'light'; 17 + 18 + export function createTheme< 19 + C extends ThemeColors, 20 + T extends ThemeDefinition<C> = ThemeDefinition<C>, 21 + >(defaultTheme: T) { 22 + const themeStore = new KeyValueStore(); 23 + const keys = { theme: 'theme', scheme: 'scheme' }; 24 + 25 + const stringifiedDefaultTheme = JSON.stringify(defaultTheme); 26 + 27 + const definitions = { 28 + theme: { 29 + default: defaultTheme, 30 + validate: (_value: T) => true, // If it passes TypeScript, it's valid 31 + update: async (value: T) => 32 + await themeStore.put(keys.theme, JSON.stringify(value)), 33 + read: async () => 34 + JSON.parse( 35 + await themeStore.read(keys.theme, stringifiedDefaultTheme) 36 + ) as T, 37 + } satisfies Setting<T>, 38 + scheme: { 39 + default: DEFAULT_COLOR_SCHEME as ColorScheme, 40 + validate: (value: ColorScheme) => COLOR_SCHEMES.includes(value), 41 + update: async (value: ColorScheme) => 42 + await themeStore.put(keys.scheme, value), 43 + read: async () => 44 + (await themeStore.read( 45 + keys.scheme, 46 + DEFAULT_COLOR_SCHEME 47 + )) as ColorScheme, 48 + } satisfies Setting<ColorScheme>, 49 + } satisfies Settings; 50 + 51 + const { useSettings, settingsStore } = createSettings(definitions); 52 + 53 + function initTheme(theme: T, scheme: ColorScheme = DEFAULT_COLOR_SCHEME) { 54 + settingsStore.update({ theme, scheme }); 55 + } 56 + 57 + function useTheme() { 58 + const [settings, updateSettings, resetSettings] = useSettings('useTheme'); 59 + const systemColorScheme = useSystemColorScheme(); 60 + 61 + const currentScheme = 62 + settings.scheme === 'system' 63 + ? (systemColorScheme ?? DEFAULT_SYSTEM_COLOR_SCHEME) 64 + : settings.scheme; 65 + const colors = 66 + currentScheme === 'dark' ? settings.theme.dark : settings.theme.light; 67 + 68 + return { 69 + theme: { fonts: settings.theme.fonts, colors } as Theme<C>, 70 + setTheme: (theme: T) => updateSettings({ theme }), 71 + setScheme: (scheme: ColorScheme) => updateSettings({ scheme }), 72 + resetTheme: resetSettings, 73 + }; 74 + } 75 + 76 + return { initTheme, useTheme }; 77 + }
+11
src/theme/index.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + export type { Theme, ThemeColors, ThemeDefinition } from './types'; 8 + export { createTheme } from './createTheme'; 9 + 10 + export * from './fonts'; 11 + export * from './material';
+21
src/theme/types.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import type { ThemeFonts } from './fonts/types'; 8 + 9 + export interface ThemeColors extends Record<string, string | ThemeColors> {} 10 + 11 + export type Theme<C extends ThemeColors> = { 12 + fonts: ThemeFonts; 13 + colors: C; 14 + }; 15 + 16 + export type ThemeDefinition<C extends ThemeColors> = { 17 + name: string; 18 + fonts: ThemeFonts; 19 + dark: C; 20 + light: C; 21 + };
+10
src/utils/typing.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + /** 8 + * `Optional<Type, Union>` makes only the attributes in `Union` optional, as opposed to `Partial<Type>` which makes _all_ attributes of `Type` optional. 9 + */ 10 + export type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>; // Source: https://stackoverflow.com/a/61108377/14765128
+183
src/settings/__tests__/SettingStore.test.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { SettingsStore } from '../SettingsStore'; 8 + import type { Setting, Settings } from '../types'; 9 + 10 + describe('settings', () => { 11 + it('initialize to their defined default values', () => { 12 + const store = new SettingsStore(createTestDefinitions()); 13 + 14 + expect(store.getSnapshot()).toEqual({ 15 + dessert: 'cookies', 16 + price: 10, 17 + open: true, 18 + }); 19 + }); 20 + 21 + it('can update one at a time', async () => { 22 + const store = new SettingsStore(createTestDefinitions()); 23 + 24 + store.update({ dessert: 'pie' }); 25 + 26 + expect(store.getSnapshot()).toEqual({ 27 + dessert: 'pie', 28 + price: 10, 29 + open: true, 30 + }); 31 + }); 32 + 33 + it('can update multiple at once', async () => { 34 + const store = new SettingsStore(createTestDefinitions()); 35 + 36 + store.update({ dessert: 'cake', price: 20, open: false }); 37 + 38 + expect(store.getSnapshot()).toEqual({ 39 + dessert: 'cake', 40 + price: 20, 41 + open: false, 42 + }); 43 + }); 44 + 45 + it('rejects invalid updates', async () => { 46 + const store = new SettingsStore(createTestDefinitions()); 47 + 48 + store.update({ 49 + dessert: 'brussel sprouts', 50 + price: -5, 51 + open: 'yes' as unknown as boolean, 52 + }); 53 + 54 + expect(store.getSnapshot()).toEqual({ 55 + dessert: 'cookies', 56 + price: 10, 57 + open: true, 58 + }); 59 + }); 60 + 61 + it('rejects updates to unknown settings', async () => { 62 + const store = new SettingsStore(createTestDefinitions()); 63 + 64 + // @ts-expect-error Testing invalid key 65 + store.update({ unknown: 'value' }); 66 + 67 + expect(store.getSnapshot()).toEqual({ 68 + dessert: 'cookies', 69 + price: 10, 70 + open: true, 71 + }); 72 + }); 73 + 74 + it('can reset to default values', async () => { 75 + const store = new SettingsStore(createTestDefinitions()); 76 + 77 + store.update({ dessert: 'pie', price: 15, open: false }); 78 + expect(store.getSnapshot()).toEqual({ 79 + dessert: 'pie', 80 + price: 15, 81 + open: false, 82 + }); 83 + 84 + store.reset(); 85 + expect(store.getSnapshot()).toEqual({ 86 + dessert: 'cookies', 87 + price: 10, 88 + open: true, 89 + }); 90 + }); 91 + }); 92 + 93 + describe('subscribe', () => { 94 + it('supports listening for updates', async () => { 95 + const store = new SettingsStore(createTestDefinitions()); 96 + const listener = jest.fn(); 97 + 98 + const unsubscribe = store.subscribe(listener, 'test-listener'); 99 + store.update({ dessert: 'cake' }); 100 + 101 + expect(listener).toHaveBeenCalledTimes(1); 102 + expect(listener).toHaveBeenCalledWith(); 103 + 104 + unsubscribe(); 105 + store.update({ price: 20 }); 106 + 107 + expect(listener).toHaveBeenCalledTimes(1); // No additional calls after unsubscribe 108 + }); 109 + 110 + it('does not notify listeners on invalid updates', () => { 111 + const store = new SettingsStore(createTestDefinitions()); 112 + const listener = jest.fn(); 113 + 114 + store.subscribe(listener, 'test-listener'); 115 + store.update({ dessert: 'brussel sprouts' }); 116 + 117 + expect(listener).not.toHaveBeenCalled(); 118 + }); 119 + 120 + it('does not notify listeners when no values change', () => { 121 + const store = new SettingsStore(createTestDefinitions()); 122 + const listener = jest.fn(); 123 + 124 + store.subscribe(listener, 'test-listener'); 125 + store.update({ dessert: 'cookies' }); // same as default 126 + 127 + expect(listener).not.toHaveBeenCalled(); 128 + }); 129 + }); 130 + 131 + describe('snapshots', () => { 132 + test('stay stable across reads', () => { 133 + const store = new SettingsStore(createTestDefinitions()); 134 + 135 + const snapshot1 = store.getSnapshot(); 136 + const snapshot2 = store.getSnapshot(); 137 + 138 + expect(snapshot1).toBe(snapshot2); 139 + }); 140 + 141 + test('update after state change', () => { 142 + const store = new SettingsStore(createTestDefinitions()); 143 + 144 + const snapshot1 = store.getSnapshot(); 145 + store.update({ dessert: 'pie' }); 146 + const snapshot2 = store.getSnapshot(); 147 + 148 + expect(snapshot1).not.toBe(snapshot2); 149 + expect(snapshot2).toEqual({ dessert: 'pie', price: 10, open: true }); 150 + }); 151 + }); 152 + 153 + function createTestDefinitions() { 154 + let dessert = 'cookies'; 155 + let price = 10; 156 + let open = true; 157 + return { 158 + dessert: { 159 + default: 'cookies', 160 + validate: (value: string) => ['cookies', 'cake', 'pie'].includes(value), 161 + update: async (value: string) => { 162 + dessert = value; 163 + }, 164 + read: async () => dessert, 165 + } satisfies Setting<string>, 166 + price: { 167 + default: 10, 168 + validate: (value: number) => value >= 0, 169 + update: async (value: number) => { 170 + price = value; 171 + }, 172 + read: async () => price, 173 + } satisfies Setting<number>, 174 + open: { 175 + default: true as boolean, 176 + validate: (value: boolean) => typeof value === 'boolean', 177 + update: async (value: boolean) => { 178 + open = value; 179 + }, 180 + read: async () => open, 181 + } satisfies Setting<boolean>, 182 + } satisfies Settings; 183 + }
+70
src/settings/_utils/NamedSubscriberStore.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import structuredClone from '@ungap/structured-clone'; 8 + 9 + export class NamedSubscriberStore<State extends Record<string, any>> { 10 + state: State; 11 + validKeys: Set<keyof State>; 12 + listeners = new Map<string, Array<Function>>(); 13 + snapshot: State | null = null; 14 + 15 + constructor(defaultState: State) { 16 + this.state = defaultState; 17 + this.validKeys = new Set(Object.keys(defaultState)); 18 + } 19 + 20 + subscribe(callback: Function, name: string = 'unknown') { 21 + const namedListeners = this.listeners.get(name) ?? []; 22 + namedListeners.push(callback); 23 + this.listeners.set(name, namedListeners); 24 + 25 + const unsubscribe = () => { 26 + const listeners = this.listeners.get(name) ?? []; 27 + this.listeners.set( 28 + name, 29 + listeners.filter((l) => l !== callback) 30 + ); 31 + }; 32 + return unsubscribe; 33 + } 34 + 35 + update(updates: Partial<State>) { 36 + let changed = false; 37 + for (const key in updates) { 38 + if ( 39 + this.validKeys.has(key) && 40 + updates[key] !== undefined && 41 + updates[key] !== this.state[key] && 42 + this.isValidUpdate(key, updates[key]) 43 + ) { 44 + this.state[key] = updates[key]; 45 + this.onUpdate(key, updates[key]); 46 + changed = true; 47 + } 48 + } 49 + if (changed) { 50 + this.snapshot = null; // Invalidate snapshot to ensure fresh data on next getSnapshot 51 + this.listeners.forEach((batch) => batch.forEach((notify) => notify())); 52 + } 53 + } 54 + 55 + /** Assesses if the key can be assigned to the value */ 56 + protected isValidUpdate<K extends keyof State>( 57 + _key: K, 58 + _value: State[K] 59 + ): boolean { 60 + return true; 61 + } 62 + 63 + /** Allows child classes to trigger additional effects after successful updates (e.g. sync external stores) */ 64 + protected onUpdate<K extends keyof State>(_key: K, _value: State[K]) {} 65 + 66 + getSnapshot(): State { 67 + if (this.snapshot === null) this.snapshot = structuredClone(this.state); 68 + return this.snapshot; 69 + } 70 + }
+62
src/stores/__tests__/KeyValueStore.test.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import KeyValueStore, { type ExternalKeyValueStore } from '../KeyValueStore'; 8 + 9 + describe('default behavior', () => { 10 + it('stores and retrieves values', async () => { 11 + const store = new KeyValueStore(); 12 + 13 + await store.put('key', 'value'); 14 + await store.put('dessert', 'cookies'); 15 + 16 + expect(await store.read('key')).toBe('value'); 17 + expect(await store.read('dessert')).toBe('cookies'); 18 + }); 19 + 20 + it('returns default value if key not found', async () => { 21 + const store = new KeyValueStore(); 22 + 23 + expect(await store.read('cookies', 'default')).toBe('default'); 24 + }); 25 + 26 + it('returns empty string if key not found and no default value provided', async () => { 27 + const store = new KeyValueStore(); 28 + 29 + expect(await store.read('cookies')).toBe(''); 30 + }); 31 + }); 32 + 33 + describe('with custom external store', () => { 34 + class TestExternalStore implements ExternalKeyValueStore { 35 + async get(key: string): Promise<string | null> { 36 + return key === 'exists' ? 'stored value' : null; 37 + } 38 + async set(_key: string, _value: string): Promise<void> { 39 + /* no-op */ 40 + } 41 + } 42 + 43 + it('uses external store to read values', async () => { 44 + const store = new KeyValueStore(new TestExternalStore()); 45 + 46 + store.put('exists', 'new value'); // should be ignored by external store 47 + 48 + expect(await store.read('exists')).toBe('stored value'); 49 + }); 50 + 51 + it('returns a default value when external store returns null', async () => { 52 + const store = new KeyValueStore(new TestExternalStore()); 53 + 54 + expect(await store.read('nonexistent', 'default')).toBe('default'); 55 + }); 56 + 57 + it('returns an empty string when external store returns null and no default provided', async () => { 58 + const store = new KeyValueStore(new TestExternalStore()); 59 + 60 + expect(await store.read('nonexistent')).toBe(''); 61 + }); 62 + });
+101
src/theme/__tests__/useTheme.test.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import DefaultFonts from '../fonts/DefaultFonts'; 8 + import type { ThemeColors, ThemeDefinition } from '../types'; 9 + import { createTheme } from '../createTheme'; 10 + import { act, renderHook } from '@testing-library/react-native'; 11 + 12 + interface BasicColors extends ThemeColors { 13 + primary: string; 14 + background: string; 15 + } 16 + 17 + const basicTheme: ThemeDefinition<BasicColors> = { 18 + name: 'basic', 19 + fonts: DefaultFonts, 20 + light: { 21 + primary: 'basic-light-primary', 22 + background: 'basic-light-background', 23 + } satisfies BasicColors, 24 + dark: { 25 + primary: 'basic-dark-primary', 26 + background: 'basic-dark-background', 27 + } satisfies BasicColors, 28 + }; 29 + 30 + const altTheme: ThemeDefinition<BasicColors> = { 31 + name: 'basic-alt', 32 + fonts: DefaultFonts, 33 + light: { 34 + primary: 'alt-light-primary', 35 + background: 'alt-light-background', 36 + }, 37 + dark: { 38 + primary: 'alt-dark-primary', 39 + background: 'alt-dark-background', 40 + }, 41 + }; 42 + 43 + describe('useTheme', () => { 44 + it('provides the default theme', () => { 45 + const { useTheme } = createTheme<BasicColors>(basicTheme); 46 + 47 + const { result } = renderHook(() => useTheme()); 48 + const { theme } = result.current; 49 + 50 + expect(theme.colors.primary).toBe(basicTheme.light.primary); 51 + expect(theme.colors.background).toBe(basicTheme.light.background); 52 + }); 53 + 54 + it('can initialize a default theme/scheme', () => { 55 + const { initTheme, useTheme } = createTheme<BasicColors>(altTheme); 56 + initTheme(altTheme, 'dark'); 57 + 58 + const { result } = renderHook(() => useTheme()); 59 + const { theme } = result.current; 60 + 61 + expect(theme.colors.primary).toBe(altTheme.dark.primary); 62 + expect(theme.colors.background).toBe(altTheme.dark.background); 63 + }); 64 + 65 + it('reacts to theme changes', () => { 66 + const { useTheme } = createTheme<BasicColors>(basicTheme); 67 + 68 + const { result } = renderHook(() => useTheme()); 69 + const { setTheme } = result.current; 70 + act(() => setTheme(altTheme)); 71 + const { theme } = result.current; 72 + 73 + expect(theme.colors.primary).toBe(altTheme.light.primary); 74 + expect(theme.colors.background).toBe(altTheme.light.background); 75 + }); 76 + 77 + it('reacts to color scheme changes', () => { 78 + const { useTheme } = createTheme<BasicColors>(basicTheme); 79 + 80 + const { result } = renderHook(() => useTheme()); 81 + const { setScheme } = result.current; 82 + act(() => setScheme('dark')); 83 + const { theme } = result.current; 84 + 85 + expect(theme.colors.primary).toBe(basicTheme.dark.primary); 86 + expect(theme.colors.background).toBe(basicTheme.dark.background); 87 + }); 88 + 89 + it('can reset to default theme and scheme', () => { 90 + const { initTheme, useTheme } = createTheme<BasicColors>(basicTheme); 91 + initTheme(altTheme, 'dark'); 92 + 93 + const { result } = renderHook(() => useTheme()); 94 + const { resetTheme } = result.current; 95 + act(() => resetTheme()); 96 + const { theme } = result.current; 97 + 98 + expect(theme.colors.primary).toBe(basicTheme.light.primary); 99 + expect(theme.colors.background).toBe(basicTheme.light.background); 100 + }); 101 + });
+182
src/theme/fonts/DefaultFonts.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // Adapted from React Native Paper. Used under the terms of the MIT license. 8 + // https://github.com/callstack/react-native-paper/blob/ff0df5454eb13d6e8e2d8f1c87c0bca8bb3635f0/src/styles/themes/v3/tokens.tsx 9 + 10 + import { Platform } from 'react-native'; 11 + import type { ThemeFonts } from './types'; 12 + 13 + const DefaultFontFamily = Platform.select({ 14 + web: 'Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif', 15 + ios: 'System', 16 + default: 'sans-serif', 17 + }); 18 + 19 + const DefaultFonts: ThemeFonts = { 20 + display: { 21 + small: { 22 + fontSize: 36, 23 + lineHeight: 44, 24 + fontWeight: '400', 25 + fontStyle: 'normal', 26 + fontFamily: DefaultFontFamily, 27 + letterSpacing: 0, 28 + }, 29 + medium: { 30 + fontSize: 45, 31 + lineHeight: 52, 32 + fontWeight: '400', 33 + fontStyle: 'normal', 34 + fontFamily: DefaultFontFamily, 35 + letterSpacing: 0, 36 + }, 37 + large: { 38 + fontSize: 57, 39 + lineHeight: 64, 40 + fontWeight: '400', 41 + fontStyle: 'normal', 42 + fontFamily: DefaultFontFamily, 43 + letterSpacing: 0, 44 + }, 45 + }, 46 + headline: { 47 + small: { 48 + fontSize: 24, 49 + lineHeight: 32, 50 + fontWeight: '400', 51 + fontStyle: 'normal', 52 + fontFamily: DefaultFontFamily, 53 + letterSpacing: 0, 54 + }, 55 + medium: { 56 + fontSize: 28, 57 + lineHeight: 36, 58 + fontWeight: '400', 59 + fontStyle: 'normal', 60 + fontFamily: DefaultFontFamily, 61 + letterSpacing: 0, 62 + }, 63 + large: { 64 + fontSize: 32, 65 + lineHeight: 40, 66 + fontWeight: '400', 67 + fontStyle: 'normal', 68 + fontFamily: DefaultFontFamily, 69 + letterSpacing: 0, 70 + }, 71 + }, 72 + title: { 73 + small: { 74 + fontSize: 14, 75 + lineHeight: 20, 76 + fontWeight: '500', 77 + fontStyle: 'normal', 78 + fontFamily: DefaultFontFamily, 79 + letterSpacing: 0.1, 80 + }, 81 + medium: { 82 + fontSize: 16, 83 + lineHeight: 24, 84 + fontWeight: '500', 85 + fontStyle: 'normal', 86 + fontFamily: DefaultFontFamily, 87 + letterSpacing: 0.15, 88 + }, 89 + large: { 90 + fontSize: 22, 91 + lineHeight: 28, 92 + fontWeight: '400', 93 + fontStyle: 'normal', 94 + fontFamily: DefaultFontFamily, 95 + letterSpacing: 0, 96 + }, 97 + }, 98 + label: { 99 + small: { 100 + fontSize: 11, 101 + lineHeight: 16, 102 + fontWeight: '500', 103 + fontStyle: 'normal', 104 + fontFamily: DefaultFontFamily, 105 + letterSpacing: 0.5, 106 + }, 107 + medium: { 108 + fontSize: 12, 109 + lineHeight: 16, 110 + fontWeight: '500', 111 + fontStyle: 'normal', 112 + fontFamily: DefaultFontFamily, 113 + letterSpacing: 0.5, 114 + }, 115 + large: { 116 + fontSize: 14, 117 + lineHeight: 20, 118 + fontWeight: '500', 119 + fontStyle: 'normal', 120 + fontFamily: DefaultFontFamily, 121 + letterSpacing: 0.1, 122 + }, 123 + }, 124 + body: { 125 + small: { 126 + fontSize: 12, 127 + lineHeight: 16, 128 + fontWeight: '400', 129 + fontStyle: 'normal', 130 + fontFamily: DefaultFontFamily, 131 + letterSpacing: 0.4, 132 + }, 133 + medium: { 134 + fontSize: 14, 135 + lineHeight: 20, 136 + fontWeight: '400', 137 + fontStyle: 'normal', 138 + fontFamily: DefaultFontFamily, 139 + letterSpacing: 0.25, 140 + }, 141 + large: { 142 + fontSize: 16, 143 + lineHeight: 24, 144 + fontWeight: '400', 145 + fontStyle: 'normal', 146 + fontFamily: DefaultFontFamily, 147 + letterSpacing: 0.15, 148 + }, 149 + }, 150 + }; 151 + 152 + export function withFontFamily(fontFamily: string): ThemeFonts { 153 + return { 154 + display: { 155 + small: { ...DefaultFonts.display.small, fontFamily }, 156 + medium: { ...DefaultFonts.display.medium, fontFamily }, 157 + large: { ...DefaultFonts.display.large, fontFamily }, 158 + }, 159 + headline: { 160 + small: { ...DefaultFonts.headline.small, fontFamily }, 161 + medium: { ...DefaultFonts.headline.medium, fontFamily }, 162 + large: { ...DefaultFonts.headline.large, fontFamily }, 163 + }, 164 + title: { 165 + small: { ...DefaultFonts.title.small, fontFamily }, 166 + medium: { ...DefaultFonts.title.medium, fontFamily }, 167 + large: { ...DefaultFonts.title.large, fontFamily }, 168 + }, 169 + label: { 170 + small: { ...DefaultFonts.label.small, fontFamily }, 171 + medium: { ...DefaultFonts.label.medium, fontFamily }, 172 + large: { ...DefaultFonts.label.large, fontFamily }, 173 + }, 174 + body: { 175 + small: { ...DefaultFonts.body.small, fontFamily }, 176 + medium: { ...DefaultFonts.body.medium, fontFamily }, 177 + large: { ...DefaultFonts.body.large, fontFamily }, 178 + }, 179 + }; 180 + } 181 + 182 + export default DefaultFonts;
+8
src/theme/fonts/index.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + export type { ThemeFonts } from './types'; 8 + export { default as DefaultFonts, withFontFamily } from './DefaultFonts';
+45
src/theme/fonts/types.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // Modified from React Native Paper. Used under the terms of the MIT license. 8 + // https://github.com/callstack/react-native-paper/blob/ff0df5454eb13d6e8e2d8f1c87c0bca8bb3635f0/src/types.tsx# 9 + 10 + export type ThemeFonts = { 11 + display: ThemeFont; 12 + headline: ThemeFont; 13 + title: ThemeFont; 14 + label: ThemeFont; 15 + body: ThemeFont; 16 + }; 17 + 18 + //////////////////////////////////////////////////////////// 19 + // Private types below here. 20 + 21 + type ThemeFont = { 22 + small: Font; 23 + medium: Font; 24 + large: Font; 25 + }; 26 + 27 + type Font = { 28 + fontFamily: string; 29 + fontWeight: 30 + | 'normal' 31 + | 'bold' 32 + | '100' 33 + | '200' 34 + | '300' 35 + | '400' 36 + | '500' 37 + | '600' 38 + | '700' 39 + | '800' 40 + | '900'; 41 + fontStyle: 'normal' | 'italic'; 42 + fontSize: number; 43 + letterSpacing: number; 44 + lineHeight: number; 45 + };
+107
src/theme/material/MaterialBlue.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightBlue: MaterialThemeColors = { 15 + primary: 'rgb(52, 61, 255)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(224, 224, 255)', 18 + onPrimaryContainer: 'rgb(0, 0, 110)', 19 + secondary: 'rgb(92, 93, 114)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(225, 224, 249)', 22 + onSecondaryContainer: 'rgb(25, 26, 44)', 23 + tertiary: 'rgb(120, 83, 107)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(255, 216, 238)', 26 + onTertiaryContainer: 'rgb(46, 17, 38)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(255, 251, 255)', 32 + onBackground: 'rgb(27, 27, 31)', 33 + surface: 'rgb(255, 251, 255)', 34 + onSurface: 'rgb(27, 27, 31)', 35 + surfaceVariant: 'rgb(228, 225, 236)', 36 + onSurfaceVariant: 'rgb(70, 70, 79)', 37 + outline: 'rgb(119, 118, 128)', 38 + outlineVariant: 'rgb(199, 197, 208)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(48, 48, 52)', 42 + inverseOnSurface: 'rgb(243, 239, 244)', 43 + inversePrimary: 'rgb(190, 194, 255)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(245, 242, 255)', 47 + level2: 'rgb(239, 236, 255)', 48 + level3: 'rgb(233, 230, 255)', 49 + level4: 'rgb(231, 228, 255)', 50 + level5: 'rgb(227, 224, 255)', 51 + }, 52 + surfaceDisabled: 'rgba(27, 27, 31, 0.12)', 53 + onSurfaceDisabled: 'rgba(27, 27, 31, 0.38)', 54 + backdrop: 'rgba(48, 48, 56, 0.4)', 55 + }; 56 + 57 + const DarkBlue: MaterialThemeColors = { 58 + primary: 'rgb(190, 194, 255)', 59 + onPrimary: 'rgb(0, 1, 172)', 60 + primaryContainer: 'rgb(0, 0, 239)', 61 + onPrimaryContainer: 'rgb(224, 224, 255)', 62 + secondary: 'rgb(197, 196, 221)', 63 + onSecondary: 'rgb(46, 47, 66)', 64 + secondaryContainer: 'rgb(68, 69, 89)', 65 + onSecondaryContainer: 'rgb(225, 224, 249)', 66 + tertiary: 'rgb(232, 185, 213)', 67 + onTertiary: 'rgb(70, 38, 59)', 68 + tertiaryContainer: 'rgb(94, 60, 82)', 69 + onTertiaryContainer: 'rgb(255, 216, 238)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(27, 27, 31)', 75 + onBackground: 'rgb(229, 225, 230)', 76 + surface: 'rgb(27, 27, 31)', 77 + onSurface: 'rgb(229, 225, 230)', 78 + surfaceVariant: 'rgb(70, 70, 79)', 79 + onSurfaceVariant: 'rgb(199, 197, 208)', 80 + outline: 'rgb(145, 144, 154)', 81 + outlineVariant: 'rgb(70, 70, 79)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(229, 225, 230)', 85 + inverseOnSurface: 'rgb(48, 48, 52)', 86 + inversePrimary: 'rgb(52, 61, 255)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(35, 35, 42)', 90 + level2: 'rgb(40, 40, 49)', 91 + level3: 'rgb(45, 45, 56)', 92 + level4: 'rgb(47, 47, 58)', 93 + level5: 'rgb(50, 50, 62)', 94 + }, 95 + surfaceDisabled: 'rgba(229, 225, 230, 0.12)', 96 + onSurfaceDisabled: 'rgba(229, 225, 230, 0.38)', 97 + backdrop: 'rgba(48, 48, 56, 0.4)', 98 + }; 99 + 100 + const MaterialBlue: MaterialThemeDefinition = { 101 + name: 'material-blue', 102 + fonts: DefaultFonts, 103 + light: LightBlue, 104 + dark: DarkBlue, 105 + }; 106 + 107 + export default MaterialBlue;
+288
src/theme/material/MaterialColors.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // Taken from React Native Paper. Used under the terms of the MIT license. 8 + // https://github.com/callstack/react-native-paper/blob/ff0df5454eb13d6e8e2d8f1c87c0bca8bb3635f0/src/styles/themes/v2/colors.tsx 9 + 10 + // It appears that React Native Paper copied the colors from https://materialui.co/colors 11 + 12 + export const transparent = 'rgba(255, 255, 255, 0)'; 13 + 14 + export const red50 = '#ffebee'; 15 + export const red100 = '#ffcdd2'; 16 + export const red200 = '#ef9a9a'; 17 + export const red300 = '#e57373'; 18 + export const red400 = '#ef5350'; 19 + export const red500 = '#f44336'; 20 + export const red600 = '#e53935'; 21 + export const red700 = '#d32f2f'; 22 + export const red800 = '#c62828'; 23 + export const red900 = '#b71c1c'; 24 + export const redA100 = '#ff8a80'; 25 + export const redA200 = '#ff5252'; 26 + export const redA400 = '#ff1744'; 27 + export const redA700 = '#d50000'; 28 + 29 + export const pink50 = '#fce4ec'; 30 + export const pink100 = '#f8bbd0'; 31 + export const pink200 = '#f48fb1'; 32 + export const pink300 = '#f06292'; 33 + export const pink400 = '#ec407a'; 34 + export const pink500 = '#e91e63'; 35 + export const pink600 = '#d81b60'; 36 + export const pink700 = '#c2185b'; 37 + export const pink800 = '#ad1457'; 38 + export const pink900 = '#880e4f'; 39 + export const pinkA100 = '#ff80ab'; 40 + export const pinkA200 = '#ff4081'; 41 + export const pinkA400 = '#f50057'; 42 + export const pinkA700 = '#c51162'; 43 + 44 + export const purple50 = '#f3e5f5'; 45 + export const purple100 = '#e1bee7'; 46 + export const purple200 = '#ce93d8'; 47 + export const purple300 = '#ba68c8'; 48 + export const purple400 = '#ab47bc'; 49 + export const purple500 = '#9c27b0'; 50 + export const purple600 = '#8e24aa'; 51 + export const purple700 = '#7b1fa2'; 52 + export const purple800 = '#6a1b9a'; 53 + export const purple900 = '#4a148c'; 54 + export const purpleA100 = '#ea80fc'; 55 + export const purpleA200 = '#e040fb'; 56 + export const purpleA400 = '#d500f9'; 57 + export const purpleA700 = '#aa00ff'; 58 + 59 + export const deepPurple50 = '#ede7f6'; 60 + export const deepPurple100 = '#d1c4e9'; 61 + export const deepPurple200 = '#b39ddb'; 62 + export const deepPurple300 = '#9575cd'; 63 + export const deepPurple400 = '#7e57c2'; 64 + export const deepPurple500 = '#673ab7'; 65 + export const deepPurple600 = '#5e35b1'; 66 + export const deepPurple700 = '#512da8'; 67 + export const deepPurple800 = '#4527a0'; 68 + export const deepPurple900 = '#311b92'; 69 + export const deepPurpleA100 = '#b388ff'; 70 + export const deepPurpleA200 = '#7c4dff'; 71 + export const deepPurpleA400 = '#651fff'; 72 + export const deepPurpleA700 = '#6200ea'; 73 + 74 + export const indigo50 = '#e8eaf6'; 75 + export const indigo100 = '#c5cae9'; 76 + export const indigo200 = '#9fa8da'; 77 + export const indigo300 = '#7986cb'; 78 + export const indigo400 = '#5c6bc0'; 79 + export const indigo500 = '#3f51b5'; 80 + export const indigo600 = '#3949ab'; 81 + export const indigo700 = '#303f9f'; 82 + export const indigo800 = '#283593'; 83 + export const indigo900 = '#1a237e'; 84 + export const indigoA100 = '#8c9eff'; 85 + export const indigoA200 = '#536dfe'; 86 + export const indigoA400 = '#3d5afe'; 87 + export const indigoA700 = '#304ffe'; 88 + 89 + export const blue50 = '#e3f2fd'; 90 + export const blue100 = '#bbdefb'; 91 + export const blue200 = '#90caf9'; 92 + export const blue300 = '#64b5f6'; 93 + export const blue400 = '#42a5f5'; 94 + export const blue500 = '#2196f3'; 95 + export const blue600 = '#1e88e5'; 96 + export const blue700 = '#1976d2'; 97 + export const blue800 = '#1565c0'; 98 + export const blue900 = '#0d47a1'; 99 + export const blueA100 = '#82b1ff'; 100 + export const blueA200 = '#448aff'; 101 + export const blueA400 = '#2979ff'; 102 + export const blueA700 = '#2962ff'; 103 + 104 + export const lightBlue50 = '#e1f5fe'; 105 + export const lightBlue100 = '#b3e5fc'; 106 + export const lightBlue200 = '#81d4fa'; 107 + export const lightBlue300 = '#4fc3f7'; 108 + export const lightBlue400 = '#29b6f6'; 109 + export const lightBlue500 = '#03a9f4'; 110 + export const lightBlue600 = '#039be5'; 111 + export const lightBlue700 = '#0288d1'; 112 + export const lightBlue800 = '#0277bd'; 113 + export const lightBlue900 = '#01579b'; 114 + export const lightBlueA100 = '#80d8ff'; 115 + export const lightBlueA200 = '#40c4ff'; 116 + export const lightBlueA400 = '#00b0ff'; 117 + export const lightBlueA700 = '#0091ea'; 118 + 119 + export const cyan50 = '#e0f7fa'; 120 + export const cyan100 = '#b2ebf2'; 121 + export const cyan200 = '#80deea'; 122 + export const cyan300 = '#4dd0e1'; 123 + export const cyan400 = '#26c6da'; 124 + export const cyan500 = '#00bcd4'; 125 + export const cyan600 = '#00acc1'; 126 + export const cyan700 = '#0097a7'; 127 + export const cyan800 = '#00838f'; 128 + export const cyan900 = '#006064'; 129 + export const cyanA100 = '#84ffff'; 130 + export const cyanA200 = '#18ffff'; 131 + export const cyanA400 = '#00e5ff'; 132 + export const cyanA700 = '#00b8d4'; 133 + 134 + export const teal50 = '#e0f2f1'; 135 + export const teal100 = '#b2dfdb'; 136 + export const teal200 = '#80cbc4'; 137 + export const teal300 = '#4db6ac'; 138 + export const teal400 = '#26a69a'; 139 + export const teal500 = '#009688'; 140 + export const teal600 = '#00897b'; 141 + export const teal700 = '#00796b'; 142 + export const teal800 = '#00695c'; 143 + export const teal900 = '#004d40'; 144 + export const tealA100 = '#a7ffeb'; 145 + export const tealA200 = '#64ffda'; 146 + export const tealA400 = '#1de9b6'; 147 + export const tealA700 = '#00bfa5'; 148 + 149 + export const green50 = '#e8f5e9'; 150 + export const green100 = '#c8e6c9'; 151 + export const green200 = '#a5d6a7'; 152 + export const green300 = '#81c784'; 153 + export const green400 = '#66bb6a'; 154 + export const green500 = '#4caf50'; 155 + export const green600 = '#43a047'; 156 + export const green700 = '#388e3c'; 157 + export const green800 = '#2e7d32'; 158 + export const green900 = '#1b5e20'; 159 + export const greenA100 = '#b9f6ca'; 160 + export const greenA200 = '#69f0ae'; 161 + export const greenA400 = '#00e676'; 162 + export const greenA700 = '#00c853'; 163 + 164 + export const lightGreen50 = '#f1f8e9'; 165 + export const lightGreen100 = '#dcedc8'; 166 + export const lightGreen200 = '#c5e1a5'; 167 + export const lightGreen300 = '#aed581'; 168 + export const lightGreen400 = '#9ccc65'; 169 + export const lightGreen500 = '#8bc34a'; 170 + export const lightGreen600 = '#7cb342'; 171 + export const lightGreen700 = '#689f38'; 172 + export const lightGreen800 = '#558b2f'; 173 + export const lightGreen900 = '#33691e'; 174 + export const lightGreenA100 = '#ccff90'; 175 + export const lightGreenA200 = '#b2ff59'; 176 + export const lightGreenA400 = '#76ff03'; 177 + export const lightGreenA700 = '#64dd17'; 178 + 179 + export const lime50 = '#f9fbe7'; 180 + export const lime100 = '#f0f4c3'; 181 + export const lime200 = '#e6ee9c'; 182 + export const lime300 = '#dce775'; 183 + export const lime400 = '#d4e157'; 184 + export const lime500 = '#cddc39'; 185 + export const lime600 = '#c0ca33'; 186 + export const lime700 = '#afb42b'; 187 + export const lime800 = '#9e9d24'; 188 + export const lime900 = '#827717'; 189 + export const limeA100 = '#f4ff81'; 190 + export const limeA200 = '#eeff41'; 191 + export const limeA400 = '#c6ff00'; 192 + export const limeA700 = '#aeea00'; 193 + 194 + export const yellow50 = '#fffde7'; 195 + export const yellow100 = '#fff9c4'; 196 + export const yellow200 = '#fff59d'; 197 + export const yellow300 = '#fff176'; 198 + export const yellow400 = '#ffee58'; 199 + export const yellow500 = '#ffeb3b'; 200 + export const yellow600 = '#fdd835'; 201 + export const yellow700 = '#fbc02d'; 202 + export const yellow800 = '#f9a825'; 203 + export const yellow900 = '#f57f17'; 204 + export const yellowA100 = '#ffff8d'; 205 + export const yellowA200 = '#ffff00'; 206 + export const yellowA400 = '#ffea00'; 207 + export const yellowA700 = '#ffd600'; 208 + 209 + export const amber50 = '#fff8e1'; 210 + export const amber100 = '#ffecb3'; 211 + export const amber200 = '#ffe082'; 212 + export const amber300 = '#ffd54f'; 213 + export const amber400 = '#ffca28'; 214 + export const amber500 = '#ffc107'; 215 + export const amber600 = '#ffb300'; 216 + export const amber700 = '#ffa000'; 217 + export const amber800 = '#ff8f00'; 218 + export const amber900 = '#ff6f00'; 219 + export const amberA100 = '#ffe57f'; 220 + export const amberA200 = '#ffd740'; 221 + export const amberA400 = '#ffc400'; 222 + export const amberA700 = '#ffab00'; 223 + 224 + export const orange50 = '#fff3e0'; 225 + export const orange100 = '#ffe0b2'; 226 + export const orange200 = '#ffcc80'; 227 + export const orange300 = '#ffb74d'; 228 + export const orange400 = '#ffa726'; 229 + export const orange500 = '#ff9800'; 230 + export const orange600 = '#fb8c00'; 231 + export const orange700 = '#f57c00'; 232 + export const orange800 = '#ef6c00'; 233 + export const orange900 = '#e65100'; 234 + export const orangeA100 = '#ffd180'; 235 + export const orangeA200 = '#ffab40'; 236 + export const orangeA400 = '#ff9100'; 237 + export const orangeA700 = '#ff6d00'; 238 + 239 + export const deepOrange50 = '#fbe9e7'; 240 + export const deepOrange100 = '#ffccbc'; 241 + export const deepOrange200 = '#ffab91'; 242 + export const deepOrange300 = '#ff8a65'; 243 + export const deepOrange400 = '#ff7043'; 244 + export const deepOrange500 = '#ff5722'; 245 + export const deepOrange600 = '#f4511e'; 246 + export const deepOrange700 = '#e64a19'; 247 + export const deepOrange800 = '#d84315'; 248 + export const deepOrange900 = '#bf360c'; 249 + export const deepOrangeA100 = '#ff9e80'; 250 + export const deepOrangeA200 = '#ff6e40'; 251 + export const deepOrangeA400 = '#ff3d00'; 252 + export const deepOrangeA700 = '#dd2c00'; 253 + 254 + export const brown50 = '#efebe9'; 255 + export const brown100 = '#d7ccc8'; 256 + export const brown200 = '#bcaaa4'; 257 + export const brown300 = '#a1887f'; 258 + export const brown400 = '#8d6e63'; 259 + export const brown500 = '#795548'; 260 + export const brown600 = '#6d4c41'; 261 + export const brown700 = '#5d4037'; 262 + export const brown800 = '#4e342e'; 263 + export const brown900 = '#3e2723'; 264 + 265 + export const blueGrey50 = '#eceff1'; 266 + export const blueGrey100 = '#cfd8dc'; 267 + export const blueGrey200 = '#b0bec5'; 268 + export const blueGrey300 = '#90a4ae'; 269 + export const blueGrey400 = '#78909c'; 270 + export const blueGrey500 = '#607d8b'; 271 + export const blueGrey600 = '#546e7a'; 272 + export const blueGrey700 = '#455a64'; 273 + export const blueGrey800 = '#37474f'; 274 + export const blueGrey900 = '#263238'; 275 + 276 + export const grey50 = '#fafafa'; 277 + export const grey100 = '#f5f5f5'; 278 + export const grey200 = '#eeeeee'; 279 + export const grey300 = '#e0e0e0'; 280 + export const grey400 = '#bdbdbd'; 281 + export const grey500 = '#9e9e9e'; 282 + export const grey600 = '#757575'; 283 + export const grey700 = '#616161'; 284 + export const grey800 = '#424242'; 285 + export const grey900 = '#212121'; 286 + 287 + export const black = '#000000'; 288 + export const white = '#ffffff';
+107
src/theme/material/MaterialCyan.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightCyan: MaterialThemeColors = { 15 + primary: 'rgb(0, 106, 106)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(0, 251, 251)', 18 + onPrimaryContainer: 'rgb(0, 32, 32)', 19 + secondary: 'rgb(74, 99, 99)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(204, 232, 231)', 22 + onSecondaryContainer: 'rgb(5, 31, 31)', 23 + tertiary: 'rgb(75, 96, 124)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(211, 228, 255)', 26 + onTertiaryContainer: 'rgb(4, 28, 53)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(250, 253, 252)', 32 + onBackground: 'rgb(25, 28, 28)', 33 + surface: 'rgb(250, 253, 252)', 34 + onSurface: 'rgb(25, 28, 28)', 35 + surfaceVariant: 'rgb(218, 229, 228)', 36 + onSurfaceVariant: 'rgb(63, 73, 72)', 37 + outline: 'rgb(111, 121, 121)', 38 + outlineVariant: 'rgb(190, 201, 200)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(45, 49, 49)', 42 + inverseOnSurface: 'rgb(239, 241, 240)', 43 + inversePrimary: 'rgb(0, 221, 221)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(238, 246, 245)', 47 + level2: 'rgb(230, 241, 240)', 48 + level3: 'rgb(223, 237, 236)', 49 + level4: 'rgb(220, 235, 235)', 50 + level5: 'rgb(215, 232, 232)', 51 + }, 52 + surfaceDisabled: 'rgba(25, 28, 28, 0.12)', 53 + onSurfaceDisabled: 'rgba(25, 28, 28, 0.38)', 54 + backdrop: 'rgba(41, 50, 50, 0.4)', 55 + }; 56 + 57 + const DarkCyan: MaterialThemeColors = { 58 + primary: 'rgb(0, 221, 221)', 59 + onPrimary: 'rgb(0, 55, 55)', 60 + primaryContainer: 'rgb(0, 79, 79)', 61 + onPrimaryContainer: 'rgb(0, 251, 251)', 62 + secondary: 'rgb(176, 204, 203)', 63 + onSecondary: 'rgb(27, 53, 52)', 64 + secondaryContainer: 'rgb(50, 75, 75)', 65 + onSecondaryContainer: 'rgb(204, 232, 231)', 66 + tertiary: 'rgb(179, 200, 232)', 67 + onTertiary: 'rgb(28, 49, 75)', 68 + tertiaryContainer: 'rgb(51, 72, 99)', 69 + onTertiaryContainer: 'rgb(211, 228, 255)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(25, 28, 28)', 75 + onBackground: 'rgb(224, 227, 226)', 76 + surface: 'rgb(25, 28, 28)', 77 + onSurface: 'rgb(224, 227, 226)', 78 + surfaceVariant: 'rgb(63, 73, 72)', 79 + onSurfaceVariant: 'rgb(190, 201, 200)', 80 + outline: 'rgb(136, 147, 146)', 81 + outlineVariant: 'rgb(63, 73, 72)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(224, 227, 226)', 85 + inverseOnSurface: 'rgb(45, 49, 49)', 86 + inversePrimary: 'rgb(0, 106, 106)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(24, 38, 38)', 90 + level2: 'rgb(23, 43, 43)', 91 + level3: 'rgb(22, 49, 49)', 92 + level4: 'rgb(22, 51, 51)', 93 + level5: 'rgb(22, 55, 55)', 94 + }, 95 + surfaceDisabled: 'rgba(224, 227, 226, 0.12)', 96 + onSurfaceDisabled: 'rgba(224, 227, 226, 0.38)', 97 + backdrop: 'rgba(41, 50, 50, 0.4)', 98 + }; 99 + 100 + const MaterialCyan: MaterialThemeDefinition = { 101 + name: 'material-cyan', 102 + fonts: DefaultFonts, 103 + light: LightCyan, 104 + dark: DarkCyan, 105 + }; 106 + 107 + export default MaterialCyan;
+107
src/theme/material/MaterialGreen.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightGreen: MaterialThemeColors = { 15 + primary: 'rgb(0, 110, 0)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(141, 251, 119)', 18 + onPrimaryContainer: 'rgb(0, 34, 0)', 19 + secondary: 'rgb(84, 99, 77)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(215, 232, 205)', 22 + onSecondaryContainer: 'rgb(18, 31, 14)', 23 + tertiary: 'rgb(56, 101, 104)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(188, 235, 238)', 26 + onTertiaryContainer: 'rgb(0, 32, 34)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(252, 253, 246)', 32 + onBackground: 'rgb(26, 28, 24)', 33 + surface: 'rgb(252, 253, 246)', 34 + onSurface: 'rgb(26, 28, 24)', 35 + surfaceVariant: 'rgb(223, 228, 215)', 36 + onSurfaceVariant: 'rgb(67, 72, 63)', 37 + outline: 'rgb(115, 121, 110)', 38 + outlineVariant: 'rgb(195, 200, 188)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(47, 49, 45)', 42 + inverseOnSurface: 'rgb(241, 241, 235)', 43 + inversePrimary: 'rgb(114, 222, 94)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(239, 246, 234)', 47 + level2: 'rgb(232, 242, 226)', 48 + level3: 'rgb(224, 237, 219)', 49 + level4: 'rgb(222, 236, 217)', 50 + level5: 'rgb(217, 233, 212)', 51 + }, 52 + surfaceDisabled: 'rgba(26, 28, 24, 0.12)', 53 + onSurfaceDisabled: 'rgba(26, 28, 24, 0.38)', 54 + backdrop: 'rgba(44, 50, 41, 0.4)', 55 + }; 56 + 57 + const DarkGreen: MaterialThemeColors = { 58 + primary: 'rgb(114, 222, 94)', 59 + onPrimary: 'rgb(0, 58, 0)', 60 + primaryContainer: 'rgb(0, 83, 0)', 61 + onPrimaryContainer: 'rgb(141, 251, 119)', 62 + secondary: 'rgb(187, 203, 178)', 63 + onSecondary: 'rgb(38, 52, 34)', 64 + secondaryContainer: 'rgb(60, 75, 55)', 65 + onSecondaryContainer: 'rgb(215, 232, 205)', 66 + tertiary: 'rgb(160, 207, 210)', 67 + onTertiary: 'rgb(0, 55, 57)', 68 + tertiaryContainer: 'rgb(30, 77, 80)', 69 + onTertiaryContainer: 'rgb(188, 235, 238)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(26, 28, 24)', 75 + onBackground: 'rgb(226, 227, 220)', 76 + surface: 'rgb(26, 28, 24)', 77 + onSurface: 'rgb(226, 227, 220)', 78 + surfaceVariant: 'rgb(67, 72, 63)', 79 + onSurfaceVariant: 'rgb(195, 200, 188)', 80 + outline: 'rgb(141, 147, 135)', 81 + outlineVariant: 'rgb(67, 72, 63)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(226, 227, 220)', 85 + inverseOnSurface: 'rgb(47, 49, 45)', 86 + inversePrimary: 'rgb(0, 110, 0)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(30, 38, 28)', 90 + level2: 'rgb(33, 44, 30)', 91 + level3: 'rgb(36, 49, 32)', 92 + level4: 'rgb(37, 51, 32)', 93 + level5: 'rgb(38, 55, 34)', 94 + }, 95 + surfaceDisabled: 'rgba(226, 227, 220, 0.12)', 96 + onSurfaceDisabled: 'rgba(226, 227, 220, 0.38)', 97 + backdrop: 'rgba(44, 50, 41, 0.4)', 98 + }; 99 + 100 + const MaterialGreen: MaterialThemeDefinition = { 101 + name: 'material-green', 102 + fonts: DefaultFonts, 103 + light: LightGreen, 104 + dark: DarkGreen, 105 + }; 106 + 107 + export default MaterialGreen;
+107
src/theme/material/MaterialOrange.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightOrange: MaterialThemeColors = { 15 + primary: 'rgb(133, 84, 0)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(255, 221, 183)', 18 + onPrimaryContainer: 'rgb(42, 23, 0)', 19 + secondary: 'rgb(112, 91, 65)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(252, 222, 188)', 22 + onSecondaryContainer: 'rgb(40, 24, 5)', 23 + tertiary: 'rgb(83, 100, 62)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(214, 233, 185)', 26 + onTertiaryContainer: 'rgb(18, 31, 3)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(255, 251, 255)', 32 + onBackground: 'rgb(31, 27, 22)', 33 + surface: 'rgb(255, 251, 255)', 34 + onSurface: 'rgb(31, 27, 22)', 35 + surfaceVariant: 'rgb(240, 224, 208)', 36 + onSurfaceVariant: 'rgb(80, 69, 57)', 37 + outline: 'rgb(130, 117, 104)', 38 + outlineVariant: 'rgb(212, 196, 181)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(53, 47, 42)', 42 + inverseOnSurface: 'rgb(249, 239, 231)', 43 + inversePrimary: 'rgb(255, 185, 92)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(249, 243, 242)', 47 + level2: 'rgb(245, 238, 235)', 48 + level3: 'rgb(242, 233, 227)', 49 + level4: 'rgb(240, 231, 224)', 50 + level5: 'rgb(238, 228, 219)', 51 + }, 52 + surfaceDisabled: 'rgba(31, 27, 22, 0.12)', 53 + onSurfaceDisabled: 'rgba(31, 27, 22, 0.38)', 54 + backdrop: 'rgba(56, 47, 36, 0.4)', 55 + }; 56 + 57 + const DarkOrange: MaterialThemeColors = { 58 + primary: 'rgb(255, 185, 92)', 59 + onPrimary: 'rgb(70, 42, 0)', 60 + primaryContainer: 'rgb(101, 62, 0)', 61 + onPrimaryContainer: 'rgb(255, 221, 183)', 62 + secondary: 'rgb(223, 194, 162)', 63 + onSecondary: 'rgb(63, 45, 23)', 64 + secondaryContainer: 'rgb(87, 67, 43)', 65 + onSecondaryContainer: 'rgb(252, 222, 188)', 66 + tertiary: 'rgb(186, 205, 159)', 67 + onTertiary: 'rgb(38, 53, 20)', 68 + tertiaryContainer: 'rgb(60, 76, 40)', 69 + onTertiaryContainer: 'rgb(214, 233, 185)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(31, 27, 22)', 75 + onBackground: 'rgb(235, 225, 217)', 76 + surface: 'rgb(31, 27, 22)', 77 + onSurface: 'rgb(235, 225, 217)', 78 + surfaceVariant: 'rgb(80, 69, 57)', 79 + onSurfaceVariant: 'rgb(212, 196, 181)', 80 + outline: 'rgb(156, 142, 128)', 81 + outlineVariant: 'rgb(80, 69, 57)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(235, 225, 217)', 85 + inverseOnSurface: 'rgb(53, 47, 42)', 86 + inversePrimary: 'rgb(133, 84, 0)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(42, 35, 26)', 90 + level2: 'rgb(49, 40, 28)', 91 + level3: 'rgb(56, 44, 30)', 92 + level4: 'rgb(58, 46, 30)', 93 + level5: 'rgb(62, 49, 32)', 94 + }, 95 + surfaceDisabled: 'rgba(235, 225, 217, 0.12)', 96 + onSurfaceDisabled: 'rgba(235, 225, 217, 0.38)', 97 + backdrop: 'rgba(56, 47, 36, 0.4)', 98 + }; 99 + 100 + const MaterialOrange: MaterialThemeDefinition = { 101 + name: 'material-orange', 102 + fonts: DefaultFonts, 103 + light: LightOrange, 104 + dark: DarkOrange, 105 + }; 106 + 107 + export default MaterialOrange;
+107
src/theme/material/MaterialPink.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightPink: MaterialThemeColors = { 15 + primary: 'rgb(154, 64, 87)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(255, 217, 223)', 18 + onPrimaryContainer: 'rgb(63, 0, 22)', 19 + secondary: 'rgb(117, 86, 92)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(255, 217, 223)', 22 + onSecondaryContainer: 'rgb(43, 21, 26)', 23 + tertiary: 'rgb(122, 87, 50)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(255, 220, 188)', 26 + onTertiaryContainer: 'rgb(44, 23, 0)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(255, 251, 255)', 32 + onBackground: 'rgb(32, 26, 27)', 33 + surface: 'rgb(255, 251, 255)', 34 + onSurface: 'rgb(32, 26, 27)', 35 + surfaceVariant: 'rgb(243, 221, 224)', 36 + onSurfaceVariant: 'rgb(82, 67, 69)', 37 + outline: 'rgb(132, 115, 117)', 38 + outlineVariant: 'rgb(214, 194, 196)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(54, 47, 48)', 42 + inverseOnSurface: 'rgb(250, 238, 238)', 43 + inversePrimary: 'rgb(255, 177, 192)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(250, 242, 247)', 47 + level2: 'rgb(247, 236, 242)', 48 + level3: 'rgb(244, 230, 237)', 49 + level4: 'rgb(243, 229, 235)', 50 + level5: 'rgb(241, 225, 232)', 51 + }, 52 + surfaceDisabled: 'rgba(32, 26, 27, 0.12)', 53 + onSurfaceDisabled: 'rgba(32, 26, 27, 0.38)', 54 + backdrop: 'rgba(58, 45, 47, 0.4)', 55 + }; 56 + 57 + const DarkPink: MaterialThemeColors = { 58 + primary: 'rgb(255, 177, 192)', 59 + onPrimary: 'rgb(95, 17, 42)', 60 + primaryContainer: 'rgb(124, 41, 64)', 61 + onPrimaryContainer: 'rgb(255, 217, 223)', 62 + secondary: 'rgb(228, 189, 195)', 63 + onSecondary: 'rgb(67, 41, 46)', 64 + secondaryContainer: 'rgb(92, 63, 68)', 65 + onSecondaryContainer: 'rgb(255, 217, 223)', 66 + tertiary: 'rgb(236, 190, 144)', 67 + onTertiary: 'rgb(70, 42, 8)', 68 + tertiaryContainer: 'rgb(95, 64, 29)', 69 + onTertiaryContainer: 'rgb(255, 220, 188)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(32, 26, 27)', 75 + onBackground: 'rgb(236, 224, 224)', 76 + surface: 'rgb(32, 26, 27)', 77 + onSurface: 'rgb(236, 224, 224)', 78 + surfaceVariant: 'rgb(82, 67, 69)', 79 + onSurfaceVariant: 'rgb(214, 194, 196)', 80 + outline: 'rgb(159, 140, 143)', 81 + outlineVariant: 'rgb(82, 67, 69)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(236, 224, 224)', 85 + inverseOnSurface: 'rgb(54, 47, 48)', 86 + inversePrimary: 'rgb(154, 64, 87)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(43, 34, 35)', 90 + level2: 'rgb(50, 38, 40)', 91 + level3: 'rgb(57, 43, 45)', 92 + level4: 'rgb(59, 44, 47)', 93 + level5: 'rgb(63, 47, 50)', 94 + }, 95 + surfaceDisabled: 'rgba(236, 224, 224, 0.12)', 96 + onSurfaceDisabled: 'rgba(236, 224, 224, 0.38)', 97 + backdrop: 'rgba(58, 45, 47, 0.4)', 98 + }; 99 + 100 + const MaterialPink: MaterialThemeDefinition = { 101 + name: 'material-pink', 102 + fonts: DefaultFonts, 103 + light: LightPink, 104 + dark: DarkPink, 105 + }; 106 + 107 + export default MaterialPink;
+107
src/theme/material/MaterialRed.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightRed: MaterialThemeColors = { 15 + primary: 'rgb(192, 1, 0)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(255, 218, 212)', 18 + onPrimaryContainer: 'rgb(65, 0, 0)', 19 + secondary: 'rgb(119, 86, 81)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(255, 218, 212)', 22 + onSecondaryContainer: 'rgb(44, 21, 18)', 23 + tertiary: 'rgb(112, 92, 46)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(251, 223, 166)', 26 + onTertiaryContainer: 'rgb(37, 26, 0)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(255, 251, 255)', 32 + onBackground: 'rgb(32, 26, 25)', 33 + surface: 'rgb(255, 251, 255)', 34 + onSurface: 'rgb(32, 26, 25)', 35 + surfaceVariant: 'rgb(245, 221, 218)', 36 + onSurfaceVariant: 'rgb(83, 67, 65)', 37 + outline: 'rgb(133, 115, 112)', 38 + outlineVariant: 'rgb(216, 194, 190)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(54, 47, 46)', 42 + inverseOnSurface: 'rgb(251, 238, 236)', 43 + inversePrimary: 'rgb(255, 180, 168)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(252, 239, 242)', 47 + level2: 'rgb(250, 231, 235)', 48 + level3: 'rgb(248, 224, 227)', 49 + level4: 'rgb(247, 221, 224)', 50 + level5: 'rgb(246, 216, 219)', 51 + }, 52 + surfaceDisabled: 'rgba(32, 26, 25, 0.12)', 53 + onSurfaceDisabled: 'rgba(32, 26, 25, 0.38)', 54 + backdrop: 'rgba(59, 45, 43, 0.4)', 55 + }; 56 + 57 + const DarkRed: MaterialThemeColors = { 58 + primary: 'rgb(255, 180, 168)', 59 + onPrimary: 'rgb(105, 1, 0)', 60 + primaryContainer: 'rgb(147, 1, 0)', 61 + onPrimaryContainer: 'rgb(255, 218, 212)', 62 + secondary: 'rgb(231, 189, 182)', 63 + onSecondary: 'rgb(68, 41, 37)', 64 + secondaryContainer: 'rgb(93, 63, 59)', 65 + onSecondaryContainer: 'rgb(255, 218, 212)', 66 + tertiary: 'rgb(222, 196, 140)', 67 + onTertiary: 'rgb(62, 46, 4)', 68 + tertiaryContainer: 'rgb(86, 68, 25)', 69 + onTertiaryContainer: 'rgb(251, 223, 166)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(32, 26, 25)', 75 + onBackground: 'rgb(237, 224, 221)', 76 + surface: 'rgb(32, 26, 25)', 77 + onSurface: 'rgb(237, 224, 221)', 78 + surfaceVariant: 'rgb(83, 67, 65)', 79 + onSurfaceVariant: 'rgb(216, 194, 190)', 80 + outline: 'rgb(160, 140, 137)', 81 + outlineVariant: 'rgb(83, 67, 65)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(237, 224, 221)', 85 + inverseOnSurface: 'rgb(54, 47, 46)', 86 + inversePrimary: 'rgb(192, 1, 0)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(43, 34, 32)', 90 + level2: 'rgb(50, 38, 36)', 91 + level3: 'rgb(57, 43, 41)', 92 + level4: 'rgb(59, 45, 42)', 93 + level5: 'rgb(63, 48, 45)', 94 + }, 95 + surfaceDisabled: 'rgba(237, 224, 221, 0.12)', 96 + onSurfaceDisabled: 'rgba(237, 224, 221, 0.38)', 97 + backdrop: 'rgba(59, 45, 43, 0.4)', 98 + }; 99 + 100 + const MaterialRed: MaterialThemeDefinition = { 101 + name: 'material-red', 102 + fonts: DefaultFonts, 103 + light: LightRed, 104 + dark: DarkRed, 105 + }; 106 + 107 + export default MaterialRed;
+107
src/theme/material/MaterialYellow.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // This theme comes from the React Native Paper demonstration app. Used under the terms of the MIT license. 8 + // Source: https://github.com/callstack/react-native-paper/blob/ac3820a50fe1afb82c19cc0853a451e5c190b769/example/utils/index.ts 9 + 10 + import type { MaterialThemeColors, MaterialThemeDefinition } from './types'; 11 + 12 + import DefaultFonts from '../fonts/DefaultFonts'; 13 + 14 + const LightYellow: MaterialThemeColors = { 15 + primary: 'rgb(98, 98, 0)', 16 + onPrimary: 'rgb(255, 255, 255)', 17 + primaryContainer: 'rgb(234, 234, 0)', 18 + onPrimaryContainer: 'rgb(29, 29, 0)', 19 + secondary: 'rgb(96, 96, 67)', 20 + onSecondary: 'rgb(255, 255, 255)', 21 + secondaryContainer: 'rgb(231, 228, 191)', 22 + onSecondaryContainer: 'rgb(29, 29, 6)', 23 + tertiary: 'rgb(61, 102, 87)', 24 + onTertiary: 'rgb(255, 255, 255)', 25 + tertiaryContainer: 'rgb(191, 236, 216)', 26 + onTertiaryContainer: 'rgb(0, 33, 23)', 27 + error: 'rgb(186, 26, 26)', 28 + onError: 'rgb(255, 255, 255)', 29 + errorContainer: 'rgb(255, 218, 214)', 30 + onErrorContainer: 'rgb(65, 0, 2)', 31 + background: 'rgb(255, 251, 255)', 32 + onBackground: 'rgb(28, 28, 23)', 33 + surface: 'rgb(255, 251, 255)', 34 + onSurface: 'rgb(28, 28, 23)', 35 + surfaceVariant: 'rgb(230, 227, 209)', 36 + onSurfaceVariant: 'rgb(72, 71, 58)', 37 + outline: 'rgb(121, 120, 105)', 38 + outlineVariant: 'rgb(202, 199, 182)', 39 + shadow: 'rgb(0, 0, 0)', 40 + scrim: 'rgb(0, 0, 0)', 41 + inverseSurface: 'rgb(49, 49, 43)', 42 + inverseOnSurface: 'rgb(244, 240, 232)', 43 + inversePrimary: 'rgb(205, 205, 0)', 44 + elevation: { 45 + level0: 'transparent', 46 + level1: 'rgb(247, 243, 242)', 47 + level2: 'rgb(242, 239, 235)', 48 + level3: 'rgb(238, 234, 227)', 49 + level4: 'rgb(236, 233, 224)', 50 + level5: 'rgb(233, 230, 219)', 51 + }, 52 + surfaceDisabled: 'rgba(28, 28, 23, 0.12)', 53 + onSurfaceDisabled: 'rgba(28, 28, 23, 0.38)', 54 + backdrop: 'rgba(49, 49, 37, 0.4)', 55 + }; 56 + 57 + const DarkYellow: MaterialThemeColors = { 58 + primary: 'rgb(205, 205, 0)', 59 + onPrimary: 'rgb(50, 50, 0)', 60 + primaryContainer: 'rgb(73, 73, 0)', 61 + onPrimaryContainer: 'rgb(234, 234, 0)', 62 + secondary: 'rgb(202, 200, 165)', 63 + onSecondary: 'rgb(50, 50, 24)', 64 + secondaryContainer: 'rgb(73, 72, 45)', 65 + onSecondaryContainer: 'rgb(231, 228, 191)', 66 + tertiary: 'rgb(164, 208, 189)', 67 + onTertiary: 'rgb(11, 55, 42)', 68 + tertiaryContainer: 'rgb(37, 78, 64)', 69 + onTertiaryContainer: 'rgb(191, 236, 216)', 70 + error: 'rgb(255, 180, 171)', 71 + onError: 'rgb(105, 0, 5)', 72 + errorContainer: 'rgb(147, 0, 10)', 73 + onErrorContainer: 'rgb(255, 180, 171)', 74 + background: 'rgb(28, 28, 23)', 75 + onBackground: 'rgb(230, 226, 217)', 76 + surface: 'rgb(28, 28, 23)', 77 + onSurface: 'rgb(230, 226, 217)', 78 + surfaceVariant: 'rgb(72, 71, 58)', 79 + onSurfaceVariant: 'rgb(202, 199, 182)', 80 + outline: 'rgb(147, 145, 130)', 81 + outlineVariant: 'rgb(72, 71, 58)', 82 + shadow: 'rgb(0, 0, 0)', 83 + scrim: 'rgb(0, 0, 0)', 84 + inverseSurface: 'rgb(230, 226, 217)', 85 + inverseOnSurface: 'rgb(49, 49, 43)', 86 + inversePrimary: 'rgb(98, 98, 0)', 87 + elevation: { 88 + level0: 'transparent', 89 + level1: 'rgb(37, 37, 22)', 90 + level2: 'rgb(42, 42, 21)', 91 + level3: 'rgb(48, 48, 21)', 92 + level4: 'rgb(49, 49, 20)', 93 + level5: 'rgb(53, 53, 20)', 94 + }, 95 + surfaceDisabled: 'rgba(230, 226, 217, 0.12)', 96 + onSurfaceDisabled: 'rgba(230, 226, 217, 0.38)', 97 + backdrop: 'rgba(49, 49, 37, 0.4)', 98 + }; 99 + 100 + const MaterialYellow: MaterialThemeDefinition = { 101 + name: 'material-yellow', 102 + fonts: DefaultFonts, 103 + light: LightYellow, 104 + dark: DarkYellow, 105 + }; 106 + 107 + export default MaterialYellow;
+22
src/theme/material/index.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + export type { 8 + MaterialTheme, 9 + MaterialThemeDefinition, 10 + MaterialThemeColors, 11 + } from './types'; 12 + 13 + export { initMaterialTheme, useMaterialTheme } from './useMaterialTheme'; 14 + export * as MaterialColors from './MaterialColors'; 15 + 16 + export { default as MaterialBlue } from './MaterialBlue'; 17 + export { default as MaterialCyan } from './MaterialCyan'; 18 + export { default as MaterialGreen } from './MaterialGreen'; 19 + export { default as MaterialOrange } from './MaterialOrange'; 20 + export { default as MaterialPink } from './MaterialPink'; 21 + export { default as MaterialRed } from './MaterialRed'; 22 + export { default as MaterialYellow } from './MaterialYellow';
+66
src/theme/material/types.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + // Modified from React Native Paper. Used under the terms of the MIT license. 8 + // https://github.com/callstack/react-native-paper/blob/ff0df5454eb13d6e8e2d8f1c87c0bca8bb3635f0/src/types.tsx# 9 + 10 + import type { ThemeFonts } from '../fonts/types'; 11 + import type { Theme, ThemeDefinition, ThemeColors } from '../types'; 12 + 13 + export interface MaterialTheme extends Theme<MaterialThemeColors> { 14 + fonts: ThemeFonts; 15 + colors: MaterialThemeColors; 16 + } 17 + 18 + export interface MaterialThemeDefinition 19 + extends ThemeDefinition<MaterialThemeColors> { 20 + fonts: ThemeFonts; 21 + dark: MaterialThemeColors; 22 + light: MaterialThemeColors; 23 + } 24 + 25 + export interface MaterialThemeColors extends ThemeColors { 26 + primary: string; 27 + primaryContainer: string; 28 + secondary: string; 29 + secondaryContainer: string; 30 + tertiary: string; 31 + tertiaryContainer: string; 32 + surface: string; 33 + surfaceVariant: string; 34 + surfaceDisabled: string; 35 + background: string; 36 + error: string; 37 + errorContainer: string; 38 + onPrimary: string; 39 + onPrimaryContainer: string; 40 + onSecondary: string; 41 + onSecondaryContainer: string; 42 + onTertiary: string; 43 + onTertiaryContainer: string; 44 + onSurface: string; 45 + onSurfaceVariant: string; 46 + onSurfaceDisabled: string; 47 + onError: string; 48 + onErrorContainer: string; 49 + onBackground: string; 50 + outline: string; 51 + outlineVariant: string; 52 + inverseSurface: string; 53 + inverseOnSurface: string; 54 + inversePrimary: string; 55 + shadow: string; 56 + scrim: string; 57 + backdrop: string; 58 + elevation: { 59 + level0: string; 60 + level1: string; 61 + level2: string; 62 + level3: string; 63 + level4: string; 64 + level5: string; 65 + }; 66 + }
+13
src/theme/material/useMaterialTheme.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import type { MaterialThemeColors } from './types'; 8 + import { createTheme } from '../createTheme'; 9 + import MaterialGreen from './MaterialGreen'; 10 + 11 + const { initTheme, useTheme } = createTheme<MaterialThemeColors>(MaterialGreen); 12 + 13 + export { initTheme as initMaterialTheme, useTheme as useMaterialTheme };
+64
src/theme/material/__tests__/useMaterialTheme.test.ts
··· 1 + // Copyright (c) 2025 Joseph Hale 2 + // 3 + // This Source Code Form is subject to the terms of the Mozilla Public 4 + // License, v. 2.0. If a copy of the MPL was not distributed with this 5 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 6 + 7 + import { act, renderHook } from '@testing-library/react-native'; 8 + import { initMaterialTheme, useMaterialTheme } from '../useMaterialTheme'; 9 + 10 + import MaterialGreen from '../MaterialGreen'; 11 + import MaterialPink from '../MaterialPink'; 12 + 13 + describe('useMaterialTheme', () => { 14 + beforeEach(() => { 15 + const { result } = renderHook(() => useMaterialTheme()); 16 + act(() => result.current.resetTheme()); 17 + }); 18 + 19 + it('defaults to the Green theme', () => { 20 + const { result } = renderHook(() => useMaterialTheme()); 21 + const { theme } = result.current; 22 + 23 + expect(theme.colors).toEqual(MaterialGreen.light); 24 + }); 25 + 26 + it('can initialize a custom theme', () => { 27 + act(() => initMaterialTheme(MaterialPink)); 28 + const { result } = renderHook(() => useMaterialTheme()); 29 + const { theme } = result.current; 30 + 31 + expect(theme.colors).toEqual(MaterialPink.light); 32 + }); 33 + 34 + it('reacts to theme changes', () => { 35 + const { result } = renderHook(() => useMaterialTheme()); 36 + const { setTheme } = result.current; 37 + 38 + act(() => setTheme(MaterialPink)); 39 + 40 + const { theme } = result.current; 41 + expect(theme.colors).toEqual(MaterialPink.light); 42 + }); 43 + 44 + it('reacts to color scheme changes', () => { 45 + const { result } = renderHook(() => useMaterialTheme()); 46 + const { setScheme } = result.current; 47 + 48 + act(() => setScheme('dark')); 49 + 50 + const { theme } = result.current; 51 + expect(theme.colors).toEqual(MaterialGreen.dark); 52 + }); 53 + 54 + it('can reset to default theme and scheme', () => { 55 + act(() => initMaterialTheme(MaterialPink, 'dark')); 56 + 57 + const { result } = renderHook(() => useMaterialTheme()); 58 + const { resetTheme } = result.current; 59 + act(() => resetTheme()); 60 + const { theme } = result.current; 61 + 62 + expect(theme.colors).toEqual(MaterialGreen.light); 63 + }); 64 + });