This commit is contained in:
Morten Olsen
2022-05-02 14:26:11 +02:00
commit d83a4aebc7
77 changed files with 16638 additions and 0 deletions

6
.expo-shared/assets.json Normal file
View File

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

14
.gitignore vendored Normal file
View File

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

4
App.tsx Normal file
View File

@@ -0,0 +1,4 @@
import 'react-native-get-random-values';
import { App } from './src/app';
export default App;

34
app.json Normal file
View File

@@ -0,0 +1,34 @@
{
"expo": {
"name": "bob",
"slug": "bob",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"updates": {
"fallbackToCacheTimeout": 0
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"favicon": "./assets/images/favicon.png"
}
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

13
babel.config.js Normal file
View File

@@ -0,0 +1,13 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[require.resolve('babel-plugin-module-resolver'), {
alias: {
'#': './src',
},
}],
],
};
};

63
package.json Normal file
View File

@@ -0,0 +1,63 @@
{
"name": "bob",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"eject": "expo eject",
"test": "jest --watchAll"
},
"jest": {
"preset": "jest-expo"
},
"dependencies": {
"@expo/vector-icons": "^12.0.0",
"@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",
"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-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",
"@types/chroma-js": "^2.1.3",
"@types/react": "~17.0.21",
"@types/react-native": "~0.64.12",
"@types/styled-components-react-native": "^5.1.3",
"babel-plugin-module-resolver": "^4.1.0",
"expo-cli": "^5.4.3",
"jest": "^26.6.3",
"jest-expo": "~44.0.1",
"react-test-renderer": "17.0.1",
"typescript": "~4.3.5"
},
"private": true
}

38
src/app.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useCallback, useMemo, useState } from 'react';
import { Setup } from './features/setup';
import { Router } from './ui/router';
import { ThemeProvider } from 'styled-components/native';
import { light } from './ui';
import { set } from 'date-fns';
const App: React.FC = () => {
const [day, setDate] = useState(() => set(new Date, {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
}));
const getTransit = useCallback(
async (from: any, to: any) => ({
to,
from,
time: 45 * 60 * 1000,
usableTime: 0,
}),
[],
)
return (
<SafeAreaProvider>
<StatusBar />
<ThemeProvider theme={light}>
<Setup getTransit={getTransit} day={day} setDate={setDate}>
<Router />
</Setup>
</ThemeProvider>
</SafeAreaProvider>
);
};
export { App };

View File

@@ -0,0 +1,21 @@
import { UserLocation } from "#/types/location"
import { createContext } from "react"
type AgendaContext = {
enabled: boolean;
locations?: UserLocation[];
startMax?: Date;
startMin?: Date;
duration?: number;
count?: number;
}
type AgendaContextContextValue = {
contexts: {[id: string]: AgendaContext};
set: (id: string, context: AgendaContext) => Promise<void>;
}
const AgendaContextContext = createContext<AgendaContextContextValue>(undefined as any);
export type { AgendaContext, AgendaContextContextValue };
export {AgendaContextContext };

View File

@@ -0,0 +1,56 @@
import { useAsyncCallback } from "#/hooks/async";
import { Task } from "#/types/task";
import { set } from "date-fns";
import { useContext, useMemo } from "react"
import { useDate } from "../calendar";
import { useTasks } from "../tasks";
import { AgendaContextContext } from "./context"
const toToday = (today: Date, target: Date) => set(target, {
year: today.getFullYear(),
month: today.getMonth(),
date: today.getDate(),
})
export const useAgendaContext = () => {
const { contexts } = useContext(AgendaContextContext);
return contexts;
}
export const useSetAgendaContext = () => {
const { set } = useContext(AgendaContextContext);
const result = useAsyncCallback(set, [set]);
return result;
}
export const useTasksWithContext = () => {
const { all } = useTasks();
const date = useDate();
const contexts = useAgendaContext();
const withContext = useMemo<(Task & { enabled: boolean })[]>(
() => all.map((task) => {
const context = contexts[task.id];
if (!context) {
return { ...task, enabled: true };
}
return {
...task,
locations: context.locations?.length || 0 > 0 ? context.locations : task.locations,
start: {
min: context.startMin ? toToday(date, context.startMin) : task.start.min,
max: context.startMax ? toToday(date, context.startMax) : task.start.max,
},
duration: {
...task.duration,
min: context.duration || task.duration.min,
},
count: context.count,
enabled: typeof context.enabled === 'undefined' ? true : context.enabled,
}
}),
[all, contexts],
);
return withContext;
}

View File

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

View File

@@ -0,0 +1,64 @@
import { useAsync } from "#/hooks/async";
import AsyncStorageLib from "@react-native-async-storage/async-storage";
import { format } from "date-fns";
import { ReactNode, useCallback, useMemo, useState } from "react";
import { AgendaContext, AgendaContextContext, AgendaContextContextValue } from "./context";
type AgendaContextProviderProps = {
children: ReactNode;
day: Date;
}
const AGENDA_CONTEXT_STORAGE_KEY = 'agenda-contexts';
const AgendaContextProvider: React.FC<AgendaContextProviderProps> = ({
children,
day,
}) => {
const [contexts, setContexts] = useState<AgendaContextContextValue['contexts']>({});
const key = useMemo(
() => `${AGENDA_CONTEXT_STORAGE_KEY}-${format(day, 'yyyy-MM-dd')}`,
[day],
);
const set = useCallback(
async (id: string, context: AgendaContext) => {
const index = {
...contexts,
[id]: {...context},
};
setContexts(index);
await AsyncStorageLib.setItem(key, JSON.stringify(contexts));
},
[setContexts, contexts, key],
);
useAsync(
async () => {
const raw = await AsyncStorageLib.getItem(key);
if (!raw) {
return;
}
const items = JSON.parse(raw) as AgendaContextContextValue['contexts'];
Object.values(items).forEach((item) => {
if (item.startMax) {
item.startMax = new Date(item.startMax);
}
if (item.startMin) {
item.startMin = new Date(item.startMin);
}
})
setContexts(items);
},
[key],
)
return (
<AgendaContextContext.Provider value={{ contexts, set }}>
{children}
</AgendaContextContext.Provider>
);
};
export type { AgendaContextProviderProps };
export { AgendaContextProvider };

View File

@@ -0,0 +1,17 @@
import { Calendar } from "expo-calendar";
import { createContext } from "react";
type CalendarContextValue = {
date: Date;
setDate: (date: Date) => void;
calendars: Calendar[];
calendar: Calendar;
selected: Calendar[];
setSelected: (calendars: Calendar[]) => void;
error?: any;
}
const CalendarContext = createContext<CalendarContextValue>(undefined as any);
export type { CalendarContextValue };
export { CalendarContext };

View File

@@ -0,0 +1,95 @@
import { useContext } from "react"
import { CalendarContext } from "./context"
import { set } from 'date-fns'
import { useAsync, useAsyncCallback } from "#/hooks/async";
import { createEventAsync, deleteEventAsync, getEventsAsync } from "expo-calendar";
import { PlanItem } from "#/types/plans";
export const useCalendar = () => {
const { calendar } = useContext(CalendarContext);
return calendar;
}
export const useCalendars = () => {
const { calendars } = useContext(CalendarContext);
return calendars;
}
export const useSelectedCalendars = () => {
const { selected } = useContext(CalendarContext);
return selected;
}
export const useSetSelectedCalendars = () => {
const { setSelected } = useContext(CalendarContext);
return setSelected;
}
export const useDate = () => {
const { date } = useContext(CalendarContext);
return date;
}
export const useSetDate = () => {
const { setDate } = useContext(CalendarContext);
return setDate;
}
export const useCommit = () => {
const date = useDate();
const calendar = useCalendar();
const result = useAsyncCallback(
async (plan: PlanItem[]) => {
const end = set(date, {
hours: 24,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
const current = await getEventsAsync([calendar.id], date, end);
await Promise.all(
current.map(async (item) => {
await deleteEventAsync(item.id)
}),
);
for (let item of plan) {
if (item.type === 'task' && item.external) {
continue;
}
const title = item.type === 'task' ? item.name : `${item.from.title} to ${item.to.title}`;
await createEventAsync(calendar.id, {
title: title,
startDate: item.start,
endDate: item.end,
})
}
},
[date],
);
return result;
}
export const useToday = (start: Date, end?: Date) => {
const selectedCalendars = useSelectedCalendars();
if (!end) {
end = set(start, {
hours: 24,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
}
const result = useAsync(
async () => {
if (selectedCalendars.length === 0) {
return [];
}
return getEventsAsync(selectedCalendars.map(c => c.id), start, end!)
},
[selectedCalendars, start.getTime()],
);
return result;
}

View File

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

View File

@@ -0,0 +1,105 @@
import { Calendar, CalendarAccessLevel, createCalendarAsync, EntityTypes, getCalendarsAsync, getDefaultCalendarAsync, requestCalendarPermissionsAsync } from "expo-calendar";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { useAsync } from "#/hooks/async";
import { CalendarContext } from "./context";
import AsyncStorage from '@react-native-async-storage/async-storage';
const SELECTED_STORAGE_KEY = 'selected_calendars';
type CalendarProviderProps = {
calendarName?: string,
date: Date;
children: ReactNode;
setDate: (date: Date) => void;
}
type SetupResponse = {
status: 'rejected';
} | {
status: 'ready';
calendar: Calendar;
calendars: Calendar[];
};
const CalendarProvider: React.FC<CalendarProviderProps> = ({
date,
children,
setDate,
calendarName = 'Bob the planner',
}) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [value] = useAsync<SetupResponse>(
async () => {
const { status } = await requestCalendarPermissionsAsync();
if (status !== 'granted') {
return { status: 'rejected' };
}
let calendars = await getCalendarsAsync(EntityTypes.EVENT);
let calendar = calendars.find(c => c.title === calendarName);
if (!calendar) {
const defaultCalendar = await getDefaultCalendarAsync();
await createCalendarAsync({
title: calendarName,
source: defaultCalendar.source,
sourceId: defaultCalendar.source.id,
ownerAccount: 'personal',
accessLevel: CalendarAccessLevel.OWNER,
entityType: EntityTypes.EVENT,
name: calendarName,
});
calendars = await getCalendarsAsync(EntityTypes.EVENT);
calendar = calendars.find(c => c.name === calendarName)!;
}
const selectedRaw = await AsyncStorage.getItem(SELECTED_STORAGE_KEY)
if (selectedRaw) {
setSelectedIds(JSON.parse(selectedRaw));
}
return {
status: 'ready',
calendars,
calendar,
};
},
[],
);
const setSelected = useCallback(
(calendars: Calendar[]) => {
const ids = calendars.map(c => c.id);
setSelectedIds(ids);
AsyncStorage.setItem(SELECTED_STORAGE_KEY, JSON.stringify(ids));
},
[setSelectedIds]
)
const selected = useMemo(
() => {
if (value?.status !== 'ready') {
return [];
}
return value.calendars.filter(c => selectedIds.includes(c.id));
},
[value, selectedIds],
);
if (!value || value.status !== 'ready') {
return <></>
}
return (
<CalendarContext.Provider
value={{
setDate,
date,
selected,
setSelected,
calendar: value.calendar,
calendars: value.calendars,
}}
>
{children}
</CalendarContext.Provider>
)
};
export type { CalendarProviderProps };
export { CalendarProvider };

View File

View File

@@ -0,0 +1,17 @@
import { GetTransition, UserLocation } from "#/types/location";
import { createContext } from "react"
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 };
export { LocationContext };

View File

@@ -0,0 +1,73 @@
import { useAsync } from "#/hooks/async";
import { useContext } from "react"
import { requestForegroundPermissionsAsync, getCurrentPositionAsync } from 'expo-location';
import { LocationContext } from "./context"
import { UserLocation } from "#/types/location";
import { getDistanceFromLatLonInKm } from "./utils";
export const useLocations = () => {
const { locations } = useContext(LocationContext);
return locations;
}
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 = useLocations();
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.location) {
return;
}
const distance = getDistanceFromLatLonInKm(
position.coords.latitude,
position.coords.longitude,
location.location.latitude,
location.location.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',
location: {
latitude: position.coords.latitude,
longitute: position.coords.longitude,
},
};
}
return current.location;
},
[],
);
return result;
}

View File

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

View File

@@ -0,0 +1,72 @@
import { useAsync, useAsyncCallback } from "#/hooks/async";
import { GetTransition, UserLocation } from "#/types/location";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { ReactNode, useState } from "react";
import { LocationContext } from "./context";
type LocationProviderProps = {
children: ReactNode;
lookup: (address: string) => UserLocation[];
getTransition: GetTransition;
}
const LOCATION_STORAGE_KEY = 'location_storage';
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,144 @@
import { Context, GraphNode } from "#/types/graph";
import { UserLocation } from "#/types/location";
import { Task } from "#/types/task";
import { 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;
cancel: () => void;
}
type CompletedStatus = {
current: 'completed';
start: Date;
end: Date;
nodes: number;
}
type Status = RunningStatus | CompletedStatus;
type BuildGraphOptions = {
location: UserLocation;
time: Date;
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]);
continue;
}
}
}
return output;
};
const buildGraph = async ({
location,
time,
tasks,
context,
strategy = Strategies.allValid,
callback,
batchSize = 1000,
sleepTime = 10,
}: BuildGraphOptions) => {
const start = new Date();
let leafs: GraphNode[] = [{
location,
time: {
end: time,
start: time,
},
score: 0,
remainingTasks: tasks,
impossibeTasks: [],
status: {
dead: false,
completed: false,
},
}];
let nodes = 0;
let running = true;
const final: GraphNode[] = [];
while (true) {
nodes++;
if (!running) {
return [];
}
const node = leafs.pop();
if (!node) {
break;
}
if (nodes % batchSize === 1) {
if (callback) {
callback({
current: 'running',
nodes,
start,
cancel: () => {
running = false;
}
})
}
await sleep(sleepTime);
}
const next = await getNext(node, context);
const [alive, completed] = fil([
n => !n.status.dead && !n.status.completed,
n => !!n.status.completed && !n.status.dead
], next);
leafs.push(...alive);
if (strategy === Strategies.firstValid && completed.length > 0) {
if (callback) {
callback({ current: 'completed', nodes, start, end: new Date() })
}
return completed;
}
if (completed.length > 0) {
final.push(...completed)
}
if (strategy === Strategies.firstComplet) {
const fullComplete = completed.find(c => c.impossibeTasks.length === 0);
if (fullComplete) {
if (callback) {
callback({ current: 'completed', nodes, start, end: new Date() })
}
return [fullComplete];
}
}
}
console.log('nodes', nodes);
if (callback) {
callback({ current: 'completed', nodes, start, end: new Date() })
}
return final
.filter(n => n.status.completed)
.sort((a, b) => b.score - a.score);
}
export type { Status, BuildGraphOptions };
export { buildGraph, Strategies };

View File

@@ -0,0 +1,40 @@
import { GraphNode } from "#/types/graph";
import { PlanItem } from "#/types/plans";
const constructDay = (node: GraphNode) => {
let current: GraphNode | undefined = node;
const plans: PlanItem[] = [];
while(current) {
if (current.task) {
plans.push({
type: 'task',
name: current.task?.name || 'start',
external: current.task?.external,
start: new Date(
current.time.start.getTime()
+ (current.transition?.time || 0),
),
end: current.time.end,
score: current.score,
})
}
if (current.transition) {
plans.push({
type: 'transition',
start: current.time.start,
end: new Date(
current.time.start.getTime()
+ current.transition.time,
),
from: current.transition.from,
to: current.transition.to,
})
}
current = current.parent;
}
return plans.reverse();
}
export { constructDay };

View File

@@ -0,0 +1,146 @@
import { GraphNode, Context } from '#/types/graph';
import { Transition } from '#/types/location';
import { Task } from '#/types/task';
import { getRemainingLocations, listContainLocation } from './utils';
const isDead = (impossible: Task[]) => {
const missingRequered = impossible.find(t => t.required);
return !!missingRequered;
}
type GetImpossibleResult = {
remaining: Task[];
impossible: Task[];
}
const getImpossible = (
tasks: Task[],
time: Date,
) => {
const result: GetImpossibleResult = {
remaining: [],
impossible: [],
}
for (let task of tasks) {
if (time > task.start.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 * 10;
impossible.forEach((task) => {
if (task.required) {
score -= 1000;
} else {
score -= task.priority;
}
});
});
if (transition) {
const minutes = transition.time / 1000 / 60
score -= 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 = new Date(currentNode.time.end.getTime() + 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: 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};
task.count = (task.count || 1) - 1
let startTime = new Date(
Math.max(
currentNode.time.end.getTime(),
task.start.min.getTime(),
),
);
const parentRemainging = currentNode.remainingTasks.filter(t => t !== orgTask);
let endTime = new Date(startTime.getTime() + task.duration.min);
const { remaining, impossible } = getImpossible(
task.count > 0
? [...parentRemainging, task]
: 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,65 @@
import { useGetTransition } from "#/features/location";
import { buildGraph, Status, Strategies } from "./algorithm/build-graph";
import { constructDay } from "./algorithm/construct-day";
import { useAsyncCallback } from "#/hooks/async";
import { UserLocation } from "#/types/location";
import { useDate } from "../calendar";
import { useTasksWithContext } from "../agenda-context";
import { useMemo, useState } from "react";
import { PlanItem } from "#/types/plans";
import { Task } from "#/types/task";
export type UsePlanOptions = {
location: UserLocation;
}
export type UsePlan = [
(start?: Date) => Promise<any>,
{
result?: { agenda: PlanItem[], impossible: Task[] };
status?: Status;
loading: boolean;
error?: any;
}
]
export const usePlan = ({
location,
}: UsePlanOptions): UsePlan => {
const today = useDate();
const [status, setStatus] = useState<Status>();
const all = useTasksWithContext();
const enabled = useMemo(() => all.filter(f => f.enabled), [all])
const getTransition = useGetTransition();
const [invoke, options] = useAsyncCallback(
async (start?: Date) => {
const graph = await buildGraph({
location,
time: start || today,
tasks: enabled,
strategy: Strategies.firstComplet,
context: {
getTransition,
},
callback: setStatus,
});
const valid = graph.filter(a => !a.status.dead && a.status.completed).sort((a, b) => b.score - a.score);
const day = constructDay(valid[0]);
return {
impossible: valid[0].impossibeTasks,
agenda: day,
};
},
[today, location, all, setStatus],
);
return [
invoke,
{
result: options.result,
loading: options.loading,
error: options.error,
status: status,
}
];
}

View File

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

View File

@@ -0,0 +1,27 @@
import { UserLocation } from "#/types/location";
import { createContext } from "react"
type Routine = {
id: string;
title: string;
required: boolean;
priority: number;
start: {
min: Date;
max: Date;
};
duration: number;
location?: UserLocation[];
days?: boolean[];
}
type RoutinesContextValue = {
routines: Routine[];
remove: (id: string) => any;
set: (routine: Routine) => any;
}
const RoutinesContext = createContext<RoutinesContextValue>(undefined as any);
export type { Routine, RoutinesContextValue };
export { RoutinesContext };

View File

@@ -0,0 +1,36 @@
import { useCallback, useContext, useMemo } from "react"
import { Routine, RoutinesContext } from "./context"
export const useRoutines = (day?: number) => {
const { routines } = useContext(RoutinesContext);
const current = useMemo(
() => routines.filter(
r => typeof day === undefined
|| !r.days
|| r.days[day!],
),
[routines],
);
return current;
};
export const useSetRoutine = () => {
const { set } = useContext(RoutinesContext);
const setRoutine = useCallback(
(routine: Routine) => set(routine),
[set],
);
return setRoutine;
}
export const useRemoveRoutine = () => {
const { remove } = useContext(RoutinesContext);
const removeRoutine = useCallback(
(id: string) => remove(id),
[remove],
);
return removeRoutine;
}

View File

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

View File

@@ -0,0 +1,74 @@
import { useAsync, useAsyncCallback } from "#/hooks/async";
import AsyncStorage from "@react-native-async-storage/async-storage";
import React, { ReactNode, useMemo, useState } from "react";
import { Routine, RoutinesContext } from "./context";
type RoutinesProviderProps = {
children: ReactNode;
}
const ROUTINES_STORAGE_KEY = 'routines-items';
const RoutinesProvider: React.FC<RoutinesProviderProps> = ({ children }) => {
const [routineIndex, setRoutineIndex] = useState<{[id: string]: Routine}>({});
const routines = useMemo(
() => Object.values(routineIndex),
[routineIndex]
);
useAsync(
async () => {
const raw = await AsyncStorage.getItem(ROUTINES_STORAGE_KEY);
if (!raw) {
return;
}
const result = JSON.parse(raw) as {[name: string]: Routine};
Object.values(result).forEach(item => {
item.start.max = new Date(item.start.max);
item.start.min = new Date(item.start.min);
});
setRoutineIndex(result);
},
[setRoutineIndex],
);
const [set] = useAsyncCallback(
async (routine: Routine) => {
const index = {
...routineIndex,
[routine.id]: routine,
};
setRoutineIndex(index);
await AsyncStorage.setItem(ROUTINES_STORAGE_KEY, JSON.stringify(index));
},
[setRoutineIndex, routineIndex],
);
const [remove] = useAsyncCallback(
async (id: string) => {
const index = {
...routineIndex,
};
delete index[id];
setRoutineIndex(index);
await AsyncStorage.setItem(ROUTINES_STORAGE_KEY, JSON.stringify(index));
},
[setRoutineIndex, routineIndex],
);
return (
<RoutinesContext.Provider
value={{
routines,
set,
remove,
}}
>
{children}
</RoutinesContext.Provider>
)
}
export type { RoutinesProviderProps };
export { RoutinesProvider };

32
src/features/setup.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { GetTransition } from "#/types/location"
import { ReactNode } from "react"
import { AgendaContextProvider } from "./agenda-context"
import { CalendarProvider } from "./calendar"
import { LocationProvider } from "./location"
import { RoutinesProvider } from "./routines"
type SetupProps = {
day: Date;
setDate: (date: Date) => void;
children: ReactNode;
getTransit: GetTransition;
}
const Setup: React.FC<SetupProps> = ({
children,
day,
setDate,
getTransit,
}) => (
<CalendarProvider date={day} setDate={setDate}>
<RoutinesProvider>
<LocationProvider getTransition={getTransit} lookup={() => []}>
<AgendaContextProvider day={day}>
{children}
</AgendaContextProvider>
</LocationProvider>
</RoutinesProvider>
</CalendarProvider>
);
export type { SetupProps };
export { Setup };

View File

@@ -0,0 +1,76 @@
import { useMemo } from "react";
import { useDate, useToday } from "#/features/calendar"
import { useRoutines } from "#/features/routines";
import { Task } from "#/types/task";
import { set } from "date-fns";
const toToday = (today: Date, target: Date) => set(target, {
year: today.getFullYear(),
month: today.getMonth(),
date: today.getDate(),
})
export const useTasks = () => {
const start = useDate();
const day = useMemo(
() => start.getDay(),
[start],
)
const [fromCalendar = []] = useToday(start);
const fromRoutines = useRoutines(day);
const tasksFromCalendar = useMemo<Task[]>(
() => fromCalendar.filter(e => !e.allDay).map(task => {
const start = new Date(task.startDate);
const end = new Date(task.endDate);
const duration = end.getTime() - start.getTime();
return {
id: task.id,
name: task.title,
external: true,
required: true,
start: {
min: start,
max: start,
},
priority: 100,
duration: {
min: duration,
},
};
}),
[fromCalendar],
);
const tasksFromRoutines = useMemo<Task[]>(
() => fromRoutines.map(task => ({
id: task.id,
name: task.title,
locations: task.location,
start: {
min: toToday(start, task.start.min),
max: toToday(start, task.start.max),
},
priority: task.priority,
required: task.required,
duration: {
min: task.duration,
},
})),
[fromRoutines, start],
);
const tasks = useMemo(
() => ({
calendar: tasksFromCalendar,
routines: tasksFromRoutines,
all: [
...tasksFromRoutines,
...tasksFromCalendar,
],
}),
[tasksFromCalendar, tasksFromRoutines],
);
return tasks;
}

View File

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

97
src/hooks/async.ts Normal file
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],
);
const options = useMemo(
() => {
const output: AsyncCallbackOutput<TArgs, TResult> = [
invoke,
{
result,
loading,
error,
args: prevArgs,
}
];
return output;
},
[invoke, result, loading, error, prevArgs],
);
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 };

31
src/types/graph.ts Normal file
View File

@@ -0,0 +1,31 @@
import { GetTransition, Transition, UserLocation } from "./location";
import { Task } from "./task";
type Context = {
getTransition: GetTransition;
};
type GraphNode = {
location: UserLocation;
task?: Task;
transition?: Transition;
parent?: GraphNode;
remainingTasks: Task[];
impossibeTasks: Task[];
score: number;
time: {
start: Date;
end: Date;
};
status: {
dead: boolean;
completed: boolean;
};
};
export type {
GraphNode,
Context,
};

21
src/types/location.ts Normal file
View File

@@ -0,0 +1,21 @@
export type UserLocation = {
id: string;
title: string;
location?: {
longitute: number;
latitude: number;
};
}
export type Transition = {
time: number;
usableTime: number;
to: UserLocation;
from: UserLocation;
};
export type GetTransition = (
from: UserLocation,
to: UserLocation,
time: Date,
) => Promise<Transition>;

20
src/types/plans.ts Normal file
View File

@@ -0,0 +1,20 @@
import { UserLocation } from "./location";
export type PlannedTask = {
type: 'task';
name: string;
start: Date;
external?: boolean;
end: Date;
score: number;
}
export type PlannedTransition = {
type: 'transition';
start: Date;
end: Date;
from: UserLocation;
to: UserLocation;
};
export type PlanItem = PlannedTask | PlannedTransition;

19
src/types/task.ts Normal file
View File

@@ -0,0 +1,19 @@
import { UserLocation } from "./location";
export type Task = {
id: string;
external?: boolean;
name: string;
locations?: UserLocation[];
count?: number;
required: boolean;
priority: number;
start: {
min: Date;
max: Date;
};
duration: {
min: number;
prefered?: number;
};
}

View File

@@ -0,0 +1,50 @@
import React, { ReactNode } from 'react';
import styled from 'styled-components/native';
import { TouchableOpacity } from 'react-native';
import { IconNames, Icon } from '#/ui/components';
import { Theme } from '#/ui/theme';
import { Link } from '#/ui/typography';
interface Props {
title?: string;
icon?: IconNames;
onPress?: () => any;
accessibilityRole?: TouchableOpacity['props']['accessibilityRole'];
accessibilityLabel?: string;
accessibilityHint?: string;
type?: 'primary' | 'secondary' | 'destructive';
}
const Touch = styled.TouchableOpacity``;
const Wrapper = styled.View<{ theme: Theme }>`
color: ${({ theme }) => theme.colors.primary};
padding: ${({ theme }) => theme.margins.small}px;
border-radius: ${({ theme }) => theme.sizes.corners}px;
align-items: center;
`;
const Button: React.FC<Props> = ({
title,
icon,
type,
onPress,
accessibilityHint,
accessibilityRole,
accessibilityLabel,
}) => (
<Touch
onPress={onPress}
accessible
accessibilityHint={accessibilityHint}
accessibilityRole={accessibilityRole}
accessibilityLabel={accessibilityLabel}
>
<Wrapper>
{title && <Link color={type}>{title}</Link>}
{icon && <Icon name={icon} color={type} />}
</Wrapper>
</Touch>
);
export { Button };

View File

@@ -0,0 +1,21 @@
import { Row } from "../../row"
type CheckboxProps = {
value?: boolean;
label: string;
onChange: (value: boolean) => void;
}
const Checkbok: React.FC<CheckboxProps> = ({
value,
label,
onChange,
}) => (
<Row
overline={label}
title={value? 'Yes' : 'No'}
onPress={() => onChange(!value)}
/>
);
export { Checkbok };

View File

@@ -0,0 +1,2 @@
export * from './input';
export * from './checkbox';

View File

@@ -0,0 +1,33 @@
import React from 'react';
import styled, { useTheme } from 'styled-components/native';
import { Row, RowProps } from '../../row';
type Props = RowProps & {
placeholder?: string;
value: string;
onChangeText: (text: string) => any;
}
const InputField = styled.TextInput`
background: ${({ theme }) => theme.colors.input};
color: ${({ theme }) => theme.colors.text};
padding: ${({ theme }) => theme.margins.small}px;
font-size: ${({ theme }) => theme.font.baseSize}px;
border-radius: ${({ theme }) => theme.sizes.corners}px;
width: 100%;
`;
const TextInput: React.FC<Props> = ({ placeholder, value, onChangeText, children, ...row }) => {
const theme = useTheme();
return (
<Row overline={placeholder} {...row}>
<InputField
value={value}
onChangeText={onChangeText}
/>
{children}
</Row>
);
};
export { TextInput };

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Feather, } from '@expo/vector-icons';
import { useTheme } from 'styled-components/native';
import { Theme } from '#/ui/theme';
type IconNames = keyof typeof Feather.glyphMap;
type Props = {
size?: number;
color?: keyof Theme['colors'];
name: IconNames;
}
function Icon({
size,
color,
name,
}: Props) {
const theme = useTheme();
return (
<Feather
name={name}
color={color ? theme.colors[color] : theme.colors.icon}
size={size ?? theme.sizes.icons}
/>
)
};
export type { IconNames };
export { Icon };

View File

@@ -0,0 +1,7 @@
export * from './icon';
export * from './form';
export * from './page';
export * from './popup';
export * from './row';
export * from './form';
export * from './button';

View File

@@ -0,0 +1,39 @@
import React, { useState, useEffect } from 'react';
import styled from 'styled-components/native';
import { Keyboard, Platform } from 'react-native';
const KeyboardAvoiding = styled.KeyboardAvoidingView`
flex: 1;
`;
const Pressable = styled.Pressable`
flex: 1;
`
// background-color: ${({ theme }) => theme.colors.background};
const Page: React.FC = ({ children }) => {
const [keyboardShown, setKeyboardShown] = useState(false);
useEffect(() => {
const keyboardDidShow = () => setKeyboardShown(true);
const keyboardDidHide = () => setKeyboardShown(false);
Keyboard.addListener('keyboardDidShow', keyboardDidShow);
Keyboard.addListener('keyboardDidHide', keyboardDidHide);
return () => {
Keyboard.removeListener('keyboardDidShow', keyboardDidShow);
Keyboard.removeListener('keyboardDidHide', keyboardDidHide);
};
}, []);
return (
<Pressable
disabled={!keyboardShown}
onPress={() => Keyboard.dismiss()}
>
<KeyboardAvoiding behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
{children}
</KeyboardAvoiding>
</Pressable>
);
};
export { Page };

View File

@@ -0,0 +1,54 @@
import React, { ReactNode } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import styled from 'styled-components/native';
import { Icon } from '../icon';
import { Row, Cell } from '../row';
import { Page } from '../page';
interface Props {
onClose?: () => void;
children: ReactNode;
}
const Top = styled.Pressable`
flex: 1;
`;
const Wrapper = styled.View`
background: ${({ theme }) => theme.colors.background};
width: 100%;
shadow-color: ${({ theme }) => theme.colors.shadow};
shadow-offset: 0 0;
shadow-opacity: 1;
shadow-radius: 200px;
border-radius: 12px;
margin-bottom: -12px;
`;
const Outer = styled.View`
flex: 1;
`;
const Popup: React.FC<Props> = ({ visible, children, onClose }) => {
const insets = useSafeAreaInsets();
return (
<Page>
<Outer>
<Top onPress={onClose} />
<Wrapper style={{ paddingBottom: insets.bottom + 12 }}>
<Row
right={
<Cell onPress={onClose}>
<Icon name="x-circle" />
</Cell>
}
/>
{children}
</Wrapper>
</Outer>
</Page>
);
};
export default Popup;

View File

@@ -0,0 +1,65 @@
import React, { ReactNode } from 'react';
import { TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
import { Theme } from '#/ui/theme';
interface Props {
accessibilityRole?: TouchableOpacity['props']['accessibilityRole'];
accessibilityLabel?: string;
accessibilityHint?: string;
children?: ReactNode;
onPress?: () => any;
background?: string;
flex?: string | number;
direction?: 'row' | 'column';
align?: 'flex-start' | 'flex-end' | 'center' | 'stretch';
opacity?: number;
}
const Wrapper = styled.View<{
background?: string;
flex?: string | number;
direction?: 'row' | 'column';
theme: Theme;
align?: 'flex-start' | 'flex-end' | 'center' | 'stretch';
opacity?: number;
}>`
padding: ${({ theme }) => theme.margins.medium / 2}px;
${({ background }) => (background ? `background: ${background};` : '')}
${({ flex }) => (flex ? `flex: ${flex};` : '')}
flex-direction: ${({ direction }) => (direction ? direction : 'row')};
align-items: ${({ align }) => (align ? align : 'center')};
${({ opacity }) => (opacity? `opacity: ${opacity};` : '')}
`;
const Touch = styled.TouchableOpacity``;
const Cell: React.FC<Props> = ({ children, onPress, ...props}) => {
const {
accessibilityLabel,
accessibilityRole,
accessibilityHint,
...others
} = props;
const node = (
<Wrapper {...others}>
{children}
</Wrapper>
);
if (onPress) {
return (
<Touch
accessible
accessibilityRole={accessibilityRole || 'button'}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
onPress={onPress}
>
{node}
</Touch>
);
}
return node;
};
export { Cell };

View File

@@ -0,0 +1,3 @@
export * from './cell';
export * from './row';
export * from './placeholder-icon';

View File

@@ -0,0 +1,28 @@
import React from 'react';
import styled from 'styled-components/native';
import { Cell } from './cell';
interface Props {
color?: string;
size?: number;
onPress?: () => void;
}
const Icon = styled.View<{ size: number; color: string }>`
background: ${({ color }) => color};
width: ${({ size }) => size}px;
height: ${({ size }) => size}px;
border-radius: ${({ size }) => size / 4}px;
`;
const PlaceholderIcon: React.FC<Props> = ({
color = 'red',
size = 24,
onPress,
}) => (
<Cell onPress={onPress}>
<Icon color={color} size={size} />
</Cell>
);
export { PlaceholderIcon };

View File

@@ -0,0 +1,60 @@
import React, { ReactNode } from 'react';
import styled from 'styled-components/native';
import { Title1, Body1, Overline } from '#/ui/typography';
import { Cell } from './cell';
type RowProps = {
background?: string;
top?: ReactNode;
left?: ReactNode;
right?: ReactNode;
title?: ReactNode;
overline?: ReactNode;
description?: ReactNode;
children?: ReactNode;
opacity?: number;
onPress?: () => any;
}
const Children = styled.View``;
const componentOrString = (
input: ReactNode,
Component: React.FC<{ children: ReactNode }>
) => {
if (!input) {
return null;
}
if (typeof input === 'string') {
return <Component>{input}</Component>;
}
return input;
};
const Row: React.FC<RowProps> = ({
background,
top,
left,
right,
title,
opacity,
overline,
description,
children,
onPress,
}) => (
<Cell background={background} opacity={opacity} onPress={onPress}>
{left}
<Cell flex={1} direction="column" align="stretch">
{!!top}
{componentOrString(overline, Overline)}
{componentOrString(title, Title1)}
{componentOrString(description, Body1)}
{!!children && <Children>{children}</Children>}
</Cell>
{right}
</Cell>
);
export type { RowProps };
export { Row };

View File

@@ -0,0 +1,111 @@
import React, { ReactNode, useMemo } from "react";
import styled from "styled-components/native";
import stringToColor from 'string-to-color';
import parseCSSColor from "parse-css-color";
import chroma from 'chroma-js';
import { PlanItem } from "#/types/plans";
type AgendaItemProps = {
item: LayoutItem;
onPress?: () => void;
}
type LayoutItem = {
height: number;
color: string;
body?: ReactNode;
start: Date;
end: Date;
}
const Time = styled.Text<{background : string}>`
font-size: 10px;
color: #fff;
font-weight: bold;
color: ${({ background }) => background === 'transparent' ? '#222' : '#fff'};
`;
const TimeBox = styled.View<{
background: string;
}>`
margin-right: 10px;
width: 50px;
height: 100%;
align-items: center;
justify-content: center;
background: ${({ background }) => background === 'transparent' ? background : chroma(background).darken(1.5).hex()};
`;
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: 3px;
border: solid 1px ${({ background }) => background === 'transparent' ? background : chroma(background).darken(0.3).hex()};
`;
const Main = styled.View`
flex: 1;
`
const isDark = (color: string) => {
const parsed = parseCSSColor(color);
const [r, g, b] = parsed!.values;
var luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709
return luma < 150;
}
const formatTime = (time: Date) => {
const hours = time.getHours().toString().padStart(2, '0')
const minutes = time.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`;
};
const Touch = styled.TouchableOpacity`
`;
const AgendaItemView: React.FC<AgendaItemProps> = ({ item, onPress }) => {
const view = (
<Block height={Math.max(70, item.height / 15000)} background={item.color}>
<TimeBox background={item.color}>
<Time background={item.color}>{formatTime(item.start)}</Time>
<Time background={item.color}>{formatTime(item.end)}</Time>
</TimeBox>
<Main>
{item.body}
</Main>
<Filler />
</Block>
);
if (onPress) {
return (
<Touch onPress={onPress}>
{view}
</Touch>
);
}
return view;
};
export type { AgendaItemProps };
export { AgendaItemView };

View File

@@ -0,0 +1,76 @@
import React, { ReactNode, useMemo } from "react";
import styled from "styled-components/native";
import stringToColor from 'string-to-color';
import chroma from 'chroma-js';
import { PlanItem } from "#/types/plans";
import { AgendaItemView } from "./agenda-item";
type DayViewProps = {
plan: PlanItem[];
}
type LayoutItem = {
height: number;
color: string;
body?: ReactNode;
start: Date;
end: Date;
}
const Wrapper = styled.View`
`;
const Title = styled.Text`
`;
const getBody = (item: PlanItem) => {
if (item.type === 'transition') {
return <Title>{item.from.title} {item.to.title}</Title>
} else {
return <Title>{item.name}</Title>
}
}
const DayView: React.FC<DayViewProps> = ({ plan }) => {
const layout = useMemo(
() => {
const [...planItems] = [...plan];
const items: LayoutItem[] = [];
var lastPlanItem: PlanItem | undefined;
for (let planItem of planItems) {
if (lastPlanItem && planItem.start.getTime() - lastPlanItem.end.getTime() > 0) {
items.push({
height: planItem.start.getTime() - lastPlanItem.end.getTime(),
color: 'transparent',
start: lastPlanItem.end,
end: planItem.start,
})
}
let color = planItem.type === 'transition' ? '#34495e' : stringToColor(planItem.name);
color = chroma(color).luminance(0.7).saturate(1).brighten(0.6).hex();
items.push({
height: planItem.end.getTime() - planItem.start.getTime(),
color,
start: planItem.start,
end: planItem.end,
body: getBody(planItem),
});
lastPlanItem = planItem;
}
return items;
},
[plan],
);
return (
<Wrapper>
{layout.map((item, i) => (
<AgendaItemView key={i} item={item} />
))}
</Wrapper>
)
};
export type { DayViewProps };
export { DayView };

0
src/ui/helpers/react.tsx Normal file
View File

2
src/ui/index.ts Normal file
View File

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

111
src/ui/router/index.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { useMemo } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useTheme } from 'styled-components/native';
import { LocationListScreen } from '#/ui/screens/locations/list';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { RoutinesListScreen } from '../screens/routines/list';
import { LocationSetScreen } from '../screens/locations/set';
import { PlanDayScreen } from '../screens/plan/day';
import { CalendarSelectScreen } from '../screens/calendars/select';
import { RoutineSetScreen } from '../screens/routines/set';
import { TaskListScreen } from '../screens/plan/tasks';
import { AgendaContextSetScreen } from '../screens/plan/set';
import { Icon } from '../components';
const MainTabsNvaigator = createBottomTabNavigator();
const MainTabs: React.FC = () => {
const theme = useTheme();
return (
<MainTabsNvaigator.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
}}
>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: 'Prepare',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="check-square" />,
}}
name="tasks"
component={TaskListScreen}
/>
<MainTabsNvaigator.Screen
name="plan"
component={PlanDayScreen}
options={{
tabBarLabel: 'Plan',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="calendar" />,
}}
/>
<MainTabsNvaigator.Screen
name="locations"
component={LocationListScreen}
options={{
tabBarLabel: 'Locations',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="map-pin" />,
}}
/>
<MainTabsNvaigator.Screen
name="routines"
component={RoutinesListScreen}
options={{
tabBarLabel: 'Routines',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="activity" />,
}}
/>
<MainTabsNvaigator.Screen
name="calendars"
component={CalendarSelectScreen}
options={{
tabBarLabel: 'Calendars',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="more-vertical" />,
}}
/>
</MainTabsNvaigator.Navigator>
);
};
const RootNavigator = createNativeStackNavigator();
const Root: React.FC = () => (
<RootNavigator.Navigator screenOptions={{ headerShown: false }}>
<RootNavigator.Group>
<RootNavigator.Screen name="main" component={MainTabs} />
</RootNavigator.Group>
<RootNavigator.Group screenOptions={{ presentation: 'transparentModal' }}>
<RootNavigator.Screen name="locationSet" component={LocationSetScreen} />
<RootNavigator.Screen name="routineSet" component={RoutineSetScreen} />
<RootNavigator.Screen name="agendaContextSet" component={AgendaContextSetScreen} />
</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,47 @@
import { useCalendars, useSelectedCalendars, useSetSelectedCalendars } from "#/features/calendar"
import { Calendar } from "expo-calendar";
import { useCallback } from "react";
import styled from "styled-components/native";
const Wrapper = styled.View`
`;
const Button = styled.Button`
`;
const CalendarSelectScreen: React.FC = () => {
const calendars = useCalendars();
const selected = useSelectedCalendars();
const setSelected = useSetSelectedCalendars();
const toggle = useCallback(
(calendar: Calendar) => {
const isSelected = !!selected.find(c => c.id === calendar.id);
if (isSelected) {
setSelected(selected.filter(c => c.id !== calendar.id));
} else {
setSelected([
...selected,
calendar,
]);
}
},
[selected]
)
return (
<Wrapper>
{calendars.map((calendar) => (
<Button
key={calendar.id}
title={calendar.title + (selected.includes(calendar) ? ' -y' : '-n')}
onPress={() => toggle(calendar)}
/>
))}
</Wrapper>
)
}
export { CalendarSelectScreen };

View File

@@ -0,0 +1,44 @@
import { useLocations, useRemoveLocation } from "#/features/location"
import { Button, Cell } from "#/ui/components";
import { Row } from "#/ui/components/row/row";
import { useNavigation } from "@react-navigation/native";
import { FlatList } from "react-native";
import styled from "styled-components/native";
const Wrapper = styled.View`
`;
const Name = styled.Text`
`;
const LocationListScreen: React.FC = () => {
const locations = useLocations();
const removeLocation = useRemoveLocation();
const { navigate } = useNavigation();
return (
<Wrapper>
<Button icon="plus-circle" onPress={() => navigate('locationSet')} />
<FlatList
data={Object.values(locations)}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Row
title={item.title}
onPress={() => {
navigate('locationSet', { id: item.id });
}}
right={
<Cell>
<Button type="destructive" icon="trash" onPress={() => removeLocation(item.id)} />
</Cell>
}
/>
)}
/>
</Wrapper>
);
}
export { LocationListScreen };

View File

@@ -0,0 +1,62 @@
import { useLocations, useSetLocation } from "#/features/location";
import { useNavigation, useRoute } from '@react-navigation/native';
import { Button, TextInput } from "#/ui/components";
import { useCallback, useEffect, useMemo, useState } from "react";
import { nanoid } from 'nanoid';
import Popup from "#/ui/components/popup";
const LocationSetScreen: React.FC = () => {
const { params = {} } = useRoute() as any;
const id = useMemo(
() => params.id || nanoid(),
[params.id],
)
const locations = useLocations();
const { navigate, goBack } = useNavigation();
const [title, setTitle] = useState('');
const [lng, setLng] = useState('');
const [lat, setLat] = useState('');
const set = useSetLocation();
useEffect(
() => {
const current = locations[id];
if (!current) {
return;
}
setTitle(current.title);
setLng(current.location?.longitute.toString() || '');
setLat(current.location?.latitude.toString() || '');
},
[locations, id],
)
const save = useCallback(
() => {
const lngParsed = parseFloat(lng);
const latParsed = parseFloat(lat);
set({
id,
title,
location: {
longitute: lngParsed,
latitude: latParsed,
},
});
navigate('main');
},
[title, lng, lat, id],
)
return (
<Popup onClose={goBack}>
<TextInput value={title} onChangeText={setTitle} placeholder="Title" />
<TextInput value={lng} onChangeText={setLng} placeholder="Longitute" />
<TextInput value={lat} onChangeText={setLat} placeholder="Latitude" />
<Button title="Save" onPress={save} />
</Popup>
)
}
export { LocationSetScreen };

View File

@@ -0,0 +1,97 @@
import { useCurrentLocation } from "#/features/location"
import { usePlan } from "#/features/planner"
import { Button, Cell, Page, Row, TextInput } from "#/ui/components";
import { DayView } from "#/ui/components/specialized/plan/day";
import { Body1 } from "#/ui/typography";
import { useCallback, useMemo, useState } from "react";
import { useCommit, useDate } from "#/features/calendar";
import { format, formatDistance, formatDistanceToNow, set } from "date-fns";
import styled from "styled-components/native";
import { Status } from "#/features/planner/algorithm/build-graph";
const Wrapper = styled.ScrollView`
`;
const getStats = (status: Status) => {
console.log('status', status);
if (status.current === 'running') {
const runTime = formatDistanceToNow(status.start, { includeSeconds: true })
return `calulated ${status.nodes} nodes in ${runTime}`;
}
const runTime = formatDistance(status.start, status.end, { includeSeconds: true })
return `calulated ${status.nodes} nodes in ${runTime}`;
};
const PlanDayScreen: React.FC = () => {
const date = useDate();
const [location] = useCurrentLocation();
const [startTime, setStartTime] = useState('06:00');
const [commit] = useCommit();
const current = useMemo(
() => location || {
id: 'unknown',
title: 'foo',
},
[location]
)
const [plan, options] = usePlan({
location: current,
})
const update = useCallback(
() => {
const target = new Date(`2000-01-01T${startTime}:00`)
const corrected = set(date, {
hours: target.getHours(),
minutes: target.getMinutes(),
})
plan(corrected);
},
[date, plan, startTime],
)
return (
<Wrapper>
<Page>
<TextInput
overline="Start time"
value={startTime}
onChangeText={setStartTime}
right={(
<>
<Cell>
{!options.error && options.status && options.status.current === 'running' ? (
<Button type="destructive" onPress={options.status.cancel} icon="x" />
) : (
<Button icon="play" onPress={update} />
)}
</Cell>
{!!options.result?.agenda && (
<Cell>
<Button onPress={() => commit(options.result?.agenda || [])} icon="download" />
</Cell>
)}
</>
)}
/>
{!!options.error && (
<Row title={JSON.stringify(options.error)} />
)}
{options.status?.current === 'running' && (
<Row
title={getStats(options.status)}
/>
)}
{!!options.result && options.status?.current === 'completed' && (
<Row title={format(date, 'EEEE - do MMMM')} overline={getStats(options.status)}>
{options.result.impossible && options.result.impossible.length > 0 && <Body1>Impossible: {options.result.impossible.map(i => i.name).join(', ')}</Body1>}
<DayView plan={options.result.agenda} />
</Row>
)}
</Page>
</Wrapper>
)
}
export { PlanDayScreen };

View File

@@ -0,0 +1,82 @@
import { useLocations, useSetLocation } from "#/features/location";
import { useNavigation, useRoute } from '@react-navigation/native';
import { Button, Checkbok, TextInput } from "#/ui/components";
import { useCallback, useEffect, useMemo, useState } from "react";
import { nanoid } from 'nanoid';
import { useAgendaContext, useSetAgendaContext } from "#/features/agenda-context";
import { format } from "date-fns";
import Popup from "#/ui/components/popup";
const AgendaContextSetScreen: React.FC = () => {
const { params = {} } = useRoute() as any;
const id = useMemo(
() => params.id || nanoid(),
[params.id],
)
const contexts = useAgendaContext();
const { navigate, goBack } = useNavigation();
const locations = useLocations();
const [location, setLocation] = useState('');
const [enabled, setEnabled] = useState(true);
const [startMin, setStartMin] = useState('');
const [startMax, setStartMax] = useState('');
const [duration, setDuration] = useState('');
const [count, setCount] = useState('1');
const [set] = useSetAgendaContext();
useEffect(
() => {
const current = contexts[id];
if (!current) {
return;
}
const name = current.locations?.map(l => l.title).join(',') || '';
if (current.startMin) {
setStartMin(format(current.startMin, 'HH:mm'));
}
if (current.startMax) {
setStartMax(format(current.startMax, 'HH:mm'));
}
if (current.duration) {
setDuration((current.duration / 1000 / 60).toString());
}
if (current.count) {
setCount(current.count.toString());
}
setLocation(name);
setEnabled(current.enabled);
},
[contexts, id],
)
const save = useCallback(
() => {
const name = location.split(',').map(a => Object.values(locations).find(i => i.title.toLowerCase() === a.trim().toLowerCase())).filter(Boolean);
set(id, {
enabled,
locations: name as any,
count: parseInt(count),
startMin: startMin ? new Date(`2020-01-01T${startMin}:00`) : undefined,
startMax: startMax ? new Date(`2020-01-01T${startMax}:00`) : undefined,
duration: duration ? parseInt(duration) * 1000 * 60 : undefined,
});
navigate('main');
},
[set, id, enabled, location, count, locations, startMin, startMax, duration],
)
return (
<Popup onClose={goBack}>
<TextInput value={location} onChangeText={setLocation} placeholder="Locations" />
<TextInput value={startMin} onChangeText={setStartMin} placeholder="Start min" />
<TextInput value={startMax} onChangeText={setStartMax} placeholder="Start max" />
<TextInput value={duration} onChangeText={setDuration} placeholder="Duration" />
<TextInput value={count} onChangeText={setCount} placeholder="Count" />
<Checkbok label="Enabled" value={enabled} onChange={setEnabled} />
<Button title="Save" onPress={save} />
</Popup>
)
}
export { AgendaContextSetScreen };

View File

@@ -0,0 +1,132 @@
import { useAgendaContext, useSetAgendaContext, useTasksWithContext } from "#/features/agenda-context";
import { set } from 'date-fns';
import chroma from 'chroma-js';
import stringToColor from 'string-to-color';
import { Button, Cell, Icon } from "#/ui/components";
import { Row } from "#/ui/components";
import { AgendaItemView } from "#/ui/components/specialized/plan/agenda-item";
import { Body1 } from "#/ui/typography";
import { useNavigation } from "@react-navigation/native";
import { useCallback, useMemo } from "react";
import { FlatList } from "react-native";
import CalendarStrip from 'react-native-calendar-strip';
import styled, { useTheme } from "styled-components/native";
import { useDate, useSetDate } from "#/features/calendar";
const Wrapper = styled.View`
`;
const Strip = () => {
const date = useDate();
const theme = useTheme();
const setDate = useSetDate();
const selected = useMemo(
() => [{
date,
lines: [{ color: theme.colors.icon }],
}],
[date],
);
return (
<CalendarStrip
markedDates={selected}
style={{
height: 150,
paddingTop: 60,
paddingBottom: 10,
backgroundColor: theme.colors.background,
}}
calendarColor={'#fff'}
selectedDate={date}
startingDate={date}
onDateSelected={(date) => {
setDate(set(date.toDate(), { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }));
}}
shouldAllowFontScaling={false}
iconContainer={{flex: 0.1}}
calendarHeaderStyle={{
color: theme.colors.text,
fontSize: theme.font.baseSize * 1.2,
}}
highlightDateNameStyle={{
color: theme.colors.icon,
fontSize: theme.font.baseSize * 0.6,
}}
iconLeftStyle={{
tintColor: theme.colors.text,
}}
iconRightStyle={{
tintColor: theme.colors.text,
}}
highlightDateNumberStyle={{
color: theme.colors.icon,
fontSize: theme.font.baseSize * 1.2,
}}
dateNumberStyle={{
color: theme.colors.text,
fontSize: theme.font.baseSize * 1.2,
}}
dateNameStyle={{
color: theme.colors.text,
fontSize: theme.font.baseSize * 0.6,
}}
/>
);
}
const TaskListScreen: React.FC = () => {
const tasks = useTasksWithContext();
const { navigate } = useNavigation();
const contexts = useAgendaContext();
const [set] = useSetAgendaContext();
const toggle = useCallback(
(task: any) => {
const context = contexts[task.id] || {};
set(task.id, {
...context,
enabled: !task.enabled,
})
},
[set],
)
return (
<Wrapper>
<FlatList
ListHeaderComponent={Strip}
data={Object.values(tasks)}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Row
onPress={() => {
toggle(item);
//navigate('agendaContextSet', { id: item.id });
}}
opacity={item.enabled ? undefined : 0.3}
right={(
<Button
icon="edit"
onPress={() => {
navigate('agendaContextSet', { id: item.id });
}}
/>
)}
>
<AgendaItemView
item={{
height: 1000 * 60 * 30,
body: <Body1>{item.name}</Body1>,
start: item.start.min,
color: chroma(stringToColor(item.name)).luminance(0.7).saturate(1).brighten(0.6).hex(),
end: new Date(item.start.max.getTime() + item.duration.min),
}}
/>
</Row>
)}
/>
</Wrapper>
);
}
export { TaskListScreen };

View File

@@ -0,0 +1,41 @@
import { useRemoveRoutine, useRoutines } from "#/features/routines";
import { Button, Cell } from "#/ui/components";
import { Row } from "#/ui/components/row/row";
import { useNavigation } from "@react-navigation/native";
import { FlatList } from "react-native";
import styled from "styled-components/native";
const Wrapper = styled.View`
`;
const RoutinesListScreen: React.FC = () => {
const routines = useRoutines();
const removeRoutine = useRemoveRoutine();
const { navigate } = useNavigation();
return (
<Wrapper>
<Button icon="plus-circle" onPress={() => navigate('routineSet')} />
<FlatList
data={Object.values(routines)}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<Row
title={item.title}
subtitle={item.location?.map(l => l.title).join(', ')}
onPress={() => {
navigate('routineSet', { id: item.id });
}}
right={
<Cell>
<Button icon="trash" type="destructive" onPress={() => removeRoutine(item.id)} />
</Cell>
}
/>
)}
/>
</Wrapper>
);
}
export { RoutinesListScreen };

View File

@@ -0,0 +1,84 @@
import { useNavigation, useRoute } from '@react-navigation/native';
import { Button, Checkbok, TextInput } from "#/ui/components";
import { useCallback, useEffect, useMemo, useState } from "react";
import { nanoid } from 'nanoid';
import { useRoutines, useSetRoutine } from '#/features/routines';
import { format } from 'date-fns';
import { useLocations } from '#/features/location';
import Popup from '#/ui/components/popup';
const RoutineSetScreen: React.FC = () => {
const { params = {} } = useRoute() as any;
const id = useMemo(
() => params.id || nanoid(),
[params.id],
)
const routines = useRoutines();
const { navigate, goBack } = useNavigation();
const [title, setTitle] = useState('');
const [startMin, setStartMin] = useState('');
const [startMax, setStartMax] = useState('');
const [duration, setDuration] = useState('');
const locations = useLocations();
const [required, setRequired] = useState(false);
const [location, setLocation] = useState('');
const set = useSetRoutine();
useEffect(
() => {
const current = routines.find(r => r.id === id);
if (!current) {
return;
}
setTitle(current.title);
if (current.start.min) {
setStartMin(format(current.start.min, 'HH:mm'));
}
if (current.start.max) {
setStartMax(format(current.start.max, 'HH:mm'));
}
if (current.duration) {
setDuration((current.duration / 1000 / 60).toString());
}
setRequired(!!current.required);
const name = current.location?.map(l => l.title).join(',') || '';
setLocation(name);
},
[routines, id],
)
const save = useCallback(
() => {
const name = location.split(',').map(a => Object.values(locations).find(i => i.title.toLowerCase() === a.trim().toLowerCase())).filter(Boolean);
set({
id,
title,
priority: 50,
required: required,
location: name.length > 0 ? name as any : undefined,
start: {
min: new Date(`2020-01-01T${startMin}:00`),
max: new Date(`2020-01-01T${startMax}:00`),
},
duration: parseInt(duration) * 1000 * 60
});
navigate('main');
},
[title, startMin, startMax, duration, location, required],
)
return (
<Popup onClose={goBack}>
<TextInput value={title} onChangeText={setTitle} placeholder="Title" />
<TextInput value={startMin} onChangeText={setStartMin} placeholder="Start min" />
<TextInput value={startMax} onChangeText={setStartMax} placeholder="Start max" />
<TextInput value={duration} onChangeText={setDuration} placeholder="Duration" />
<TextInput value={location} onChangeText={setLocation} placeholder="Location" />
<Checkbok label="Required" value={required} onChange={setRequired} />
<Button title="Save" onPress={save} />
</Popup>
)
}
export { RoutineSetScreen };

5
src/ui/theme/global.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
import {} from 'styled-components';
import Theme from './Theme'; // Import type from above file
declare module 'styled-components' {
export interface DefaultTheme extends Theme {} // extends the global DefaultTheme with our ThemeType.
}

2
src/ui/theme/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './theme';
export * from './light';

30
src/ui/theme/light.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Theme } from './theme';
const light: Theme = {
colors: {
primary: '#1abc9c',
icon: '#1abc9c',
destructive: '#e74c3c',
shade: '#ededed',
input: '#ddd',
secondary: 'blue',
shadow: '#000',
background: '#fff',
text: '#000',
textShade: '#999',
},
sizes: {
corners: 5,
icons: 24,
},
margins: {
small: 8,
medium: 16,
large: 24,
},
font: {
baseSize: 14,
},
};
export { light };

28
src/ui/theme/theme.ts Normal file
View File

@@ -0,0 +1,28 @@
interface Theme {
colors: {
primary: string;
destructive: string;
icon: string;
input: string;
secondary: string;
background: string;
shadow: string;
shade: string;
text: string;
textShade: string;
};
sizes: {
corners: number;
icons: number;
};
margins: {
small: number;
medium: number;
large: number;
};
font: {
baseSize: number;
};
}
export { Theme };

View File

@@ -0,0 +1,47 @@
import styled from 'styled-components/native';
import { Theme } from 'theme';
interface TextProps {
color?: keyof Theme['colors'];
bold?: boolean;
theme: Theme;
}
const BaseText = styled.Text<TextProps>`
color: ${({ color, theme }) =>
color ? theme.colors[color] : theme.colors.text};
font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')};
font-size: ${({ theme }) => theme.font.baseSize}px;
`;
const Jumbo = styled(BaseText)`
font-size: ${({ theme }) => theme.font.baseSize * 2.8}px;
font-weight: bold;
`;
const Title2 = styled(BaseText)`
font-size: ${({ theme }) => theme.font.baseSize * 1.3}px;
font-weight: bold;
`;
const Title1 = styled(BaseText)`
font-weight: bold;
`;
const Body1 = styled(BaseText)``;
const Overline = styled(BaseText)`
font-size: ${({ theme }) => theme.font.baseSize * 0.6}px;
text-transform: uppercase;
`;
const Caption = styled(BaseText)`
font-size: ${({ theme }) => theme.font.baseSize * 0.8}px;
`;
const Link = styled(BaseText)`
text-transform: uppercase;
`;
export type { TextProps };
export { Jumbo, Title2, Title1, Body1, Overline, Caption, Link };

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": "./",
"paths": {
"#/*": ["./src/*"]
}
}
}

35
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>
>;

13615
yarn.lock Normal file

File diff suppressed because it is too large Load Diff