mirror of
https://github.com/morten-olsen/bob-the-algorithm.git
synced 2026-02-08 00:46:25 +01:00
v3
This commit is contained in:
45
.github/workflows/expo-pr.yml
vendored
Normal file
45
.github/workflows/expo-pr.yml
vendored
Normal 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
|
||||

|
||||
Published to https://exp.host/${{ env.EXPO_PROJECT }}?release-channel=pr${{ github.event.number }}
|
||||
@@ -1,5 +1,5 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
api.cache.using(() => process.env.NODE_ENV);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
|
||||
15
package.json
15
package.json
@@ -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
|
||||
}
|
||||
|
||||
11
src/app.tsx
11
src/app.tsx
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AgendaContextProvider } from './provider';
|
||||
export * from './hooks';
|
||||
@@ -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 };
|
||||
35
src/features/appointments/context.ts
Normal file
35
src/features/appointments/context.ts
Normal 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 };
|
||||
45
src/features/appointments/hooks.ts
Normal file
45
src/features/appointments/hooks.ts
Normal 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;
|
||||
}
|
||||
2
src/features/appointments/index.ts
Normal file
2
src/features/appointments/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AppointmentsProvider } from './provider';
|
||||
export * from './hooks';
|
||||
35
src/features/appointments/provider.tsx
Normal file
35
src/features/appointments/provider.tsx
Normal 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 };
|
||||
1
src/features/async/index.ts
Normal file
1
src/features/async/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hooks';
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { CalendarProvider } from './provider';
|
||||
export * from './hooks';
|
||||
@@ -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 };
|
||||
2
src/features/data/index.ts
Normal file
2
src/features/data/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export { timeUtils } from './utils';
|
||||
55
src/features/data/types.ts
Normal file
55
src/features/data/types.ts
Normal 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;
|
||||
37
src/features/data/utils.ts
Normal file
37
src/features/data/utils.ts
Normal 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 };
|
||||
12
src/features/day/context.ts
Normal file
12
src/features/day/context.ts
Normal 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
7
src/features/day/day.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
type Day = {
|
||||
year: number;
|
||||
month: number;
|
||||
date: number;
|
||||
}
|
||||
|
||||
export type { Day };
|
||||
12
src/features/day/hooks.ts
Normal file
12
src/features/day/hooks.ts
Normal 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;
|
||||
}
|
||||
4
src/features/day/index.ts
Normal file
4
src/features/day/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DateProvider } from './provider';
|
||||
export type { Day } from './day';
|
||||
export * from './hooks';
|
||||
export { dayUtils } from './utils';
|
||||
20
src/features/day/provider.tsx
Normal file
20
src/features/day/provider.tsx
Normal 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
30
src/features/day/utils.ts
Normal 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 };
|
||||
11
src/features/goals/context.ts
Normal file
11
src/features/goals/context.ts
Normal 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 };
|
||||
41
src/features/goals/hooks.ts
Normal file
41
src/features/goals/hooks.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export type { Transition, GetTransition } from './context';
|
||||
export { LocationProvider } from './provider';
|
||||
export * from './hooks';
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
src/features/overrides/context.ts
Normal file
30
src/features/overrides/context.ts
Normal 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 };
|
||||
33
src/features/overrides/hooks.ts
Normal file
33
src/features/overrides/hooks.ts
Normal 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;
|
||||
};
|
||||
3
src/features/overrides/index.ts
Normal file
3
src/features/overrides/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { Override, OverrideIndex } from './context';
|
||||
export { OverrideProvider } from './provider';
|
||||
export * from './hooks';
|
||||
59
src/features/overrides/provider.tsx
Normal file
59
src/features/overrides/provider.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { RoutinesProvider } from './provider';
|
||||
export { Routine } from './context';
|
||||
export { RoutinesProvider } from './context';
|
||||
export * from './hooks';
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
}) => {
|
||||
return (
|
||||
<DateProvider>
|
||||
<PlannerProvider storageKey="planner">
|
||||
<LocationProvider getTransition={getTransit} lookup={() => []}>
|
||||
<AgendaContextProvider day={day}>
|
||||
<PlannerProvider>
|
||||
<AppointmentsProvider>
|
||||
<GoalsProvider storageKey="goals">
|
||||
<RoutinesProvider storageKey="routines">
|
||||
<OverrideProvider>
|
||||
{children}
|
||||
</PlannerProvider>
|
||||
</AgendaContextProvider>
|
||||
</LocationProvider>
|
||||
</OverrideProvider>
|
||||
</RoutinesProvider>
|
||||
</CalendarProvider>
|
||||
</GoalsProvider>
|
||||
</AppointmentsProvider>
|
||||
</LocationProvider>
|
||||
</PlannerProvider>
|
||||
</DateProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export type { SetupProps };
|
||||
export { Setup };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
46
src/features/tasks/hooks.tsx
Normal file
46
src/features/tasks/hooks.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
29
src/ui/components/base/group/header.tsx
Normal file
29
src/ui/components/base/group/header.tsx
Normal 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 };
|
||||
75
src/ui/components/base/group/index.tsx
Normal file
75
src/ui/components/base/group/index.tsx
Normal 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 };
|
||||
@@ -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';
|
||||
@@ -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={
|
||||
<>
|
||||
{right}
|
||||
<Cell onPress={onClose}>
|
||||
<Icon name="x-circle" />
|
||||
</Cell>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Content>
|
||||
{children}
|
||||
</Content>
|
||||
</Wrapper>
|
||||
</Outer>
|
||||
</Page>
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
64
src/ui/components/date/bar/index.tsx
Normal file
64
src/ui/components/date/bar/index.tsx
Normal 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 };
|
||||
1
src/ui/components/date/index.ts
Normal file
1
src/ui/components/date/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './bar';
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './input';
|
||||
export * from './checkbox';
|
||||
export * from './time';
|
||||
export * from './optional-selector';
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
116
src/ui/components/form/optional-selector/index.tsx
Normal file
116
src/ui/components/form/optional-selector/index.tsx
Normal 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 }
|
||||
55
src/ui/components/form/time/index.tsx
Normal file
55
src/ui/components/form/time/index.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
44
src/ui/components/tasks/group/index.tsx
Normal file
44
src/ui/components/tasks/group/index.tsx
Normal 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 };
|
||||
1
src/ui/components/tasks/index.ts
Normal file
1
src/ui/components/tasks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './list-item';
|
||||
17
src/ui/components/tasks/list-item/index.tsx
Normal file
17
src/ui/components/tasks/list-item/index.tsx
Normal 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 };
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './components/base';
|
||||
export * from './theme';
|
||||
|
||||
@@ -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
98
src/ui/router/router.tsx
Normal 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
34
src/ui/router/types.ts
Normal 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'>;
|
||||
@@ -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 };
|
||||
34
src/ui/screens/day/index.tsx
Normal file
34
src/ui/screens/day/index.tsx
Normal 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 };
|
||||
@@ -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 { navigate } = useNavigation();
|
||||
const locations = useLocations();
|
||||
const removeLocation = useRemoveLocation();
|
||||
const { navigate } = useNavigation();
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
<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 };
|
||||
|
||||
|
||||
28
src/ui/screens/more/index.tsx
Normal file
28
src/ui/screens/more/index.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
171
src/ui/screens/task/add.tsx
Normal 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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
68
src/utils/data-context.tsx
Normal file
68
src/utils/data-context.tsx
Normal 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
17
webpack.config.js
Normal 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
Reference in New Issue
Block a user