├── hooks ├── useColorScheme.ts ├── useLocalMigrations.ts ├── useColorScheme.web.ts ├── useThemeColor.ts ├── useExpoUpdates.tsx └── useStoreLocation.tsx ├── global.css ├── bun.lockb ├── assets ├── todo_bg.png ├── images │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ └── adaptive-icon.png └── fonts │ └── SpaceMono-Regular.ttf ├── screenshots ├── dark.png ├── light.png ├── dark_expanded.png ├── light_expanded.png └── monodo-android-banner.png ├── constants ├── types.ts ├── queryClient.ts ├── persistance.ts ├── Colors.ts ├── css-animations.ts └── openweather.ts ├── nativewind-env.d.ts ├── .env.local.example ├── drizzle.config.ts ├── drizzle ├── 0000_dusty_morg.sql ├── migrations.js ├── meta │ ├── _journal.json │ ├── 0000_snapshot.json │ └── 0001_snapshot.json └── 0001_lumpy_sasquatch.sql ├── utils ├── misc.ts ├── mock.ts ├── cssInterop.ts └── constants.ts ├── app ├── index.tsx ├── +not-found.tsx └── _layout.tsx ├── babel.config.js ├── tsconfig.json ├── metro.config.js ├── eas.json ├── db ├── schema.ts └── init.ts ├── state ├── location.ts └── weather.ts ├── components ├── Icon.tsx ├── NoiseBackground.tsx ├── Todo.tsx ├── Week.tsx ├── Todos.tsx └── Day.tsx ├── .easignore ├── .gitignore ├── tailwind.config.js ├── scripts └── reset-project.js ├── README.md ├── package.json └── app.json /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/bun.lockb -------------------------------------------------------------------------------- /assets/todo_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/assets/todo_bg.png -------------------------------------------------------------------------------- /screenshots/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/screenshots/dark.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /screenshots/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/screenshots/light.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/assets/images/splash.png -------------------------------------------------------------------------------- /constants/types.ts: -------------------------------------------------------------------------------- 1 | import { icons } from "lucide-react-native"; 2 | 3 | export type LucideIconName = keyof typeof icons; 4 | -------------------------------------------------------------------------------- /nativewind-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /screenshots/dark_expanded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/screenshots/dark_expanded.png -------------------------------------------------------------------------------- /screenshots/light_expanded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/screenshots/light_expanded.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /constants/queryClient.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export const queryClient = new QueryClient(); 4 | -------------------------------------------------------------------------------- /screenshots/monodo-android-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/catalinmiron/react-native-monodo/HEAD/screenshots/monodo-android-banner.png -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | EXPO_PUBLIC_OPENWEATHER_API="YOUR_API_KEY" 2 | EXPO_PUBLIC_VEXO_API_KEY="YOUR_VEXO_API_KEY" 3 | SENTRY_AUTH_TOKEN="YOUR_SENTRY_AUTH_TOKEN" -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | export default { 3 | schema: "./db/schema.ts", 4 | out: "./drizzle", 5 | dialect: "sqlite", 6 | driver: "expo", // <--- very important 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /drizzle/0000_dusty_morg.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `todos` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `content` text NOT NULL, 4 | `date` text NOT NULL, 5 | `description` text DEFAULT '', 6 | `created_at` text DEFAULT (CURRENT_TIMESTAMP), 7 | `done` integer DEFAULT 0 8 | ); 9 | -------------------------------------------------------------------------------- /utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { Insets } from "react-native"; 2 | 3 | export const hitSlop: Insets = { 4 | top: 10, 5 | right: 10, 6 | bottom: 10, 7 | left: 10, 8 | }; 9 | 10 | export async function wait(ms: number) { 11 | return new Promise((resolve) => setTimeout(resolve, ms)); 12 | } 13 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import { NoiseBackground } from "@/components/NoiseBackground"; 2 | import { Week } from "@/components/Week"; 3 | import { View } from "react-native"; 4 | 5 | export default function Home() { 6 | return ( 7 | 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: [ 5 | ["babel-preset-expo", { jsxImportSource: "nativewind" }], 6 | "nativewind/babel", 7 | ], 8 | plugins: [ 9 | ["inline-import", { extensions: [".sql"] }], 10 | "react-native-worklets/plugin", 11 | ], 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /constants/persistance.ts: -------------------------------------------------------------------------------- 1 | import { observablePersistSqlite } from "@legendapp/state/persist-plugins/expo-sqlite"; 2 | import { configureSynced } from "@legendapp/state/sync"; 3 | import { Storage } from "expo-sqlite/kv-store"; 4 | 5 | export const persistOptions = configureSynced({ 6 | persist: { 7 | plugin: observablePersistSqlite(Storage), 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /utils/mock.ts: -------------------------------------------------------------------------------- 1 | export const _todos = [ 2 | { content: "5km run", done: true, id: 1 }, 3 | { content: "Read 10 pages", done: false, id: 2 }, 4 | { content: "Walk the dog", done: false, id: 3 }, 5 | { content: "Get groceries", done: false, id: 4 }, 6 | { content: "Design a to-do app (?)", done: false, id: 4 }, 7 | ]; 8 | 9 | export type Todo = (typeof _todos)[0]; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": ["./*"] 7 | } 8 | }, 9 | "include": [ 10 | "**/*.ts", 11 | "**/*.tsx", 12 | ".expo/types/**/*.ts", 13 | "expo-env.d.ts", 14 | "expo-env.d.ts", 15 | "nativewind-env.d.ts", 16 | "babel.config.js" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { withNativeWind } = require("nativewind/metro"); 2 | const { 3 | getSentryExpoConfig 4 | } = require("@sentry/react-native/metro"); 5 | 6 | /** @type {import('expo/metro-config').MetroConfig} */ 7 | const config = getSentryExpoConfig(__dirname); 8 | 9 | config.resolver.sourceExts.push("sql"); 10 | 11 | module.exports = withNativeWind(config, { input: "./global.css" }); -------------------------------------------------------------------------------- /drizzle/migrations.js: -------------------------------------------------------------------------------- 1 | // This file is required for Expo/React Native SQLite migrations - https://orm.drizzle.team/quick-sqlite/expo 2 | 3 | import journal from './meta/_journal.json'; 4 | import m0000 from './0000_dusty_morg.sql'; 5 | import m0001 from './0001_lumpy_sasquatch.sql'; 6 | 7 | export default { 8 | journal, 9 | migrations: { 10 | m0000, 11 | m0001 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1737630564922, 9 | "tag": "0000_dusty_morg", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1737630978341, 16 | "tag": "0001_lumpy_sasquatch", 17 | "breakpoints": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /hooks/useLocalMigrations.ts: -------------------------------------------------------------------------------- 1 | import { db, sqliteDb } from "@/db/init"; 2 | import migrations from "@/drizzle/migrations"; 3 | import { useMigrations } from "drizzle-orm/expo-sqlite/migrator"; 4 | import { useDrizzleStudio } from "expo-drizzle-studio-plugin"; 5 | export function useLocalMigrations() { 6 | const migrationData = useMigrations(db, migrations); 7 | 8 | if (__DEV__) { 9 | useDrizzleStudio(sqliteDb()); 10 | } 11 | return migrationData; 12 | } 13 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 15.0.5", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": { 20 | "ascAppId": "6742146882" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from "expo-router"; 2 | import { Text, View } from "react-native"; 3 | 4 | export default function NotFoundScreen() { 5 | return ( 6 | <> 7 | 8 | 9 | This screen doesn't exist. 10 | 11 | Go to home screen! 12 | 13 | 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /db/schema.ts: -------------------------------------------------------------------------------- 1 | // nothing 2 | 3 | import { sql } from "drizzle-orm"; 4 | import * as t from "drizzle-orm/sqlite-core"; 5 | 6 | export const todos = t.sqliteTable("todos", { 7 | id: t.integer({ mode: "number" }).primaryKey({ autoIncrement: true }), 8 | content: t.text().notNull(), 9 | // this should be formated as YYYY-MM-DD 10 | date: t.integer({ mode: "timestamp" }).notNull(), 11 | description: t.text().default(""), 12 | created_at: t.text().default(sql`(CURRENT_TIMESTAMP)`), 13 | done: t.integer().default(0), 14 | }); 15 | -------------------------------------------------------------------------------- /state/location.ts: -------------------------------------------------------------------------------- 1 | import { persistOptions } from "@/constants/persistance"; 2 | import { observable } from "@legendapp/state"; 3 | import { syncObservable } from "@legendapp/state/sync"; 4 | import { PermissionStatus } from "expo-location"; 5 | 6 | export const location$ = observable({ 7 | latitude: null as number | null, 8 | longitude: null as number | null, 9 | status: null as PermissionStatus | null, 10 | }); 11 | 12 | syncObservable( 13 | location$, 14 | persistOptions({ 15 | persist: { 16 | name: "location", 17 | }, 18 | }) 19 | ); 20 | -------------------------------------------------------------------------------- /components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIconName } from "@/constants/types"; 2 | import { icons, LucideProps } from "lucide-react-native"; 3 | import { MotiProps } from "moti"; 4 | import { motifySvg } from "moti/svg"; 5 | 6 | type IconProps = LucideProps & { 7 | name: LucideIconName; 8 | color?: string; 9 | size?: number; 10 | } & MotiProps; 11 | 12 | export function Icon({ name, color = "#000", size = 18, ...rest }: IconProps) { 13 | // @ts-ignore 14 | const LucideIcon = motifySvg(icons[name])(); 15 | 16 | return ; 17 | } 18 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useColorScheme as useRNColorScheme } from "react-native"; 3 | 4 | /** 5 | * To support static rendering, this value needs to be re-calculated on the client side for web 6 | */ 7 | export function useColorScheme() { 8 | const [hasHydrated, setHasHydrated] = useState(true); 9 | 10 | // useEffect(() => { 11 | // setHasHydrated(true); 12 | // }, []); 13 | 14 | const colorScheme = useRNColorScheme(); 15 | 16 | if (hasHydrated) { 17 | return colorScheme; 18 | } 19 | 20 | return "light"; 21 | } 22 | -------------------------------------------------------------------------------- /.easignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | 40 | /ios/ 41 | /android/ 42 | /screenshots/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | 40 | /ios/ 41 | /android/ 42 | .env.local 43 | -------------------------------------------------------------------------------- /hooks/useThemeColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about light and dark modes: 3 | * https://docs.expo.dev/guides/color-schemes/ 4 | */ 5 | 6 | import { Colors } from '@/constants/Colors'; 7 | import { useColorScheme } from '@/hooks/useColorScheme'; 8 | 9 | export function useThemeColor( 10 | props: { light?: string; dark?: string }, 11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 12 | ) { 13 | const theme = useColorScheme() ?? 'light'; 14 | const colorFromProps = props[theme]; 15 | 16 | if (colorFromProps) { 17 | return colorFromProps; 18 | } else { 19 | return Colors[theme][colorName]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | // NOTE: Update this to include the paths to all of your component files. 4 | content: ["./app/**/*.{js,jsx,ts,tsx}", "./components/**/*.{js,jsx,ts,tsx}"], 5 | presets: [require("nativewind/preset")], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | "barlow-300": "Barlow_300Light", 10 | "barlow-400": "Barlow_400Regular", 11 | "barlow-500": "Barlow_500Medium", 12 | "barlow-700": "Barlow_700Bold", 13 | "barlow-900": "Barlow_900Black", 14 | }, 15 | colors: { 16 | selected: "#F25606", 17 | }, 18 | }, 19 | }, 20 | plugins: [], 21 | }; 22 | -------------------------------------------------------------------------------- /drizzle/0001_lumpy_sasquatch.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 | CREATE TABLE `__new_todos` ( 3 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 4 | `content` text NOT NULL, 5 | `date` integer NOT NULL, 6 | `description` text DEFAULT '', 7 | `created_at` text DEFAULT (CURRENT_TIMESTAMP), 8 | `done` integer DEFAULT 0 9 | ); 10 | --> statement-breakpoint 11 | INSERT INTO `__new_todos`("id", "content", "date", "description", "created_at", "done") SELECT "id", "content", "date", "description", "created_at", "done" FROM `todos`;--> statement-breakpoint 12 | DROP TABLE `todos`;--> statement-breakpoint 13 | ALTER TABLE `__new_todos` RENAME TO `todos`;--> statement-breakpoint 14 | PRAGMA foreign_keys=ON; -------------------------------------------------------------------------------- /components/NoiseBackground.tsx: -------------------------------------------------------------------------------- 1 | import { useColorScheme } from "@/hooks/useColorScheme.web"; 2 | import { LinearGradient } from "expo-linear-gradient"; 3 | import { Image } from "react-native"; 4 | 5 | export function NoiseBackground() { 6 | const theme = useColorScheme() ?? "light"; 7 | return ( 8 | 13 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /utils/cssInterop.ts: -------------------------------------------------------------------------------- 1 | import { Icon } from "@/components/Icon"; 2 | import { Stagger } from "@animatereactnative/stagger"; 3 | import { LinearGradient } from "expo-linear-gradient"; 4 | import { icons } from "lucide-react-native"; 5 | import { cssInterop } from "nativewind"; 6 | import Swipeable from "react-native-gesture-handler/ReanimatedSwipeable"; 7 | 8 | Object.keys(icons).forEach((key) => { 9 | const IconComponent = icons[key]; 10 | cssInterop(IconComponent, { 11 | className: "style", 12 | }); 13 | }); 14 | 15 | cssInterop(Icon, { 16 | className: "style", 17 | }); 18 | cssInterop(Swipeable, { 19 | className: "style", 20 | }); 21 | cssInterop(LinearGradient, { 22 | className: "style", 23 | }); 24 | 25 | cssInterop(Stagger, { 26 | className: "style", 27 | }); 28 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode. 3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. 4 | */ 5 | 6 | const tintColorLight = '#0a7ea4'; 7 | const tintColorDark = '#fff'; 8 | 9 | export const Colors = { 10 | light: { 11 | text: '#11181C', 12 | background: '#fff', 13 | tint: tintColorLight, 14 | icon: '#687076', 15 | tabIconDefault: '#687076', 16 | tabIconSelected: tintColorLight, 17 | }, 18 | dark: { 19 | text: '#ECEDEE', 20 | background: '#151718', 21 | tint: tintColorDark, 22 | icon: '#9BA1A6', 23 | tabIconDefault: '#9BA1A6', 24 | tabIconSelected: tintColorDark, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /hooks/useExpoUpdates.tsx: -------------------------------------------------------------------------------- 1 | import * as Updates from "expo-updates"; 2 | import { useEffect } from "react"; 3 | import { AppState } from "react-native"; 4 | export function useExpoUpdates() { 5 | useEffect(() => { 6 | AppState.addEventListener("change", async (state) => { 7 | if (state === "active") { 8 | console.log("Checking for updates..."); 9 | try { 10 | const update = await Updates.checkForUpdateAsync(); 11 | if (update.isAvailable) { 12 | console.log("Update is available, reloading..."); 13 | await Updates.fetchUpdateAsync(); 14 | await Updates.reloadAsync(); 15 | } 16 | } catch (error) { 17 | // You can also add an alert() to see the error message in case of an error when fetching updates. 18 | console.error(`Error fetching latest Expo update: ${error}`); 19 | } 20 | } 21 | }); 22 | }, []); 23 | } 24 | -------------------------------------------------------------------------------- /utils/constants.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import isoWeek from "dayjs/plugin/isoWeek"; 3 | import weekday from "dayjs/plugin/weekday"; 4 | 5 | dayjs.extend(weekday); 6 | dayjs.extend(isoWeek); 7 | 8 | export const globalFormatter = `YYYY-MM-DD`; 9 | export const localFormatter = `MMMM, DD YYYY`; 10 | export const weekDayFormatter = `dddd`; 11 | 12 | export const getWeekDays = () => 13 | [...Array(7).keys()].map((key) => { 14 | return dayjs().startOf("isoWeek").add(key, "day").format(globalFormatter); 15 | }); 16 | 17 | export type WeekDayIndex = 1 | 2 | 3 | 4 | 5 | 6 | 7; 18 | 19 | const _opacity = 0.4; 20 | export const weekDayColors = { 21 | 0: `rgba(132, 132, 132, ${_opacity})`, 22 | 1: `rgba(215, 215, 215, ${_opacity})`, // Monday 23 | 2: `rgba(206, 206, 206, ${_opacity})`, 24 | 3: `rgba(201, 201, 201, ${_opacity})`, 25 | 4: `rgba(192, 192, 192, ${_opacity})`, 26 | 5: `rgba(172, 172, 172, ${_opacity})`, 27 | 6: `rgba(152, 152, 152, ${_opacity})`, 28 | 7: `rgba(132, 132, 132, ${_opacity})`, 29 | }; 30 | 31 | import colors from "tailwindcss/colors"; 32 | 33 | export const twColors = colors; 34 | -------------------------------------------------------------------------------- /db/init.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/expo-sqlite"; 2 | import { defaultDatabaseDirectory, openDatabaseSync } from "expo-sqlite"; 3 | 4 | // https://orm.drizzle.team/docs/connect-expo-sqlite#add-migrations-to-your-app 5 | // Check the documentation when we actually have something to migrate. 6 | 7 | import { Paths } from "expo-file-system/next"; 8 | import { Platform } from "react-native"; 9 | const dbPath = 10 | Platform.OS === "ios" 11 | ? Object.values(Paths.appleSharedContainers)?.[0]?.uri 12 | : defaultDatabaseDirectory; 13 | export const sqliteDb = (shouldCloseConnection?: boolean) => { 14 | const db = openDatabaseSync( 15 | "db.db", 16 | { 17 | enableChangeListener: true, 18 | }, 19 | dbPath 20 | ); 21 | 22 | if (shouldCloseConnection) { 23 | if (db.isInTransactionSync()) { 24 | db.closeSync(); 25 | 26 | return openDatabaseSync( 27 | "db.db", 28 | { 29 | enableChangeListener: true, 30 | }, 31 | dbPath 32 | ); 33 | } 34 | } 35 | 36 | return db; 37 | }; 38 | 39 | // console.log("sqliteDb.databasePath: ", sqliteDb.databasePath); 40 | // console.log(Object.values(Paths.appleSharedContainers)); 41 | 42 | export const db = drizzle(sqliteDb(), { 43 | logger: false, 44 | }); 45 | -------------------------------------------------------------------------------- /state/weather.ts: -------------------------------------------------------------------------------- 1 | import { API_URL, ForecastPayload } from "@/constants/openweather"; 2 | import { queryClient } from "@/constants/queryClient"; 3 | import { observable, observe } from "@legendapp/state"; 4 | import { currentDay } from "@legendapp/state/helpers/time"; 5 | import { syncedQuery } from "@legendapp/state/sync-plugins/tanstack-query"; 6 | import { location$ } from "./location"; 7 | 8 | export const weatherQuery$ = observable( 9 | syncedQuery({ 10 | queryClient: queryClient, 11 | query: { 12 | queryKey: [ 13 | "weather", 14 | currentDay.get().toDateString(), 15 | `latitude-${location$.latitude.get()}`, 16 | `longitude-${location$.longitude.get()}`, 17 | ], 18 | queryFn: async () => { 19 | const data = (await fetch( 20 | `${API_URL}?units=metric&lat=${location$.latitude.get()}&lon=${location$.longitude.get()}&appid=${ 21 | process.env.EXPO_PUBLIC_OPENWEATHER_API 22 | }` 23 | ).then((x) => x.json())) as ForecastPayload; 24 | 25 | return data; 26 | // fetch weather data from openweather API 27 | }, 28 | }, 29 | }) 30 | ); 31 | 32 | observe(location$.get(), (e) => { 33 | // console.log(e.value); 34 | 35 | if (!e.value?.latitude || !e.value?.longitude) { 36 | return; 37 | } 38 | // get() the value to start syncing, and it will be reactive to updates coming in 39 | 40 | weatherQuery$.get(); 41 | }); 42 | -------------------------------------------------------------------------------- /hooks/useStoreLocation.tsx: -------------------------------------------------------------------------------- 1 | import { queryClient } from "@/constants/queryClient"; 2 | import { location$ } from "@/state/location"; 3 | import { weatherQuery$ } from "@/state/weather"; 4 | import * as Location from "expo-location"; 5 | import { useEffect, useRef } from "react"; 6 | import { AppState, NativeEventSubscription } from "react-native"; 7 | 8 | async function getCurrentLocation() { 9 | let { status } = await Location.requestForegroundPermissionsAsync(); 10 | location$.status.set(status); 11 | if (status !== "granted") { 12 | // setErrorMsg("Permission to access location was denied"); 13 | return; 14 | } 15 | 16 | let location = await Location.getCurrentPositionAsync({}); 17 | const { latitude, longitude } = location.coords; 18 | console.log({ location }); 19 | location$.assign({ 20 | latitude, 21 | longitude, 22 | }); 23 | await queryClient.refetchQueries({ 24 | queryKey: ["weather"], 25 | }); 26 | 27 | weatherQuery$.delete(); 28 | weatherQuery$.get(); 29 | } 30 | export function useStoreLocation() { 31 | useEffect(() => { 32 | getCurrentLocation(); 33 | }, []); 34 | 35 | const appStateListener = useRef(); 36 | 37 | useEffect(() => { 38 | appStateListener.current = AppState.addEventListener( 39 | "change", 40 | (appState) => { 41 | if (appState === "active") { 42 | console.log("AppState?"); 43 | // getCurrentLocation(); 44 | } 45 | } 46 | ); 47 | 48 | return () => { 49 | appStateListener.current!.remove(); 50 | }; 51 | }, []); 52 | } 53 | -------------------------------------------------------------------------------- /constants/css-animations.ts: -------------------------------------------------------------------------------- 1 | import { css } from "react-native-reanimated"; 2 | 3 | const angle = 4; 4 | export const cssAnimations = css.create({ 5 | shakeX: { 6 | animationName: { 7 | "10%, 30%, 50%, 70%, 90%": { 8 | transform: [ 9 | { 10 | translateX: -10, 11 | }, 12 | { 13 | translateY: 0, 14 | }, 15 | ], 16 | }, 17 | "20%, 40%, 60%, 80%": { 18 | transform: [ 19 | { 20 | translateX: 10, 21 | }, 22 | { 23 | translateY: 0, 24 | }, 25 | ], 26 | }, 27 | }, 28 | }, 29 | tada: { 30 | animationName: { 31 | from: { 32 | transform: [ 33 | { 34 | scale: 1, 35 | }, 36 | { 37 | rotateZ: `${angle}deg`, 38 | }, 39 | ], 40 | }, 41 | "10%, 20%": { 42 | transform: [ 43 | { 44 | scale: 1, 45 | }, 46 | { 47 | rotateZ: `-${angle}deg`, 48 | }, 49 | ], 50 | }, 51 | "30%, 50%, 70%, 90%": { 52 | transform: [ 53 | { 54 | scale: 1, 55 | }, 56 | 57 | { 58 | rotateZ: `${angle}deg`, 59 | }, 60 | ], 61 | }, 62 | "40%, 60%, 80%": { 63 | transform: [ 64 | { 65 | scale: 1, 66 | }, 67 | { 68 | rotateZ: `-${angle}deg`, 69 | }, 70 | ], 71 | }, 72 | to: { 73 | transform: [ 74 | { 75 | scale: 1, 76 | }, 77 | { 78 | rotateZ: `-${angle}deg`, 79 | }, 80 | ], 81 | }, 82 | }, 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "083328c7-55e7-499d-8633-6e451176af9f", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "todos": { 8 | "name": "todos", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "content": { 18 | "name": "content", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "date": { 25 | "name": "date", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "description": { 32 | "name": "description", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false, 37 | "default": "''" 38 | }, 39 | "created_at": { 40 | "name": "created_at", 41 | "type": "text", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false, 45 | "default": "(CURRENT_TIMESTAMP)" 46 | }, 47 | "done": { 48 | "name": "done", 49 | "type": "integer", 50 | "primaryKey": false, 51 | "notNull": false, 52 | "autoincrement": false, 53 | "default": 0 54 | } 55 | }, 56 | "indexes": {}, 57 | "foreignKeys": {}, 58 | "compositePrimaryKeys": {}, 59 | "uniqueConstraints": {}, 60 | "checkConstraints": {} 61 | } 62 | }, 63 | "views": {}, 64 | "enums": {}, 65 | "_meta": { 66 | "schemas": {}, 67 | "tables": {}, 68 | "columns": {} 69 | }, 70 | "internal": { 71 | "indexes": {} 72 | } 73 | } -------------------------------------------------------------------------------- /drizzle/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "df2ad12f-3caf-49a6-be42-bf04059e2e9b", 5 | "prevId": "083328c7-55e7-499d-8633-6e451176af9f", 6 | "tables": { 7 | "todos": { 8 | "name": "todos", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "content": { 18 | "name": "content", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "date": { 25 | "name": "date", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "description": { 32 | "name": "description", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false, 37 | "default": "''" 38 | }, 39 | "created_at": { 40 | "name": "created_at", 41 | "type": "text", 42 | "primaryKey": false, 43 | "notNull": false, 44 | "autoincrement": false, 45 | "default": "(CURRENT_TIMESTAMP)" 46 | }, 47 | "done": { 48 | "name": "done", 49 | "type": "integer", 50 | "primaryKey": false, 51 | "notNull": false, 52 | "autoincrement": false, 53 | "default": 0 54 | } 55 | }, 56 | "indexes": {}, 57 | "foreignKeys": {}, 58 | "compositePrimaryKeys": {}, 59 | "uniqueConstraints": {}, 60 | "checkConstraints": {} 61 | } 62 | }, 63 | "views": {}, 64 | "enums": {}, 65 | "_meta": { 66 | "schemas": {}, 67 | "tables": {}, 68 | "columns": {} 69 | }, 70 | "internal": { 71 | "indexes": {} 72 | } 73 | } -------------------------------------------------------------------------------- /constants/openweather.ts: -------------------------------------------------------------------------------- 1 | import { LucideIconName } from "./types"; 2 | 3 | export const API_URL = `https://api.openweathermap.org/data/2.5/forecast/daily`; 4 | 5 | // ?lat=44.34&lon=10.99&cnt=7&appid={API key} 6 | 7 | export interface ForecastPayload { 8 | city: City; 9 | cod: string; 10 | message: number; 11 | cnt: number; 12 | list: DayWeather[]; 13 | } 14 | 15 | export interface City { 16 | id: number; 17 | name: string; 18 | coord: Coord; 19 | country: string; 20 | population: number; 21 | timezone: number; 22 | } 23 | 24 | export interface Coord { 25 | lon: number; 26 | lat: number; 27 | } 28 | 29 | export interface DayWeather { 30 | dt: number; 31 | sunrise: number; 32 | sunset: number; 33 | temp: Temp; 34 | feels_like: FeelsLike; 35 | pressure: number; 36 | humidity: number; 37 | weather: Weather[]; 38 | speed: number; 39 | deg: number; 40 | gust: number; 41 | clouds: number; 42 | pop: number; 43 | rain?: number; 44 | } 45 | 46 | export interface Temp { 47 | day: number; 48 | min: number; 49 | max: number; 50 | night: number; 51 | eve: number; 52 | morn: number; 53 | } 54 | 55 | export interface FeelsLike { 56 | day: number; 57 | night: number; 58 | eve: number; 59 | morn: number; 60 | } 61 | 62 | export interface Weather { 63 | id: number; 64 | main: string; 65 | description: string; 66 | icon: string; 67 | } 68 | 69 | export function getIcon(id: number): LucideIconName { 70 | if (id >= 200 && id < 300) { 71 | return "CloudRain"; 72 | } else if (id >= 300 && id < 500) { 73 | return "CloudHail"; 74 | } else if (id >= 500 && id < 600) { 75 | return "CloudRain"; 76 | } else if (id >= 600 && id < 700) { 77 | return "Snowflake"; 78 | } else if (id >= 700 && id < 800) { 79 | return "CloudFog"; 80 | } else if (id === 800) { 81 | return "SunMedium"; 82 | } else if (id >= 801 && id < 803) { 83 | return "Cloudy"; 84 | } else if (id >= 802 && id < 900) { 85 | return "Cloudy"; 86 | } else if (id === 905 || (id >= 951 && id <= 956)) { 87 | return "Wind"; 88 | } else if (id >= 900 && id < 1000) { 89 | return "CloudRain"; 90 | } 91 | 92 | return "Sun"; 93 | } 94 | -------------------------------------------------------------------------------- /components/Todo.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db/init"; 2 | import { todos } from "@/db/schema"; 3 | import { hitSlop } from "@/utils/misc"; 4 | import { eq } from "drizzle-orm"; 5 | import { Check } from "lucide-react-native"; 6 | import { Alert, Text, TouchableOpacity, View } from "react-native"; 7 | import Animated, { ZoomIn, ZoomOut } from "react-native-reanimated"; 8 | 9 | export function Todo({ todo }: { todo: typeof todos.$inferSelect }) { 10 | return ( 11 | { 15 | Alert.alert( 16 | "Delete todo", 17 | `Are you sure you want to delete \n"${todo.content}"`, 18 | [ 19 | { 20 | text: "Cancel", 21 | style: "cancel", 22 | }, 23 | { 24 | text: "Delete", 25 | style: "destructive", 26 | onPress: async () => { 27 | await db.delete(todos).where(eq(todos.id, todo.id)).execute(); 28 | }, 29 | }, 30 | ] 31 | ); 32 | // db.delete(todos).where(eq(todos.id, todo.id)).execute(); 33 | }} 34 | onPress={() => { 35 | db.update(todos) 36 | .set({ 37 | done: Boolean(todo.done) ? 0 : 1, 38 | }) 39 | .where(eq(todos.id, todo.id)) 40 | .execute(); 41 | }}> 42 | 43 | 49 | {Boolean(todo.done) && ( 50 | 53 | 54 | 55 | )} 56 | 57 | 61 | {todo.content} 62 | 63 | 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /scripts/reset-project.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This script is used to reset the project to a blank state. 5 | * It moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file. 6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it. 7 | */ 8 | 9 | const fs = require("fs"); 10 | const path = require("path"); 11 | 12 | const root = process.cwd(); 13 | const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; 14 | const newDir = "app-example"; 15 | const newAppDir = "app"; 16 | const newDirPath = path.join(root, newDir); 17 | 18 | const indexContent = `import { Text, View } from "react-native"; 19 | 20 | export default function Index() { 21 | return ( 22 | 29 | Edit app/index.tsx to edit this screen. 30 | 31 | ); 32 | } 33 | `; 34 | 35 | const layoutContent = `import { Stack } from "expo-router"; 36 | 37 | export default function RootLayout() { 38 | return ; 39 | } 40 | `; 41 | 42 | const moveDirectories = async () => { 43 | try { 44 | // Create the app-example directory 45 | await fs.promises.mkdir(newDirPath, { recursive: true }); 46 | console.log(`📁 /${newDir} directory created.`); 47 | 48 | // Move old directories to new app-example directory 49 | for (const dir of oldDirs) { 50 | const oldDirPath = path.join(root, dir); 51 | const newDirPath = path.join(root, newDir, dir); 52 | if (fs.existsSync(oldDirPath)) { 53 | await fs.promises.rename(oldDirPath, newDirPath); 54 | console.log(`➡️ /${dir} moved to /${newDir}/${dir}.`); 55 | } else { 56 | console.log(`➡️ /${dir} does not exist, skipping.`); 57 | } 58 | } 59 | 60 | // Create new /app directory 61 | const newAppDirPath = path.join(root, newAppDir); 62 | await fs.promises.mkdir(newAppDirPath, { recursive: true }); 63 | console.log("\n📁 New /app directory created."); 64 | 65 | // Create index.tsx 66 | const indexPath = path.join(newAppDirPath, "index.tsx"); 67 | await fs.promises.writeFile(indexPath, indexContent); 68 | console.log("📄 app/index.tsx created."); 69 | 70 | // Create _layout.tsx 71 | const layoutPath = path.join(newAppDirPath, "_layout.tsx"); 72 | await fs.promises.writeFile(layoutPath, layoutContent); 73 | console.log("📄 app/_layout.tsx created."); 74 | 75 | console.log("\n✅ Project reset complete. Next steps:"); 76 | console.log( 77 | "1. Run `npx expo start` to start a development server.\n2. Edit app/index.tsx to edit the main screen.\n3. Delete the /app-example directory when you're done referencing it." 78 | ); 79 | } catch (error) { 80 | console.error(`Error during script execution: ${error}`); 81 | } 82 | }; 83 | 84 | moveDirectories(); 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | AnimateReactNative.com - Premium and Custom React Native animations. 6 | 7 | 8 |

9 | 10 | --- 11 | 12 | --- 13 | 14 |

15 | 16 | # MonoDo 👋 17 | 18 | TestFlight: https://testflight.apple.com/join/A5G6jwVv 19 | 20 | Android: https://play.google.com/store/apps/details?id=com.animatereactnative.monodo 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 36 | 41 | 42 | 43 |
24 | Switch the theme (Easter egg 🥚) 25 |
ClosedOpened
33 | 34 | 35 | 37 | 38 | 39 | 40 |
44 | 45 | ## Get started 46 | 47 | This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). 48 | 49 | 1. Install dependencies 50 | 51 | ```bash 52 | npm install 53 | ``` 54 | 55 | 2. Start the app 56 | 57 | ```bash 58 | npx expo start 59 | ``` 60 | 61 | In the output, you'll find options to open the app in a 62 | 63 | - [development build](https://docs.expo.dev/develop/development-builds/introduction/) 64 | - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) 65 | - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) 66 | - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo 67 | 68 | You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). 69 | 70 | ## Get a fresh project 71 | 72 | When you're ready, run: 73 | 74 | ```bash 75 | npm run reset-project 76 | ``` 77 | 78 | This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. 79 | 80 | ## Learn more 81 | 82 | To learn more about developing your project with Expo, look at the following resources: 83 | 84 | - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). 85 | - [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. 86 | 87 | ## Join the community 88 | 89 | Join our community of developers creating universal apps. 90 | 91 | - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. 92 | - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. 93 | 94 | ### Inspiration 95 | 96 | > [!TIP] 97 | > This project was 100% inspired by [Brett](https://x.com/thebtjackson/status/1881325871304421532). Thank you! 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monodo", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start --dev-client", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo run:android", 9 | "ios": "expo run:ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint", 13 | "generate": "drizzle-kit generate", 14 | "studio": "drizzle-kit studio", 15 | "build:development": "eas build --profile development --platform ios", 16 | "build:preview": "eas build --profile preview --platform ios", 17 | "build:production": "eas build --profile production", 18 | "push:update": "eas update --channel production && npx sentry-expo-upload-sourcemaps dist", 19 | "push:update:clean": "eas update --channel production --clear-cache && npx sentry-expo-upload-sourcemaps dist" 20 | }, 21 | "jest": { 22 | "preset": "jest-expo" 23 | }, 24 | "dependencies": { 25 | "@animatereactnative/accordion": "^0.1.5", 26 | "@animatereactnative/stagger": "^0.3.0", 27 | "@expo-google-fonts/barlow": "^0.2.3", 28 | "@expo/vector-icons": "^15.0.2", 29 | "@legendapp/state": "^3.0.0-beta.26", 30 | "@react-native-masked-view/masked-view": "^0.3.2", 31 | "@react-navigation/bottom-tabs": "^7.4.0", 32 | "@react-navigation/elements": "^2.6.3", 33 | "@react-navigation/native": "^7.1.8", 34 | "@sentry/react-native": "~6.20.0", 35 | "@tanstack/react-query": "^5.65.1", 36 | "babel-plugin-inline-import": "^3.0.0", 37 | "dayjs": "^1.11.13", 38 | "drizzle-orm": "^0.38.4", 39 | "expo": "^54.0.0", 40 | "expo-blur": "~15.0.7", 41 | "expo-constants": "~18.0.8", 42 | "expo-dev-client": "~6.0.12", 43 | "expo-drizzle-studio-plugin": "^0.1.1", 44 | "expo-file-system": "~19.0.14", 45 | "expo-font": "~14.0.8", 46 | "expo-haptics": "~15.0.7", 47 | "expo-linear-gradient": "~15.0.7", 48 | "expo-linking": "~8.0.8", 49 | "expo-location": "~19.0.7", 50 | "expo-router": "~6.0.4", 51 | "expo-splash-screen": "~31.0.10", 52 | "expo-sqlite": "~16.0.8", 53 | "expo-status-bar": "~3.0.8", 54 | "expo-symbols": "~1.0.7", 55 | "expo-system-ui": "~6.0.7", 56 | "expo-updates": "~29.0.10", 57 | "expo-web-browser": "~15.0.7", 58 | "lucide-react-native": "^0.473.0", 59 | "moti": "^0.29.0", 60 | "nativewind": "^4.2.1", 61 | "react": "19.1.0", 62 | "react-dom": "19.1.0", 63 | "react-native": "0.81.4", 64 | "react-native-css-animations": "^0.1.1", 65 | "react-native-edge-to-edge": "^1.4.3", 66 | "react-native-gesture-handler": "~2.28.0", 67 | "react-native-keyboard-controller": "1.18.5", 68 | "react-native-reanimated": "^4.1.0", 69 | "react-native-safe-area-context": "~5.6.0", 70 | "react-native-screens": "~4.16.0", 71 | "react-native-svg": "15.12.1", 72 | "react-native-web": "^0.21.0", 73 | "react-native-webview": "13.15.0", 74 | "react-native-worklets": "^0.5.1", 75 | "tailwindcss": "^3.4.17", 76 | "vexo-analytics": "^1.5.2" 77 | }, 78 | "devDependencies": { 79 | "@babel/core": "^7.25.2", 80 | "@types/jest": "^29.5.12", 81 | "@types/react": "~19.1.10", 82 | "@types/react-test-renderer": "^18.3.0", 83 | "drizzle-kit": "^0.30.2", 84 | "jest": "^29.2.1", 85 | "jest-expo": "~54.0.11", 86 | "react-test-renderer": "18.3.1", 87 | "typescript": "~5.9.2" 88 | }, 89 | "private": true 90 | } 91 | -------------------------------------------------------------------------------- /components/Week.tsx: -------------------------------------------------------------------------------- 1 | import { DayWeather } from "@/constants/openweather"; 2 | import { useStoreLocation } from "@/hooks/useStoreLocation"; 3 | import { location$ } from "@/state/location"; 4 | import { weatherQuery$ } from "@/state/weather"; 5 | import { getWeekDays } from "@/utils/constants"; 6 | import { currentDay } from "@legendapp/state/helpers/time"; 7 | import { observer } from "@legendapp/state/react"; 8 | import MaskedView from "@react-native-masked-view/masked-view"; 9 | import dayjs from "dayjs"; 10 | import { BlurView } from "expo-blur"; 11 | import { LinearGradient } from "expo-linear-gradient"; 12 | import React, { useMemo } from "react"; 13 | import { Platform, ScrollView, StyleSheet, View } from "react-native"; 14 | import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; 15 | 16 | import { useAnimatedRef } from "react-native-reanimated"; 17 | import { 18 | SafeAreaView, 19 | useSafeAreaInsets, 20 | } from "react-native-safe-area-context"; 21 | import { Day } from "./Day"; 22 | 23 | export const Week = observer(() => { 24 | const list = weatherQuery$.list.get(); 25 | const { latitude, longitude } = location$.get(); 26 | 27 | const data = useMemo(() => { 28 | return list?.reduce( 29 | (acc, item) => ({ 30 | ...acc, 31 | [dayjs(item.dt * 1000).format("YYYY-MM-DD")]: item, 32 | }), 33 | {} as Record 34 | ); 35 | }, [list, latitude, longitude]); 36 | 37 | useStoreLocation(); 38 | 39 | // To handle gestures, check https://github.com/kirillzyusko/react-native-keyboard-controller/blob/main/example/src/screens/Examples/InteractiveKeyboard/index.tsx#L90 40 | const { bottom, top } = useSafeAreaInsets(); 41 | const ref = useAnimatedRef(); 42 | const _currentDay = currentDay.get(); 43 | const weekDays = useMemo(() => getWeekDays(), [_currentDay]); 44 | 45 | // Compensate for Android 46 | const topSpacing = 32 + top; 47 | 48 | return ( 49 | <> 50 | 58 | 59 | {weekDays.map((day, index) => ( 60 | 66 | ))} 67 | 68 | 69 | 75 | 84 | } 85 | style={[StyleSheet.absoluteFill]}> 86 | 92 | 93 | 94 | 95 | ); 96 | }); 97 | -------------------------------------------------------------------------------- /components/Todos.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db/init"; 2 | import { todos } from "@/db/schema"; 3 | import { hitSlop } from "@/utils/misc"; 4 | import { Stagger } from "@animatereactnative/stagger"; 5 | import dayjs from "dayjs"; 6 | import { between } from "drizzle-orm"; 7 | import { useLiveQuery } from "drizzle-orm/expo-sqlite"; 8 | import { Plus } from "lucide-react-native"; 9 | import React, { useRef, useState } from "react"; 10 | import { Pressable, Text, TextInput, View } from "react-native"; 11 | import Animated, { 12 | FadeInDown, 13 | FadeOutDown, 14 | FadeOutLeft, 15 | LinearTransition, 16 | } from "react-native-reanimated"; 17 | import { Todo } from "./Todo"; 18 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 19 | 20 | export function Todos({ day }: { day: string }) { 21 | const { data } = useLiveQuery( 22 | db 23 | .select() 24 | .from(todos) 25 | .where( 26 | between( 27 | todos.date, 28 | dayjs(day).startOf("day").toDate(), 29 | dayjs(day).endOf("day").toDate() 30 | ) 31 | ) 32 | .orderBy(todos.created_at), 33 | [day] 34 | ); 35 | 36 | const [content, setContent] = useState(""); 37 | const inputRef = useRef(null); 38 | 39 | const addTodo = () => { 40 | inputRef.current?.clear(); 41 | inputRef.current?.blur(); 42 | db.insert(todos) 43 | .values({ 44 | date: dayjs(day).toDate(), 45 | content: content, 46 | }) 47 | .run(); 48 | setContent(""); 49 | }; 50 | const isDisabled = !content || content === ""; 51 | 52 | return ( 53 | 54 | FadeOutLeft} 59 | // enterDirection={-1} 60 | > 61 | {data?.map((todo, index) => ( 62 | 63 | ))} 64 | 65 | 70 | { 76 | if (!isDisabled) { 77 | addTodo(); 78 | } 79 | }} 80 | onChangeText={(text) => { 81 | setContent(text.trim()); 82 | }} 83 | className='flex-1 border-b border-black/50 dark:border-white/50 rounded-md p-2 font-barlow-400 dark:text-white/60 placeholder:dark:text-white/30' 84 | placeholder='What needs to be done?' 85 | /> 86 | 92 | 93 | 94 | 95 | Add 96 | 97 | 98 | 99 | 100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "monodo", 4 | "slug": "monodo", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "monodo", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true, 13 | "bundleIdentifier": "com.animatereactnative.monodo", 14 | "config": { 15 | "usesNonExemptEncryption": false 16 | }, 17 | "entitlements": { 18 | "aps-environment": "development", 19 | "com.apple.security.application-groups": [ 20 | "group.com.animatereactnative.monodo" 21 | ] 22 | } 23 | }, 24 | "android": { 25 | "adaptiveIcon": { 26 | "foregroundImage": "./assets/images/adaptive-icon.png", 27 | "backgroundColor": "#ffffff" 28 | }, 29 | "package": "com.animatereactnative.monodo", 30 | "permissions": [ 31 | "android.permission.ACCESS_COARSE_LOCATION", 32 | "android.permission.ACCESS_FINE_LOCATION", 33 | "android.permission.FOREGROUND_SERVICE", 34 | "android.permission.FOREGROUND_SERVICE_LOCATION" 35 | ], 36 | "blockedPermissions": [ 37 | "android.permission.WRITE_EXTERNAL_STORAGE", 38 | "android.permission.READ_EXTERNAL_STORAGE", 39 | "android.permission.READ_MEDIA_IMAGES", 40 | "android.permission.READ_MEDIA_VIDEO" 41 | ] 42 | }, 43 | "web": { 44 | "bundler": "metro", 45 | "output": "static", 46 | "favicon": "./assets/images/favicon.png" 47 | }, 48 | "plugins": [ 49 | "expo-router", 50 | [ 51 | "expo-splash-screen", 52 | { 53 | "image": "./assets/images/splash.png", 54 | "imageWidth": 1000, 55 | "resizeMode": "cover", 56 | "backgroundColor": "#ffffff", 57 | "enableFullScreenImage_legacy": true, 58 | "android": { 59 | "resizeMode": "contain", 60 | "backgroundColor": "#ffffff", 61 | "image": "./assets/images/icon.png", 62 | "imageWidth": 300 63 | } 64 | } 65 | ], 66 | "expo-sqlite", 67 | [ 68 | "expo-location", 69 | { 70 | "locationWhenInUsePermission": "Allow $(PRODUCT_NAME) to use your location. Only when you are using the app. This is used to display the weather for each day based on your location.", 71 | "isAndroidForegroundServiceEnabled": true 72 | } 73 | ], 74 | [ 75 | "react-native-edge-to-edge", 76 | { 77 | "android": { 78 | "parentTheme": "Light", 79 | "enforceNavigationBarContrast": false 80 | } 81 | } 82 | ], 83 | [ 84 | "@sentry/react-native/expo", 85 | { 86 | "url": "https://sentry.io/", 87 | "project": "monodo", 88 | "organization": "animatereactnative" 89 | } 90 | ], 91 | "expo-font", 92 | "expo-web-browser" 93 | ], 94 | "experiments": { 95 | "typedRoutes": true 96 | }, 97 | "extra": { 98 | "router": { 99 | "origin": false 100 | }, 101 | "eas": { 102 | "projectId": "5b5466b2-9d6f-43dd-ac13-0ae1a08050cb" 103 | } 104 | }, 105 | "owner": "catalinmiron", 106 | "runtimeVersion": { 107 | "policy": "appVersion" 108 | }, 109 | "updates": { 110 | "url": "https://u.expo.dev/5b5466b2-9d6f-43dd-ac13-0ae1a08050cb" 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/utils/cssInterop"; 2 | import { 3 | Barlow_300Light, 4 | Barlow_400Regular, 5 | Barlow_500Medium, 6 | Barlow_700Bold, 7 | Barlow_900Black, 8 | useFonts, 9 | } from "@expo-google-fonts/barlow"; 10 | import { 11 | DarkTheme, 12 | DefaultTheme, 13 | ThemeProvider, 14 | } from "@react-navigation/native"; 15 | import { Stack, useNavigationContainerRef } from "expo-router"; 16 | import * as SplashScreen from "expo-splash-screen"; 17 | import { StatusBar } from "expo-status-bar"; 18 | import { useEffect } from "react"; 19 | import "react-native-reanimated"; 20 | import "../global.css"; 21 | 22 | import { useColorScheme } from "@/hooks/useColorScheme"; 23 | import { useLocalMigrations } from "@/hooks/useLocalMigrations"; 24 | import { vexo } from "vexo-analytics"; 25 | 26 | // Prevent the splash screen from auto-hiding before asset loading is complete. 27 | SplashScreen.preventAutoHideAsync(); 28 | SplashScreen.setOptions({ 29 | fade: true, 30 | duration: 500, 31 | }); 32 | 33 | import { queryClient } from "@/constants/queryClient"; 34 | import { useExpoUpdates } from "@/hooks/useExpoUpdates"; 35 | import * as Sentry from "@sentry/react-native"; 36 | import { QueryClientProvider } from "@tanstack/react-query"; 37 | import Constants, { ExecutionEnvironment } from "expo-constants"; 38 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 39 | import { KeyboardProvider } from "react-native-keyboard-controller"; 40 | 41 | const navigationIntegration = Sentry.reactNavigationIntegration({ 42 | enableTimeToInitialDisplay: 43 | Constants.executionEnvironment === ExecutionEnvironment.StoreClient, // Only in native builds, not in Expo Go. 44 | }); 45 | Sentry.init({ 46 | dsn: "https://73cab5913e1839cb5f83a625a4431681@o1316893.ingest.us.sentry.io/4508840580481024", 47 | 48 | // uncomment the line below to enable Spotlight (https://spotlightjs.com) 49 | // spotlight: __DEV__, 50 | tracesSampleRate: 1.0, 51 | integrations: [navigationIntegration], 52 | enableNativeFramesTracking: 53 | Constants.executionEnvironment === ExecutionEnvironment.StoreClient, 54 | }); 55 | vexo(process.env.EXPO_PUBLIC_VEXO_API_KEY as string); 56 | 57 | function RootLayout() { 58 | const colorScheme = useColorScheme(); 59 | const [loaded] = useFonts({ 60 | Barlow_300Light: Barlow_300Light, 61 | Barlow_400Regular: Barlow_400Regular, 62 | Barlow_500Medium: Barlow_500Medium, 63 | Barlow_700Bold: Barlow_700Bold, 64 | Barlow_900Black: Barlow_900Black, 65 | }); 66 | 67 | useLocalMigrations(); 68 | if (!__DEV__) { 69 | useExpoUpdates(); 70 | } 71 | 72 | useEffect(() => { 73 | if (loaded) { 74 | SplashScreen.hideAsync(); 75 | } 76 | }, [loaded]); 77 | 78 | const ref = useNavigationContainerRef(); 79 | useEffect(() => { 80 | if (ref) { 81 | navigationIntegration.registerNavigationContainer(ref); 82 | } 83 | }, [ref]); 84 | 85 | if (!loaded) { 86 | return null; 87 | } 88 | 89 | return ( 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | ); 104 | } 105 | 106 | export default Sentry.wrap(RootLayout); 107 | -------------------------------------------------------------------------------- /components/Day.tsx: -------------------------------------------------------------------------------- 1 | import { DayWeather, getIcon } from "@/constants/openweather"; 2 | import { location$ } from "@/state/location"; 3 | import { 4 | localFormatter, 5 | weekDayFormatter, 6 | WeekDayIndex, 7 | } from "@/utils/constants"; 8 | import Accordion from "@animatereactnative/accordion"; 9 | import { currentDay } from "@legendapp/state/helpers/time"; 10 | import { observer } from "@legendapp/state/react"; 11 | import dayjs from "dayjs"; 12 | import { Text, useWindowDimensions } from "react-native"; 13 | import Animated, { 14 | AnimatedRef, 15 | FadeInDown, 16 | FadeInRight, 17 | measure, 18 | runOnUI, 19 | scrollTo, 20 | useAnimatedRef, 21 | } from "react-native-reanimated"; 22 | import { AnimatedScrollView } from "react-native-reanimated/lib/typescript/component/ScrollView"; 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 24 | import { Icon } from "./Icon"; 25 | import { Todos } from "./Todos"; 26 | 27 | // day will be formatted as YYYY-MM-DD / check @/utils/constants 28 | export const Day = observer( 29 | ({ 30 | day, 31 | weather, 32 | scrollRef, 33 | }: { 34 | day: string; 35 | weather?: DayWeather; 36 | scrollRef: AnimatedRef; 37 | }) => { 38 | const { height } = useWindowDimensions(); 39 | const { top, bottom } = useSafeAreaInsets(); 40 | const isCurrentDay = dayjs(day).isSame(dayjs(currentDay.get()), "day"); 41 | const aRef = useAnimatedRef(); 42 | const currentDayIndex = dayjs(day).isoWeekday() as WeekDayIndex; 43 | 44 | const dayBg = { 45 | 1: `bg-stone-900/5 dark:bg-black/5`, 46 | 2: `bg-stone-900/10 dark:bg-black/10`, 47 | 3: `bg-stone-900/15 dark:bg-black/15`, 48 | 4: `bg-stone-900/20 dark:bg-black/20`, 49 | 5: `bg-stone-900/25 dark:bg-black/25`, 50 | 6: `bg-stone-900/30 dark:bg-black/30`, 51 | 7: `bg-stone-900/35 dark:bg-black/45`, // Sunday 52 | } as const; 53 | return ( 54 | { 61 | runOnUI(() => { 62 | const measurement = measure(aRef); 63 | if (isOn) { 64 | scrollTo(scrollRef, 0, measurement!.pageY - height / 2, true); 65 | } else { 66 | scrollTo(scrollRef, 0, -measurement!.pageY, true); 67 | } 68 | })(); 69 | }} 70 | style={{ 71 | minHeight: (height - top - bottom) / 7, 72 | // backgroundColor: weekDayColors[dayjs(day).weekday()], 73 | // experimental_backgroundImage: `linear-gradient(to bottom, ${ 74 | // weekDayColors[dayjs(day).weekday()] 75 | // }, rgba(0,0,0,0.1))`, 76 | }}> 77 | 78 | 79 | 80 | {dayjs(day).format(weekDayFormatter)} 81 | 82 | 83 | 84 | {dayjs(day).format(localFormatter)} 85 | 86 | {weather && ( 87 | 93 | 98 | 99 | {weather?.temp.day.toFixed(1)}°C 100 | 101 | 102 | )} 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ); 112 | } 113 | ); 114 | --------------------------------------------------------------------------------