commit d83a4aebc7d242dcb6cd1b893c9571d4074c948d Author: Morten Olsen Date: Mon May 2 14:26:11 2022 +0200 init diff --git a/.expo-shared/assets.json b/.expo-shared/assets.json new file mode 100644 index 0000000..a9e8834 --- /dev/null +++ b/.expo-shared/assets.json @@ -0,0 +1,6 @@ +{ + "e997a5256149a4b76e6bfd6cbf519c5e5a0f1d278a3d8fa1253022b03c90473b": true, + "af683c96e0ffd2cf81287651c9433fa44debc1220ca7cb431fe482747f34a505": true, + "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, + "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec8a36a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules/ +.expo/ +dist/ +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# macOS +.DS_Store diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..2f3fa3c --- /dev/null +++ b/App.tsx @@ -0,0 +1,4 @@ +import 'react-native-get-random-values'; +import { App } from './src/app'; + +export default App; diff --git a/app.json b/app.json new file mode 100644 index 0000000..f805048 --- /dev/null +++ b/app.json @@ -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" + } + } +} diff --git a/assets/fonts/SpaceMono-Regular.ttf b/assets/fonts/SpaceMono-Regular.ttf new file mode 100755 index 0000000..28d7ff7 Binary files /dev/null and b/assets/fonts/SpaceMono-Regular.ttf differ diff --git a/assets/images/adaptive-icon.png b/assets/images/adaptive-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/assets/images/adaptive-icon.png differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png new file mode 100644 index 0000000..e75f697 Binary files /dev/null and b/assets/images/favicon.png differ diff --git a/assets/images/icon.png b/assets/images/icon.png new file mode 100644 index 0000000..a0b1526 Binary files /dev/null and b/assets/images/icon.png differ diff --git a/assets/images/splash.png b/assets/images/splash.png new file mode 100644 index 0000000..0e89705 Binary files /dev/null and b/assets/images/splash.png differ diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..b8c0a62 --- /dev/null +++ b/babel.config.js @@ -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', + }, + }], + ], + }; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a01610d --- /dev/null +++ b/package.json @@ -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 +} diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..a65d015 --- /dev/null +++ b/src/app.tsx @@ -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 ( + + + + + + + + + ); +}; + +export { App }; diff --git a/src/features/agenda-context/context.ts b/src/features/agenda-context/context.ts new file mode 100644 index 0000000..5757956 --- /dev/null +++ b/src/features/agenda-context/context.ts @@ -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; +} + +const AgendaContextContext = createContext(undefined as any); + +export type { AgendaContext, AgendaContextContextValue }; +export {AgendaContextContext }; diff --git a/src/features/agenda-context/hooks.ts b/src/features/agenda-context/hooks.ts new file mode 100644 index 0000000..c4883fa --- /dev/null +++ b/src/features/agenda-context/hooks.ts @@ -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; +} diff --git a/src/features/agenda-context/index.ts b/src/features/agenda-context/index.ts new file mode 100644 index 0000000..b4160a3 --- /dev/null +++ b/src/features/agenda-context/index.ts @@ -0,0 +1,2 @@ +export { AgendaContextProvider } from './provider'; +export * from './hooks'; diff --git a/src/features/agenda-context/provider.tsx b/src/features/agenda-context/provider.tsx new file mode 100644 index 0000000..3b5caba --- /dev/null +++ b/src/features/agenda-context/provider.tsx @@ -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 = ({ + children, + day, +}) => { + const [contexts, setContexts] = useState({}); + 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 ( + + {children} + + ); +}; + +export type { AgendaContextProviderProps }; +export { AgendaContextProvider }; diff --git a/src/features/calendar/context.ts b/src/features/calendar/context.ts new file mode 100644 index 0000000..80027ad --- /dev/null +++ b/src/features/calendar/context.ts @@ -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(undefined as any); + +export type { CalendarContextValue }; +export { CalendarContext }; diff --git a/src/features/calendar/hooks.ts b/src/features/calendar/hooks.ts new file mode 100644 index 0000000..46f9e85 --- /dev/null +++ b/src/features/calendar/hooks.ts @@ -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; +} diff --git a/src/features/calendar/index.ts b/src/features/calendar/index.ts new file mode 100644 index 0000000..4fc35c9 --- /dev/null +++ b/src/features/calendar/index.ts @@ -0,0 +1,2 @@ +export { CalendarProvider } from './provider'; +export * from './hooks'; diff --git a/src/features/calendar/provider.tsx b/src/features/calendar/provider.tsx new file mode 100644 index 0000000..29feee8 --- /dev/null +++ b/src/features/calendar/provider.tsx @@ -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 = ({ + date, + children, + setDate, + calendarName = 'Bob the planner', +}) => { + const [selectedIds, setSelectedIds] = useState([]); + const [value] = useAsync( + 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 ( + + {children} + + ) +}; + +export type { CalendarProviderProps }; +export { CalendarProvider }; diff --git a/src/features/calendar/utils.ts b/src/features/calendar/utils.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/features/location/context.ts b/src/features/location/context.ts new file mode 100644 index 0000000..1c9f0b5 --- /dev/null +++ b/src/features/location/context.ts @@ -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(undefined as any); + +export type { LocationContextValue }; +export { LocationContext }; diff --git a/src/features/location/hooks.ts b/src/features/location/hooks.ts new file mode 100644 index 0000000..0ccbd8c --- /dev/null +++ b/src/features/location/hooks.ts @@ -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( + 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; +} diff --git a/src/features/location/index.ts b/src/features/location/index.ts new file mode 100644 index 0000000..09edba4 --- /dev/null +++ b/src/features/location/index.ts @@ -0,0 +1,2 @@ +export { LocationProvider } from './provider'; +export * from './hooks'; diff --git a/src/features/location/provider.tsx b/src/features/location/provider.tsx new file mode 100644 index 0000000..057e7bb --- /dev/null +++ b/src/features/location/provider.tsx @@ -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 = ({ + 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 ( + + {children} + + ) +} + +export type { LocationProviderProps }; +export { LocationProvider }; diff --git a/src/features/location/utils.ts b/src/features/location/utils.ts new file mode 100644 index 0000000..d9c86ad --- /dev/null +++ b/src/features/location/utils.ts @@ -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) +} diff --git a/src/features/planner/algorithm/build-graph.ts b/src/features/planner/algorithm/build-graph.ts new file mode 100644 index 0000000..c29797e --- /dev/null +++ b/src/features/planner/algorithm/build-graph.ts @@ -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 = ( + 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 }; diff --git a/src/features/planner/algorithm/construct-day.ts b/src/features/planner/algorithm/construct-day.ts new file mode 100644 index 0000000..1015dff --- /dev/null +++ b/src/features/planner/algorithm/construct-day.ts @@ -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 }; diff --git a/src/features/planner/algorithm/get-next.ts b/src/features/planner/algorithm/get-next.ts new file mode 100644 index 0000000..cc0fef0 --- /dev/null +++ b/src/features/planner/algorithm/get-next.ts @@ -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 => { + 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 }; diff --git a/src/features/planner/algorithm/utils.ts b/src/features/planner/algorithm/utils.ts new file mode 100644 index 0000000..73b2b55 --- /dev/null +++ b/src/features/planner/algorithm/utils.ts @@ -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; +}; + diff --git a/src/features/planner/hooks.ts b/src/features/planner/hooks.ts new file mode 100644 index 0000000..18357f7 --- /dev/null +++ b/src/features/planner/hooks.ts @@ -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, + { + result?: { agenda: PlanItem[], impossible: Task[] }; + status?: Status; + loading: boolean; + error?: any; + } +] + +export const usePlan = ({ + location, +}: UsePlanOptions): UsePlan => { + const today = useDate(); + const [status, setStatus] = useState(); + 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, + } + ]; +} diff --git a/src/features/planner/index.ts b/src/features/planner/index.ts new file mode 100644 index 0000000..4cc90d0 --- /dev/null +++ b/src/features/planner/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/features/routines/context.ts b/src/features/routines/context.ts new file mode 100644 index 0000000..a8de507 --- /dev/null +++ b/src/features/routines/context.ts @@ -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(undefined as any); + +export type { Routine, RoutinesContextValue }; +export { RoutinesContext }; diff --git a/src/features/routines/hooks.ts b/src/features/routines/hooks.ts new file mode 100644 index 0000000..365eb07 --- /dev/null +++ b/src/features/routines/hooks.ts @@ -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; +} diff --git a/src/features/routines/index.ts b/src/features/routines/index.ts new file mode 100644 index 0000000..60dc3b8 --- /dev/null +++ b/src/features/routines/index.ts @@ -0,0 +1,3 @@ +export { RoutinesProvider } from './provider'; +export { Routine } from './context'; +export * from './hooks'; diff --git a/src/features/routines/provider.tsx b/src/features/routines/provider.tsx new file mode 100644 index 0000000..4f73869 --- /dev/null +++ b/src/features/routines/provider.tsx @@ -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 = ({ 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 ( + + {children} + + ) +} + +export type { RoutinesProviderProps }; +export { RoutinesProvider }; diff --git a/src/features/setup.tsx b/src/features/setup.tsx new file mode 100644 index 0000000..77e0525 --- /dev/null +++ b/src/features/setup.tsx @@ -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 = ({ + children, + day, + setDate, + getTransit, +}) => ( + + + []}> + + {children} + + + + +); + +export type { SetupProps }; +export { Setup }; diff --git a/src/features/tasks/hooks.ts b/src/features/tasks/hooks.ts new file mode 100644 index 0000000..5a01f85 --- /dev/null +++ b/src/features/tasks/hooks.ts @@ -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( + () => 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( + () => 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; +} diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts new file mode 100644 index 0000000..4cc90d0 --- /dev/null +++ b/src/features/tasks/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/src/hooks/async.ts b/src/hooks/async.ts new file mode 100644 index 0000000..4d57394 --- /dev/null +++ b/src/hooks/async.ts @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useMemo, useState } from "react" + +type AsyncCallbackOutput = [ + (...args: TArgs) => Promise, + { + loading: boolean; + error?: any; + result?: TResult; + args?: TArgs; + } +]; + +type AsyncOutput = [ + TResult | undefined, + { + loading: boolean; + error?: any; + rerun: () => Promise; + } +] + +const useAsyncCallback = < + TArgs extends any[], + TResult, +>(fn: (...args: TArgs) => Promise, deps: any[]): AsyncCallbackOutput => { + const [result, setResult] = useState(); + const [prevArgs, setPrevArgs] = useState(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + + 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 = [ + invoke, + { + result, + loading, + error, + args: prevArgs, + } + ]; + return output; + }, + [invoke, result, loading, error, prevArgs], + ); + + return options; +}; + +const useAsync = (fn: () => Promise, deps: any[]): AsyncOutput => { + 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 }; diff --git a/src/types/graph.ts b/src/types/graph.ts new file mode 100644 index 0000000..696a6a4 --- /dev/null +++ b/src/types/graph.ts @@ -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, +}; diff --git a/src/types/location.ts b/src/types/location.ts new file mode 100644 index 0000000..d82e1e2 --- /dev/null +++ b/src/types/location.ts @@ -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; diff --git a/src/types/plans.ts b/src/types/plans.ts new file mode 100644 index 0000000..f548650 --- /dev/null +++ b/src/types/plans.ts @@ -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; diff --git a/src/types/task.ts b/src/types/task.ts new file mode 100644 index 0000000..e7e0472 --- /dev/null +++ b/src/types/task.ts @@ -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; + }; +} diff --git a/src/ui/components/button/index.tsx b/src/ui/components/button/index.tsx new file mode 100644 index 0000000..114b024 --- /dev/null +++ b/src/ui/components/button/index.tsx @@ -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 = ({ + title, + icon, + type, + onPress, + accessibilityHint, + accessibilityRole, + accessibilityLabel, +}) => ( + + + {title && {title}} + {icon && } + + +); + +export { Button }; diff --git a/src/ui/components/form/checkbox/index.tsx b/src/ui/components/form/checkbox/index.tsx new file mode 100644 index 0000000..b981342 --- /dev/null +++ b/src/ui/components/form/checkbox/index.tsx @@ -0,0 +1,21 @@ +import { Row } from "../../row" + +type CheckboxProps = { + value?: boolean; + label: string; + onChange: (value: boolean) => void; +} + +const Checkbok: React.FC = ({ + value, + label, + onChange, +}) => ( + onChange(!value)} + /> +); + +export { Checkbok }; diff --git a/src/ui/components/form/index.ts b/src/ui/components/form/index.ts new file mode 100644 index 0000000..a27526b --- /dev/null +++ b/src/ui/components/form/index.ts @@ -0,0 +1,2 @@ +export * from './input'; +export * from './checkbox'; diff --git a/src/ui/components/form/input/index.tsx b/src/ui/components/form/input/index.tsx new file mode 100644 index 0000000..3c70145 --- /dev/null +++ b/src/ui/components/form/input/index.tsx @@ -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 = ({ placeholder, value, onChangeText, children, ...row }) => { + const theme = useTheme(); + return ( + + + {children} + + ); +}; + +export { TextInput }; diff --git a/src/ui/components/icon/index.tsx b/src/ui/components/icon/index.tsx new file mode 100644 index 0000000..b9df46c --- /dev/null +++ b/src/ui/components/icon/index.tsx @@ -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 ( + + ) +}; + +export type { IconNames }; +export { Icon }; diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts new file mode 100644 index 0000000..4e95bd1 --- /dev/null +++ b/src/ui/components/index.ts @@ -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'; diff --git a/src/ui/components/page/index.tsx b/src/ui/components/page/index.tsx new file mode 100644 index 0000000..b39eb73 --- /dev/null +++ b/src/ui/components/page/index.tsx @@ -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 ( + Keyboard.dismiss()} + > + + {children} + + + ); +}; + +export { Page }; diff --git a/src/ui/components/popup/index.tsx b/src/ui/components/popup/index.tsx new file mode 100644 index 0000000..99cacc8 --- /dev/null +++ b/src/ui/components/popup/index.tsx @@ -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 = ({ visible, children, onClose }) => { + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + } + /> + {children} + + + + ); +}; + +export default Popup; diff --git a/src/ui/components/row/cell.tsx b/src/ui/components/row/cell.tsx new file mode 100644 index 0000000..0406a01 --- /dev/null +++ b/src/ui/components/row/cell.tsx @@ -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 = ({ children, onPress, ...props}) => { + const { + accessibilityLabel, + accessibilityRole, + accessibilityHint, + ...others + } = props; + const node = ( + + {children} + + ); + if (onPress) { + return ( + + {node} + + ); + } + return node; +}; + +export { Cell }; diff --git a/src/ui/components/row/index.ts b/src/ui/components/row/index.ts new file mode 100644 index 0000000..d11355f --- /dev/null +++ b/src/ui/components/row/index.ts @@ -0,0 +1,3 @@ +export * from './cell'; +export * from './row'; +export * from './placeholder-icon'; diff --git a/src/ui/components/row/placeholder-icon.tsx b/src/ui/components/row/placeholder-icon.tsx new file mode 100644 index 0000000..b7bed4a --- /dev/null +++ b/src/ui/components/row/placeholder-icon.tsx @@ -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 = ({ + color = 'red', + size = 24, + onPress, +}) => ( + + + +); + +export { PlaceholderIcon }; diff --git a/src/ui/components/row/row.tsx b/src/ui/components/row/row.tsx new file mode 100644 index 0000000..61035fe --- /dev/null +++ b/src/ui/components/row/row.tsx @@ -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 {input}; + } + return input; +}; + +const Row: React.FC = ({ + background, + top, + left, + right, + title, + opacity, + overline, + description, + children, + onPress, +}) => ( + + {left} + + {!!top} + {componentOrString(overline, Overline)} + {componentOrString(title, Title1)} + {componentOrString(description, Body1)} + {!!children && {children}} + + {right} + +); + +export type { RowProps }; +export { Row }; diff --git a/src/ui/components/specialized/plan/agenda-item.tsx b/src/ui/components/specialized/plan/agenda-item.tsx new file mode 100644 index 0000000..7137b71 --- /dev/null +++ b/src/ui/components/specialized/plan/agenda-item.tsx @@ -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 = ({ item, onPress }) => { + const view = ( + + + + + +
+ {item.body} +
+ +
+ ); + + if (onPress) { + return ( + + {view} + + ); + } + return view; +}; + +export type { AgendaItemProps }; +export { AgendaItemView }; diff --git a/src/ui/components/specialized/plan/day.tsx b/src/ui/components/specialized/plan/day.tsx new file mode 100644 index 0000000..da8e81a --- /dev/null +++ b/src/ui/components/specialized/plan/day.tsx @@ -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 {item.from.title} ➜ {item.to.title} + } else { + return {item.name} + } +} + + +const DayView: React.FC = ({ 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 ( + + {layout.map((item, i) => ( + + ))} + + ) +}; + +export type { DayViewProps }; +export { DayView }; diff --git a/src/ui/helpers/react.tsx b/src/ui/helpers/react.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 0000000..7f64104 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,2 @@ +export * from './components'; +export * from './theme'; diff --git a/src/ui/router/index.tsx b/src/ui/router/index.tsx new file mode 100644 index 0000000..3104893 --- /dev/null +++ b/src/ui/router/index.tsx @@ -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 ( + + , + }} + name="tasks" + component={TaskListScreen} + /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ); +}; + +const RootNavigator = createNativeStackNavigator(); + +const Root: React.FC = () => ( + + + + + + + + + + +); + +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 ( + + + + ) +}; + +export { Router }; diff --git a/src/ui/screens/calendars/select.tsx b/src/ui/screens/calendars/select.tsx new file mode 100644 index 0000000..d78924e --- /dev/null +++ b/src/ui/screens/calendars/select.tsx @@ -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 ( + + {calendars.map((calendar) => ( +