diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..852b677 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + AlarmApp/package-lock.json + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-36" "build-tools;36.0.0" "ndk;27.1.12297006" + + - name: Install root dependencies + run: npm ci + + - name: Install app dependencies + run: npm ci + working-directory: AlarmApp + + - name: Run tests with coverage + run: npm test -- --coverage --coverageReporters=text --coverageReporters=lcov --coverageReporters=cobertura --watchAll=false + working-directory: AlarmApp + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: jest-coverage + path: AlarmApp/coverage + if-no-files-found: error + + - name: Build Android debug APK + run: ./gradlew assembleDebug + working-directory: AlarmApp/android diff --git a/AlarmApp/AlarmScheduler.tsx b/AlarmApp/AlarmScheduler.tsx index 8b72439..a94ccbf 100644 --- a/AlarmApp/AlarmScheduler.tsx +++ b/AlarmApp/AlarmScheduler.tsx @@ -283,11 +283,14 @@ export async function scheduleAlarmSet( let current = new Date(startDate); const now = Date.now(); + // REPEAT ALARM let scheduledCount = 0; let index = 0; while (current <= endDate) { // Only schedule future alarms (skip times that already passed) + console.log("current <= endDate"); if (current.getTime() > now) { + console.log('triggering...'); const trigger: TimestampTrigger = { type: TriggerType.TIMESTAMP, timestamp: current.getTime(), @@ -345,6 +348,55 @@ export async function scheduleAlarmSet( current = new Date(current.getTime() + intervalMs); } + // SINGLE ALARM + if (startDate == endDate) { + const trigger: TimestampTrigger = { + type: TriggerType.TIMESTAMP, + timestamp: current.getTime(), + alarmManager: { + allowWhileIdle: true, // fires during Doze mode + }, + }; + const timeLabel = current.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + const id = await notifee.createTriggerNotification({ + title: 'Alarm Flow', + body: `Alarm #${0 + index} — ${timeLabel}`, + android: { + channelId: CHANNEL_ID, + importance: AndroidImportance.HIGH, + category: AndroidCategory.ALARM, + visibility: AndroidVisibility.PUBLIC, + + // RING, don't ping ---------------------------------------- + sound: 'default', + loopSound: true, + // Vibrate in a long pattern so it feels like an alarm + vibrationPattern: [300, 500, 300, 500, 300, 500, 300, 500], + + // Persist on screen until dismissed + ongoing: true, + autoCancel: false, + + // Pop over the lockscreen / wake the device + fullScreenAction: { id: 'default' }, + pressAction: { id: 'default' }, + + // Explicit dismiss button — the user-facing way to silence it + actions: [ + { + title: 'Dismiss', + pressAction: { id: DISMISS_ACTION_ID }, + }, + ], + }, + }, trigger, ); + + scheduledCount = 1; + } + return { count: scheduledCount, notificationIds }; } diff --git a/AlarmApp/App copy v0.tsx b/AlarmApp/App copy v0.tsx new file mode 100644 index 0000000..3e3857f --- /dev/null +++ b/AlarmApp/App copy v0.tsx @@ -0,0 +1,760 @@ +import React, { useState, useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + View, + Text, + Button, + FlatList, + TouchableOpacity, + Switch, + Alert, + Platform, + Modal, // overlay for edit form + KeyboardAvoidingView, // keeps pickers visible + Pressable, // tap-to-dismiss +} from 'react-native'; +import DateTimePicker from '@react-native-community/datetimepicker'; + +import styles from "./styles.js" + +import { + createAlarmChannel, + requestAlarmPermissions, + scheduleAlarmSet, + cancelAlarmSet, + registerForegroundHandler, +} from './AlarmScheduler'; + +interface AlarmSet { + id: string; + start: string; + end: string; + interval: number; // minutes + count: number; // num of alarms + active: boolean; + notificationIds?: string[]; // Added for OS scheduling +} + +function check_time(current_time: Date, alarm_time: Date): boolean { + let CT: Date = current_time; // current time + let AT: Date = alarm_time; // alarm time + + // alarm should go off when both times match + if(CT.getHours() === AT.getHours()){ + if(CT.getMinutes() === AT.getMinutes()){ + return true; + } + } + + // the alarm should NOT go off when they are different + return false; +} + + +interface EditModalProps { + visible: boolean; + initialStart: Date; + initialEnd: Date; + initialInterval: number; + use24Hour: boolean; + onSave: (start: Date, end: Date, interval: number) => void; + onCancel: () => void; +} + +function EditModal({ + visible, + initialStart, + initialEnd, + initialInterval, + use24Hour, + onSave, + onCancel, +}: EditModalProps) { + // copies of the three time pickers + const [modalStart, setModalStart] = useState(initialStart); + const [modalEnd, setModalEnd] = useState(initialEnd); + const [modalInterval, setModalInterval] = useState(initialInterval); + + const [showStartPicker, setShowStartPicker] = useState(false); + const [showEndPicker, setShowEndPicker] = useState(false); + const [showIntervalPicker, setShowIntervalPicker] = useState(false); + + // sync local state each time the modal opens with fresh values + useEffect(() => { + if (visible) { + setModalStart(initialStart); + setModalEnd(initialEnd); + setModalInterval(initialInterval); + setShowStartPicker(false); + setShowEndPicker(false); + setShowIntervalPicker(false); + } + }, [visible, initialStart, initialEnd, initialInterval]); + + const intervalOptions = [1, 2, 3, 5, 10, 15, 20, 30]; + + // the options for the slider + const timeLocaleOpts = use24Hour + ? { hour: '2-digit' as const, minute: '2-digit' as const, hour12: false } + : { hour: '2-digit' as const, minute: '2-digit' as const }; + + return ( + + + {}}> + + + + + + + Edit Alarm Set + + + {/* Start */} + Start Time + { + setShowEndPicker(false); + setShowIntervalPicker(false); + setShowStartPicker(true); + }} + > + + {modalStart.toLocaleTimeString([], timeLocaleOpts)} + + + + {showStartPicker && ( + { + setShowStartPicker(Platform.OS === 'ios'); + if (selectedDate) setModalStart(selectedDate); + }} + /> + )} + + {/* End */} + End Time + { + setShowStartPicker(false); + setShowIntervalPicker(false); + setShowEndPicker(true); + }} + > + + {modalEnd.toLocaleTimeString([], timeLocaleOpts)} + + + + {showEndPicker && ( + { + setShowEndPicker(Platform.OS === 'ios'); + if (selectedDate) setModalEnd(selectedDate); + }} + /> + )} + + {/* Interval */} + Interval + { + setShowStartPicker(false); + setShowEndPicker(false); + setShowIntervalPicker((prev) => !prev); + }} + > + + Every {modalInterval} minutes + + + + {showIntervalPicker && ( + + {intervalOptions.map((min) => ( + { + setModalInterval(min); + setShowIntervalPicker(false); + }} + style={[ + styles.editIntervalButton, + modalInterval === min && styles.editIntervalButtonActive + ]} + > + + {min} min + + + ))} + + )} + + {/* Buttons */} + + + Cancel + + + onSave(modalStart, modalEnd, modalInterval)} + style={[styles.editButton, styles.editSaveButton]} + > + Save Edit + + + + + + + + +); +} + + +export default function App() { + const [alarms, setAlarms] = useState([]); + const [startTime, setStartTime] = useState(new Date()); + const [endTime, setEndTime] = useState(() => { + const d = new Date(); + d.setHours(d.getHours() + 1); + return d; + }); + const [intervalMinutes, setIntervalMinutes] = useState(10); + const [showStartPicker, setShowStartPicker] = useState(false); + const [showEndPicker, setShowEndPicker] = useState(false); + const [showIntervalPicker, setShowIntervalPicker] = useState(false); + const [editingAlarmId, setEditingAlarmId] = useState(null); + + // store the full alarm object being edited + // null means the modal is closed. + const [editingAlarm, setEditingAlarm] = useState(null); + + // 24hour toggle + const [use24Hour, setUse24Hour] = useState(false); + + const timeLocaleOpts = use24Hour + ? { hour: '2-digit' as const, minute: '2-digit' as const, hour12: false } + : { hour: '2-digit' as const, minute: '2-digit' as const }; + + //guard against overlapping alarms + const lastFiredRef = React.useRef>({}); + + const [isLoaded, setIsLoaded] = useState(false); + + // helper. parses a stored time string ("02:30 PM" or "14:30") into a Date + const parseTimeString = (t: string): Date | null => { + const parts = t.match(/(\d+):(\d+)\s?(AM|PM)?/i); + if (!parts) return null; + let h = Number(parts[1]); + const m = Number(parts[2]); + const p = parts[3]?.toUpperCase(); + if (p === 'PM' && h !== 12) h += 12; + if (p === 'AM' && h === 12) h = 0; + const d = new Date(); d.setHours(h, m, 0, 0); return d; + }; + + // changes all existing alarm when switching modes + const toggleUse24Hour = (val: boolean) => { + const newOpts = val + ? { hour: '2-digit' as const, minute: '2-digit' as const, hour12: false } + : { hour: '2-digit' as const, minute: '2-digit' as const }; + setAlarms((prev) => + prev.map((a) => { + const startDate = parseTimeString(a.start); + const endDate = parseTimeString(a.end); + return { + ...a, + start: startDate ? startDate.toLocaleTimeString([], newOpts) : a.start, + end: endDate ? endDate.toLocaleTimeString([], newOpts) : a.end, + }; + }) + ); + setUse24Hour(val); + }; + + // load 24h preference + useEffect(() => { + AsyncStorage.getItem('USE_24_HOUR').then((val) => { + if (val !== null) setUse24Hour(val === 'true'); + }); + }, []); + + // save 24h preference whenever it changes + useEffect(() => { + AsyncStorage.setItem('USE_24_HOUR', String(use24Hour)); + }, [use24Hour]); + + // check every second + useEffect(() => { + const interval = setInterval(() => { + const now = new Date(); + const nowStr = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + for (const set of alarms) { + if (!set.active) continue; + + //fire only once per minute to avoid overlapping alarms + if (now.getSeconds() !== 0) continue; + + if (nowStr === set.end) { + + //checks the date so that alarm can fire each day + const minuteKey = `${now.toDateString()} ${now.getHours()}:${now.getMinutes()}`; + + //guards against overlapping alarms and re-firing within the same minute + if (lastFiredRef.current[set.id] !== minuteKey) { + lastFiredRef.current[set.id] = minuteKey; + Alert.alert("Alarm!!!!", `Alarm set ended at ${set.end}`); + } + } + } + }, 1000); + + return () => clearInterval(interval); + }, [alarms]); + + useEffect(() => { + const init = async () => { + await createAlarmChannel(); + await requestAlarmPermissions(); + }; + init(); + + // Register the foreground event handler so taps/dismissals stop the + // looping alarm sound while the app is open. (Background events are + // registered in index.js.) + const unsubscribe = registerForegroundHandler(); + return unsubscribe; + }, []); + + // save alarms + useEffect(() => { + if (!isLoaded) return; + + const handle = setTimeout(() => { + AsyncStorage.setItem('ALARMS', JSON.stringify(alarms)).catch((e) => + console.log('Failed to save alarms', e) + ); + }, 250); + + return () => clearTimeout(handle); + }, [alarms, isLoaded]); + + // load alarms + useEffect(() => { + const loadAlarms = async () => { + try { // load stored alarms set + const stored = await AsyncStorage.getItem('ALARMS'); + if (stored !== null) { + setAlarms(JSON.parse(stored)); + } + } catch (e) { + console.log('Failed to load alarms', e); + } finally { + setIsLoaded(true); + } + }; + + loadAlarms(); + }, []); + + const CreateIntervalAlarms = async () => { + const allowed = await requestAlarmPermissions(); + if (!allowed) return; + + try { + const { count, notificationIds } = await scheduleAlarmSet( + startTime, + endTime, + intervalMinutes + ); + + const newAlarmSet: AlarmSet = { + id: Date.now().toString(), + start: startTime.toLocaleTimeString([], timeLocaleOpts), + end: endTime.toLocaleTimeString([], timeLocaleOpts), + interval: intervalMinutes, + count, + active: true, + notificationIds, + }; + + setAlarms((prev) => [...prev, newAlarmSet]); + Alert.alert('Alarms Created!', `${count} alarms would be scheduled!`); + } catch (e: any) { + console.log('Failed to schedule alarms', e); + + Alert.alert('Error', String(e?.message ?? e)); + } +}; + + + const openEditAlarmSet = (alarm: AlarmSet) => { + const today = new Date(); + // parse stored time + const startParts = alarm.start.match(/(\d+):(\d+)\s?(AM|PM)?/i); + const endParts = alarm.end.match(/(\d+):(\d+)\s?(AM|PM)?/i); + + if (!startParts || !endParts) { + Alert.alert("Can't edit alarm!"); + return; + } + + // gets the alarm's start time + let startHour = Number(startParts[1]); + const startMinute = Number(startParts[2]); + const startPeriod = startParts[3]?.toUpperCase(); + // gets the alarm's end time + let endHour = Number(endParts[1]); + const endMinute = Number(endParts[2]); + const endPeriod = endParts[3]?.toUpperCase(); + + // had to do it like this or it breaks. conversion to 24hr time + if (startPeriod === "PM" && startHour !== 12) startHour += 12; + if (startPeriod === "AM" && startHour === 12) startHour = 0; + + if (endPeriod === "PM" && endHour !== 12) endHour += 12; + if (endPeriod === "AM" && endHour === 12) endHour = 0; + + const start = new Date(today); + start.setHours(startHour, startMinute, 0, 0); + + const end = new Date(today); + end.setHours(endHour, endMinute, 0, 0); + + // set editing state + setEditingAlarmId(alarm.id); + setStartTime(start); + setEndTime(end); + setIntervalMinutes(alarm.interval); + + // also open the modal with this alarm's values + setEditingAlarm(alarm); + }; + + const saveEditAlarmSet = () => { + if (!editingAlarmId) return; + + let current = new Date(startTime); + const end = new Date(endTime); + const intervalMs = intervalMinutes * 60 * 1000; + let count = 0; + + while (current <= end) { + count++; + current = new Date(current.getTime() + intervalMs); + } + + setAlarms((prev) => + prev.map((a) => + a.id === editingAlarmId + ? { + ...a, + start: startTime.toLocaleTimeString([], timeLocaleOpts), + end: endTime.toLocaleTimeString([], timeLocaleOpts), + interval: intervalMinutes, + count, + } + : a + ) + ); + + setEditingAlarmId(null); + Alert.alert("Updated", "Alarm set updated."); + }; + + // modal save + const saveEditAlarmSetFromModal = (newStart: Date, newEnd: Date, newInterval: number) => { + if (!editingAlarm) return; + + let current = new Date(newStart); + const end = new Date(newEnd); + const intervalMs = newInterval * 60 * 1000; + let count = 0; + + while (current <= end) { + count++; + current = new Date(current.getTime() + intervalMs); + } + + setAlarms((prev) => + prev.map((a) => + a.id === editingAlarm.id + ? { + ...a, + start: newStart.toLocaleTimeString([], timeLocaleOpts), + end: newEnd.toLocaleTimeString([], timeLocaleOpts), + interval: newInterval, + count, + } + : a + ) + ); + + setEditingAlarmId(null); + setEditingAlarm(null); // close modal + Alert.alert("Updated", "Alarm set updated."); + }; + + const toggleAlarmSet = async (id: string) => { + const target = alarms.find((a) => a.id === id); + if (!target) return; + + if (target.active) { + if (target.notificationIds) { + await cancelAlarmSet(target.notificationIds); + } + setAlarms((prev) => + prev.map((a) => (a.id === id ? { ...a, active: false, notificationIds: [] } : a)) + ); + } else { + const allowed = await requestAlarmPermissions(); + if (!allowed) return; + + // Create temporary dates based on the stored local time strings to reschedule + const startDate = new Date(); + const [startHours, startMinutes] = target.start.split(/[: ]/); + startDate.setHours(target.start.includes('PM') && startHours !== '12' ? parseInt(startHours) + 12 : parseInt(startHours), parseInt(startMinutes)); + + const endDate = new Date(); + const [endHours, endMinutes] = target.end.split(/[: ]/); + endDate.setHours(target.end.includes('PM') && endHours !== '12' ? parseInt(endHours) + 12 : parseInt(endHours), parseInt(endMinutes)); + + const { notificationIds } = await scheduleAlarmSet(startDate, endDate, target.interval); + setAlarms((prev) => + prev.map((a) => (a.id === id ? { ...a, active: true, notificationIds } : a)) + ); + } + }; + + +const confirmDeleteAlarmSet = (id: string) => { + Alert.alert( + "Delete alarm set?", + "This will remove entire batch.", + [ + { + text: "Cancel", style: "cancel"}, + { + text: "Delete", + style: "destructive", + onPress: async () => { + const target = alarms.find((a) => a.id === id); + if (target && target.notificationIds) { + await cancelAlarmSet(target.notificationIds); + } + setAlarms(prev => prev.filter(a => a.id !== id)); + if (editingAlarmId === id) { + setEditingAlarmId(null); + } + // close modal if the alarm being edited is deleted + if (editingAlarm?.id === id) { + setEditingAlarm(null); + } + }, + }, + ], + {cancelable: true} + ); + + }; + + // derive Date objects from editingAlarm for the modal's initial values + const editInitialStart = React.useMemo(() => { + if (!editingAlarm) return new Date(); + const parts = editingAlarm.start.match(/(\d+):(\d+)\s?(AM|PM)?/i); + if (!parts) return new Date(); + let h = Number(parts[1]); + const m = Number(parts[2]); + const p = parts[3]?.toUpperCase(); + if (p === 'PM' && h !== 12) h += 12; + if (p === 'AM' && h === 12) h = 0; + const d = new Date(); d.setHours(h, m, 0, 0); return d; + }, [editingAlarm]); + + const editInitialEnd = React.useMemo(() => { + if (!editingAlarm) return new Date(); + const parts = editingAlarm.end.match(/(\d+):(\d+)\s?(AM|PM)?/i); + if (!parts) return new Date(); + let h = Number(parts[1]); + const m = Number(parts[2]); + const p = parts[3]?.toUpperCase(); + if (p === 'PM' && h !== 12) h += 12; + if (p === 'AM' && h === 12) h = 0; + const d = new Date(); d.setHours(h, m, 0, 0); return d; + }, [editingAlarm]); + + const intervalOptions = [1, 2, 3, 5, 10, 15, 20, 30]; + + return ( + + + {/* 24h toggle added to top right */} + + Alarm Flow + + {use24Hour ? '24h' : '12h'} + + + + + {/* Alarms List */} + item.id} + renderItem={({ item }) => ( + openEditAlarmSet(item)} + > + toggleAlarmSet(item.id)} + /> + {/* summarized alarm text */} + + Start Time: {item.start} {'\n'} + End Time: {item.end} {'\n'} + Interval: {item.interval} min + + + confirmDeleteAlarmSet(item.id)} + style={styles.deleteButton} + > + Delete + + + + )} + ListEmptyComponent={No alarms yet} + /> + + {/*Start time picker*/} + + Start Time + setShowStartPicker(true)}> + + {startTime.toLocaleTimeString([], timeLocaleOpts)} + + + + {/* end time picker */} + End Time + setShowEndPicker(true)}> + + {endTime.toLocaleTimeString([], timeLocaleOpts)} + + + + {/* interval picker */} + Interval + setShowIntervalPicker(true)} + activeOpacity={0.7} + > + Every {intervalMinutes} minutes + + + +