diff --git a/apps/api/src/endpoints/trpc/chores/archiveChore.ts b/apps/api/src/endpoints/trpc/chores/archiveChore.ts new file mode 100644 index 0000000..0a1a43d --- /dev/null +++ b/apps/api/src/endpoints/trpc/chores/archiveChore.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { choreProcedure } from '../../../procedures/choreProcedure' + +export const archiveChore = choreProcedure + .input(z.object({ + id: z.string(), + })) + .mutation(async ({ ctx }) => { + ctx.chore.deletedAt = new Date() + + await ctx.chore.save() + + return ctx.chore + }) diff --git a/apps/api/src/endpoints/trpc/chores/listChores.ts b/apps/api/src/endpoints/trpc/chores/listChores.ts index 471dc20..05dca81 100644 --- a/apps/api/src/endpoints/trpc/chores/listChores.ts +++ b/apps/api/src/endpoints/trpc/chores/listChores.ts @@ -8,6 +8,7 @@ const defaultSortBy: { field: 'createdAt' | 'nextDate' | 'plant', direction: 'de export const listChores = authProcedure .input(z.object({ + includeDeletedItems: z.boolean().optional().default(false), includeDoneItems: z.boolean().optional(), includeNotYetDueItems: z.boolean().optional(), q: z.string().optional(), @@ -20,12 +21,14 @@ export const listChores = authProcedure }) .optional() .default({ + includeDeletedItems: false, sortBy: defaultSortBy, }) ) .query(async ({ ctx, input }) => { const chores = await choresService.getChores({ userId: ctx.userId, + includeDeletedItems: input.includeDeletedItems, includeDoneItems: input.includeDoneItems, includeNotYetDueItems: input.includeNotYetDueItems, q: input.q, diff --git a/apps/api/src/endpoints/trpc/chores/unarchiveChore.ts b/apps/api/src/endpoints/trpc/chores/unarchiveChore.ts new file mode 100644 index 0000000..684a75b --- /dev/null +++ b/apps/api/src/endpoints/trpc/chores/unarchiveChore.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { choreProcedure } from '../../../procedures/choreProcedure' + +export const unarchiveChore = choreProcedure + .input(z.object({ + id: z.string(), + })) + .mutation(async ({ ctx }) => { + ctx.chore.deletedAt = null + + await ctx.chore.save() + + return ctx.chore + }) diff --git a/apps/api/src/endpoints/trpc/fertilizers/archiveFertilizer.ts b/apps/api/src/endpoints/trpc/fertilizers/archiveFertilizer.ts new file mode 100644 index 0000000..bc785c0 --- /dev/null +++ b/apps/api/src/endpoints/trpc/fertilizers/archiveFertilizer.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { fertilizerProcedure } from '../../../procedures/fertilizerProcedure' + +export const archiveFertilizer = fertilizerProcedure + .input(z.object({ + id: z.string(), + })) + .mutation(async ({ ctx }) => { + ctx.fertilizer.deletedAt = new Date() + + await ctx.fertilizer.save() + + return ctx.fertilizer + }) diff --git a/apps/api/src/endpoints/trpc/fertilizers/listFertilizers.ts b/apps/api/src/endpoints/trpc/fertilizers/listFertilizers.ts index 9a69adc..1c7f9e5 100644 --- a/apps/api/src/endpoints/trpc/fertilizers/listFertilizers.ts +++ b/apps/api/src/endpoints/trpc/fertilizers/listFertilizers.ts @@ -12,6 +12,7 @@ const defaultSortBy: { field: 'name', direction: 'desc' | 'asc' }[] = [ { field: export const listFertilizers = authProcedure .input(z.object({ + includeDeletedItems: z.boolean().optional().default(false), q: z.string().optional(), sortBy: z.array(z.object({ field: z.enum(['name']), @@ -21,7 +22,7 @@ export const listFertilizers = authProcedure .default(defaultSortBy), }) .optional() - .default({ sortBy: defaultSortBy }) + .default({ includeDeletedItems: false, sortBy: defaultSortBy }) ) .query(async ({ ctx, input }) => { const query: FilterQuery = { user: ctx.userId } @@ -31,6 +32,9 @@ export const listFertilizers = authProcedure { notes: { $regex: input.q, $options: 'i' } }, ] } + if (!input.includeDeletedItems) { + query.deletedAt = null + } const fertilizers = await Fertilizer .find(query) diff --git a/apps/api/src/endpoints/trpc/fertilizers/unarchiveFertilizer.ts b/apps/api/src/endpoints/trpc/fertilizers/unarchiveFertilizer.ts new file mode 100644 index 0000000..ccf5850 --- /dev/null +++ b/apps/api/src/endpoints/trpc/fertilizers/unarchiveFertilizer.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { fertilizerProcedure } from '../../../procedures/fertilizerProcedure' + +export const unarchiveFertilizer = fertilizerProcedure + .input(z.object({ + id: z.string(), + })) + .mutation(async ({ ctx }) => { + ctx.fertilizer.deletedAt = null + + await ctx.fertilizer.save() + + return ctx.fertilizer + }) diff --git a/apps/api/src/endpoints/trpc/plants/archivePlant.ts b/apps/api/src/endpoints/trpc/plants/archivePlant.ts new file mode 100644 index 0000000..b4288f1 --- /dev/null +++ b/apps/api/src/endpoints/trpc/plants/archivePlant.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { plantProcedure } from '../../../procedures/plantProcedure' + +export const archivePlant = plantProcedure + .input(z.object({ + id: z.string(), + })) + .mutation(async ({ ctx }) => { + ctx.plant.deletedAt = new Date() + + await ctx.plant.save() + + return ctx.plant + }) diff --git a/apps/api/src/endpoints/trpc/plants/listPlants.ts b/apps/api/src/endpoints/trpc/plants/listPlants.ts index 25036f1..940ea83 100644 --- a/apps/api/src/endpoints/trpc/plants/listPlants.ts +++ b/apps/api/src/endpoints/trpc/plants/listPlants.ts @@ -18,6 +18,7 @@ const defaultSortBy: { field: 'name' | 'plantedAt' | 'createdAt', direction: 'de export const listPlants = authProcedure .input(z.object({ + includeDeletedItems: z.boolean().optional().default(false), q: z.string().optional(), sortBy: z.array(z.object({ field: z.enum(['name', 'plantedAt', 'createdAt']), @@ -27,7 +28,7 @@ export const listPlants = authProcedure .default(defaultSortBy), }) .optional() - .default({ sortBy: defaultSortBy }) + .default({ includeDeletedItems: false, sortBy: defaultSortBy }) ) .query(async ({ ctx, input }) => { const query: FilterQuery = { user: ctx.userId } @@ -37,6 +38,9 @@ export const listPlants = authProcedure { notes: { $regex: input.q, $options: 'i' } }, ] } + if (!input.includeDeletedItems) { + query.deletedAt = null + } const plants = await Plant .find(query) diff --git a/apps/api/src/endpoints/trpc/plants/unarchivePlant.ts b/apps/api/src/endpoints/trpc/plants/unarchivePlant.ts new file mode 100644 index 0000000..2653500 --- /dev/null +++ b/apps/api/src/endpoints/trpc/plants/unarchivePlant.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +import { plantProcedure } from '../../../procedures/plantProcedure' + +export const unarchivePlant = plantProcedure + .input(z.object({ + id: z.string(), + })) + .mutation(async ({ ctx }) => { + ctx.plant.deletedAt = null + + await ctx.plant.save() + + return ctx.plant + }) diff --git a/apps/api/src/models/Chore.ts b/apps/api/src/models/Chore.ts index 0df0ae8..4814680 100644 --- a/apps/api/src/models/Chore.ts +++ b/apps/api/src/models/Chore.ts @@ -26,6 +26,7 @@ export interface IChore { createdAt: Date, updatedAt: Date, + deletedAt: Date | null, } // export type DocIChore = mongoose.Document & Omit @@ -75,6 +76,9 @@ export const choreSchema = new mongoose.Schema({ type: [mongoose.Schema.Types.ObjectId], ref: 'ChoreLog', }, + deletedAt: { + type: Date, + }, }, { collation: { locale: 'en', strength: 2 }, timestamps: true, diff --git a/apps/api/src/models/Fertilizer.ts b/apps/api/src/models/Fertilizer.ts index 54bdc76..34fbb56 100644 --- a/apps/api/src/models/Fertilizer.ts +++ b/apps/api/src/models/Fertilizer.ts @@ -49,6 +49,9 @@ export const fertilizerSchema = new mongoose.Schema({ potassium: { type: Number, }, + deletedAt: { + type: Date, + }, }, { collation: { locale: 'en', strength: 2 }, timestamps: true, diff --git a/apps/api/src/models/Plant.ts b/apps/api/src/models/Plant.ts index 9a7a805..bc4e14f 100644 --- a/apps/api/src/models/Plant.ts +++ b/apps/api/src/models/Plant.ts @@ -55,6 +55,9 @@ export const plantSchema = new mongoose.Schema({ type: String, default: null, }, + deletedAt: { + type: Date, + }, }, { collation: { locale: 'en', strength: 2 }, timestamps: true, diff --git a/apps/api/src/routers/trpc/chores.ts b/apps/api/src/routers/trpc/chores.ts index 57e1d17..0e2c399 100644 --- a/apps/api/src/routers/trpc/chores.ts +++ b/apps/api/src/routers/trpc/chores.ts @@ -1,14 +1,18 @@ import { router } from '../../trpc' +import { archiveChore } from '../../endpoints/trpc/chores/archiveChore' import { createChore } from '../../endpoints/trpc/chores/createChore' import { deleteChore } from '../../endpoints/trpc/chores/deleteChore' import { listChores } from '../../endpoints/trpc/chores/listChores' +import { unarchiveChore } from '../../endpoints/trpc/chores/unarchiveChore' import { updateChore } from '../../endpoints/trpc/chores/updateChore' export const choresRouter = router({ + archive: archiveChore, create: createChore, delete: deleteChore, list: listChores, + unarchive: unarchiveChore, update: updateChore, }) diff --git a/apps/api/src/routers/trpc/fertilizers.ts b/apps/api/src/routers/trpc/fertilizers.ts index d740f7b..1ac1ad6 100644 --- a/apps/api/src/routers/trpc/fertilizers.ts +++ b/apps/api/src/routers/trpc/fertilizers.ts @@ -1,14 +1,18 @@ import { router } from '../../trpc' +import { archiveFertilizer } from '../../endpoints/trpc/fertilizers/archiveFertilizer' import { createFertilizer } from '../../endpoints/trpc/fertilizers/createFertilizer' import { deleteFertilizer } from '../../endpoints/trpc/fertilizers/deleteFertilizer' import { listFertilizers } from '../../endpoints/trpc/fertilizers/listFertilizers' +import { unarchiveFertilizer } from '../../endpoints/trpc/fertilizers/unarchiveFertilizer' import { updateFertilizer } from '../../endpoints/trpc/fertilizers/updateFertilizer' export const fertilizersRouter = router({ + archive: archiveFertilizer, create: createFertilizer, delete: deleteFertilizer, list: listFertilizers, + unarchive: unarchiveFertilizer, update: updateFertilizer, }) diff --git a/apps/api/src/routers/trpc/plants.ts b/apps/api/src/routers/trpc/plants.ts index 0f271bb..49a6f18 100644 --- a/apps/api/src/routers/trpc/plants.ts +++ b/apps/api/src/routers/trpc/plants.ts @@ -1,16 +1,20 @@ import { router } from '../../trpc' +import { archivePlant } from '../../endpoints/trpc/plants/archivePlant' import { createPlant } from '../../endpoints/trpc/plants/createPlant' import { deletePlant } from '../../endpoints/trpc/plants/deletePlant' import { listPlantLifecycleEvents } from '../../endpoints/trpc/plants/listPlantLifecycleEvents' import { listPlants } from '../../endpoints/trpc/plants/listPlants' +import { unarchivePlant } from '../../endpoints/trpc/plants/unarchivePlant' import { updatePlant } from '../../endpoints/trpc/plants/updatePlant' export const plantsRouter = router({ + archive: archivePlant, create: createPlant, delete: deletePlant, list: listPlants, listLifecycleEvents: listPlantLifecycleEvents, + unarchive: unarchivePlant, update: updatePlant, }) diff --git a/apps/api/src/services/chores/index.ts b/apps/api/src/services/chores/index.ts index 0a677d0..f93b8cf 100644 --- a/apps/api/src/services/chores/index.ts +++ b/apps/api/src/services/chores/index.ts @@ -12,12 +12,14 @@ import { } from '../../models' export const getChores = async ({ + includeDeletedItems = false, includeDoneItems = true, includeNotYetDueItems = true, q, sortBy, userId, }: { + includeDeletedItems?: boolean, includeDoneItems?: boolean, includeNotYetDueItems?: boolean, q?: string, @@ -25,6 +27,9 @@ export const getChores = async ({ userId?: string, }) => { const query: FilterQuery = userId ? { user: userId } : {} + if (!includeDeletedItems) { + query.deletedAt = null + } if (q?.trim()) { const searchRegex = { $regex: q?.trim(), $options: 'i' } @@ -39,6 +44,7 @@ export const getChores = async ({ await Plant .find({ ...(userId ? { user: userId } : {}), + ...(!includeDeletedItems ? { deletedAt: null } : {}), $or: [ { name: searchRegex }, { notes: searchRegex }, @@ -60,6 +66,7 @@ export const getChores = async ({ await Fertilizer .find({ ...(userId ? { user: userId } : {}), + ...(!includeDeletedItems ? { deletedAt: null } : {}), $or: [ { name: searchRegex }, { notes: searchRegex }, @@ -115,6 +122,11 @@ export const getChores = async ({ chores = chores.filter((chore) => !chore.isDone) } + // Filter out chores for archived plants if includeDeletedItems is false + if (includeDeletedItems === false) { + chores = chores.filter((chore) => !chore.plant?.deletedAt) + } + // Sort by all criteria at once // Each sort after the first is only applied if the previous sort was a tie if (sortBy && sortBy.length > 0) { diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx index bfb904f..fff4655 100644 --- a/apps/mobile/src/app/(tabs)/fertilizers.tsx +++ b/apps/mobile/src/app/(tabs)/fertilizers.tsx @@ -6,7 +6,7 @@ import { keepPreviousData } from '@tanstack/react-query' import { ExpandableArrow } from '../../components/ExpandableArrow' import { Fab } from '../../components/Fab' -import { FilterAndSearchModal } from '../../components/FilterAndSearchModal' +import { FilterAndSearchModal, filterModalStyles } from '../../components/FilterAndSearchModal' import { IconFilter } from '../../components/IconFilter' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' import { ScreenTitle } from '../../components/ScreenTitle' @@ -37,6 +37,7 @@ export function FertilizersScreen() { const [expandedIds, setExpandedIds] = React.useState>(new Set()) const [filterModalVisible, setFilterModalVisible] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState('') + const [includeDeletedItems, setIncludeDeletedItems] = React.useState(false) const debouncedSearchQuery = useDebounce(searchQuery.trim(), 300) useLayoutEffect(() => { @@ -81,6 +82,7 @@ export function FertilizersScreen() { isRefetching, refetch, } = trpc.fertilizers.list.useQuery({ + includeDeletedItems, q: debouncedSearchQuery || undefined, sortBy: [ { field: 'name', direction: 'asc' }, @@ -131,6 +133,18 @@ export function FertilizersScreen() { }, }) + const archiveMutation = trpc.fertilizers.archive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + const unarchiveMutation = trpc.fertilizers.unarchive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + const handleAddSubmit = () => { createMutation.mutate({ name: addFormData.name, @@ -176,14 +190,14 @@ export function FertilizersScreen() { const handleDeleteClick = (fertilizer: NonNullable['fertilizers'][0]) => { alert( 'Delete Fertilizer', - `Are you sure you want to delete ${fertilizer.name}?`, + `Are you sure you want to delete ${fertilizer.name}? THIS ACTION IS NOT UNDOABLE!`, [ { text: 'Cancel', style: 'cancel', }, { - text: 'Delete', + text: 'Delete Forever', style: 'destructive', onPress: () => { deleteMutation.mutate({ id: fertilizer._id }) @@ -193,6 +207,46 @@ export function FertilizersScreen() { ) } + const handleArchiveClick = (fertilizer: NonNullable['fertilizers'][0]) => { + alert( + 'Archive Fertilizer', + `Are you sure you want to archive ${fertilizer.name}?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Archive', + style: 'default', + onPress: () => { + archiveMutation.mutate({ id: fertilizer._id }) + }, + }, + ] + ) + } + + const handleRestoreClick = (fertilizer: NonNullable['fertilizers'][0]) => { + alert( + 'Restore Fertilizer', + `Are you sure you want to restore ${fertilizer.name}?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Restore', + style: 'default', + onPress: () => { + unarchiveMutation.mutate({ id: fertilizer._id }) + }, + }, + ] + ) + } + const toggleExpand = (fertilizerId: string) => { setExpandedIds(prev => { const newSet = new Set(prev) @@ -377,7 +431,12 @@ export function FertilizersScreen() { }) }} > - handleDeleteClick(fertilizer)}> + handleArchiveClick(fertilizer)} + onDelete={fertilizer.deletedAt ? () => handleDeleteClick(fertilizer) : undefined} + onRestore={fertilizer.deletedAt ? () => handleRestoreClick(fertilizer) : undefined} + disabled={isExpanded} + > + > + + Show archived fertilizers + + + ) } diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 5fca6c9..0e1768a 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -5,13 +5,14 @@ import { keepPreviousData } from '@tanstack/react-query' import { Checkbox } from '../../components/Checkbox' import { DateTimePicker, parseYMDToDate, formatDateToYMD } from '../../components/DateTimePicker' -import { FilterAndSearchModal } from '../../components/FilterAndSearchModal' +import { FilterAndSearchModal, filterModalStyles } from '../../components/FilterAndSearchModal' import { IconFilter } from '../../components/IconFilter' import { IconSnooze } from '../../components/IconSnooze' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' import { SnoozeChoreModal } from '../../components/SnoozeChoreModal' +import { SwipeToDelete } from '../../components/SwipeToDelete' import { useAlert } from '../../contexts/AlertContext' @@ -35,6 +36,7 @@ function ToDoScreen() { const [snoozeChore, setSnoozeChore] = useState['chores'][0] | null>(null) const [filterModalVisible, setFilterModalVisible] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [includeDeletedItems, setIncludeDeletedItems] = useState(false) const debouncedSearchQuery = useDebounce(searchQuery.trim(), 300) useLayoutEffect(() => { @@ -60,6 +62,7 @@ function ToDoScreen() { isRefetching, refetch, } = trpc.chores.list.useQuery({ + includeDeletedItems, includeDoneItems: true, // load all items from API, filter out done items in the client includeNotYetDueItems: true, // load all items from API, filter out future items in the client q: debouncedSearchQuery || undefined, @@ -112,6 +115,24 @@ function ToDoScreen() { }, }) + const archiveChoreMutation = trpc.chores.archive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + const unarchiveChoreMutation = trpc.chores.unarchive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + const deleteChoreMutation = trpc.chores.delete.useMutation({ + onSuccess: () => { + refetch() + }, + }) + useEffect(() => { return () => { if (modalTimeoutRef.current) { @@ -269,6 +290,74 @@ function ToDoScreen() { })) } + const handleArchiveClick = (chore: NonNullable['chores'][0]) => { + alert( + 'Archive Chore', + `Are you sure you want to archive this chore?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Archive', + style: 'default', + onPress: () => { + archiveChoreMutation.mutate({ id: chore._id }) + }, + }, + ] + ) + } + + const handleRestoreClick = (chore: NonNullable['chores'][0]) => { + const choreDescription = chore.description || 'this chore' + alert( + 'Restore Chore', + `Are you sure you want to restore ${choreDescription}?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Restore', + style: 'default', + onPress: () => { + unarchiveChoreMutation.mutate({ id: chore._id }) + }, + }, + ] + ) + } + + const handleDeleteClick = (chore: NonNullable['chores'][0]) => { + const plantId = chore.plant?._id + if (!plantId) { + alert('Error', 'Cannot delete: Plant information missing') + return + } + + const choreDescription = chore.description || 'this chore' + alert( + 'Delete Chore', + `Are you sure you want to delete ${choreDescription}? THIS ACTION IS NOT UNDOABLE!`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Delete Forever', + style: 'destructive', + onPress: () => { + deleteChoreMutation.mutate({ id: chore._id, plantId }) + }, + }, + ] + ) + } + const chores = (data?.chores || []) .filter((chore) => (includeDoneItems || !chore.isDone) && (includeNotYetDueItems || chore.isDue)) .filter((chore) => { @@ -342,8 +431,14 @@ function ToDoScreen() { const dateStr = chore.nextDate ? new Date(chore.nextDate).toLocaleDateString('en-US') : '' return ( - - + handleArchiveClick(chore)} + onDelete={chore.deletedAt ? () => handleDeleteClick(chore) : undefined} + onRestore={chore.deletedAt ? () => handleRestoreClick(chore) : undefined} + > + + handleCheckboxToggle(chore)} @@ -405,6 +500,7 @@ function ToDoScreen() { )} + ) } )} @@ -522,7 +618,7 @@ function ToDoScreen() { searchPlaceholder="Search chores..." > - Show Done Items + Show already done chores - Show Future Items + Show future chores + + + Show archived chores + + )} @@ -543,14 +647,4 @@ function ToDoScreen() { ) } -const filterModalStyles = StyleSheet.create({ - switchContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 20, - paddingVertical: 8, - }, -}) - export default ToDoScreen diff --git a/apps/mobile/src/app/(tabs)/plants.tsx b/apps/mobile/src/app/(tabs)/plants.tsx index 804287c..06157eb 100644 --- a/apps/mobile/src/app/(tabs)/plants.tsx +++ b/apps/mobile/src/app/(tabs)/plants.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ActivityIndicator, Image, Modal, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native' +import { ActivityIndicator, Image, Modal, ScrollView, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native' import { useLayoutEffect } from 'react' import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' import { keepPreviousData } from '@tanstack/react-query' @@ -11,7 +11,7 @@ import { BarChart } from '../../components/BarChart' import { DateTimePicker } from '../../components/DateTimePicker' import { ExpandableArrow } from '../../components/ExpandableArrow' import { Fab } from '../../components/Fab' -import { FilterAndSearchModal } from '../../components/FilterAndSearchModal' +import { FilterAndSearchModal, filterModalStyles } from '../../components/FilterAndSearchModal' import { IconFilter } from '../../components/IconFilter' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' import { ScreenTitle } from '../../components/ScreenTitle' @@ -107,6 +107,7 @@ export function PlantsScreen() { const [editingPlantSpecies, setEditingPlantSpecies] = React.useState(null) const [filterModalVisible, setFilterModalVisible] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState('') + const [includeDeletedItems, setIncludeDeletedItems] = React.useState(false) const debouncedSearchQuery = useDebounce(searchQuery.trim(), 300) useLayoutEffect(() => { @@ -132,6 +133,7 @@ export function PlantsScreen() { isRefetching, refetch, } = trpc.plants.list.useQuery({ + includeDeletedItems, q: debouncedSearchQuery || undefined, sortBy: [ { field: 'name', direction: 'asc' }, @@ -191,6 +193,18 @@ export function PlantsScreen() { }, }) + const archiveMutation = trpc.plants.archive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + const unarchiveMutation = trpc.plants.unarchive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + const createChoreMutation = trpc.chores.create.useMutation({ onSuccess: () => { refetch() @@ -298,14 +312,14 @@ export function PlantsScreen() { const handleDeleteClick = (plant: NonNullable['plants'][0]) => { alert( 'Delete Plant', - `Are you sure you want to delete ${plant.name}?`, + `Are you sure you want to delete ${plant.name}? THIS ACTION IS NOT UNDOABLE!`, [ { text: 'Cancel', style: 'cancel', }, { - text: 'Delete', + text: 'Delete Forever', style: 'destructive', onPress: () => { deleteMutation.mutate({ id: plant._id }) @@ -315,6 +329,46 @@ export function PlantsScreen() { ) } + const handleArchiveClick = (plant: NonNullable['plants'][0]) => { + alert( + 'Archive Plant', + `Are you sure you want to archive ${plant.name}?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Archive', + style: 'default', + onPress: () => { + archiveMutation.mutate({ id: plant._id }) + }, + }, + ] + ) + } + + const handleRestoreClick = (plant: NonNullable['plants'][0]) => { + alert( + 'Restore Plant', + `Are you sure you want to restore ${plant.name}?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Restore', + style: 'default', + onPress: () => { + unarchiveMutation.mutate({ id: plant._id }) + }, + }, + ] + ) + } + const toggleExpand = (plantId: string) => { setExpandedIds(prev => { const newSet = new Set(prev) @@ -582,7 +636,12 @@ export function PlantsScreen() { }) }} > - handleDeleteClick(plant)} disabled={isExpanded}> + handleArchiveClick(plant)} + onDelete={plant.deletedAt ? () => handleDeleteClick(plant) : undefined} + onRestore={plant.deletedAt ? () => handleRestoreClick(plant) : undefined} + disabled={isExpanded} + > + > + + Show archived plants + + + ) } @@ -1080,12 +1147,14 @@ function PlantHistogram({ ) // Get bar chart data from all chore logs for this plant + // Include logs from all chores, including those with deletedAt values const histogramData = React.useMemo(() => { if (!plant.chores || plant.chores.length === 0) return [] - // Collect all logs from all chores + // Collect all logs from all chores (including deleted chores) const allLogs: Array = [] plant.chores.forEach(chore => { + // Include logs from all chores, regardless of deletedAt status if (chore.logs && Array.isArray(chore.logs)) { chore.logs.forEach(log => { if (log.doneAt) { @@ -1097,6 +1166,7 @@ function PlantHistogram({ ? log.fertilizers.map(f => { const fertObj = f.fertilizer as any const name = fertObj && typeof fertObj === 'object' && fertObj.name ? fertObj.name : '' + return `${name}${f.amount ? ` (${f.amount})` : ''}` }).join(', ') : chore.description || 'Chore') diff --git a/apps/mobile/src/app/chores/[id].tsx b/apps/mobile/src/app/chores/[id].tsx index 5ddfb24..5859383 100644 --- a/apps/mobile/src/app/chores/[id].tsx +++ b/apps/mobile/src/app/chores/[id].tsx @@ -60,6 +60,7 @@ export default function ChoreDetailScreen() { isRefetching, refetch, } = trpc.chores.list.useQuery({ + includeDeletedItems: true, q: '', sortBy: [ { field: 'plant', direction: 'asc' }, @@ -97,6 +98,18 @@ export default function ChoreDetailScreen() { }, }) + const archiveMutation = trpc.chores.archive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + + const unarchiveMutation = trpc.chores.unarchive.useMutation({ + onSuccess: () => { + refetch() + }, + }) + const deleteMutation = trpc.chores.delete.useMutation({ onSuccess: () => { router.back() @@ -146,6 +159,46 @@ export default function ChoreDetailScreen() { }) } + const handleArchiveClick = (chore: NonNullable['chores'][0]) => { + alert( + 'Archive Chore', + `Are you sure you want to archive this chore?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Archive', + style: 'default', + onPress: () => { + archiveMutation.mutate({ id: chore._id }) + }, + }, + ] + ) + } + + const handleRestoreClick = (chore: NonNullable['chores'][0]) => { + alert( + 'Restore Chore', + `Are you sure you want to restore this chore?`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Restore', + style: 'default', + onPress: () => { + unarchiveMutation.mutate({ id: chore._id }) + }, + }, + ] + ) + } + const handleDeleteClick = (chore: NonNullable['chores'][0]) => { const plantId = chore.plant?._id if (!plantId) { @@ -155,14 +208,14 @@ export default function ChoreDetailScreen() { alert( 'Delete Chore', - `Are you sure you want to delete this chore?`, + `Are you sure you want to delete this chore? THIS ACTION IS NOT UNDOABLE!`, [ { text: 'Cancel', style: 'cancel', }, { - text: 'Delete', + text: 'Delete Permanently', style: 'destructive', onPress: () => { deleteMutation.mutate({ id: chore._id, plantId }) @@ -424,13 +477,32 @@ export default function ChoreDetailScreen() { > ✏️ - handleDeleteClick(chore)} - disabled={deleteMutation.isPending} - > - Delete - + {chore.deletedAt ? ( + + handleRestoreClick(chore)} + disabled={unarchiveMutation.isPending} + > + Restore + + handleDeleteClick(chore)} + disabled={deleteMutation.isPending} + > + Delete + + + ) : ( + handleArchiveClick(chore)} + disabled={archiveMutation.isPending} + > + Archive + + )} @@ -442,17 +514,15 @@ export default function ChoreDetailScreen() { onValueChange={(value) => setTimeRange(value)} /> - {histogramData.length > 0 && ( - - - - )} + + + + Create Account Sign up for Plannting @@ -127,16 +129,11 @@ export default function CreateAccountScreen() { Back to Login - + ) } const localStyles = StyleSheet.create({ - container: { - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f5f5f5', - }, form: { width: '100%', maxWidth: 400, diff --git a/apps/mobile/src/app/edit-password.tsx b/apps/mobile/src/app/edit-password.tsx index fa2f987..aa78543 100644 --- a/apps/mobile/src/app/edit-password.tsx +++ b/apps/mobile/src/app/edit-password.tsx @@ -3,11 +3,13 @@ import { ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, TouchableOp import { useRouter } from 'expo-router' import { useQueryClient } from '@tanstack/react-query' +import { ScreenWrapper } from '../components/ScreenWrapper' + import { useAlert } from '../contexts/AlertContext' import { trpc } from '../trpc' -import { palette, styles as globalStyles } from '../styles' +import { palette } from '../styles' export default function EditPasswordScreen() { const router = useRouter() @@ -58,7 +60,7 @@ export default function EditPasswordScreen() { } return ( - + Change Password @@ -121,15 +123,11 @@ export default function EditPasswordScreen() { - + ) } const localStyles = StyleSheet.create({ - container: { - justifyContent: 'flex-start', - backgroundColor: '#f5f5f5', - }, scrollView: { flex: 1, }, diff --git a/apps/mobile/src/app/edit-profile.tsx b/apps/mobile/src/app/edit-profile.tsx index 2d070a2..b62ae9a 100644 --- a/apps/mobile/src/app/edit-profile.tsx +++ b/apps/mobile/src/app/edit-profile.tsx @@ -3,12 +3,14 @@ import { ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, TouchableOp import { useRouter } from 'expo-router' import { useQueryClient } from '@tanstack/react-query' +import { ScreenWrapper } from '../components/ScreenWrapper' + import { useAlert } from '../contexts/AlertContext' import { useAuth } from '../contexts/AuthContext' import { trpc } from '../trpc' -import { palette, styles as globalStyles } from '../styles' +import { palette } from '../styles' export default function EditProfileScreen() { const router = useRouter() @@ -76,7 +78,7 @@ export default function EditProfileScreen() { } return ( - + Edit Profile @@ -144,15 +146,11 @@ export default function EditProfileScreen() { - + ) } const localStyles = StyleSheet.create({ - container: { - justifyContent: 'flex-start', - backgroundColor: '#f5f5f5', - }, scrollView: { flex: 1, }, diff --git a/apps/mobile/src/app/forgot-password.tsx b/apps/mobile/src/app/forgot-password.tsx index a27aeeb..147d303 100644 --- a/apps/mobile/src/app/forgot-password.tsx +++ b/apps/mobile/src/app/forgot-password.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react' import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' +import { ScreenWrapper } from '../components/ScreenWrapper' + import { useAlert } from '../contexts/AlertContext' import { trpc } from '../trpc' @@ -43,7 +45,7 @@ export default function ForgotPasswordScreen() { } return ( - + Forgot Password @@ -81,16 +83,11 @@ export default function ForgotPasswordScreen() { Back to Login - + ) } const localStyles = StyleSheet.create({ - container: { - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f5f5f5', - }, form: { width: '100%', maxWidth: 400, diff --git a/apps/mobile/src/app/login.tsx b/apps/mobile/src/app/login.tsx index 335e52c..9fc11f5 100644 --- a/apps/mobile/src/app/login.tsx +++ b/apps/mobile/src/app/login.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react' import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native' import { useRouter } from 'expo-router' +import { ScreenWrapper } from '../components/ScreenWrapper' + import { useAlert } from '../contexts/AlertContext' import { useAuth } from '../contexts/AuthContext' @@ -47,7 +49,7 @@ export default function LoginScreen() { } return ( - + Plannting Login to your account @@ -100,15 +102,13 @@ export default function LoginScreen() { Create Account - + ) } const localStyles = StyleSheet.create({ container: { justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f5f5f5', }, form: { width: '100%', diff --git a/apps/mobile/src/app/reset-password.tsx b/apps/mobile/src/app/reset-password.tsx index cfd57bf..480f86e 100644 --- a/apps/mobile/src/app/reset-password.tsx +++ b/apps/mobile/src/app/reset-password.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react' import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native' import { useRouter, useLocalSearchParams } from 'expo-router' +import { ScreenWrapper } from '../components/ScreenWrapper' + import { useAlert } from '../contexts/AlertContext' import { trpc } from '../trpc' @@ -102,7 +104,7 @@ export default function ResetPasswordScreen() { } return ( - + Reset Password @@ -181,16 +183,11 @@ export default function ResetPasswordScreen() { Back to Login - + ) } const localStyles = StyleSheet.create({ - container: { - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#f5f5f5', - }, form: { width: '100%', maxWidth: 400, diff --git a/apps/mobile/src/components/FilterAndSearchModal.tsx b/apps/mobile/src/components/FilterAndSearchModal.tsx index c1fdd9e..9cae1fb 100644 --- a/apps/mobile/src/components/FilterAndSearchModal.tsx +++ b/apps/mobile/src/components/FilterAndSearchModal.tsx @@ -71,3 +71,13 @@ const modalStyles = StyleSheet.create({ color: palette.textPrimary, }, }) + +export const filterModalStyles = StyleSheet.create({ + switchContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 20, + paddingVertical: 8, + }, +}) diff --git a/apps/mobile/src/components/ScreenWrapper.tsx b/apps/mobile/src/components/ScreenWrapper.tsx index c8fc3d4..3c36ee8 100644 --- a/apps/mobile/src/components/ScreenWrapper.tsx +++ b/apps/mobile/src/components/ScreenWrapper.tsx @@ -7,6 +7,7 @@ import { palette } from '../styles' type ScreenWrapperProps = { children: React.ReactNode, + contentContainerStyle?: StyleProp, onRefresh?: () => Promise | void, scrollViewRef?: React.RefObject, style?: StyleProp, @@ -28,6 +29,7 @@ export function useScreenScrollControl() { export function ScreenWrapper({ children, + contentContainerStyle, onRefresh, scrollViewRef, style, @@ -62,7 +64,7 @@ export function ScreenWrapper({ diff --git a/apps/mobile/src/components/SwipeToDelete.tsx b/apps/mobile/src/components/SwipeToDelete.tsx index 4ca1c7f..e82f0f5 100644 --- a/apps/mobile/src/components/SwipeToDelete.tsx +++ b/apps/mobile/src/components/SwipeToDelete.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { Animated, PanResponder, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { palette } from '../styles' @@ -6,9 +6,13 @@ import { useScreenScrollControl } from './ScreenWrapper' type SwipeToDeleteProps = { children: React.ReactNode, - onDelete: () => void, - deleteLabel?: string, disabled?: boolean, + archiveLabel?: string, + deleteLabel?: string, + restoreLabel?: string, + onArchive?: () => void, + onDelete?: () => void, + onRestore?: () => void, } const ACTION_WIDTH = 96 @@ -17,22 +21,32 @@ const DRAG_RESISTANCE = 2 const RETURN_SPRING_FRICTION = 20 const RETURN_SPRING_TENSION = 150 -export function SwipeToDelete({ children, onDelete, deleteLabel = 'Delete', disabled = false }: SwipeToDeleteProps) { +export function SwipeToDelete({ + children, + disabled = false, + archiveLabel = 'Archive', + deleteLabel = 'Delete Forever', + restoreLabel = 'Restore', + onArchive, + onDelete, + onRestore, +}: SwipeToDeleteProps) { const translateX = useRef(new Animated.Value(0)).current const isOpen = useRef(false) const isDragging = useRef(false) const { disableScroll, enableScroll } = useScreenScrollControl() + const actionWidth = (onRestore ? ACTION_WIDTH : 0) + (onArchive ? ACTION_WIDTH : 0) + (onDelete ? ACTION_WIDTH : 0) const open = useCallback(() => { Animated.spring(translateX, { - toValue: -ACTION_WIDTH, + toValue: -actionWidth, useNativeDriver: true, tension: RETURN_SPRING_TENSION, friction: RETURN_SPRING_FRICTION, }).start(() => { isOpen.current = true }) - }, [translateX]) + }, [actionWidth, translateX]) const close = useCallback(() => { Animated.spring(translateX, { @@ -60,8 +74,8 @@ export function SwipeToDelete({ children, onDelete, deleteLabel = 'Delete', disa } }, [releaseScroll]) - const panResponder = useRef( - PanResponder.create({ + const panResponder = useMemo( + () => PanResponder.create({ onMoveShouldSetPanResponder: (_, gesture) => { if (disabled) return false @@ -77,7 +91,7 @@ export function SwipeToDelete({ children, onDelete, deleteLabel = 'Delete', disa }, onPanResponderMove: (_, gesture) => { if (gesture.dx < 0) { - const resisted = Math.max(gesture.dx * (1 / DRAG_RESISTANCE), -ACTION_WIDTH) + const resisted = Math.max(gesture.dx * (1 / DRAG_RESISTANCE), -actionWidth) translateX.setValue(resisted) } else if (!isOpen.current) { translateX.setValue(gesture.dx * 0.15) @@ -97,20 +111,55 @@ export function SwipeToDelete({ children, onDelete, deleteLabel = 'Delete', disa releaseScroll() close() }, - }) - ).current + }), + [actionWidth, disabled, translateX, disableScroll, releaseScroll, open, close] + ) const handleDelete = () => { - close() - onDelete() + if (onDelete) { + close() + onDelete() + } + } + + const handleArchive = () => { + if (onArchive) { + close() + onArchive() + } + } + + const handleRestore = () => { + if (onRestore) { + close() + onRestore() + } } return ( - - - {deleteLabel} - + + {onRestore && ( + + {restoreLabel} + + )} + {onArchive && ( + + {archiveLabel} + + )} + {onDelete && ( + + {deleteLabel} + + )}