feat: integrate DateTimePickerAndroid for date/time selection (#26979)

* feat: integrate DateTimePickerAndroid for date/time selection

- Added @react-native-community/datetimepicker dependency for native date/time picker functionality.
- Replaced modal-based date/time picker implementation with DateTimePickerAndroid for improved user experience.
- Updated RescheduleScreen to utilize the new date/time picker methods, enhancing the selection process and ensuring better styling consistency.

* feat: add LimitsTabDatePicker component for date selection

- Introduced a new LimitsTabDatePicker component utilizing DateTimePickerAndroid for improved date selection on Android.
- Implemented date parsing and formatting for user-friendly display.
- Enhanced user interaction with a pressable element to open the date picker.

* feat: add EditAvailabilityOverrideScreen for managing schedule overrides

- Introduced EditAvailabilityOverrideScreen component to allow users to create and edit availability overrides.
- Implemented date and time selection using DateTimePickerAndroid for improved user experience.
- Added functionality to handle existing overrides, including editing and deleting options.
- Enhanced user interaction with clear alerts for success and error states during save operations.

* fixed lint warnings, info

* address cubics comments

---------

Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
This commit is contained in:
Beto
2026-01-20 09:46:42 -03:00
committed by GitHub
co-authored by Dhairyashil
parent 72dc999108
commit d67bf45527
6 changed files with 734 additions and 385 deletions
+21
View File
@@ -57,6 +57,27 @@
"image": "./assets/splash-icon.png",
"imageWidth": 200
}
],
[
"@react-native-community/datetimepicker",
{
"android": {
"datePicker": {
"colorAccent": {
"light": "#000000"
},
"textColorPrimary": {
"light": "#000000"
}
},
"timePicker": {
"background": { "light": "#ffffff" },
"numbersBackgroundColor": { "light": "#ffffff" },
"headerBackground": { "light": "#000000" },
"numbersSelectorColor": { "light": "#000000" }
}
}
}
]
],
"experiments": {
+3
View File
@@ -7,6 +7,7 @@
"@expo/ui": "0.2.0-canary-20251230-fc48ddc",
"@expo/vector-icons": "15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@rn-primitives/alert-dialog": "1.2.0",
@@ -548,6 +549,8 @@
"@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="],
"@react-native-community/datetimepicker": ["@react-native-community/datetimepicker@8.4.4", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "expo": ">=52.0.0", "react": "*", "react-native": "*", "react-native-windows": "*" }, "optionalPeers": ["expo", "react-native-windows"] }, "sha512-bc4ZixEHxZC9/qf5gbdYvIJiLZ5CLmEsC3j+Yhe1D1KC/3QhaIfGDVdUcid0PdlSoGOSEq4VlB93AWyetEyBSQ=="],
"@react-native-community/netinfo": ["@react-native-community/netinfo@11.4.1", "", { "peerDependencies": { "react-native": ">=0.59" } }, "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg=="],
"@react-native-segmented-control/segmented-control": ["@react-native-segmented-control/segmented-control@2.5.7", "", { "peerDependencies": { "react": ">=16.0", "react-native": ">=0.62" } }, "sha512-l84YeVX8xAU3lvOJSvV4nK/NbGhIm2gBfveYolwaoCbRp+/SLXtc6mYrQmM9ScXNwU14mnzjQTpTHWl5YPnkzQ=="],
@@ -0,0 +1,60 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text } from "react-native";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
interface LimitsTabDatePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function LimitsTabDatePicker({ value, onChange, placeholder }: LimitsTabDatePickerProps) {
// Parse value string to Date object
const dateValue = useMemo(() => {
if (!value) return new Date();
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}, [value]);
// Format date for display (compact, human-readable)
const displayText = useMemo(() => {
if (!value) return placeholder || "Select date";
return dateValue.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}, [value, dateValue, placeholder]);
// Handle date selection - convert to YYYY-MM-DD format
const handleDateChange = useCallback(
(date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
onChange(`${year}-${month}-${day}`);
},
[onChange]
);
// Open native Android date picker
const openDatePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: dateValue,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
handleDateChange(date);
}
},
});
}, [dateValue, handleDateChange]);
return (
<Pressable onPress={openDatePicker} className="rounded-lg bg-[#F2F2F7] px-3 py-2">
<Text className={`text-[15px] ${value ? "text-black" : "text-[#8E8E93]"}`}>
{displayText}
</Text>
</Pressable>
);
}
@@ -0,0 +1,482 @@
import { Ionicons } from "@expo/vector-icons";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
import { useUpdateSchedule } from "@/hooks/useSchedules";
import type { Schedule } from "@/services/calcom";
import { showErrorAlert, showSuccessAlert } from "@/utils/alerts";
// Convert 24-hour time to 12-hour format with AM/PM
const formatTime12Hour = (time24: string): string => {
const [hours, minutes] = time24.split(":");
const hour = parseInt(hours, 10);
const min = minutes || "00";
const period = hour >= 12 ? "PM" : "AM";
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${hour12}:${min} ${period}`;
};
// Parse time string to Date object (for DatePicker)
const timeStringToDate = (timeStr: string): Date => {
const [hours, minutes] = timeStr.split(":");
const date = new Date();
date.setHours(parseInt(hours, 10));
date.setMinutes(parseInt(minutes || "0", 10));
date.setSeconds(0);
date.setMilliseconds(0);
return date;
};
// Convert Date to time string (HH:mm)
const dateToTimeString = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
// Normalize time string to HH:mm format (API may return HH:mm:ss)
const normalizeTimeString = (time: string): string => {
return time.substring(0, 5);
};
// Convert Date to date string (YYYY-MM-DD)
const dateToDateString = (date: Date): string => {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Parse date string to Date object
const dateStringToDate = (dateStr: string): Date => {
const [year, month, day] = dateStr.split("-").map(Number);
return new Date(year, month - 1, day);
};
// Format date for display
const formatDateForDisplay = (date: Date): string => {
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
export interface EditAvailabilityOverrideScreenProps {
schedule: Schedule | null;
overrideIndex?: number;
onSuccess: () => void;
onSavingChange?: (isSaving: boolean) => void;
onEditOverride?: (index: number) => void;
transparentBackground?: boolean;
}
export interface EditAvailabilityOverrideScreenHandle {
submit: () => void;
}
export const EditAvailabilityOverrideScreen = forwardRef<
EditAvailabilityOverrideScreenHandle,
EditAvailabilityOverrideScreenProps
>(function EditAvailabilityOverrideScreen(
{
schedule,
overrideIndex,
onSuccess,
onSavingChange,
onEditOverride,
transparentBackground = false,
},
ref
) {
const insets = useSafeAreaInsets();
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
// Use mutation hook for cache-synchronized updates
const { mutate: updateSchedule, isPending: isMutating } = useUpdateSchedule();
const isEditing = overrideIndex !== undefined;
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [isUnavailable, setIsUnavailable] = useState(false);
const [startTime, setStartTime] = useState<Date>(timeStringToDate("09:00"));
const [endTime, setEndTime] = useState<Date>(timeStringToDate("17:00"));
// Initialize from existing override if editing
useEffect(() => {
if (schedule && isEditing && schedule.overrides) {
const override = schedule.overrides[overrideIndex];
if (override) {
setSelectedDate(dateStringToDate(override.date ?? ""));
// Normalize time strings to HH:mm (API may return HH:mm:ss)
const start = normalizeTimeString(override.startTime ?? "00:00");
const end = normalizeTimeString(override.endTime ?? "00:00");
setIsUnavailable(start === "00:00" && end === "00:00");
setStartTime(timeStringToDate(start === "00:00" ? "09:00" : start));
setEndTime(timeStringToDate(end === "00:00" ? "17:00" : end));
}
}
}, [schedule, isEditing, overrideIndex]);
// Notify parent of saving state
useEffect(() => {
onSavingChange?.(isMutating);
}, [isMutating, onSavingChange]);
// Open native Android date picker
const openDatePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: selectedDate,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
setSelectedDate(date);
}
},
});
}, [selectedDate]);
// Open native Android time picker for start time
const openStartTimePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: startTime,
mode: "time",
is24Hour: false,
onChange: (event, date) => {
if (event.type === "set" && date) {
setStartTime(date);
}
},
});
}, [startTime]);
// Open native Android time picker for end time
const openEndTimePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: endTime,
mode: "time",
is24Hour: false,
onChange: (event, date) => {
if (event.type === "set" && date) {
setEndTime(date);
}
},
});
}, [endTime]);
const saveOverrides = useCallback(
(
newOverrides: { date: string; startTime: string; endTime: string }[],
successMessage: string
) => {
if (!schedule) return;
updateSchedule(
{ id: schedule.id, updates: { overrides: newOverrides } },
{
onSuccess: () => {
showSuccessAlert("Success", successMessage);
onSuccess();
},
onError: () => {
showErrorAlert("Error", "Failed to save override. Please try again.");
},
}
);
},
[schedule, onSuccess, updateSchedule]
);
const handleDeleteOverride = useCallback(
(indexToDelete: number) => {
if (!schedule || !schedule.overrides) return;
const override = schedule.overrides[indexToDelete];
const dateDisplay = formatDateForDisplay(dateStringToDate(override?.date ?? ""));
Alert.alert(
"Delete Override",
`Are you sure you want to delete the override for ${dateDisplay}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
const newOverrides = schedule.overrides
? schedule.overrides
.filter((_, idx) => idx !== indexToDelete)
.map((o) => ({
date: o.date ?? "",
startTime: (o.startTime ?? "00:00").substring(0, 5),
endTime: (o.endTime ?? "00:00").substring(0, 5),
}))
: [];
await saveOverrides(newOverrides, "Override deleted successfully");
},
},
]
);
},
[schedule, saveOverrides]
);
const handleSubmit = useCallback(() => {
if (!schedule || isMutating) return;
const dateStr = dateToDateString(selectedDate);
// Validate end time is after start time (only when not marking as unavailable)
// Compare time strings to avoid issues with Date object day components
const startTimeStr = dateToTimeString(startTime);
const endTimeStr = dateToTimeString(endTime);
if (!isUnavailable && endTimeStr <= startTimeStr) {
showErrorAlert("Error", "End time must be after start time");
return;
}
// Build the new override
const newOverride = {
date: dateStr,
startTime: isUnavailable ? "00:00" : dateToTimeString(startTime),
endTime: isUnavailable ? "00:00" : dateToTimeString(endTime),
};
// Build new overrides array
let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
if (schedule.overrides && Array.isArray(schedule.overrides)) {
newOverrides = schedule.overrides.map((o) => ({
date: o.date ?? "",
startTime: (o.startTime ?? "00:00").substring(0, 5),
endTime: (o.endTime ?? "00:00").substring(0, 5),
}));
}
const successMessage = isEditing
? "Override updated successfully"
: "Override added successfully";
if (isEditing && overrideIndex !== undefined) {
// Check if date was changed to one that already exists (exclude current override)
const existingIndex = newOverrides.findIndex(
(o, idx) => o.date === dateStr && idx !== overrideIndex
);
if (existingIndex >= 0) {
Alert.alert(
"Date Already Exists",
"An override for this date already exists. Do you want to replace it?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Replace",
onPress: () => {
// Remove the conflicting override and update the current one
const filteredOverrides = newOverrides.filter((_, idx) => idx !== existingIndex);
// Adjust index if the removed item was before our edit index
const adjustedIndex =
existingIndex < overrideIndex ? overrideIndex - 1 : overrideIndex;
filteredOverrides[adjustedIndex] = newOverride;
saveOverrides(filteredOverrides, "Override updated successfully");
},
},
]
);
return;
}
// Update existing override
newOverrides[overrideIndex] = newOverride;
} else {
// Check if date already exists
const existingIndex = newOverrides.findIndex((o) => o.date === dateStr);
if (existingIndex >= 0) {
Alert.alert(
"Date Already Exists",
"An override for this date already exists. Do you want to replace it?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Replace",
onPress: () => {
newOverrides[existingIndex] = newOverride;
saveOverrides(newOverrides, "Override replaced successfully");
},
},
]
);
return;
}
// Add new override
newOverrides.push(newOverride);
}
saveOverrides(newOverrides, successMessage);
}, [
schedule,
selectedDate,
isUnavailable,
startTime,
endTime,
isEditing,
overrideIndex,
saveOverrides,
isMutating,
]);
// Expose submit to parent via ref
useImperativeHandle(
ref,
() => ({
submit: handleSubmit,
}),
[handleSubmit]
);
if (!schedule) {
return (
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
<Text className="text-[#8E8E93]">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className={`flex-1 ${backgroundStyle}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={!transparentBackground}
>
{/* Date Picker */}
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Date</Text>
<Pressable
onPress={openDatePicker}
className={`mb-4 rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
<Ionicons name="calendar" size={20} color="#007AFF" />
</View>
<Text className="text-[17px] text-black">{formatDateForDisplay(selectedDate)}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</View>
</Pressable>
{/* Unavailable Toggle */}
<View
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#FF3B30]/10">
<Ionicons name="moon-outline" size={20} color="#FF3B30" />
</View>
<View>
<Text className="text-[17px] text-black">Mark as unavailable</Text>
<Text className="text-[13px] text-[#8E8E93]">Block this entire day</Text>
</View>
</View>
<Switch
value={isUnavailable}
onValueChange={setIsUnavailable}
trackColor={{ false: "#E5E5EA", true: "#000000" }}
thumbColor="#fff"
/>
</View>
{/* Time Range (only when available) */}
{!isUnavailable && (
<>
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Available Hours</Text>
<View
className={`mb-4 rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center">
<Pressable onPress={openStartTimePicker} className="flex-1">
<Text className="mb-1 text-[13px] text-[#8E8E93]">Start Time</Text>
<Text className="text-[17px] text-black">
{formatTime12Hour(dateToTimeString(startTime))}
</Text>
</Pressable>
<Text className="mx-4 text-[17px] text-[#8E8E93]">–</Text>
<Pressable onPress={openEndTimePicker} className="flex-1">
<Text className="mb-1 text-[13px] text-[#8E8E93]">End Time</Text>
<Text className="text-[17px] text-black">
{formatTime12Hour(dateToTimeString(endTime))}
</Text>
</Pressable>
</View>
</View>
</>
)}
{/* Existing Overrides (if any) */}
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
<>
<Text className="mb-2 mt-4 px-1 text-[13px] font-medium text-[#8E8E93]">
Existing Overrides
</Text>
<View
className={`overflow-hidden rounded-xl ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
{schedule.overrides.map((override, index) => (
<Pressable
key={override.date}
className={`flex-row items-center justify-between px-4 py-3 ${
index > 0 ? "border-t border-[#E5E5EA]" : ""
}`}
onPress={() => onEditOverride?.(index)}
>
<View className="flex-1">
<Text className="text-[15px] text-black">
{formatDateForDisplay(dateStringToDate(override.date ?? ""))}
</Text>
{normalizeTimeString(override.startTime ?? "00:00") === "00:00" &&
normalizeTimeString(override.endTime ?? "00:00") === "00:00" ? (
<Text className="text-[13px] text-[#FF3B30]">Unavailable</Text>
) : (
<Text className="text-[13px] text-[#8E8E93]">
{formatTime12Hour(normalizeTimeString(override.startTime ?? "00:00"))} –{" "}
{formatTime12Hour(normalizeTimeString(override.endTime ?? "00:00"))}
</Text>
)}
</View>
<View className="flex-row items-center">
<Pressable
className="rounded-full bg-[#FF3B30]/10 p-2"
onPress={() => handleDeleteOverride(index)}
>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
</Pressable>
<Pressable
className="ml-3 rounded-full bg-[#8E8E93]/10 p-2"
onPress={() => onEditOverride?.(index)}
>
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
</Pressable>
</View>
</Pressable>
))}
</View>
</>
)}
</ScrollView>
);
});
export default EditAvailabilityOverrideScreen;
@@ -1,17 +1,16 @@
/**
* RescheduleScreen Component (Android)
*
* Android-specific implementation with modal-based date/time picker.
* Uses custom modal dialogs with Material Design 3 inspired styling.
* Android-specific implementation using the DateTimePickerAndroid imperative API
* for native Material Design date/time picker dialogs.
*/
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Modal,
Pressable,
ScrollView,
Text,
@@ -24,6 +23,7 @@ import { useRescheduleBooking } from "@/hooks/useBookings";
import type { Booking } from "@/services/calcom";
import { showErrorAlert } from "@/utils/alerts";
import { safeLogError, safeLogInfo } from "@/utils/safeLogger";
import { DateTimePickerAndroid } from "@react-native-community/datetimepicker";
export interface RescheduleScreenProps {
booking: Booking | null;
@@ -50,8 +50,6 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
const router = useRouter();
const insets = useSafeAreaInsets();
const [selectedDateTime, setSelectedDateTime] = useState<Date>(new Date());
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [reason, setReason] = useState("");
// Use React Query mutation for automatic cache invalidation
@@ -100,6 +98,40 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
);
}, [booking, selectedDateTime, reason, onSuccess, isSaving, rescheduleBooking]);
const openDatePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: selectedDateTime,
mode: "date",
onChange: (event, date) => {
if (event.type === "set" && date) {
// Preserve the current time when changing date
const newDate = new Date(date);
newDate.setHours(selectedDateTime.getHours());
newDate.setMinutes(selectedDateTime.getMinutes());
setSelectedDateTime(newDate);
}
},
minimumDate: new Date(), // Prevent selecting past dates
});
}, [selectedDateTime]);
const openTimePicker = useCallback(() => {
DateTimePickerAndroid.open({
value: selectedDateTime,
mode: "time",
is24Hour: false, // Match current 12-hour format
onChange: (event, date) => {
if (event.type === "set" && date) {
// Preserve the current date when changing time
const newDate = new Date(selectedDateTime);
newDate.setHours(date.getHours());
newDate.setMinutes(date.getMinutes());
setSelectedDateTime(newDate);
}
},
});
}, [selectedDateTime]);
// Format date for display
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
weekday: "short",
@@ -111,73 +143,6 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
// Format time for display (12-hour)
const formattedTime = formatTime12Hour(selectedDateTime);
// Generate date options for picker (next 90 days)
const dateOptions = React.useMemo(() => {
const options: { label: string; sublabel: string; value: Date }[] = [];
const today = new Date();
for (let i = 0; i < 90; i++) {
const date = new Date(today);
date.setDate(today.getDate() + i);
const isToday = i === 0;
const isTomorrow = i === 1;
options.push({
label: isToday
? "Today"
: isTomorrow
? "Tomorrow"
: date.toLocaleDateString(undefined, {
weekday: "long",
}),
sublabel: date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}),
value: date,
});
}
return options;
}, []);
// Extract booking times for stable dependency references
const bookingStartTime = booking?.startTime;
const bookingEndTime = booking?.endTime;
// Calculate booking duration in minutes
const bookingDurationMinutes = React.useMemo(() => {
if (!bookingStartTime || !bookingEndTime) return 30; // Default to 30 min
const start = new Date(bookingStartTime);
const end = new Date(bookingEndTime);
const durationMs = end.getTime() - start.getTime();
const durationMin = Math.round(durationMs / (1000 * 60));
// Ensure slot interval is reasonable (minimum 5 min, maximum 60 min)
if (durationMin < 5) return 5;
if (durationMin > 60) return 60;
return durationMin;
}, [bookingStartTime, bookingEndTime]);
// Generate time options based on booking duration
const timeOptions = React.useMemo(() => {
const options: {
label: string;
value: { hour: number; minute: number };
}[] = [];
const interval = bookingDurationMinutes;
for (let totalMinutes = 0; totalMinutes < 24 * 60; totalMinutes += interval) {
const hour = Math.floor(totalMinutes / 60);
const minute = totalMinutes % 60;
const ampm = hour >= 12 ? "PM" : "AM";
const displayHour = hour % 12 || 12;
const time = `${displayHour}:${String(minute).padStart(2, "0")} ${ampm}`;
options.push({
label: time,
value: { hour, minute },
});
}
return options;
}, [bookingDurationMinutes]);
useImperativeHandle(
ref,
() => ({
@@ -195,344 +160,161 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
}
return (
<>
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
{/* Header - only shown when not using native header */}
{!useNativeHeader && (
<KeyboardAvoidingView behavior="height" className="flex-1 bg-[#F2F2F7]">
{/* Header - only shown when not using native header */}
{!useNativeHeader && (
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#E5E5EA",
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
}}
>
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top,
paddingBottom: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#E5E5EA",
elevation: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
}}
>
<View
<AppPressable
onPress={() => router.back()}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 44,
width: 40,
height: 40,
alignItems: "flex-start",
justifyContent: "center",
}}
>
<AppPressable
onPress={() => router.back()}
style={{
width: 40,
height: 40,
alignItems: "flex-start",
justifyContent: "center",
}}
>
<Ionicons name="chevron-back" size={24} color="#000" />
</AppPressable>
<Ionicons name="chevron-back" size={24} color="#000" />
</AppPressable>
<Text
style={{
flex: 1,
textAlign: "center",
fontSize: 18,
fontWeight: "600",
color: "#000",
marginHorizontal: 10,
}}
numberOfLines={1}
>
Reschedule
</Text>
<AppPressable
onPress={handleSubmit}
disabled={isSaving}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
opacity: isSaving ? 0.5 : 1,
}}
>
<Text
style={{
flex: 1,
textAlign: "center",
fontSize: 18,
fontSize: 16,
fontWeight: "600",
color: "#000",
marginHorizontal: 10,
color: "#007AFF",
}}
numberOfLines={1}
>
Reschedule
Save
</Text>
<AppPressable
onPress={handleSubmit}
disabled={isSaving}
style={{
paddingHorizontal: 16,
paddingVertical: 8,
opacity: isSaving ? 0.5 : 1,
}}
>
<Text
style={{
fontSize: 16,
fontWeight: "600",
color: "#007AFF",
}}
>
Save
</Text>
</AppPressable>
</View>
</AppPressable>
</View>
)}
</View>
)}
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
keyboardShouldPersistTaps="handled"
>
{/* Booking Title Card */}
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
<Ionicons name="calendar" size={20} color="#6B7280" />
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
keyboardShouldPersistTaps="handled"
>
{/* Booking Title Card */}
<View className="mb-4 flex-row items-start rounded-xl bg-white p-4">
<View className="mr-3 h-10 w-10 items-center justify-center rounded-full bg-[#E8E8ED]">
<Ionicons name="calendar" size={20} color="#6B7280" />
</View>
<View className="flex-1">
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
{booking.title}
</Text>
</View>
</View>
{/* Form Card */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
{/* Date picker trigger */}
<Pressable
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening date picker");
openDatePicker();
}}
disabled={isSaving}
>
<View className="flex-1">
<Text className="text-[13px] font-medium text-gray-500">Rescheduling</Text>
<Text className="mt-0.5 text-[17px] font-medium text-[#000]" numberOfLines={2}>
{booking.title}
</Text>
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Form Card */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
{/* Date picker trigger */}
<Pressable
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening date picker");
setShowDatePicker(true);
}}
disabled={isSaving}
>
<View className="flex-1">
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Date</Text>
<Text className="text-[17px] text-[#000]">{formattedDate}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Time picker trigger */}
<Pressable
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening time picker");
setShowTimePicker(true);
}}
disabled={isSaving}
>
<View className="flex-1">
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Reason input */}
<View className="px-4 py-3">
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
Reason (optional)
</Text>
<TextInput
className="min-h-[80px] text-[17px] text-[#000]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={reason}
onChangeText={setReason}
multiline
textAlignVertical="top"
editable={!isSaving}
/>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
{/* Date Picker Modal */}
<Modal
visible={showDatePicker}
transparent
animationType="slide"
onRequestClose={() => setShowDatePicker(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "flex-end",
}}
>
{/* Time picker trigger */}
<Pressable
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={() => setShowDatePicker(false)}
/>
<View
style={{
backgroundColor: "#fff",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "70%",
paddingBottom: insets.bottom,
className="flex-row items-center justify-between border-b border-gray-100 px-4 py-3"
style={({ pressed }) => ({
backgroundColor: pressed ? "#F9F9F9" : "transparent",
})}
onPress={() => {
safeLogInfo("[RescheduleScreen] Opening time picker");
openTimePicker();
}}
disabled={isSaving}
>
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Pressable onPress={() => setShowDatePicker(false)}>
<Text className="text-[17px] text-gray-500">Cancel</Text>
</Pressable>
<Text className="text-[17px] font-semibold text-[#000]">Select Date</Text>
<Pressable onPress={() => setShowDatePicker(false)}>
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
</Pressable>
<View className="flex-1">
<Text className="mb-1 text-[13px] font-medium text-gray-500">New Time</Text>
<Text className="text-[17px] text-[#000]">{formattedTime}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</Pressable>
{/* Date List */}
<ScrollView showsVerticalScrollIndicator={false}>
{dateOptions.map((option, index) => {
const isSelected =
option.value.toDateString() === selectedDateTime.toDateString();
return (
<Pressable
key={option.value.toISOString()}
className="flex-row items-center justify-between px-4 py-3.5"
style={({ pressed }) => ({
backgroundColor: isSelected
? "rgba(0, 122, 255, 0.1)"
: pressed
? "#F5F5F5"
: "#fff",
borderBottomWidth: index < dateOptions.length - 1 ? 1 : 0,
borderBottomColor: "#F2F2F7",
})}
onPress={() => {
const newDate = new Date(option.value);
newDate.setHours(selectedDateTime.getHours());
newDate.setMinutes(selectedDateTime.getMinutes());
setSelectedDateTime(newDate);
}}
>
<View>
<Text
className={`text-[17px] ${
isSelected ? "font-semibold text-[#007AFF]" : "font-medium text-[#000]"
}`}
>
{option.label}
</Text>
<Text
className={`mt-0.5 text-[13px] ${isSelected ? "text-[#007AFF]" : "text-gray-500"}`}
>
{option.sublabel}
</Text>
</View>
{isSelected && <Ionicons name="checkmark" size={22} color="#007AFF" />}
</Pressable>
);
})}
</ScrollView>
{/* Reason input */}
<View className="px-4 py-3">
<Text className="mb-1.5 text-[13px] font-medium text-gray-500">
Reason (optional)
</Text>
<TextInput
className="min-h-[80px] text-[17px] text-[#000]"
placeholder="Enter reason for rescheduling..."
placeholderTextColor="#9CA3AF"
value={reason}
onChangeText={setReason}
multiline
textAlignVertical="top"
editable={!isSaving}
/>
</View>
</View>
</Modal>
{/* Time Picker Modal */}
<Modal
visible={showTimePicker}
transparent
animationType="slide"
onRequestClose={() => setShowTimePicker(false)}
>
<View
style={{
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.4)",
justifyContent: "flex-end",
}}
>
<Pressable
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={() => setShowTimePicker(false)}
/>
<View
style={{
backgroundColor: "#fff",
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: "60%",
paddingBottom: insets.bottom,
}}
>
{/* Header */}
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Pressable onPress={() => setShowTimePicker(false)}>
<Text className="text-[17px] text-gray-500">Cancel</Text>
</Pressable>
<Text className="text-[17px] font-semibold text-[#000]">Select Time</Text>
<Pressable onPress={() => setShowTimePicker(false)}>
<Text className="text-[17px] font-semibold text-[#007AFF]">Done</Text>
</Pressable>
</View>
{/* Time Grid */}
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{
flexDirection: "row",
flexWrap: "wrap",
padding: 16,
}}
>
{timeOptions.map((option) => {
const isSelected =
selectedDateTime.getHours() === option.value.hour &&
selectedDateTime.getMinutes() === option.value.minute;
return (
<Pressable
key={option.label}
style={{ width: "25%", padding: 4 }}
onPress={() => {
const newDate = new Date(selectedDateTime);
newDate.setHours(option.value.hour);
newDate.setMinutes(option.value.minute);
setSelectedDateTime(newDate);
}}
>
<View
className={`items-center justify-center rounded-lg py-3 ${
isSelected ? "bg-[#007AFF]" : "bg-[#F2F2F7]"
}`}
>
<Text
className={`text-[13px] ${
isSelected ? "font-semibold text-white" : "font-medium text-[#000]"
}`}
>
{option.label}
</Text>
</View>
</Pressable>
);
})}
</ScrollView>
</View>
</View>
</Modal>
</>
</ScrollView>
</KeyboardAvoidingView>
);
}
);
+1
View File
@@ -52,6 +52,7 @@
"@expo/ui": "0.2.0-canary-20251230-fc48ddc",
"@expo/vector-icons": "15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/datetimepicker": "8.4.4",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
"@rn-primitives/alert-dialog": "1.2.0",