This commit is contained in:
Morten Olsen
2022-05-19 15:57:20 +02:00
parent 6181eeb0c8
commit 2b0ad8592b
156 changed files with 26987 additions and 14366 deletions

View File

@@ -0,0 +1,6 @@
{
"e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
"af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
}

15
packages/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
/*.log
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store

View File

@@ -0,0 +1,51 @@
const pkg = require('./package.json');
const config = {
expo: {
name: 'Bob',
slug: 'bob',
version: pkg.version,
orientation: 'portrait',
icon: './assets/images/icon.png',
scheme: 'bobthealgorithm',
userInterfaceStyle: 'automatic',
splash: {
image: './assets/images/splash.png',
resizeMode: 'contain',
backgroundColor: '#ffffff',
},
updates: {
fallbackToCacheTimeout: 0,
},
assetBundlePatterns: ['**/*'],
ios: {
supportsTablet: true,
bundleIdentifier: 'pro.mortenolsen.bob',
buildNumber: pkg.version,
config: {
usesNonExemptEncryption: false,
},
},
android: {
adaptiveIcon: {
foregroundImage: './assets/images/adaptive-icon.png',
backgroundColor: '#ffffff',
},
package: 'pro.mortenolsen.bob',
},
web: {
favicon: './assets/images/favicon.png',
},
// hooks: {
// postPublish: [
// {
// file: 'sentry-expo/upload-sourcemaps',
// config: {
// setCommits: true,
// },
// },
// ],
// },
},
};
module.exports = config;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,14 @@
module.exports = function(api) {
api.cache.using(() => process.env.NODE_ENV);
return {
presets: ['babel-preset-expo'],
plugins: [
[require.resolve('babel-plugin-module-resolver'), {
alias: {
'#': './src',
'@morten-olsen/ui': '@morten-olsen/ui/src',
},
}],
],
};
};

25
packages/app/eas.json Normal file
View File

@@ -0,0 +1,25 @@
{
"cli": {
"version": ">= 0.42.4"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
}
},
"submit": {
"production": {
"ios": {
"appleId": "morten@olsen.pro",
"appleTeamId": "D2944KU2BE",
"ascAppId": "1623552387"
}
}
}
}

View File

@@ -0,0 +1,20 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require('expo/metro-config');
const path = require('path');
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
const workspaceRoot = path.resolve(__dirname, '../..');
const projectRoot = __dirname;
const config = getDefaultConfig(projectRoot);
// 1. Watch all files within the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages, and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
];
module.exports = config;

79
packages/app/package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "@morten-olsen/bob",
"version": "1.0.0",
"main": "./src/index.ts",
"homepage": "/bob-the-algorithm",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"test": "jest --watchAll",
"build:web": "expo build:web"
},
"jest": {
"preset": "jest-expo"
},
"resolutions": {
"@types/react": "~17.0.21",
"@types/react-dom": "~18.0.3",
"react-error-overlay": "6.0.9"
},
"dependencies": {
"@expo/vector-icons": "^12.0.0",
"@morten-olsen/ui": "workspace:^",
"@react-native-async-storage/async-storage": "~1.15.0",
"@react-navigation/bottom-tabs": "^6.0.5",
"@react-navigation/native": "^6.0.2",
"@react-navigation/native-stack": "^6.1.0",
"@react-navigation/stack": "^6.2.1",
"chroma-js": "^2.4.2",
"date-fns": "^2.28.0",
"expo": "~44.0.0",
"expo-asset": "~8.4.4",
"expo-calendar": "~10.1.0",
"expo-constants": "~13.0.0",
"expo-font": "~10.0.4",
"expo-linking": "~3.0.0",
"expo-location": "~14.0.1",
"expo-random": "^12.1.2",
"expo-splash-screen": "~0.14.0",
"expo-status-bar": "~1.2.0",
"expo-task-manager": "~10.1.0",
"expo-updates": "~0.11.7",
"expo-web-browser": "~10.1.0",
"parse-css-color": "^0.2.1",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-native": "0.64.3",
"react-native-calendar-strip": "^2.2.5",
"react-native-calendars": "^1.1284.0",
"react-native-collapsible": "^1.6.0",
"react-native-gesture-handler": "^2.4.2",
"react-native-get-random-values": "^1.8.0",
"react-native-safe-area-context": "3.3.2",
"react-native-screens": "~3.10.1",
"react-native-web": "0.17.1",
"string-to-color": "^2.2.2",
"styled-components": "^5.3.5"
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
"@types/chroma-js": "^2.1.3",
"@types/react": "~17.0.21",
"@types/react-dom": "^18.0.3",
"@types/styled-components-react-native": "^5.1.3",
"babel-loader": "^8.2.5",
"babel-plugin-module-resolver": "^4.1.0",
"expo-cli": "^5.4.3",
"jest": "^26.6.3",
"jest-expo": "~44.0.1",
"react-refresh": "^0.13.0",
"react-test-renderer": "17.0.1",
"typescript": "~4.3.5",
"webpack-hot-middleware": "^2.25.1"
},
"private": true
}

31
packages/app/src/app.tsx Normal file
View File

@@ -0,0 +1,31 @@
import 'react-native-get-random-values';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useCallback } from 'react';
import { Setup } from './features/setup';
import { Router } from './ui/router';
import { Provider } from '@morten-olsen/ui';
const App: React.FC = () => {
const getTransit = useCallback(
async (from: any, to: any) => ({
to,
from,
time: 45,
usableTime: 0,
}),
[],
)
return (
<SafeAreaProvider>
<StatusBar />
<Provider>
<Setup getTransit={getTransit}>
<Router />
</Setup>
</Provider>
</SafeAreaProvider>
);
};
export { App };

View File

@@ -0,0 +1,35 @@
import { createContext } from "react"
import { Appointment } from "../data"
import { Day } from "../day"
enum AppointmentsStatus {
unavailable = 'unavailable',
unapproved = 'unapproved',
rejected = 'rejected',
approved = 'approved',
}
type AppointmentsContextUnavailable = {
status: AppointmentsStatus.unavailable | AppointmentsStatus.rejected;
}
type AppointmentsContextUnapprovedValue = {
status: AppointmentsStatus.unapproved;
request: () => Promise<void>;
}
type AppointmentsContextApproved = {
status: AppointmentsStatus.approved;
getDay: (day: Day) => Promise<Appointment[]>
}
type AppointmentsContextValue = AppointmentsContextUnavailable
| AppointmentsContextUnapprovedValue
| AppointmentsContextApproved;
const AppointmentsContext = createContext<AppointmentsContextValue>(undefined as any);
export type {
AppointmentsContextValue,
};
export { AppointmentsContext, AppointmentsStatus };

View File

@@ -0,0 +1,45 @@
import { useAsync, useAsyncCallback } from "#/features/async";
import { useContext } from "react"
import { Day, useDate } from "../day";
import { AppointmentsContext, AppointmentsStatus } from "./context"
export const useAppointmentStatus = () => {
const { status } = useContext(AppointmentsContext);
return status;
};
export const useAppointments = () => {
const date = useDate();
const context = useContext(AppointmentsContext);
const result = useAsync(
async () => {
if (context.status !== AppointmentsStatus.approved) {
return [];
}
const appointments = await context.getDay(date);
return appointments;
},
[
context.status === AppointmentsStatus.approved && context.getDay,
date,
],
);
return result;
}
export const useGetAppointments = () => {
const context = useContext(AppointmentsContext);
const result = useAsyncCallback(
async (date: Day) => {
if (context.status !== AppointmentsStatus.approved) {
return [];
}
const appointments = await context.getDay(date);
return appointments;
},
[
context.status === AppointmentsStatus.approved && context.getDay,
],
);
return result;
}

View File

@@ -0,0 +1,2 @@
export { AppointmentsProvider } from './provider';
export * from './hooks';

View File

@@ -0,0 +1,35 @@
import { useAsync } from "#/features/async";
import { ReactNode } from "react"
import { Platform } from "react-native";
import { AppointmentsContext, AppointmentsContextValue, AppointmentsStatus } from './context';
type AppointmentsProviderProps = {
children: ReactNode;
};
const AppointmentsProvider: React.FC<AppointmentsProviderProps> = ({
children,
}) => {
const [value] = useAsync<AppointmentsContextValue>(
async () => {
if (Platform.OS !== 'ios') {
return { status: AppointmentsStatus.unavailable };
}
return { status: AppointmentsStatus.unavailable };
},
[],
);
if (!value) {
return <></>
}
return (
<AppointmentsContext.Provider value={value}>
{children}
</AppointmentsContext.Provider>
);
}
export type { AppointmentsProviderProps };
export { AppointmentsProvider };

View File

@@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useState } from "react"
type AsyncCallbackOutput<TArgs extends any[], TResult> = [
(...args: TArgs) => Promise<TResult>,
{
loading: boolean;
error?: any;
result?: TResult;
args?: TArgs;
}
];
type AsyncOutput<TResult> = [
TResult | undefined,
{
loading: boolean;
error?: any;
rerun: () => Promise<TResult>;
}
]
const useAsyncCallback = <
TArgs extends any[],
TResult,
>(fn: (...args: TArgs) => Promise<TResult>, deps: any[]): AsyncCallbackOutput<TArgs, TResult> => {
const [result, setResult] = useState<TResult>();
const [prevArgs, setPrevArgs] = useState<TArgs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<any>();
const action = useCallback(fn, deps);
const invoke = useCallback(
async (...args: TArgs) => {
setLoading(true);
setError(false);
setPrevArgs(args);
try {
const output = await action(...args);
setResult(output);
return output;
} catch (err) {
setResult(undefined);
setError(err);
throw err;
} finally {
setLoading(false);
}
},
[setLoading, setError, setResult, action, ...deps],
);
const options = useMemo(
() => {
const output: AsyncCallbackOutput<TArgs, TResult> = [
invoke,
{
result,
loading,
error,
args: prevArgs,
}
];
return output;
},
[invoke, result, loading, error, prevArgs, ...deps],
);
return options;
};
const useAsync = <TResult>(fn: () => Promise<TResult>, deps: any[]): AsyncOutput<TResult> => {
const [invoke, options] = useAsyncCallback(fn, deps);
useEffect(
() => {
invoke();
},
[invoke],
);
const localOptions = useMemo(
() => ({
loading: options.loading,
error: options.error,
rerun: invoke,
}),
[invoke, options.loading, options.error],
);
return [
options.result,
localOptions,
]
};
export type { AsyncCallbackOutput };
export { useAsync, useAsyncCallback };

View File

@@ -0,0 +1 @@
export * from './hooks';

View File

@@ -0,0 +1,2 @@
export * from './types';
export { timeUtils } from './utils';

View File

@@ -0,0 +1,55 @@
import { Day } from "../day"
export enum TaskType {
appointment = 'appointment',
goal = 'goal',
routine = 'routine',
}
export type Time = {
hour: number;
minute: number;
}
export type UserLocation = {
id: string;
title: string;
position: {
longitute: number;
latitude: number;
};
}
export type TaskBase = {
type: TaskType;
id: string;
title: string;
locations?: UserLocation[];
required: boolean;
priority?: number;
startTime: {
min: Time;
max: Time;
};
duration: number;
}
export type Appointment = TaskBase & {
type: TaskType.appointment;
calendarId: string;
}
export type Goal = TaskBase & {
type: TaskType.goal;
completed: boolean;
deadline?: Day;
startDate?: Day;
days: boolean[];
}
export type Routine = TaskBase & {
type: TaskType.routine;
days: boolean[];
}
export type Task = Appointment | Goal | Routine;

View File

@@ -0,0 +1,67 @@
import { Time } from "./types";
const equal = (a: Time, b: Time) => {
return a.hour == b.hour && a.minute === b.minute;
}
const stringToTime = (input: string) => {
const [hourPart, minutePart] = input.split(':').map(a => a.trim()).filter(Boolean);
const hour = parseInt(hourPart);
const minute = parseInt(minutePart || '0');
if (
!Number.isInteger(hour)
|| !Number.isInteger(minute)
|| Number.isNaN(hour)
|| Number.isNaN(minute)
) {
return undefined;
}
const result: Time = {
hour,
minute,
};
return result;
};
const largerThan = (a: Time, b: Time) => {
return timeToMinutes(a) > timeToMinutes(b);
}
const max = (a: Time, b: Time) => largerThan(a, b) ? a : b;
const min = (a: Time, b: Time) => largerThan(a, b) ? b : a;
const timeToString = (input: Time) => `${input.hour}:${input.minute}`;
const timeToMinutes = (time: Time) => time.hour * 60 + time.minute;
const minutesToTime = (minutes: number): Time => {
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
return { hour, minute };
}
const timeToDate = (time: Time) => {
return new Date(0, 0, 0, time.hour, time.minute);
}
const add = (a: Time, b: Time | number) => {
const toAdd = typeof b === 'number' ? b : b.hour * 60 + b.minute
const current = a.hour * 60 + a.minute + toAdd;
return minutesToTime(current);
}
const timeUtils = {
timeToString,
stringToTime,
equal,
largerThan,
timeToMinutes,
timeToDate,
max,
add,
};
export { timeUtils };

View File

@@ -0,0 +1,12 @@
import { createContext } from "react";
import { Day } from ".";
type DateContextValue = {
date: Day;
setDate: (date: Day) => void;
}
const DateContext = createContext<DateContextValue>(undefined as any);
export type { DateContextValue };
export { DateContext }

View File

@@ -0,0 +1,7 @@
type Day = {
year: number;
month: number;
date: number;
}
export type { Day };

View File

@@ -0,0 +1,12 @@
import { useContext } from "react"
import { DateContext } from "./context"
export const useDate = () => {
const { date } = useContext(DateContext);
return date;
}
export const useSetDate = () => {
const { setDate } = useContext(DateContext);
return setDate;
}

View File

@@ -0,0 +1,4 @@
export { DateProvider } from './provider';
export type { Day } from './day';
export * from './hooks';
export { dayUtils } from './utils';

View File

@@ -0,0 +1,20 @@
import { ReactNode, useState } from "react";
import { DateContext } from "./context";
import { dayUtils } from "./utils";
type DateProviderProps = {
children: ReactNode;
};
const DateProvider: React.FC<DateProviderProps> = ({ children }) => {
const [date, setDate] = useState(dayUtils.today());
return (
<DateContext.Provider value={{ date, setDate }}>
{children}
</DateContext.Provider>
);
}
export type { DateProviderProps };
export { DateProvider };

View File

@@ -0,0 +1,30 @@
import { Day } from "./day";
const today = () => {
return dateToDay(new Date());
}
const dayToDate = (day: Day) => {
return new Date(day.year, day.month - 1, day.date, 0, 0, 0, 0);
}
const dateToDay = (input: Date) => {
const year = input.getFullYear();
const month = input.getMonth() + 1;
const date = input.getDate();
const day: Day = { year, month, date };
return day;
}
const toId = (day: Day) => {
return `${day.year.toString().padStart(4, '0')}-${day.month.toString().padStart(2, '0')}-${day.date.toString().padStart(2, '0')}`;
}
const dayUtils = {
today,
dateToDay,
dayToDate,
toId,
};
export { dayUtils };

View File

@@ -0,0 +1,11 @@
import { createDataContext } from "#/utils/data-context";
import { Goal } from "../data";
const {
Context: GoalsContext,
Provider: GoalsProvider,
}= createDataContext<{[id: string]: Goal}>({
createDefault: () => ({}),
})
export { GoalsContext, GoalsProvider };

View File

@@ -0,0 +1,41 @@
import { useCallback, useContext, useMemo } from "react"
import { Goal } from "../data";
import { GoalsContext } from "./context"
export const useGoals = () => {
const { data } = useContext(GoalsContext);
const current = useMemo(
() => Object.values(data),
[data],
)
return current;
};
export const useSetGoals = () => {
const { setData } = useContext(GoalsContext);
const set = useCallback(
(goal: Goal) => setData(current => ({
...current,
[goal.id]: goal,
})),
[setData],
);
return set;
}
export const useRemoveGoal = () => {
const { setData } = useContext(GoalsContext);
const removeRoutine = useCallback(
(id: string) => {
setData(current => {
const next = {...current};
delete next[id];
return next;
})
},
[setData],
);
return removeRoutine;
}

View File

@@ -0,0 +1,30 @@
import { Time, UserLocation } from "../data";
import { createContext } from "react"
type Transition = {
time: number;
usableTime: number;
to: UserLocation;
from: UserLocation;
};
type GetTransition = (
from: UserLocation,
to: UserLocation,
time: Time,
) => Promise<Transition>;
type LocationContextValue = {
locations: {
[id: string]: UserLocation;
};
set: (location: UserLocation) => any;
remove: (id: string) => any;
lookup?: (address: string) => UserLocation[];
getTransition: GetTransition;
}
const LocationContext = createContext<LocationContextValue>(undefined as any);
export type { LocationContextValue, GetTransition, Transition };
export { LocationContext };

View File

@@ -0,0 +1,74 @@
import { useAsync } from "#/features/async";
import { useContext, useMemo } from "react"
import { requestForegroundPermissionsAsync, getCurrentPositionAsync } from 'expo-location';
import { LocationContext } from "./context"
import { UserLocation } from "../data";
import { getDistanceFromLatLonInKm } from "./utils";
export const useLocations = () => {
const { locations } = useContext(LocationContext);
const result = useMemo(() => Object.values(locations), [locations]);
return result;
}
export const useSetLocation = () => {
const { set } = useContext(LocationContext);
return set;
}
export const useRemoveLocation = () => {
const { remove } = useContext(LocationContext);
return remove;
}
export const useGetTransition = () => {
const { getTransition } = useContext(LocationContext);
return getTransition;
}
export const useLookup = () => {
const { lookup } = useContext(LocationContext);
return lookup;
}
export const useCurrentLocation = (proximity: number = 0.5) => {
const { locations } = useContext(LocationContext);
const result = useAsync<UserLocation | undefined>(
async () => {
let { status } = await requestForegroundPermissionsAsync();
if (status !== 'granted') {
return undefined;
}
let position = await getCurrentPositionAsync({});
const withDistance = Object.values(locations).map((location) => {
if (!location.position) {
return;
}
const distance = getDistanceFromLatLonInKm(
position.coords.latitude,
position.coords.longitude,
location.position.latitude,
location.position.longitute,
)
return {
distance,
location,
}
}).filter(Boolean).sort((a, b) => a!.distance - b!.distance)
const current = withDistance.find(d => d!.distance < proximity);
if (!current) {
return {
id: `${position.coords.longitude} ${position.coords.latitude}`,
title: 'Unknown',
position: {
latitude: position.coords.latitude,
longitute: position.coords.longitude,
},
};
}
return current.location;
},
[],
);
return result;
}

View File

@@ -0,0 +1,3 @@
export type { Transition, GetTransition } from './context';
export { LocationProvider } from './provider';
export * from './hooks';

View File

@@ -0,0 +1,73 @@
import { useAsync, useAsyncCallback } from "#/features/async";
import { GetTransition } from "./context";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { ReactNode, useState } from "react";
import { LocationContext } from "./context";
import { UserLocation } from "../data";
type LocationProviderProps = {
children: ReactNode;
lookup: (address: string) => UserLocation[];
getTransition: GetTransition;
}
const LOCATION_STORAGE_KEY = 'locations';
const LocationProvider: React.FC<LocationProviderProps> = ({
children,
lookup,
getTransition,
}) => {
const [locations, setLocations] = useState<{[id: string]: UserLocation}>({});
useAsync(
async () => {
const raw = await AsyncStorage.getItem(LOCATION_STORAGE_KEY);
if (raw) {
setLocations(JSON.parse(raw));
}
},
[],
);
const [set] = useAsyncCallback(
async (location: UserLocation) => {
const index = {
...locations,
[location.id]: location,
}
setLocations(index);
await AsyncStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(index));
},
[setLocations, locations],
)
const [remove] = useAsyncCallback(
async (id: string) => {
const index = {
...locations,
}
delete index[id];
setLocations(index);
await AsyncStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(index));
},
[setLocations, locations],
);
return (
<LocationContext.Provider
value={{
locations,
set,
remove,
lookup,
getTransition,
}}
>
{children}
</LocationContext.Provider>
)
}
export type { LocationProviderProps };
export { LocationProvider };

View File

@@ -0,0 +1,17 @@
export function getDistanceFromLatLonInKm(lat1: number, lon1: number, lat2: number, lon2: number) {
var R = 6371; // Radius of the earth in km
var dLat = deg2rad(lat2-lat1); // deg2rad below
var dLon = deg2rad(lon2-lon1);
var a =
Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
Math.sin(dLon/2) * Math.sin(dLon/2)
;
var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
var d = R * c; // Distance in km
return d;
}
function deg2rad(deg: number) {
return deg * (Math.PI/180)
}

View File

@@ -0,0 +1,30 @@
import { createContext, SetStateAction } from "react";
import { Time, UserLocation } from "../data";
import { Day } from "../day";
type Override = {
locations?: UserLocation[] | null;
startMin?: Time;
startMax?: Time;
duration?: number;
required?: boolean;
priority?: number;
enabled?: boolean;
}
type OverrideIndex = {
startTime?: Time;
tasks: {
[id: string]: Override;
};
};
type OverrideContextValue = {
overrides: OverrideIndex;
get: (date: Day) => Promise<OverrideIndex>;
set: React.Dispatch<SetStateAction<OverrideIndex>>;
}
const OverrideContext = createContext<OverrideContextValue>(undefined as any);
export type { Override, OverrideIndex, OverrideContextValue };
export { OverrideContext };

View File

@@ -0,0 +1,73 @@
import { useContext } from "react"
import { useAsyncCallback } from "../async";
import { Time } from "../data";
import { Override, OverrideContext } from "./context"
export const useOverrides = () => {
const { overrides } = useContext(OverrideContext);
return overrides;
}
export const useSetOverride = () => {
const { set } = useContext(OverrideContext);
return set;
}
export const useGetOverride = () => {
const { get } = useContext(OverrideContext);
return get;
}
export const useSetTaskOverride = () => {
const { set } = useContext(OverrideContext);
const setTaskOverride = useAsyncCallback(
async (id: string, overrides: Override) => {
set(current => ({
...current,
tasks: {
...current.tasks,
[id]: overrides,
},
}));
},
[set],
);
return setTaskOverride;
}
export const useClearTaskOverride = () => {
const { set } = useContext(OverrideContext);
const clearTaskOverride = useAsyncCallback(
async (id: string) => {
set(current => {
const tasks = {...current.tasks};
delete tasks[id]
return {
...current,
tasks,
};
});
},
[set],
);
return clearTaskOverride;
}
export const useStartTimeOverride = () => {
const { overrides } = useContext(OverrideContext);
return overrides.startTime;
};
export const useSetStartTimeOverride = () => {
const { set } = useContext(OverrideContext);
const setStartTime = useAsyncCallback(
async (startTime?: Time) => {
set(current => ({
...current,
startTime,
}));
},
[set],
);
return setStartTime;
};

View File

@@ -0,0 +1,3 @@
export type { Override, OverrideIndex } from './context';
export { OverrideProvider } from './provider';
export * from './hooks';

View File

@@ -0,0 +1,59 @@
import AsyncStorageLib from "@react-native-async-storage/async-storage";
import React, { ReactNode, SetStateAction, useCallback, useState } from "react";
import { useAsync } from "../async";
import { Day, useDate, dayUtils } from "../day";
import { Override, OverrideContext, OverrideIndex } from "./context";
type OverrideProviderProps = {
children: ReactNode;
}
const StorageKey = 'overrides';
const OverrideProvider: React.FC<OverrideProviderProps> = ({ children }) => {
const currentDate = useDate();
const [overrides, setOverrides] = useState<OverrideIndex>();
const get = useCallback(
async (date: Day): Promise<OverrideIndex> => {
const raw = await AsyncStorageLib.getItem(`${StorageKey}_${dayUtils.toId(date)}`);
if (!raw) {
return { tasks: {} };
}
return JSON.parse(raw);
},
[],
);
const set = useCallback(
async (override: SetStateAction<OverrideIndex>) => {
const next = typeof override === 'function' ? override(overrides!) : overrides;
setOverrides(next);
await AsyncStorageLib.setItem(
`${StorageKey}_${dayUtils.toId(currentDate)}`,
JSON.stringify(next),
);
},
[currentDate, overrides],
);
useAsync(
async () => {
setOverrides(await get(currentDate));
},
[currentDate, setOverrides],
);
if (!overrides) {
return <></>
}
return (
<OverrideContext.Provider value={{ overrides, get, set }}>
{children}
</OverrideContext.Provider>
);
}
export type { OverrideProviderProps };
export { OverrideProvider };

View File

@@ -0,0 +1,161 @@
import { Task, Time, UserLocation } from "#/features/data";
import { Context, GraphNode } from "../types";
import { getImpossible, getNext } from "./get-next";
enum Strategies {
all = 'all',
allValid = 'all-valid',
firstValid = 'first-valid',
firstComplet = 'first-complete',
}
type RunningStatus = {
current: 'running';
nodes: number;
start: Date;
strategy: Strategies,
cancel: () => void;
}
type CompletedStatus = {
current: 'completed';
start: Date;
end: Date;
nodes: number;
strategy: Strategies,
}
type Status = RunningStatus | CompletedStatus;
type BuildGraphOptions = {
location: UserLocation;
time: Time;
tasks: Task[];
context: Context;
strategy?: Strategies;
batchSize?: number;
sleepTime?: number;
callback?: (status: Status) => void;
};
const sleep = (time: number) => new Promise(resolve => setTimeout(resolve, time));
const fil = <T>(
fn: ((item: T) => boolean)[],
input: T[],
): T[][] => {
const output: T[][] = new Array(fn.length).fill(undefined).map(() => []);
for (let i = 0; i < input.length; i++) {
for (let b = 0; b < fn.length; b++) {
if (fn[b](input[i])) {
output[b].push(input[i]);
break;
}
}
}
return output;
};
const buildGraph = async ({
location,
time,
tasks,
context,
strategy = Strategies.allValid,
callback,
batchSize = 1000,
sleepTime = 10,
}: BuildGraphOptions) => {
const start = new Date();
let nodeCount = 0;
let running = true;
const { remaining, impossible } = getImpossible(tasks, time);
let leafList: GraphNode[] = [{
location,
time: {
end: time,
start: time,
},
score: 0,
remainingTasks: remaining,
impossibeTasks: impossible,
status: {
dead: false,
completed: false,
},
}];
const completedList: GraphNode[] = [];
const deadList: GraphNode[] = [];
const complete = (nodes: GraphNode[]) => {
if (callback) {
callback({
current: 'completed',
nodes: nodeCount,
start,
end: new Date(),
strategy,
});
}
return nodes.sort((a, b) => b.score - a.score);
}
while (true) {
nodeCount++;
if (!running) {
return [];
}
if (
leafList.length === 0
&& completedList.length === 0
&& strategy !== Strategies.all
) {
strategy = Strategies.all;
leafList.push(...deadList);
}
const node = leafList.pop();
if (!node) {
break;
}
if (nodeCount % batchSize === 0) {
if (callback) {
callback({
current: 'running',
nodes: nodeCount,
strategy,
start,
cancel: () => {
running = false;
}
})
}
await sleep(sleepTime);
}
const next = await getNext(node, context);
const [alive, completed, dead] = fil([
n => (strategy === Strategies.all || !n.status.dead) && !n.status.completed,
n => !!n.status.completed && (strategy === Strategies.all || !n.status.dead),
n => n.status.dead,
], next);
leafList.push(...alive);
if (strategy === Strategies.firstValid && completed.length > 0) {
return complete(completed);
}
if (completed.length > 0) {
completedList.push(...completed)
}
if (strategy === Strategies.firstComplet) {
const fullComplete = completed.find(c => c.impossibeTasks.length === 0);
if (fullComplete) {
return complete([fullComplete]);
}
}
if (strategy !== Strategies.all) {
deadList.push(...dead);
}
}
return complete(completedList);
}
export type { Status, BuildGraphOptions };
export { buildGraph, Strategies };

View File

@@ -0,0 +1,33 @@
import { timeUtils } from "#/features/data";
import { GraphNode, PlannedEntry } from "../types";
const constructDay = (node: GraphNode) => {
let current: GraphNode | undefined = node;
const plans: PlannedEntry[] = [];
while(current) {
if (current.task) {
plans.push({
type: 'task',
name: current.task?.title || 'start',
start: timeUtils.add(current.time.start, (current.transition?.time || 0)),
end: current.time.end,
score: current.score,
})
}
if (current.transition) {
plans.push({
type: 'transition',
start: current.time.start,
end: timeUtils.add(current.time.start, current.transition.time),
from: current.transition.from,
to: current.transition.to,
})
}
current = current.parent;
}
return plans.reverse();
}
export { constructDay };

View File

@@ -0,0 +1,141 @@
import { Task, Time, timeUtils } from '#/features/data';
import { Transition } from '#/features/location';
import { Context, GraphNode } from '../types';
import { getRemainingLocations, listContainLocation } from './utils';
const DEFAULT_PRIORITY = 50;
const isDead = (impossible: Task[]) => {
const missingRequered = impossible.find(t => t.required);
return !!missingRequered;
}
type GetImpossibleResult = {
remaining: Task[];
impossible: Task[];
}
export const getImpossible = (
tasks: Task[],
time: Time,
) => {
const result: GetImpossibleResult = {
remaining: [],
impossible: [],
}
for (let task of tasks) {
if (timeUtils.largerThan(time, task.startTime.max)) {
result.impossible.push(task);
} else {
result.remaining.push(task);
}
};
return result;
}
type CalculateScoreOptions = {
tasks?: Task[];
transition?: Transition;
impossible: Task[];
}
const calculateScore = ({
tasks,
transition,
impossible,
}: CalculateScoreOptions) => {
let score = 0;
tasks?.forEach((task) => {
score += (task.priority || DEFAULT_PRIORITY) * 10;
impossible.forEach((task) => {
if (task.required) {
score -= 10000 + (1 * (task.priority || DEFAULT_PRIORITY));
} else {
score -= 100 + (1 * (task.priority || DEFAULT_PRIORITY));
}
});
});
if (transition) {
const minutes = transition.time;
score -= 10 + (1 * minutes);
}
return score;
}
const getNext = async (
currentNode: GraphNode,
context: Context,
): Promise<GraphNode[]> => {
const nextNodes: GraphNode[] = [];
if (!currentNode.transition) {
const remainingLocations = getRemainingLocations(currentNode.remainingTasks, currentNode.location);
await Promise.all(remainingLocations.map(async(location) => {
const transition = await context.getTransition(currentNode.location, location, currentNode.time.end);
const endTime = timeUtils.add(currentNode.time.end, transition.time);
const { remaining, impossible } = getImpossible(currentNode.remainingTasks, endTime);
const score = calculateScore({
transition,
impossible,
});
nextNodes.push({
parent: currentNode,
location: transition.to,
remainingTasks: remaining,
transition,
impossibeTasks: [
...impossible,
...currentNode.impossibeTasks,
],
score: currentNode.score + score,
status: {
completed: false,
dead: false, // TODO: fix isDead(impossible),
},
time: {
start: currentNode.time.end,
end: endTime,
},
})
}));
}
const possibleTasks = currentNode.remainingTasks.filter(task => !task.locations || listContainLocation(task.locations, currentNode.location))
await Promise.all(possibleTasks.map(async (orgTask) => {
const task = {...orgTask};
let startTime =
timeUtils.max(
currentNode.time.end,
task.startTime.min,
);
const parentRemainging = currentNode.remainingTasks.filter(t => t !== orgTask);
let endTime = timeUtils.add(startTime, task.duration);
const { remaining, impossible } = getImpossible(parentRemainging, endTime);
const score = calculateScore({
tasks: [task],
impossible,
});
nextNodes.push({
parent: currentNode,
location: currentNode.location,
task,
remainingTasks: remaining,
impossibeTasks: [
...impossible,
...currentNode.impossibeTasks,
],
score: currentNode.score + score,
status: {
completed: remaining.length === 0,
dead: isDead(impossible),
},
time: {
start: startTime,
end: endTime,
},
})
}));
return nextNodes;
};
export { getNext };

View File

@@ -0,0 +1,38 @@
import { UserLocation } from "#/types/location";
import { Task } from "#/types/task";
export const locationEqual = (a: UserLocation, b: UserLocation) => {
if (a === b) {
return true;
}
// if (a.location === b.location) {
// return true;
// }
// if (a.location && b.location && a.location.latitude === b.location.latitude && a.location.longitute === b.location.longitute) {
// return true;
// }
if (a.title === b.title) {
return true;
}
return false;
}
export const listContainLocation = (list: UserLocation[], target: UserLocation) => {
return !!list.find(l => locationEqual(l, target));
}
export const getRemainingLocations = (tasks: Task[], current: UserLocation) => {
const result: UserLocation[] = [];
tasks.forEach((task) => {
if (!task.locations) {
return;
}
for (let location of task.locations) {
if (!listContainLocation(result, location) && !locationEqual(current, location)) {
result.push(location)
}
}
})
return result;
};

View File

@@ -0,0 +1,21 @@
import { createDataContext } from '#/utils/data-context';
import { Time } from '../data';
import { Strategies } from "./algorithm/build-graph";
type PlannerOptions = {
strategy: Strategies;
startTime: Time;
}
const {
Context: PlannerContext,
Provider: PlannerProvider,
} = createDataContext<PlannerOptions>({
createDefault: () => ({
startTime: { hour: 7, minute: 0 },
strategy: Strategies.firstComplet,
}),
});
export type { PlannerOptions };
export { PlannerContext, PlannerProvider };

View File

@@ -0,0 +1,222 @@
import { buildGraph, Status, Strategies } from "./algorithm/build-graph";
import { useContext } from "react";
import { add } from 'date-fns';
import { PlannerContext } from "./context";
import { Task, Time, UserLocation } from "../data";
import { useRoutines } from "../routines";
import { useGoals } from "../goals/hooks";
import { useAsyncCallback } from "../async";
import { Day, dayUtils } from "../day";
import { useGetOverride } from "../overrides";
import { useGetAppointments } from "../appointments";
import { useGetTransition } from "../location";
import { PlannedEntry } from "./types";
import { constructDay } from "./algorithm/construct-day";
export type PreparePlanOptions = {
start: Day;
end: Day;
}
export type PlanOptions = PreparePlanOptions & {
location: UserLocation;
}
export type PlanResultDay = {
day: Day;
start: Time;
} & ({
status: 'waiting',
} | {
status: 'running',
nodes: number;
strategy: Strategies;
} | {
status: 'done';
nodes: number;
strategy: Strategies;
plan: PlannedEntry[];
impossible: Task[];
});
export type PlanResult = {
impossible: Task[];
days: {
[day: string]: PlanResultDay;
}
}
const getDays = (start: Day, end: Day): Day[] => {
const result: Day[] = [];
let currentDate = dayUtils.dayToDate(start);
const stopDate = dayUtils.dayToDate(end);
while (currentDate <= stopDate) {
result.push(dayUtils.dateToDay(currentDate));
currentDate = add(currentDate, {
days: 1,
});
}
return result;
}
const firstValue = <T>(...args: (T | undefined)[]): T => {
for (let arg of args) {
if (typeof arg !== 'undefined') {
return arg;
}
}
return undefined as unknown as T;
}
export const useOptions = () => {
const { data } = useContext(PlannerContext);
return data;
}
export const useSetOptions = () => {
const { setData } = useContext(PlannerContext);
return setData;
}
const usePreparePlan = () => {
const routines = useRoutines();
const goals = useGoals();
const getOverrides = useGetOverride();
const [getAppontments] = useGetAppointments();
const preparePlan = useAsyncCallback(
async ({ start, end }: PreparePlanOptions) => {
const days = await Promise.all(getDays(start, end).map(async (day) => {
const overrides = await getOverrides(day);
const start: Time = firstValue(overrides.startTime, { hour: 7, minute: 0 });
const appointments = await getAppontments(day);
const tasks = [...routines, ...appointments].map<Task | undefined>((task) => {
const override = overrides.tasks[task.id];
if (override?.enabled === false) {
return undefined;
}
const result: Task = {
...task,
startTime: {
min: firstValue(override?.startMin, task.startTime.min),
max: firstValue(override?.startMax, task.startTime.max),
},
duration: firstValue(override?.duration, task.duration),
required: firstValue(override?.required, task.required),
}
return result;
}).filter(Boolean).map(a => a as Exclude<typeof a, undefined>);
return {
day,
start,
tasks,
}
}));
return {
goals: [...goals],
days,
}
},
[routines, goals, getOverrides, getAppontments],
);
return preparePlan;
}
export const usePlanOptions = () => {
const { data } = useContext(PlannerContext);
return data;
}
export const useSetPlanOptions = () => {
const { setData } = useContext(PlannerContext);
return setData;
}
export const usePlan = () => {
const [preparePlan] = usePreparePlan();
const getTransition = useGetTransition();
const options = usePlanOptions();
const createPlan = useAsyncCallback(
async ({ location, ...prepareOptions}: PlanOptions) => {
const prepared = await preparePlan(prepareOptions);
let result: PlanResult = {
impossible: [],
days: prepared.days.reduce((output, current) => ({
...output,
[dayUtils.toId(current.day)]: {
day: current.day,
start: current.start,
status: 'waiting',
},
}), {} as {[name: string]: PlanResultDay})
}
const update = (next: PlanResult) => {
result = next;
}
for (let day of prepared.days) {
const id = dayUtils.toId(day.day);
const dayGoal = prepared.goals;
const graph = await buildGraph({
location,
time: day.start,
tasks: [...day.tasks, ...dayGoal],
strategy: options.strategy,
context: {
getTransition,
},
callback: (status) => {
update({
...result,
days: {
...result.days,
[id]: {
day: day.day,
start: day.start,
status: 'running',
nodes: status.nodes,
strategy: status.strategy,
}
}
});
}
});
const [winner] = graph;
if (!winner) {
continue;
}
const plan = constructDay(winner);
update({
...result,
days: {
...result.days,
[id]: {
...result.days[id],
impossible: winner.impossibeTasks,
status: 'done',
plan,
}
}
})
prepared.goals = prepared.goals.filter((goal) => {
if (!dayGoal.find(d => d.id === goal.id)) {
return true;
}
if (!winner.impossibeTasks.find(d => d.id === goal.id)) {
return false;
}
return true;
})
}
return {
...result,
impossible: prepared.goals,
};
},
[preparePlan, getTransition, options],
);
return createPlan;
}

View File

@@ -0,0 +1,3 @@
export { PlannerProvider, PlannerOptions } from './context';
export { Strategies } from './algorithm/build-graph';
export * from './hooks';

View File

@@ -0,0 +1,49 @@
import { Task, Time, UserLocation } from "../data";
import { GetTransition, Transition } from "../location";
type Context = {
getTransition: GetTransition;
};
export type PlannedTask = {
type: 'task';
name: string;
start: Time;
external?: boolean;
end: Time;
score: number;
}
export type PlannedTransition = {
type: 'transition';
start: Time;
end: Time;
from: UserLocation;
to: UserLocation;
};
export type PlannedEntry = PlannedTask | PlannedTransition;
type GraphNode = {
location: UserLocation;
task?: Task;
transition?: Transition;
parent?: GraphNode;
remainingTasks: Task[];
impossibeTasks: Task[];
score: number;
time: {
start: Time;
end: Time;
};
status: {
dead: boolean;
completed: boolean;
};
};
export type {
GraphNode,
Context,
};

View File

@@ -0,0 +1,11 @@
import { createDataContext } from "#/utils/data-context";
import { Routine } from "../data";
const {
Context: RoutinesContext,
Provider: RoutinesProvider,
}= createDataContext<{[id: string]: Routine}>({
createDefault: () => ({}),
})
export { RoutinesContext, RoutinesProvider };

View File

@@ -0,0 +1,41 @@
import { useCallback, useContext, useMemo } from "react"
import { Routine } from "../data";
import { RoutinesContext } from "./context"
export const useRoutines = () => {
const { data } = useContext(RoutinesContext);
const current = useMemo(
() => Object.values(data),
[data],
)
return current;
};
export const useSetRoutine = () => {
const { setData } = useContext(RoutinesContext);
const set = useCallback(
(routine: Routine) => setData(current => ({
...current,
[routine.id]: routine,
})),
[setData],
);
return set;
}
export const useRemoveRoutine = () => {
const { setData } = useContext(RoutinesContext);
const removeRoutine = useCallback(
(id: string) => {
setData(current => {
const next = {...current};
delete next[id];
return next;
})
},
[setData],
);
return removeRoutine;
}

View File

@@ -0,0 +1,2 @@
export { RoutinesProvider } from './context';
export * from './hooks';

View File

@@ -0,0 +1,39 @@
import { ReactNode } from "react"
import { AppointmentsProvider } from "./appointments"
import { DateProvider } from "./day"
import { GoalsProvider } from "./goals/context"
import { GetTransition, LocationProvider } from "./location"
import { OverrideProvider } from "./overrides"
import { PlannerProvider } from "./planner"
import { RoutinesProvider } from "./routines"
type SetupProps = {
children: ReactNode;
getTransit: GetTransition;
}
const Setup: React.FC<SetupProps> = ({
children,
getTransit,
}) => {
return (
<DateProvider>
<PlannerProvider storageKey="planner">
<LocationProvider getTransition={getTransit} lookup={() => []}>
<AppointmentsProvider>
<GoalsProvider storageKey="goals">
<RoutinesProvider storageKey="routines">
<OverrideProvider>
{children}
</OverrideProvider>
</RoutinesProvider>
</GoalsProvider>
</AppointmentsProvider>
</LocationProvider>
</PlannerProvider>
</DateProvider>
);
};
export type { SetupProps };
export { Setup };

View File

@@ -0,0 +1,63 @@
import { useMemo } from "react";
import { useAppointments } from "../appointments";
import { useAsyncCallback } from "../async";
import { Task, TaskType } from "../data";
import { useGoals, useRemoveGoal, useSetGoals } from "../goals/hooks";
import { useRemoveRoutine, useRoutines, useSetRoutine } from "../routines";
export const useTasks = (type?: TaskType) => {
const [appointments] = useAppointments();
const routines = useRoutines();
const goals = useGoals();
const tasks = useMemo<Task[]>(
() => {
if (!type) {
return [...(appointments || []), ...routines, ...goals];
}
const map = {
[TaskType.routine]: routines,
[TaskType.appointment]: appointments,
[TaskType.goal]: goals,
}
return map[type] || [];
},
[appointments, routines, goals, type],
);
return tasks;
};
export const useSetTask = () => {
const setRoutine = useSetRoutine();
const setGoal = useSetGoals();
const result = useAsyncCallback(
async (task: Task) => {
if (task.type === TaskType.routine) {
await setRoutine(task);
} else if (task.type === TaskType.goal) {
await setGoal(task);
}
},
[setRoutine, setGoal],
);
return result;
};
export const useRemoveTask = () => {
const removeRoutine = useRemoveRoutine();
const removeGoal = useRemoveGoal();
const result = useAsyncCallback(
async (task: Task) => {
if (task.type === TaskType.routine) {
removeRoutine(task.id);
} else if (task.type === TaskType.goal) {
removeGoal(task.id);
}
},
[removeRoutine, removeGoal],
);
return result;
};

View File

@@ -0,0 +1 @@
export * from './hooks';

10
packages/app/src/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { registerRootComponent } from 'expo';
import './setup';
import { App } from './app';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

View File

@@ -0,0 +1 @@
export default '';

View File

@@ -0,0 +1,2 @@
import '@fontsource/montserrat';
export default '';

View File

@@ -0,0 +1,17 @@
import { Task } from "#/features/data";
import { Row, RowProps } from "@morten-olsen/ui";
type Props = RowProps & {
item: Task;
}
const TaskListItem: React.FC<Props> = ({ item, ...rowProps }) => {
return (
<Row
{...rowProps}
title={item.title}
/>
);
};
export { TaskListItem };

View File

@@ -0,0 +1,29 @@
import { dayUtils } from "#/features/day";
import { PlanResultDay } from "#/features/planner"
import { Body1, Jumbo } from "@morten-olsen/ui";
import { PlanDayTask } from "./task";
type Props = {
day: PlanResultDay;
};
const PlanDay: React.FC<Props> = ({ day }) => {
if (day.status === 'waiting') {
return <></>
}
if (day.status === 'running') {
return <Body1>Running</Body1>
}
return (
<>
{day.plan.map((item) => {
if (item.type === 'task') {
return <PlanDayTask task={item} />
}
return <Body1>Transit {item.from.title} to {item.to.title}</Body1>
})}
</>
)
}
export { PlanDay };

View File

@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { PlannedTask } from "#/features/planner/types";
import chroma from 'chroma-js';
import styled from "styled-components/native";
import stringToColor from 'string-to-color';
import { timeUtils } from '#/features/data';
import { Body1, CalendarEntry, Row } from '@morten-olsen/ui';
type Props = {
task: PlannedTask;
onPress?: () => void;
};
const Time = styled.Text<{background : string}>`
font-size: 10px;
font-weight: bold;
`;
const TimeBox = styled.View<{
background: string;
}>`
margin-right: 10px;
width: 50px;
height: 100%;
align-items: center;
justify-content: center;
`;
const Filler = styled.View`
margin: 10px;
width: 50px;
height: 50px;
align-items: center;
justify-content: center;
`;
const Block = styled.View<{
background: string;
height: number;
}>`
background: ${({ background }) => background};
height: ${({ height }) => height / 3}px;
max-height: 100px;
margin: 5px;
flex-direction: row;
align-items: center;
border-radius: 15px;
overflow: hidden;
border: solid 1px ${({ background }) => background === 'transparent' ? background : chroma(background).darken(0.3).hex()};
`;
const Main = styled.View`
flex: 1;
`
const Touch = styled.TouchableOpacity`
`;
const PlanDayTask: React.FC<Props> = ({ task, onPress }) => {
const color = useMemo(
() => chroma(stringToColor(task.name)).luminance(0.7).saturate(1).brighten(0.6).hex(),
[task.name],
);
const height = useMemo(
() => (timeUtils.timeToMinutes(task.end) - timeUtils.timeToMinutes(task.start)) / 10,
[task.start, task.end],
);
const view = (
<Row>
<CalendarEntry
location={task.location?.join(', ') || 'anywhere'}
start={timeUtils.timeToDate(task.start)}
end={timeUtils.timeToDate(task.end)}
title={task.name}
/>
</Row>
);
if (onPress) {
return (
<Touch onPress={onPress}>
{view}
</Touch>
);
}
return view;
};
export { PlanDayTask };

View File

@@ -0,0 +1 @@
export * from './day';

View File

@@ -0,0 +1,41 @@
import { TaskType } from "#/features/data";
import { useTasks } from "#/features/tasks";
import { Group } from "@morten-olsen/ui"
import { RootNavigationProp } from "#/ui/router";
import { useNavigation } from "@react-navigation/native";
import { useCallback } from "react";
type Props = {
type: TaskType;
}
const TaskGroup: React.FC<Props> = ({ type }) => {
const { navigate } = useNavigation<RootNavigationProp>();
const tasks = useTasks(type);
const add = useCallback(
(type: TaskType) => {
navigate('add-task', {
type,
})
},
[navigate],
);
return (
<Group
title={type}
add={() => add(type)}
items={tasks || []}
getKey={(task) => task.id}
render={(task) => ({
title: task.title,
onPress: () => {
navigate('set-override', { id: task.id });
},
})}
/>
);
};
export { TaskGroup };

View File

@@ -0,0 +1 @@
export * from './components/base';

View File

@@ -0,0 +1,2 @@
export { Router } from './router';
export * from './types';

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useTheme } from 'styled-components/native';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Icon } from '@morten-olsen/ui/components/base';
import { DayScreen } from '../screens/day';
import { TaskAddScreen } from '../screens/task/add';
import { MainTabParamList, RootStackParamList } from './types';
import { Platform } from 'react-native';
import { MoreScreen } from '../screens/more';
import { LocationListScreen } from '../screens/locations/list';
import { LocationSetScreen } from '../screens/locations/set';
import { TaskListScreen } from '../screens/task/list';
import { OverrideSetScreen } from '../screens/task/overrides';
import { PlanScreen } from '../screens/plan';
const MoreStackNavigator = createNativeStackNavigator();
const MoreStack: React.FC = () => (
<MoreStackNavigator.Navigator>
<MoreStackNavigator.Screen name="more-main" component={MoreScreen} />
<MoreStackNavigator.Screen name="locations" component={LocationListScreen} />
<MoreStackNavigator.Screen name="tasks" component={TaskListScreen} />
</MoreStackNavigator.Navigator>
);
const MainTabsNvaigator = createBottomTabNavigator<MainTabParamList>();
const MainTabs: React.FC = () => {
const theme = useTheme();
return (
<MainTabsNvaigator.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
}}
>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: 'Days',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="check-square" />,
}}
name="day"
component={DayScreen}
/>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: 'Plan',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="check-square" />,
}}
name="plan"
component={PlanScreen}
/>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: 'More',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="more-vertical" />,
}}
name="more"
component={MoreStack}
/>
</MainTabsNvaigator.Navigator>
);
};
const RootNavigator = Platform.OS === 'web'
? createStackNavigator<RootStackParamList>()
: createNativeStackNavigator<RootStackParamList>();
const Root: React.FC = () => (
<RootNavigator.Navigator screenOptions={{ headerShown: false, animationEnabled: true }}>
<RootNavigator.Group>
<RootNavigator.Screen name="main" component={MainTabs} />
</RootNavigator.Group>
<RootNavigator.Group screenOptions={{ presentation: 'transparentModal' }}>
<RootNavigator.Screen name="add-task" component={TaskAddScreen} />
<RootNavigator.Screen name="set-location" component={LocationSetScreen} />
<RootNavigator.Screen name="set-override" component={OverrideSetScreen} />
</RootNavigator.Group>
</RootNavigator.Navigator>
);
const Router: React.FC = () => {
const theme = useTheme();
const baseTheme = useMemo(
() => DefaultTheme,
[],
);
const navigationTheme = useMemo(
() => ({
...baseTheme,
colors: {
...baseTheme.colors,
background: theme.colors.shade,
card: theme.colors.background,
text: theme.colors.text,
}
}),
[baseTheme, theme],
);
return (
<NavigationContainer theme={navigationTheme}>
<Root />
</NavigationContainer>
)
};
export { Router };

View File

@@ -0,0 +1,51 @@
import { TaskType } from "#/features/data";
import { NavigatorScreenParams, RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
export type RootStackParamList = {
main: undefined;
'add-task': {
type: TaskType;
} | {
id: string;
};
'set-location': {
id?: string;
};
'set-override': {
id: string;
};
};
export type MainTabParamList = {
day: NavigatorScreenParams<RootStackParamList>;
plan: NavigatorScreenParams<RootStackParamList>;
more: NavigatorScreenParams<RootStackParamList>;
}
export type MoreStackParamList = {
locations: undefined;
tasks: {
type: TaskType;
};
}
export type MoreScreenNavigationProps = NativeStackNavigationProp<
MoreStackParamList
>;
export type RootRouteProp = RouteProp<RootStackParamList>;
export type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;
export type LocationSetScreenRouteProp = RouteProp<RootStackParamList, 'set-location'>;
export type TaskAddScreenRouteProp = RouteProp<RootStackParamList, 'add-task'>;
export type TaskAddScreenNavigationProp = NativeStackNavigationProp<
RootStackParamList,
'add-task'
>;
export type TaskListScreenRouteProp = RouteProp<MoreStackParamList, 'tasks'>;
export type DayScreenRouteProp = RouteProp<MainTabParamList, 'day'>;

View File

@@ -0,0 +1,50 @@
import { useAppointmentStatus } from "#/features/appointments";
import { AppointmentsStatus } from "#/features/appointments/context";
import { TaskType } from "#/features/data";
import { dayUtils, useDate, useSetDate } from "#/features/day";
import { useSetStartTimeOverride, useStartTimeOverride } from "#/features/overrides";
import { CalendarStrip } from "@morten-olsen/ui/components/date"
import { TimeInput } from "@morten-olsen/ui/components/form";
import { TaskGroup } from "#/ui/containers/tasks/group";
import styled from "styled-components/native";
const Wrapper = styled.View`
margin-top: 30px;
flex: 1;
`;
const Content = styled.ScrollView`
flex: 1;
`;
const DayScreen: React.FC = () => {
const date = useDate();
const setDate = useSetDate();
const appointmentStatus = useAppointmentStatus();
const startTimeOverride = useStartTimeOverride();
const [setStartTimeOverride] = useSetStartTimeOverride();
return (
<Wrapper>
<CalendarStrip
selected={dayUtils.dayToDate(date)}
onSelect={(date) => setDate(dayUtils.dateToDay(date))}
/>
<Content>
<TimeInput
key={dayUtils.toId(date)}
label="Start time"
value={startTimeOverride}
onChange={setStartTimeOverride}
/>
{appointmentStatus === AppointmentsStatus.rejected && (
<TaskGroup type={TaskType.appointment} />
)}
<TaskGroup type={TaskType.routine} />
<TaskGroup type={TaskType.goal} />
</Content>
</Wrapper>
);
};
export { DayScreen };

View File

@@ -0,0 +1,25 @@
import { useLocations, useRemoveLocation } from "#/features/location"
import { List, Page } from "@morten-olsen/ui/components/base";
import { useNavigation } from "@react-navigation/native";
const LocationListScreen: React.FC = () => {
const { navigate } = useNavigation();
const locations = useLocations();
const removeLocation = useRemoveLocation();
return (
<Page>
<List
items={locations}
remove={l => removeLocation(l.id)}
getKey={l => l.id}
add={() => navigate('set-location', {})}
render={(item) => ({
title: item.title,
})}
/>
</Page>
);
}
export { LocationListScreen };

View File

@@ -0,0 +1,56 @@
import { nanoid } from 'nanoid';
import { useAsyncCallback } from "#/features/async";
import { useLocations, useSetLocation } from "#/features/location"
import { Button, Popup, Row } from "@morten-olsen/ui/components/base";
import { TextInput } from "@morten-olsen/ui/components/form";
import { LocationSetScreenRouteProp, RootNavigationProp } from "#/ui/router";
import { useNavigation, useRoute } from "@react-navigation/native";
import { useEffect, useState } from "react";
const LocationSetScreen: React.FC = () => {
const {
params: { id = nanoid() },
} = useRoute<LocationSetScreenRouteProp>();
const { navigate } = useNavigation<RootNavigationProp>();
const locations = useLocations();
const [title, setTitle] = useState('');
const setLocation = useSetLocation();
useEffect(
() => {
const current = locations.find(l => l.id === id);
if (!current) {
return;
}
setTitle(current.title);
},
[id, locations],
)
const [save] = useAsyncCallback(
async () => {
await setLocation({
id,
title,
position: { longitute: 0, latitude: 0 },
});
navigate('main');
},
[id, title],
);
return (
<Popup title="Edit location">
<TextInput
label="What should it call the location?"
value={title}
onChangeText={setTitle}
/>
<Row>
<Button title="Save" onPress={save} />
</Row>
</Popup>
);
};
export { LocationSetScreen };

View File

@@ -0,0 +1,30 @@
import { TaskType } from "#/features/data";
import { Page, Row } from "@morten-olsen/ui/components/base";
import { MoreScreenNavigationProps } from "#/ui/router";
import { useNavigation } from "@react-navigation/native";
const MoreScreen: React.FC = () => {
const { navigate } = useNavigation<MoreScreenNavigationProps>();
return (
<Page>
<Row
title="Calendars"
/>
<Row
title="Locations"
onPress={() => navigate('locations')}
/>
<Row
title="Routines"
onPress={() => navigate('tasks', { type: TaskType.routine })}
/>
<Row
title="Goals"
onPress={() => navigate('tasks', { type: TaskType.goal })}
/>
</Page>
);
}
export { MoreScreen };

View File

@@ -0,0 +1,69 @@
import { Day, dayUtils, useDate } from "#/features/day"
import { usePlan } from "#/features/planner";
import { Button, Row } from "@morten-olsen/ui/components/base";
import { CalendarStrip, DateSelector, FormLayout } from "@morten-olsen/ui";
import { PlanDay } from "#/ui/containers/plan";
import { useCallback, useState } from "react";
import styled from "styled-components/native";
const Scroll = styled.ScrollView`
flex: 1;
`
const Wrapper = styled.View`
margin: 60px 0;
`
const Horizontal = styled.View`
`;
const FlexDateSelector = styled(DateSelector)`
width: 100%;
`;
const PlanScreen: React.FC = () => {
const [start, setStart] = useState<Day>(dayUtils.today());
const [end, setEnd] = useState<Day>(dayUtils.today());
const [plan, { result }] = usePlan();
const runPlanning = useCallback(
() => plan({
start,
end,
location: { id: 'sdf', title: 'sdf' },
}),
[start, end, plan],
);
return (
<Scroll>
<Wrapper>
<Horizontal>
<FlexDateSelector
label="Start date"
selected={start}
onSelect={setStart}
/>
<DateSelector
label="End date"
selected={end}
onSelect={setEnd}
/>
</Horizontal>
<Row>
<Button onPress={runPlanning} title="Plan" />
</Row>
{!!result && (
<>
<CalendarStrip
/>
{Object.entries(result.days).map(([key, day]) => (
<PlanDay day={day} />
))}
</>
)}
</Wrapper>
</Scroll>
);
}
export { PlanScreen }

View File

@@ -0,0 +1,171 @@
import { useAsyncCallback } from "#/features/async";
import { nanoid } from 'nanoid';
import { Task, TaskType, Time, UserLocation } from "#/features/data";
import { useLocations } from "#/features/location";
import { useSetTask, useTasks } from "#/features/tasks";
import { Button, Cell, Group, Popup, Row } from "@morten-olsen/ui/components/base"
import { Checkbox, TextInput, TimeInput, OptionalSelector } from "@morten-olsen/ui/components/form";
import { RootNavigationProp, TaskAddScreenRouteProp } from "#/ui/router";
import { Overline } from "@morten-olsen/ui/typography";
import { useNavigation, useRoute } from "@react-navigation/native";
import { useEffect, useState } from "react";
import styled from "styled-components/native";
const SideBySide = styled.View`
flex-direction: row;
`;
const dayNames = [
'Monday',
'Tuesday',
'Wednsday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
const days = new Array(7).fill(undefined).map((_, i) => ({
id: i,
name: dayNames[i],
}))
const TaskAddScreen: React.FC = () => {
const { params: { type, id }} = useRoute<TaskAddScreenRouteProp>();
const { navigate, goBack } = useNavigation<RootNavigationProp>();
const [currentId, setCurrentId] = useState(id || nanoid());
const [setTask] = useSetTask();
const tasks = useTasks();
const [currentType, setCurrentType] = useState<TaskType>(type);
const locations = useLocations();
const [title, setTitle] = useState('');
const [maxStart, setMaxStart] = useState<Time>();
const [minStart, setMinStart] = useState<Time>();
const [duration, setDuration] = useState('');
const [hasLocation, setHasLocation] = useState(false);
const [selectedLocations, setSelectedLocations] = useState<UserLocation[]>([]);
const [hasDays, setHasDays] = useState(false);
const [selectedDays, setSelectedDays] = useState<typeof days>([]);
const [required, setRequired] = useState(false);
useEffect(
() => {
if (!id) {
return;
}
const current = tasks.find(t => t.id === id);
if (!current) {
return;
}
setTitle(current.title);
setMaxStart(current.startTime.max);
setMinStart(current.startTime.min);
setDuration(current.duration?.toString() || '');
setHasLocation(!!current.locations);
setSelectedLocations(current.locations || []);
setCurrentType(current.type || TaskType.goal);
setRequired(current.required);
if (current.type === TaskType.goal || current.type === TaskType.routine) {
setHasDays(!!current.days);
}
},
[id],
)
const [save] = useAsyncCallback(
async () => {
const task: Partial<Task> = {
id: currentId,
title,
type: currentType,
required,
startTime: {
max: maxStart!,
min: minStart!,
},
duration: parseInt(duration),
locations: hasLocation ? selectedLocations: undefined,
};
if (task.type === TaskType.goal || task.type === TaskType.routine) {
task.days = hasDays
? new Array(7).fill(undefined).map((_, i) => !!selectedDays.find(d => d.id === i))
: undefined;
}
await setTask(task as Task);
navigate('main');
},
[
title,
currentId,
maxStart,
minStart,
duration,
hasLocation,
selectedLocations,
hasDays,
selectedDays,
required,
],
);
return (
<Popup title={type ? `Add ${type}` : `Update ${title}`} onClose={goBack}>
<Group title="Basic">
<TextInput label="Title" value={title} onChangeText={setTitle} />
<SideBySide>
<TimeInput flex={1} label="Min start" value={minStart} onChange={setMinStart} />
<TimeInput flex={1} label="Max start" value={maxStart} onChange={setMaxStart} />
</SideBySide>
<TextInput
label="Duration"
value={duration}
onChangeText={setDuration}
right={<Cell><Overline>min</Overline></Cell>}
/>
</Group>
<Group title="Optional" startHidden>
<OptionalSelector
label="Location"
enabled={hasLocation}
items={locations}
selected={selectedLocations}
onChange={setSelectedLocations}
render={location => ({
title: location.title,
})}
getKey={location => location.id}
setEnabled={setHasLocation}
disabledText="Anywhere"
enabledText="Specific location"
/>
<OptionalSelector
label="Days"
enabled={hasDays}
items={days}
selected={selectedDays}
onChange={setSelectedDays}
render={day=> ({
title: day.name
})}
getKey={day => day.id.toString()}
setEnabled={setHasDays}
disabledText="Any day"
enabledText="Specific days"
/>
<Checkbox label="Required" onChange={setRequired} value={required} />
{type === TaskType.goal && (
<SideBySide>
<TextInput label="Start" flex={1} />
<TextInput label="Deadline" flex={1} />
</SideBySide>
)}
</Group>
<Row>
<Button onPress={save} title="Save" type="primary" />
</Row>
</Popup>
);
};
export { TaskAddScreen };

View File

@@ -0,0 +1,32 @@
import { useRemoveTask, useTasks } from "#/features/tasks";
import { List, Page } from "@morten-olsen/ui/components/base";
import { RootNavigationProp, TaskListScreenRouteProp } from "#/ui/router";
import { useNavigation, useRoute } from "@react-navigation/native";
const TaskListScreen: React.FC = () => {
const {
params: { type },
} = useRoute<TaskListScreenRouteProp>();
const { navigate } = useNavigation<RootNavigationProp>();
const tasks = useTasks(type);
const [removeTask] = useRemoveTask();
return (
<Page>
<List
items={tasks}
remove={removeTask}
getKey={l => l.id}
add={() => navigate('add-task', { type })}
render={(item) => ({
title: item.title,
onPress: () => {
navigate('add-task', { id: item.id })
},
})}
/>
</Page>
);
}
export { TaskListScreen };

View File

@@ -0,0 +1,171 @@
import { useAsyncCallback } from "#/features/async";
import { Time, timeUtils, UserLocation } from "#/features/data";
import { useLocations } from "#/features/location";
import { useTasks } from "#/features/tasks";
import { Button, Cell, Popup, Row } from "@morten-olsen/ui/components/base"
import { Checkbox, TextInput, TimeInput, OptionalSelector } from "@morten-olsen/ui/components/form";
import { RootNavigationProp, TaskAddScreenRouteProp } from "#/ui/router";
import { Overline } from "@morten-olsen/ui/typography";
import { useNavigation, useRoute } from "@react-navigation/native";
import { useEffect, useMemo, useState } from "react";
import styled from "styled-components/native";
import { Override, useClearTaskOverride, useOverrides, useSetTaskOverride } from "#/features/overrides";
const SideBySide = styled.View`
flex-direction: row;
justify-content: flex-end;
`;
const dayNames = [
'Monday',
'Tuesday',
'Wednsday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
]
const days = new Array(7).fill(undefined).map((_, i) => ({
id: i,
name: dayNames[i],
}))
const OverrideSetScreen: React.FC = () => {
const { params: { id }} = useRoute<TaskAddScreenRouteProp>();
const { navigate, goBack } = useNavigation<RootNavigationProp>();
const [setOverride] = useSetTaskOverride()
const [clearOverrides] = useClearTaskOverride();
const overrides = useOverrides();
const tasks = useTasks();
const task = useMemo(
() => tasks.find(t => t.id === id)!,
[tasks, id],
);
const locations = useLocations();
const [maxStart, setMaxStart] = useState<Time>();
const [minStart, setMinStart] = useState<Time>();
const [duration, setDuration] = useState('');
const [hasLocation, setHasLocation] = useState(false);
const [selectedLocations, setSelectedLocations] = useState<UserLocation[]>([]);
const [hasDays, setHasDays] = useState(false);
const [selectedDays, setSelectedDays] = useState<typeof days>([]);
useEffect(
() => {
if (!id) {
return;
}
const current = overrides.tasks[id];
if (!current) {
return;
}
setMinStart(current.startMin);
setMaxStart(current.startMax);
setDuration(current.duration?.toString() || '');
setHasLocation(!!current.locations);
setSelectedLocations(current.locations || []);
},
[id],
)
const [save] = useAsyncCallback(
async () => {
const override: Override = {
startMin: minStart,
startMax: maxStart,
duration: duration ? parseInt(duration) : undefined,
locations: hasLocation ? selectedLocations: undefined,
};
await setOverride(id, override);
navigate('main');
},
[
id,
maxStart,
minStart,
duration,
hasLocation,
selectedLocations,
hasDays,
selectedDays,
],
);
const [clear] = useAsyncCallback(
async () => {
await clearOverrides(id);
navigate('main');
},
[id, clearOverrides],
);
return (
<Popup title={`Overrides for ${task.title}`} onClose={goBack}>
<SideBySide>
<TimeInput
flex={1}
placeholder={task.startTime.min ? timeUtils.timeToString(task.startTime.min) : undefined}
label="Min start"
value={minStart}
onChange={setMinStart}
/>
<TimeInput
flex={1}
placeholder={task.startTime.max ? timeUtils.timeToString(task.startTime.max) : undefined}
label="Max start"
value={maxStart}
onChange={setMaxStart}
/>
</SideBySide>
<TextInput
label="Duration"
value={duration}
onChangeText={setDuration}
right={<Cell><Overline>min</Overline></Cell>}
/>
<OptionalSelector
label="Location"
enabled={hasLocation}
items={locations}
selected={selectedLocations}
onChange={setSelectedLocations}
render={location => ({
title: location.title,
})}
getKey={location => location.id}
setEnabled={setHasLocation}
disabledText="Anywhere"
enabledText="Specific location"
/>
<OptionalSelector
label="Days"
enabled={hasDays}
items={days}
selected={selectedDays}
onChange={setSelectedDays}
render={day=> ({
title: day.name
})}
getKey={day => day.id.toString()}
setEnabled={setHasDays}
disabledText="Any day"
enabledText="Specific days"
/>
<Checkbox label="Required" />
<TextInput
label="Priority"
placeholder="5"
/>
<Row>
<SideBySide>
<Button onPress={clear} title="Clear" type="destructive" />
<Button onPress={save} title="Save" type="primary" />
</SideBySide>
</Row>
</Popup>
);
};
export { OverrideSetScreen };

View File

@@ -0,0 +1,64 @@
import { useAsync, useAsyncCallback } from "#/features/async";
import AsyncStorageLib from "@react-native-async-storage/async-storage";
import { createContext, ReactNode, useState } from "react"
type DataContextOptions<T> = {
createDefault: () => T;
deserialize?: (item: T) => T;
};
type DataContextProviderProps = {
storageKey: string;
children: ReactNode;
};
function createDataContext<T extends {[name: string]: any}>({
createDefault,
deserialize = a => a,
}: DataContextOptions<T>) {
const Context = createContext<{
data: T;
setData: (data: T | ((current: T) => T)) => Promise<void>;
}>(undefined as any);
const Provider: React.FC<DataContextProviderProps> = ({
storageKey: key,
children,
}) => {
const [current, setCurrent] = useState<T>();
const [setData] = useAsyncCallback(
async (input: T | ((current: T) => T)) => {
let next = typeof input === 'function'
? input(current!)
: input;
setCurrent(next);
await AsyncStorageLib.setItem(key, JSON.stringify(next));
},
[key, current, setCurrent],
);
useAsync(
async () => {
const raw = await AsyncStorageLib.getItem(key);
const next = raw ? deserialize(JSON.parse(raw)) : createDefault();
setCurrent(next);
},
[key, setCurrent],
)
if (!current) {
return <></>
}
return (
<Context.Provider value={{ data: current, setData }}>
{children}
</Context.Provider>
)
};
return { Context, Provider };
}
export { createDataContext };

View File

@@ -0,0 +1,12 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": "./",
"paths": {
"#/*": ["./src/*"],
"@morten-olsen/ui/*": ["../ui/src/*"],
"@morten-olsen/ui": ["../ui/src/index.ts"]
}
}
}

35
packages/app/types.tsx Normal file
View File

@@ -0,0 +1,35 @@
/**
* Learn more about using TypeScript with React Navigation:
* https://reactnavigation.org/docs/typescript/
*/
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
export type RootStackParamList = {
Root: NavigatorScreenParams<RootTabParamList> | undefined;
Modal: undefined;
NotFound: undefined;
};
export type RootStackScreenProps<Screen extends keyof RootStackParamList> = NativeStackScreenProps<
RootStackParamList,
Screen
>;
export type RootTabParamList = {
TabOne: undefined;
TabTwo: undefined;
};
export type RootTabScreenProps<Screen extends keyof RootTabParamList> = CompositeScreenProps<
BottomTabScreenProps<RootTabParamList, Screen>,
NativeStackScreenProps<RootStackParamList>
>;

View File

@@ -0,0 +1,26 @@
const path = require("path");
const createExpoWebpackConfigAsync = require("@expo/webpack-config");
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
module.exports = async function (env, argv) {
const config = await createExpoWebpackConfigAsync(env, argv);
// Use the React refresh plugin in development mode
if (env.mode === "development") {
config.plugins.push(
new ReactRefreshWebpackPlugin({
forceEnable: true,
})
);
}
config.module.rules.push({
test: /\.tsx?$/,
loader: require.resolve("babel-loader"),
include: [
/@morten-olsen\/ui/,
],
});
return config;
};