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