This commit is contained in:
Morten Olsen
2022-05-10 19:04:05 +02:00
parent 0b2f23ecb2
commit 8259232a83
101 changed files with 1752 additions and 1740 deletions

45
.github/workflows/expo-pr.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Deploy Expo Preview
on:
pull_request:
jobs:
deploy_branch_preview:
name: Deploy Branch Preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12.x
- uses: expo/expo-github-action@v5
with:
expo-packager: yarn
expo-username: ${{ secrets.EXPO_CLI_USERNAME }}
expo-password: ${{ secrets.EXPO_CLI_PASSWORD }}
expo-cache: true
- name: Cache Node Modules
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: ~/.cache/yarn
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Packages
run: npm i -g yarn && yarn install
- name: Expo Publish Channel
run: expo publish --non-interactive --release-channel pr${{ github.event.number }}
- name: Add Comment To PR
uses: mshick/add-pr-comment@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EXPO_PROJECT: "@${{ secrets.EXPO_CLI_USERNAME }}/bob"
with:
message: |
## Application
![Expo QR](https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=exp://exp.host/${{ env.EXPO_PROJECT }}?release-channel=pr${{ github.event.number }})
Published to https://exp.host/${{ env.EXPO_PROJECT }}?release-channel=pr${{ github.event.number }}

View File

@@ -1,5 +1,5 @@
module.exports = function(api) {
api.cache(true);
api.cache.using(() => process.env.NODE_ENV);
return {
presets: ['babel-preset-expo'],
plugins: [

View File

@@ -14,12 +14,18 @@
"jest": {
"preset": "jest-expo"
},
"resolutions": {
"@types/react": "~17.0.21",
"@types/react-dom": "~18.0.3",
"react-error-overlay": "6.0.9"
},
"dependencies": {
"@expo/vector-icons": "^12.0.0",
"@react-native-async-storage/async-storage": "~1.15.0",
"@react-navigation/bottom-tabs": "^6.0.5",
"@react-navigation/native": "^6.0.2",
"@react-navigation/native-stack": "^6.1.0",
"@react-navigation/stack": "^6.2.1",
"chroma-js": "^2.4.2",
"date-fns": "^2.28.0",
"expo": "~44.0.0",
@@ -40,6 +46,8 @@
"react-dom": "17.0.1",
"react-native": "0.64.3",
"react-native-calendar-strip": "^2.2.5",
"react-native-collapsible": "^1.6.0",
"react-native-gesture-handler": "^2.4.2",
"react-native-get-random-values": "^1.8.0",
"react-native-safe-area-context": "3.3.2",
"react-native-screens": "~3.10.1",
@@ -49,17 +57,20 @@
},
"devDependencies": {
"@babel/core": "^7.12.9",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.5",
"@types/chroma-js": "^2.1.3",
"@types/react": "~17.0.21",
"@types/react-dom": "^18.0.3",
"@types/react-native": "~0.64.12",
"@types/react-native": "^0.67.6",
"@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-refresh": "^0.13.0",
"react-test-renderer": "17.0.1",
"typescript": "~4.3.5"
"typescript": "~4.3.5",
"webpack-hot-middleware": "^2.25.1"
},
"private": true
}

View File

@@ -1,19 +1,12 @@
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useCallback, useMemo, useState } from 'react';
import { useCallback } 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,
@@ -27,7 +20,7 @@ const App: React.FC = () => {
<SafeAreaProvider>
<StatusBar />
<ThemeProvider theme={light}>
<Setup getTransit={getTransit} day={day} setDate={setDate}>
<Setup getTransit={getTransit}>
<Router />
</Setup>
</ThemeProvider>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
import { Calendar } from "expo-calendar";
import { createContext } from "react";
type RejectedCalendarContextValue = {
status: 'rejected';
date: Date;
setDate: (date: Date) => void;
}
type UnavailableCalendarContextValue = {
status: 'unavailable';
date: Date;
setDate: (date: Date) => void;
}
type AcceptedCalendarContextValue = {
status: 'ready';
date: Date;
setDate: (date: Date) => void;
calendars: Calendar[];
calendar: Calendar;
selected: Calendar[];
setSelected: (calendars: Calendar[]) => void;
error?: any;
}
type CalendarContextValue = RejectedCalendarContextValue
| UnavailableCalendarContextValue
| AcceptedCalendarContextValue
const CalendarContext = createContext<CalendarContextValue>(undefined as any);
export type { CalendarContextValue };
export { CalendarContext };

View File

@@ -1,113 +0,0 @@
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";
const emptyArray: never[] = [];
const emptyFn = () => undefined;
export const useCalendar = () => {
const context = useContext(CalendarContext);
if (context.status !== 'ready') {
return undefined;
}
return context.calendar;
}
export const useCalendars = () => {
const context = useContext(CalendarContext);
if (context.status !== 'ready') {
return emptyArray;
}
return context.calendars;
}
export const useSelectedCalendars = () => {
const context = useContext(CalendarContext);
if (context.status !== 'ready') {
return emptyArray;
}
return context.selected;
}
export const useSetSelectedCalendars = () => {
const context = useContext(CalendarContext);
if (context.status !== 'ready') {
return emptyFn;
}
return context.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[]) => {
if (!calendar) {
return;
}
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, calendar],
);
return result;
}
export const useToday = (start: Date, end?: Date) => {
const selectedCalendars = useSelectedCalendars();
if (!end) {
end = set(start, {
hours: 24,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
}
const result = useAsync(
async () => {
if (selectedCalendars.length === 0) {
return [];
}
return getEventsAsync(selectedCalendars.map(c => c.id), start, end!)
},
[selectedCalendars, start.getTime()],
);
return result;
}

View File

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

View File

@@ -1,120 +0,0 @@
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';
import { Platform } from "react-native";
const SELECTED_STORAGE_KEY = 'selected_calendars';
type CalendarProviderProps = {
calendarName?: string,
date: Date;
children: ReactNode;
setDate: (date: Date) => void;
}
type SetupResponse = {
status: 'rejected';
} | {
status: 'unavailable';
} | {
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 (Platform.OS !== 'ios') {
return { status: 'unavailable' };
}
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) {
return <></>
}
if (value.status !== 'ready') {
return (
<CalendarContext.Provider value={{ status: value.status, date, setDate }}>
{children}
</CalendarContext.Provider>
);
}
return (
<CalendarContext.Provider
value={{
status: 'ready',
setDate,
date,
selected,
setSelected,
calendar: value.calendar,
calendars: value.calendars,
}}
>
{children}
</CalendarContext.Provider>
)
};
export type { CalendarProviderProps };
export { CalendarProvider };

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import { Time } from "./types";
const equal = (a: Time, b: Time) => {
return a.hour == b.hour && a.minute === b.minute;
}
const stringToTime = (input: string) => {
const [hourPart, minutePart] = input.split(':').map(a => a.trim()).filter(Boolean);
const hour = parseInt(hourPart);
const minute = parseInt(minutePart || '0');
if (
!Number.isInteger(hour)
|| !Number.isInteger(minute)
|| Number.isNaN(hour)
|| Number.isNaN(minute)
) {
return undefined;
}
const result: Time = {
hour,
minute,
};
return result;
};
const timeToString = (input: Time) => `${input.hour}:${input.minute}`;
const timeUtils = {
timeToString,
stringToTime,
equal,
};
export { timeUtils };

View File

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

7
src/features/day/day.ts Normal file
View File

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

12
src/features/day/hooks.ts Normal file
View File

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

View File

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

View File

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

30
src/features/day/utils.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -1,6 +1,19 @@
import { GetTransition, UserLocation } from "#/types/location";
import { UserLocation } from "../data";
import { createContext } from "react"
type Transition = {
time: number;
usableTime: number;
to: UserLocation;
from: UserLocation;
};
type GetTransition = (
from: UserLocation,
to: UserLocation,
time: Date,
) => Promise<Transition>;
type LocationContextValue = {
locations: {
[id: string]: UserLocation;
@@ -13,5 +26,5 @@ type LocationContextValue = {
const LocationContext = createContext<LocationContextValue>(undefined as any);
export type { LocationContextValue };
export type { LocationContextValue, GetTransition, Transition };
export { LocationContext };

View File

@@ -1,13 +1,14 @@
import { useAsync } from "#/hooks/async";
import { useContext } from "react"
import { useAsync } from "#/features/async";
import { useContext, useMemo } from "react"
import { requestForegroundPermissionsAsync, getCurrentPositionAsync } from 'expo-location';
import { LocationContext } from "./context"
import { UserLocation } from "#/types/location";
import { UserLocation } from "../data";
import { getDistanceFromLatLonInKm } from "./utils";
export const useLocations = () => {
const { locations } = useContext(LocationContext);
return locations;
const result = useMemo(() => Object.values(locations), [locations]);
return result;
}
export const useSetLocation = () => {
@@ -31,7 +32,7 @@ export const useLookup = () => {
}
export const useCurrentLocation = (proximity: number = 0.5) => {
const locations = useLocations();
const { locations } = useContext(LocationContext);
const result = useAsync<UserLocation | undefined>(
async () => {
let { status } = await requestForegroundPermissionsAsync();
@@ -40,14 +41,14 @@ export const useCurrentLocation = (proximity: number = 0.5) => {
}
let position = await getCurrentPositionAsync({});
const withDistance = Object.values(locations).map((location) => {
if (!location.location) {
if (!location.position) {
return;
}
const distance = getDistanceFromLatLonInKm(
position.coords.latitude,
position.coords.longitude,
location.location.latitude,
location.location.longitute,
location.position.latitude,
location.position.longitute,
)
return {
distance,
@@ -59,7 +60,7 @@ export const useCurrentLocation = (proximity: number = 0.5) => {
return {
id: `${position.coords.longitude} ${position.coords.latitude}`,
title: 'Unknown',
location: {
position: {
latitude: position.coords.latitude,
longitute: position.coords.longitude,
},

View File

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

View File

@@ -1,8 +1,9 @@
import { useAsync, useAsyncCallback } from "#/hooks/async";
import { GetTransition, UserLocation } from "#/types/location";
import { useAsync, useAsyncCallback } from "#/features/async";
import { GetTransition } from "./context";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { ReactNode, useState } from "react";
import { LocationContext } from "./context";
import { UserLocation } from "../data";
type LocationProviderProps = {
children: ReactNode;
@@ -10,7 +11,7 @@ type LocationProviderProps = {
getTransition: GetTransition;
}
const LOCATION_STORAGE_KEY = 'location_storage';
const LOCATION_STORAGE_KEY = 'locations';
const LocationProvider: React.FC<LocationProviderProps> = ({
children,

View File

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

View File

@@ -0,0 +1,33 @@
import { useContext } from "react"
import { useAsyncCallback } from "../async";
import { Time } from "../data";
import { OverrideContext } from "./context"
export const useOverrides = () => {
const { overrides } = useContext(OverrideContext);
return overrides;
}
export const useSetOverride = () => {
const { set } = useContext(OverrideContext);
return set;
}
export const useStartTimeOverride = () => {
const { overrides } = useContext(OverrideContext);
return overrides.startTime;
};
export const useSetStartTimeOverride = () => {
const { set } = useContext(OverrideContext);
const setStartTime = useAsyncCallback(
async (startTime?: Time) => {
set(current => ({
...current,
startTime,
}));
},
[set],
);
return setStartTime;
};

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
import { createContext } from 'react';
import { createDataContext } from '#/utils/data-context';
import { Strategies } from "./algorithm/build-graph";
type PlannerOptions = {
strategy: Strategies;
}
type PlannerContextValue = {
options: PlannerOptions;
setOptions: (options: Partial<PlannerOptions>) => void;
}
const PlannerContext = createContext<PlannerContextValue>(undefined as any);
const {
Context: PlannerContext,
Provider: PlannerProvider,
} = createDataContext<PlannerOptions>({
createDefault: () => ({
strategy: Strategies.firstComplet,
}),
});
export type { PlannerContextValue, PlannerOptions };
export { PlannerContext };
export type { PlannerOptions };
export { PlannerContext, PlannerProvider };

View File

@@ -1,14 +1,8 @@
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 { useContext, useMemo, useState } from "react";
import { useContext } from "react";
import { PlanItem } from "#/types/plans";
import { Task } from "#/types/task";
import { PlannerContext } from "./context";
import { Task, UserLocation } from "../data";
export type UsePlanOptions = {
location: UserLocation;
@@ -25,53 +19,16 @@ export type UsePlan = [
]
export const usePlanOptions = () => {
const { options } = useContext(PlannerContext);
return options;
const { data } = useContext(PlannerContext);
return data;
}
export const useSetPlanOptions = () => {
const { setOptions } = useContext(PlannerContext);
return setOptions;
const { setData } = useContext(PlannerContext);
return setData;
}
export const usePlan = ({
location,
}: UsePlanOptions): UsePlan => {
const today = useDate();
const planOptions = usePlanOptions();
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: planOptions.strategy,
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, planOptions],
);
export const usePlan = () => {
return [
invoke,
{
result: options.result,
loading: options.loading,
error: options.error,
status: status,
}
];
}

View File

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

View File

@@ -1,32 +0,0 @@
import { ReactNode, useCallback, useState } from 'react';
import { Strategies } from './algorithm/build-graph';
import { PlannerContext, PlannerOptions } from './context';
type PlannerProviderProps = {
children: ReactNode;
};
const PlannerProvider: React.FC<PlannerProviderProps> = ({ children }) => {
const [options, setOwnOptions] = useState<PlannerOptions>({
strategy: Strategies.firstComplet,
})
const setOptions = useCallback(
(next: Partial<PlannerOptions>) => {
setOwnOptions(current => ({
...current,
...next,
}))
},
[setOwnOptions],
);
return (
<PlannerContext.Provider value={{ options, setOptions }}>
{children}
</PlannerContext.Provider>
);
}
export type { PlannerProviderProps };
export { PlannerProvider };

View File

@@ -1,10 +1,24 @@
import { GetTransition, Transition, UserLocation } from "./location";
import { Task } from "./task";
type Context = {
getTransition: GetTransition;
};
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;
};
type GraphNode = {
location: UserLocation;

View File

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

View File

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

View File

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

View File

@@ -1,74 +0,0 @@
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 };

View File

@@ -1,35 +1,39 @@
import { GetTransition } from "#/types/location"
import { ReactNode } from "react"
import { AgendaContextProvider } from "./agenda-context"
import { CalendarProvider } from "./calendar"
import { LocationProvider } from "./location"
import { AppointmentsProvider } from "./appointments"
import { DateProvider } from "./day"
import { GoalsProvider } from "./goals/context"
import { GetTransition, LocationProvider } from "./location"
import { OverrideProvider } from "./overrides"
import { PlannerProvider } from "./planner"
import { RoutinesProvider } from "./routines"
type SetupProps = {
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}>
<PlannerProvider>
{children}
</PlannerProvider>
</AgendaContextProvider>
</LocationProvider>
</RoutinesProvider>
</CalendarProvider>
);
}) => {
return (
<DateProvider>
<PlannerProvider storageKey="planner">
<LocationProvider getTransition={getTransit} lookup={() => []}>
<AppointmentsProvider>
<GoalsProvider storageKey="goals">
<RoutinesProvider storageKey="routines">
<OverrideProvider>
{children}
</OverrideProvider>
</RoutinesProvider>
</GoalsProvider>
</AppointmentsProvider>
</LocationProvider>
</PlannerProvider>
</DateProvider>
);
};
export type { SetupProps };
export { Setup };

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
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>;

View File

@@ -1,20 +0,0 @@
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;

View File

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

View File

@@ -0,0 +1,29 @@
import React, { ReactNode } from 'react';
import { Icon } from '../icon';
import { Row, Cell } from '../row';
interface Props {
title: string;
add?: () => void;
onPress?: () => void;
left?: ReactNode;
}
function Header({ title, add, onPress, left }: Props) {
return (
<Row
onPress={onPress}
left={left}
title={title}
right={
add && (
<Cell onPress={add}>
<Icon name="plus-circle" size={18} />
</Cell>
)
}
/>
);
}
export { Header };

View File

@@ -0,0 +1,75 @@
import React, { Fragment, ReactNode, useState } from 'react';
import styled from 'styled-components/native';
import Collapsible from 'react-native-collapsible';
import { Body1 } from '#/ui/typography';
import { Icon } from '../icon';
import { Row, Cell } from '../row';
import { Header } from './header';
interface ListProps<T> {
title: string;
items: T[];
startHidden?: boolean;
getKey: (item: T) => any;
render: (item: T) => ReactNode;
add?: () => void;
}
interface ChildProps {
title: string;
startHidden?: boolean;
add?: () => void;
children?: ReactNode;
}
const Wrapper = styled.View`
border-radius: 7px;
background: ${({ theme }) => theme.colors.background};
shadow-offset: 0 0;
shadow-opacity: 0.1;
shadow-color: ${({ theme }) => theme.colors.shadow};
shadow-radius: 5px;
`;
function Group<T = any>(props: ListProps<T> | ChildProps) {
const [visible, setVisible] = useState(!props.startHidden);
const { title, items, getKey, render, add, children } =
props as ListProps<T> & ChildProps;
return (
<Row>
<Wrapper>
<>
<Header
left={
<Cell><Icon name={visible ? 'chevron-down' : 'chevron-up'} size={18} /></Cell>
}
title={title}
add={add}
onPress={() => setVisible(!visible)}
/>
<Collapsible collapsed={!visible}>
{items && items.map((item, i) => (
<Fragment key={getKey(item) || i}>{render(item)}</Fragment>
))}
{children}
{!children && (!items || items.length === 0) && (
<Row
left={
<Cell>
<Icon color="textShade" name="maximize" />
</Cell>
}
>
<Body1 style={{ marginLeft: 10 }} color="textShade">
Empty
</Body1>
</Row>
)}
</Collapsible>
</>
</Wrapper>
</Row>
);
}
export { Group };

View File

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

View File

@@ -2,10 +2,10 @@ 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 { Row, Cell, RowProps } from '../row';
import { Page } from '../page';
interface Props {
type Props = RowProps & {
onClose?: () => void;
children: ReactNode;
}
@@ -17,19 +17,25 @@ const Top = styled.Pressable`
const Wrapper = styled.View`
background: ${({ theme }) => theme.colors.background};
width: 100%;
max-width: 500px;
shadow-color: ${({ theme }) => theme.colors.shadow};
shadow-offset: 0 0;
shadow-opacity: 1;
shadow-radius: 200px;
border-radius: 12px;
margin-bottom: -12px;
max-height: 80%;
`;
const Outer = styled.View`
flex: 1;
align-items: center;
`;
const Popup: React.FC<Props> = ({ visible, children, onClose }) => {
const Content = styled.ScrollView`
`;
const Popup: React.FC<Props> = ({ children, onClose, right, ...rowProps }) => {
const insets = useSafeAreaInsets();
return (
@@ -38,13 +44,19 @@ const Popup: React.FC<Props> = ({ visible, children, onClose }) => {
<Top onPress={onClose} />
<Wrapper style={{ paddingBottom: insets.bottom + 12 }}>
<Row
{...rowProps}
right={
<Cell onPress={onClose}>
<Icon name="x-circle" />
</Cell>
<>
{right}
<Cell onPress={onClose}>
<Icon name="x-circle" />
</Cell>
</>
}
/>
{children}
<Content>
{children}
</Content>
</Wrapper>
</Outer>
</Page>

View File

@@ -3,7 +3,7 @@ import { TouchableOpacity } from 'react-native';
import styled from 'styled-components/native';
import { Theme } from '#/ui/theme';
interface Props {
type CellProps = {
accessibilityRole?: TouchableOpacity['props']['accessibilityRole'];
accessibilityLabel?: string;
accessibilityHint?: string;
@@ -34,7 +34,7 @@ const Wrapper = styled.View<{
const Touch = styled.TouchableOpacity``;
const Cell: React.FC<Props> = ({ children, onPress, ...props}) => {
const Cell: React.FC<CellProps> = ({ children, onPress, ...props}) => {
const {
accessibilityLabel,
accessibilityRole,
@@ -62,4 +62,5 @@ const Cell: React.FC<Props> = ({ children, onPress, ...props}) => {
return node;
};
export type { CellProps };
export { Cell };

View File

@@ -1,9 +1,9 @@
import React, { ReactNode } from 'react';
import styled from 'styled-components/native';
import { Title1, Body1, Overline } from '#/ui/typography';
import { Cell } from './cell';
import { Cell, CellProps } from './cell';
type RowProps = {
type RowProps = CellProps & {
background?: string;
top?: ReactNode;
left?: ReactNode;
@@ -42,8 +42,9 @@ const Row: React.FC<RowProps> = ({
description,
children,
onPress,
...cellProps
}) => (
<Cell background={background} opacity={opacity} onPress={onPress}>
<Cell {...cellProps} background={background} opacity={opacity} onPress={onPress}>
{left}
<Cell flex={1} direction="column" align="stretch">
{!!top}

View File

@@ -0,0 +1,64 @@
import { useMemo } from "react";
import CalendarStrip from 'react-native-calendar-strip';
import { dayUtils, useDate, useSetDate } from "#/features/day";
import { useTheme } from "styled-components/native";
const DateBar: React.FC = () => {
const date = useDate();
const theme = useTheme();
const setDate = useSetDate();
const selected = useMemo(
() => [{
date: dayUtils.dayToDate(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={dayUtils.dayToDate(date)}
startingDate={dayUtils.dayToDate(date)}
onDateSelected={(date) => {
setDate(dayUtils.dateToDay(date.utc().toDate()));
}}
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,
}}
/>
);
};
export { DateBar };

View File

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

View File

@@ -1,6 +1,6 @@
import { Row } from "../../row"
import { Row, RowProps } from "#/ui/components/base"
type CheckboxProps = {
type CheckboxProps = RowProps & {
value?: boolean;
label: string;
onChange: (value: boolean) => void;
@@ -10,8 +10,10 @@ const Checkbok: React.FC<CheckboxProps> = ({
value,
label,
onChange,
...rowProps
}) => (
<Row
{...rowProps}
overline={label}
title={value? 'Yes' : 'No'}
onPress={() => onChange(!value)}

View File

@@ -1,2 +1,4 @@
export * from './input';
export * from './checkbox';
export * from './time';
export * from './optional-selector';

View File

@@ -1,8 +1,9 @@
import React from 'react';
import styled, { useTheme } from 'styled-components/native';
import { Row, RowProps } from '../../row';
import styled from 'styled-components/native';
import { Row, RowProps } from '#/ui/components/base';
type Props = RowProps & {
label: string;
placeholder?: string;
value: string;
onChangeText: (text: string) => any;
@@ -17,11 +18,11 @@ const InputField = styled.TextInput`
width: 100%;
`;
const TextInput: React.FC<Props> = ({ placeholder, value, onChangeText, children, ...row }) => {
const theme = useTheme();
const TextInput: React.FC<Props> = ({ label, placeholder, value, onChangeText, children, ...row }) => {
return (
<Row overline={placeholder} {...row}>
<Row overline={label} {...row}>
<InputField
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
/>

View File

@@ -0,0 +1,116 @@
import { Body1 } from "#/ui/typography";
import { useCallback } from "react";
import styled from "styled-components/native";
import { Row, RowProps, Cell, Icon } from "../../base";
type Props<T> = {
label: string;
setEnabled: (enabled: boolean) => void;
enabled: boolean;
onChange: (items: T[]) => void;
items: T[];
enabledText: string;
disabledText: string;
selected?: T[];
render: (item: T) => RowProps;
getKey: (item: T) => string;
};
const Wrapper = styled.View`
border-radius: 5px;
background: ${({ theme }) => theme.colors.shade};
border-radius: 7px;
shadow-offset: 0 0;
shadow-opacity: 0.1;
shadow-color: ${({ theme }) => theme.colors.shadow};
shadow-radius: 5px;
`;
const Top = styled.View`
flex-direction: row;
`;
const Touch = styled.TouchableOpacity`
flex: 1;
`;
const Content = styled.View`
`;
const TopButton = styled.View<{ selected: boolean }>`
background: ${({ selected, theme }) => selected ? theme.colors.shade : theme.colors.background};
padding: ${({ theme }) => theme.margins.small}px;
align-items: center;
justify-content: center;
`
function OptionalSelector<T>({
label,
enabled,
setEnabled,
onChange,
items,
enabledText,
disabledText,
selected,
render,
getKey,
}: Props<T>) {
const toggle = useCallback(
(item: T) => {
if (!selected) {
return onChange([item]);
}
const nextId = getKey(item);
const current = selected.find(i => getKey(i) === nextId);
if (current) {
onChange(selected.filter(i => i !== current));
} else {
onChange([...selected, item]);
}
},
[selected, getKey]
)
return (
<Row overline={label}>
<Wrapper>
<Top>
<Touch onPress={() => setEnabled(false)}>
<TopButton selected={!enabled}>
<Body1>{disabledText}</Body1>
</TopButton>
</Touch>
<Touch onPress={() => setEnabled(true)}>
<TopButton selected={enabled}>
<Body1>{enabledText}</Body1>
</TopButton>
</Touch>
</Top>
{enabled && (
<Content>
{items.map((item) => {
const { left, ...props } = render(item);
const isSelected = !!selected && selected.includes(item);
return (
<Row
key={getKey(item)}
{...props}
left={(
<>
<Cell onPress={() => toggle(item)}>
<Icon name={isSelected ? 'check-circle' : 'circle'} />
</Cell>
{left}
</>
)}
/>
);
})}
</Content>
)}
</Wrapper>
</Row>
)
}
export { OptionalSelector }

View File

@@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components/native';
import { Row, RowProps } from '#/ui/components/base';
import { Time, timeUtils } from '#/features/data';
type Props = RowProps & {
label: string;
placeholder?: string;
value?: Time;
onChange: (time?: Time) => any;
}
const TimeField = 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 TimeInput: React.FC<Props> = ({ label, placeholder, value, onChange, children, ...row }) => {
const [innerValue, setValue] = useState(value ? timeUtils.timeToString(value) : '');
useEffect(
() => {
if (!innerValue && value) {
onChange(undefined);
return;
}
const parsed = timeUtils.stringToTime(innerValue)
if (!parsed) {
return;
}
if (value && timeUtils.equal(parsed, value)) {
return;
}
onChange(parsed);
},
[innerValue, value, onChange],
)
return (
<Row overline={label} {...row}>
<TimeField
placeholder={placeholder}
value={innerValue}
onChangeText={setValue}
/>
{children}
</Row>
);
};
export { TimeInput };

View File

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

View File

@@ -1,76 +0,0 @@
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 };

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,113 +1,2 @@
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';
import { PlanSettingsScreen } from '../screens/plan/settings';
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.Screen name="planSettings" component={PlanSettingsScreen} />
</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 };
export { Router } from './router';
export * from './types';

98
src/ui/router/router.tsx Normal file
View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useTheme } from 'styled-components/native';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Icon } from '../components/base';
import { DayScreen } from '../screens/day';
import { TaskAddScreen } from '../screens/task/add';
import { MainTabParamList, RootStackParamList } from './types';
import { Platform } from 'react-native';
import { MoreScreen } from '../screens/more';
import { LocationListScreen } from '../screens/locations/list';
import { LocationSetScreen } from '../screens/locations/set';
const MoreStackNavigator = createNativeStackNavigator();
const MoreStack: React.FC = () => (
<MoreStackNavigator.Navigator>
<MoreStackNavigator.Screen name="more-main" component={MoreScreen} />
<MoreStackNavigator.Screen name="locations" component={LocationListScreen} />
</MoreStackNavigator.Navigator>
);
const MainTabsNvaigator = createBottomTabNavigator<MainTabParamList>();
const MainTabs: React.FC = () => {
const theme = useTheme();
return (
<MainTabsNvaigator.Navigator
screenOptions={{
tabBarActiveTintColor: theme.colors.primary,
}}
>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: 'Days',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="check-square" />,
}}
name="day"
component={DayScreen}
/>
<MainTabsNvaigator.Screen
options={{
headerShown: false,
tabBarLabel: 'More',
tabBarIcon: ({ focused }) => <Icon color={focused ? 'primary' : 'text'} name="more-vertical" />,
}}
name="more"
component={MoreStack}
/>
</MainTabsNvaigator.Navigator>
);
};
const RootNavigator = Platform.OS === 'web'
? createStackNavigator<RootStackParamList>()
: createNativeStackNavigator<RootStackParamList>();
const Root: React.FC = () => (
<RootNavigator.Navigator screenOptions={{ headerShown: false, animationEnabled: true }}>
<RootNavigator.Group>
<RootNavigator.Screen name="main" component={MainTabs} />
</RootNavigator.Group>
<RootNavigator.Group screenOptions={{ presentation: 'transparentModal' }}>
<RootNavigator.Screen name="add-task" component={TaskAddScreen} />
<RootNavigator.Screen name="set-location" component={LocationSetScreen} />
</RootNavigator.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 };

34
src/ui/router/types.ts Normal file
View File

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

View File

@@ -1,47 +0,0 @@
import { useCalendars, useSelectedCalendars, useSetSelectedCalendars } from "#/features/calendar"
import { Calendar } from "expo-calendar";
import { useCallback } from "react";
import styled from "styled-components/native";
const Wrapper = styled.View`
`;
const Button = styled.Button`
`;
const CalendarSelectScreen: React.FC = () => {
const calendars = useCalendars();
const selected = useSelectedCalendars();
const setSelected = useSetSelectedCalendars();
const toggle = useCallback(
(calendar: Calendar) => {
const isSelected = !!selected.find(c => c.id === calendar.id);
if (isSelected) {
setSelected(selected.filter(c => c.id !== calendar.id));
} else {
setSelected([
...selected,
calendar,
]);
}
},
[selected]
)
return (
<Wrapper>
{calendars.map((calendar) => (
<Button
key={calendar.id}
title={calendar.title + (selected.includes(calendar) ? ' -y' : '-n')}
onPress={() => toggle(calendar)}
/>
))}
</Wrapper>
)
}
export { CalendarSelectScreen };

View File

@@ -0,0 +1,34 @@
import { useAppointmentStatus } from "#/features/appointments";
import { AppointmentsStatus } from "#/features/appointments/context";
import { TaskType } from "#/features/data";
import { dayUtils, useDate } from "#/features/day";
import { useSetStartTimeOverride, useStartTimeOverride } from "#/features/overrides";
import { DateBar } from "#/ui/components/date"
import { TimeInput } from "#/ui/components/form";
import { TaskGroup } from "#/ui/components/tasks/group";
const DayScreen: React.FC = () => {
const date = useDate();
const appointmentStatus = useAppointmentStatus();
const startTimeOverride = useStartTimeOverride();
const [setStartTimeOverride] = useSetStartTimeOverride();
return (
<>
<DateBar />
<TimeInput
key={dayUtils.toId(date)}
label="Start time"
value={startTimeOverride}
onChange={setStartTimeOverride}
/>
{appointmentStatus === AppointmentsStatus.rejected && (
<TaskGroup type={TaskType.appointment} />
)}
<TaskGroup type={TaskType.routine} />
<TaskGroup type={TaskType.goal} />
</>
);
};
export { DayScreen };

View File

@@ -1,43 +1,34 @@
import { useLocations, useRemoveLocation } from "#/features/location"
import { Button, Cell } from "#/ui/components";
import { Row } from "#/ui/components/row/row";
import { Button, Cell, Icon, Page, Row } from "#/ui/components/base";
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();
const locations = useLocations();
const removeLocation = useRemoveLocation();
return (
<Wrapper>
<Button icon="plus-circle" onPress={() => navigate('locationSet')} />
<Page>
<Button title="Add" onPress={() => navigate('set-location', {})}/>
<FlatList
data={Object.values(locations)}
data={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)} />
right={(
<Cell onPress={() => removeLocation(item.id)}>
<Icon
name="trash"
color="destructive"
/>
</Cell>
}
)}
/>
)}
/>
</Wrapper>
</Page>
);
}

View File

@@ -1,61 +1,56 @@
import { useLocations, useSetLocation } from "#/features/location";
import { useNavigation, useRoute } from '@react-navigation/native';
import { Popup, Button, TextInput } from "#/ui/components";
import { useCallback, useEffect, useMemo, useState } from "react";
import { nanoid } from 'nanoid';
import { useAsyncCallback } from "#/features/async";
import { useLocations, useSetLocation } from "#/features/location"
import { Button, Popup, Row } from "#/ui/components/base";
import { TextInput } from "#/ui/components/form";
import { LocationSetScreenRouteProp, RootNavigationProp } from "#/ui/router";
import { useNavigation, useRoute } from "@react-navigation/native";
import { useEffect, useState } from "react";
const LocationSetScreen: React.FC = () => {
const { params = {} } = useRoute() as any;
const id = useMemo(
() => params.id || nanoid(),
[params.id],
)
const {
params: { id = nanoid() },
} = useRoute<LocationSetScreenRouteProp>();
const { navigate } = useNavigation<RootNavigationProp>();
const locations = useLocations();
const { navigate, goBack } = useNavigation();
const [title, setTitle] = useState('');
const [lng, setLng] = useState('');
const [lat, setLat] = useState('');
const set = useSetLocation();
const setLocation = useSetLocation();
useEffect(
() => {
const current = locations[id];
const current = locations.find(l => l.id === id);
if (!current) {
return;
}
setTitle(current.title);
setLng(current.location?.longitute.toString() || '');
setLat(current.location?.latitude.toString() || '');
},
[locations, id],
[id, locations],
)
const save = useCallback(
() => {
const lngParsed = parseFloat(lng);
const latParsed = parseFloat(lat);
set({
const [save] = useAsyncCallback(
async () => {
await setLocation({
id,
title,
location: {
longitute: lngParsed,
latitude: latParsed,
},
position: { longitute: 0, latitude: 0 },
});
navigate('main');
},
[title, lng, lat, id],
)
[id, title],
);
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 title="Edit location">
<TextInput
label="What should it call the location?"
value={title}
onChangeText={setTitle}
/>
<Row>
<Button title="Save" onPress={save} />
</Row>
</Popup>
)
}
);
};
export { LocationSetScreen };

View File

@@ -0,0 +1,28 @@
import { Page, Row } from "#/ui/components/base";
import { useNavigation } from "@react-navigation/native";
const MoreScreen: React.FC = () => {
const { navigate } = useNavigation();
return (
<Page>
<Row
title="Calendars"
/>
<Row
title="Locations"
onPress={() => navigate('locations')}
/>
<Row
title="Routines"
onPress={() => navigate('routines')}
/>
<Row
title="Goals"
onPress={() => navigate('goals')}
/>
</Page>
);
}
export { MoreScreen };

View File

@@ -1,101 +0,0 @@
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";
import { useNavigation } from "@react-navigation/native";
const Wrapper = styled.ScrollView`
`;
const getStats = (status: Status) => {
if (status.current === 'running') {
const runTime = formatDistanceToNow(status.start, { includeSeconds: true })
return `calulated ${status.nodes} nodes in ${runTime} using ${status.strategy}`;
}
const runTime = formatDistance(status.start, status.end, { includeSeconds: true })
return `calulated ${status.nodes} nodes in ${runTime} using ${status.strategy}`;
};
const PlanDayScreen: React.FC = () => {
const date = useDate();
const [location] = useCurrentLocation();
const [startTime, setStartTime] = useState('06:00');
const [commit] = useCommit();
const { navigate } = useNavigation();
const current = useMemo(
() => location || {
id: 'unknown',
title: 'Unknown',
},
[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>
)}
<Cell>
<Button onPress={() => navigate('planSettings')} icon="settings" />
</Cell>
</>
)}
/>
{!!options.error && (
<Row title={JSON.stringify(options.error)} />
)}
{options.status?.current === 'running' && (
<Row
title={getStats(options.status)}
/>
)}
{!!options.result && options.status?.current === 'completed' && (
<Row title={format(date, 'EEEE - do MMMM')} overline={getStats(options.status)}>
{options.result.impossible && options.result.impossible.length > 0 && <Body1>Impossible: {options.result.impossible.map(i => i.name).join(', ')}</Body1>}
<DayView plan={options.result.agenda} />
</Row>
)}
</Page>
</Wrapper>
)
}
export { PlanDayScreen };

View File

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

View File

@@ -1,40 +0,0 @@
import { Strategies, usePlanOptions, useSetPlanOptions } from "#/features/planner"
import { Selector } from "#/ui/components/form/selector";
import { Popup } from "#/ui/components";
import { useNavigation } from "@react-navigation/native";
const items = [{
display: 'First valid',
value: Strategies.firstValid,
}, {
display: 'First complete',
value: Strategies.firstComplet,
}, {
display: 'All valid',
value: Strategies.allValid,
}, {
display: 'All',
value: Strategies.all,
}];
const PlanSettingsScreen: React.FC = () => {
const options = usePlanOptions();
const setOptions = useSetPlanOptions();
const { goBack } = useNavigation();
return (
<Popup onClose={goBack}>
<Selector
label="Strategy"
items={items}
getId={i => i}
selected={options.strategy}
setSelected={(strategy) => {
setOptions({ strategy: strategy || Strategies.firstComplet });
}}
/>
</Popup>
);
}
export { PlanSettingsScreen };

View File

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

View File

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

View File

@@ -1,83 +0,0 @@
import { useNavigation, useRoute } from '@react-navigation/native';
import { Popup, 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';
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 };

171
src/ui/screens/task/add.tsx Normal file
View File

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

View File

@@ -2,8 +2,8 @@ import { Theme } from './theme';
const light: Theme = {
colors: {
primary: '#1abc9c',
icon: '#1abc9c',
primary: '#6c5ce7',
icon: '#6c5ce7',
destructive: '#e74c3c',
shade: '#ededed',
input: '#ddd',

View File

@@ -1,5 +1,5 @@
import {} from 'styled-components';
import Theme from './Theme'; // Import type from above file
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.
}

View File

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

17
webpack.config.js Normal file
View File

@@ -0,0 +1,17 @@
const createExpoWebpackConfigAsync = require("@expo/webpack-config");
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
module.exports = async function (env, argv) {
const config = await createExpoWebpackConfigAsync(env, argv);
// Use the React refresh plugin in development mode
if (env.mode === "development") {
config.plugins.push(
new ReactRefreshWebpackPlugin({
forceEnable: true,
})
);
}
return config;
};

Some files were not shown because too many files have changed in this diff Show More