mirror of
https://github.com/morten-olsen/bob-the-algorithm.git
synced 2026-02-08 00:46:25 +01:00
init
This commit is contained in:
6
.expo-shared/assets.json
Normal file
6
.expo-shared/assets.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true,
|
||||
"af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true,
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
4
App.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import 'react-native-get-random-values';
|
||||
import { App } from './src/app';
|
||||
|
||||
export default App;
|
||||
34
app.json
Normal file
34
app.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
assets/images/adaptive-icon.png
Normal file
BIN
assets/images/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/favicon.png
Normal file
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
BIN
assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/splash.png
Normal file
BIN
assets/images/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
13
babel.config.js
Normal file
13
babel.config.js
Normal 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
63
package.json
Normal 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
38
src/app.tsx
Normal 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 };
|
||||
21
src/features/agenda-context/context.ts
Normal file
21
src/features/agenda-context/context.ts
Normal 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 };
|
||||
56
src/features/agenda-context/hooks.ts
Normal file
56
src/features/agenda-context/hooks.ts
Normal 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;
|
||||
}
|
||||
2
src/features/agenda-context/index.ts
Normal file
2
src/features/agenda-context/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AgendaContextProvider } from './provider';
|
||||
export * from './hooks';
|
||||
64
src/features/agenda-context/provider.tsx
Normal file
64
src/features/agenda-context/provider.tsx
Normal 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 };
|
||||
17
src/features/calendar/context.ts
Normal file
17
src/features/calendar/context.ts
Normal 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 };
|
||||
95
src/features/calendar/hooks.ts
Normal file
95
src/features/calendar/hooks.ts
Normal 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;
|
||||
}
|
||||
2
src/features/calendar/index.ts
Normal file
2
src/features/calendar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CalendarProvider } from './provider';
|
||||
export * from './hooks';
|
||||
105
src/features/calendar/provider.tsx
Normal file
105
src/features/calendar/provider.tsx
Normal 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 };
|
||||
0
src/features/calendar/utils.ts
Normal file
0
src/features/calendar/utils.ts
Normal file
17
src/features/location/context.ts
Normal file
17
src/features/location/context.ts
Normal 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 };
|
||||
73
src/features/location/hooks.ts
Normal file
73
src/features/location/hooks.ts
Normal 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;
|
||||
}
|
||||
2
src/features/location/index.ts
Normal file
2
src/features/location/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LocationProvider } from './provider';
|
||||
export * from './hooks';
|
||||
72
src/features/location/provider.tsx
Normal file
72
src/features/location/provider.tsx
Normal 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 };
|
||||
17
src/features/location/utils.ts
Normal file
17
src/features/location/utils.ts
Normal 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)
|
||||
}
|
||||
144
src/features/planner/algorithm/build-graph.ts
Normal file
144
src/features/planner/algorithm/build-graph.ts
Normal 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 };
|
||||
40
src/features/planner/algorithm/construct-day.ts
Normal file
40
src/features/planner/algorithm/construct-day.ts
Normal 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 };
|
||||
146
src/features/planner/algorithm/get-next.ts
Normal file
146
src/features/planner/algorithm/get-next.ts
Normal 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 };
|
||||
38
src/features/planner/algorithm/utils.ts
Normal file
38
src/features/planner/algorithm/utils.ts
Normal 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;
|
||||
};
|
||||
|
||||
65
src/features/planner/hooks.ts
Normal file
65
src/features/planner/hooks.ts
Normal 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,
|
||||
}
|
||||
];
|
||||
}
|
||||
1
src/features/planner/index.ts
Normal file
1
src/features/planner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hooks';
|
||||
27
src/features/routines/context.ts
Normal file
27
src/features/routines/context.ts
Normal 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 };
|
||||
36
src/features/routines/hooks.ts
Normal file
36
src/features/routines/hooks.ts
Normal 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;
|
||||
}
|
||||
3
src/features/routines/index.ts
Normal file
3
src/features/routines/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { RoutinesProvider } from './provider';
|
||||
export { Routine } from './context';
|
||||
export * from './hooks';
|
||||
74
src/features/routines/provider.tsx
Normal file
74
src/features/routines/provider.tsx
Normal 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
32
src/features/setup.tsx
Normal 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 };
|
||||
76
src/features/tasks/hooks.ts
Normal file
76
src/features/tasks/hooks.ts
Normal 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;
|
||||
}
|
||||
1
src/features/tasks/index.ts
Normal file
1
src/features/tasks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hooks';
|
||||
97
src/hooks/async.ts
Normal file
97
src/hooks/async.ts
Normal 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
31
src/types/graph.ts
Normal 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
21
src/types/location.ts
Normal 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
20
src/types/plans.ts
Normal 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
19
src/types/task.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
50
src/ui/components/button/index.tsx
Normal file
50
src/ui/components/button/index.tsx
Normal 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 };
|
||||
21
src/ui/components/form/checkbox/index.tsx
Normal file
21
src/ui/components/form/checkbox/index.tsx
Normal 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 };
|
||||
2
src/ui/components/form/index.ts
Normal file
2
src/ui/components/form/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './input';
|
||||
export * from './checkbox';
|
||||
33
src/ui/components/form/input/index.tsx
Normal file
33
src/ui/components/form/input/index.tsx
Normal 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 };
|
||||
29
src/ui/components/icon/index.tsx
Normal file
29
src/ui/components/icon/index.tsx
Normal 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 };
|
||||
7
src/ui/components/index.ts
Normal file
7
src/ui/components/index.ts
Normal 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';
|
||||
39
src/ui/components/page/index.tsx
Normal file
39
src/ui/components/page/index.tsx
Normal 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 };
|
||||
54
src/ui/components/popup/index.tsx
Normal file
54
src/ui/components/popup/index.tsx
Normal 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;
|
||||
65
src/ui/components/row/cell.tsx
Normal file
65
src/ui/components/row/cell.tsx
Normal 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 };
|
||||
3
src/ui/components/row/index.ts
Normal file
3
src/ui/components/row/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './cell';
|
||||
export * from './row';
|
||||
export * from './placeholder-icon';
|
||||
28
src/ui/components/row/placeholder-icon.tsx
Normal file
28
src/ui/components/row/placeholder-icon.tsx
Normal 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 };
|
||||
60
src/ui/components/row/row.tsx
Normal file
60
src/ui/components/row/row.tsx
Normal 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 };
|
||||
111
src/ui/components/specialized/plan/agenda-item.tsx
Normal file
111
src/ui/components/specialized/plan/agenda-item.tsx
Normal 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 };
|
||||
76
src/ui/components/specialized/plan/day.tsx
Normal file
76
src/ui/components/specialized/plan/day.tsx
Normal 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
0
src/ui/helpers/react.tsx
Normal file
2
src/ui/index.ts
Normal file
2
src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './theme';
|
||||
111
src/ui/router/index.tsx
Normal file
111
src/ui/router/index.tsx
Normal 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 };
|
||||
47
src/ui/screens/calendars/select.tsx
Normal file
47
src/ui/screens/calendars/select.tsx
Normal 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 };
|
||||
44
src/ui/screens/locations/list.tsx
Normal file
44
src/ui/screens/locations/list.tsx
Normal 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 };
|
||||
62
src/ui/screens/locations/set.tsx
Normal file
62
src/ui/screens/locations/set.tsx
Normal 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 };
|
||||
|
||||
97
src/ui/screens/plan/day.tsx
Normal file
97
src/ui/screens/plan/day.tsx
Normal 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 };
|
||||
82
src/ui/screens/plan/set.tsx
Normal file
82
src/ui/screens/plan/set.tsx
Normal 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 };
|
||||
|
||||
132
src/ui/screens/plan/tasks.tsx
Normal file
132
src/ui/screens/plan/tasks.tsx
Normal 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 };
|
||||
41
src/ui/screens/routines/list.tsx
Normal file
41
src/ui/screens/routines/list.tsx
Normal 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 };
|
||||
84
src/ui/screens/routines/set.tsx
Normal file
84
src/ui/screens/routines/set.tsx
Normal 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
5
src/ui/theme/global.d.ts
vendored
Normal 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
2
src/ui/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './theme';
|
||||
export * from './light';
|
||||
30
src/ui/theme/light.ts
Normal file
30
src/ui/theme/light.ts
Normal 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
28
src/ui/theme/theme.ts
Normal 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 };
|
||||
47
src/ui/typography/index.ts
Normal file
47
src/ui/typography/index.ts
Normal 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
10
tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"#/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
35
types.tsx
Normal file
35
types.tsx
Normal 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>
|
||||
>;
|
||||
Reference in New Issue
Block a user