Skip to content
Merged
17 changes: 14 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"expo-constants": "~57.0.5",
"expo-crypto": "~57.0.1",
"expo-device": "~57.0.1",
"expo-document-picker": "~57.0.1",
"expo-file-system": "~57.0.2",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
"expo-image": "~57.0.1",
Expand Down
87 changes: 87 additions & 0 deletions src/alarms/audioSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { File } from "expo-file-system";
import { useAlarmStore } from "@/store/alarmStore";

export const DEFAULT_SOUND_LABEL = "Default";

export interface SoundSelection {
alarmSoundSelected: string;
alarmSoundUri: string | null;
}

export function isDefaultSound(soundUri: string | null): boolean {
return soundUri == null || soundUri === "";
}

export function defaultSoundSelection(): SoundSelection {
return {
alarmSoundSelected: DEFAULT_SOUND_LABEL,
alarmSoundUri: null,
};
}

const UNIQUE_PREFIX_PATTERN = /^\d+_/;

function decodeFileName(basename: string): string {
try {
return decodeURIComponent(basename);
} catch {
return basename;
}
}

export function soundLabelFor(soundUri: string | null): string {
if (isDefaultSound(soundUri)) {
return DEFAULT_SOUND_LABEL;
}
const uri = soundUri as string;
const slashIndex = uri.lastIndexOf("/");
const basename = slashIndex >= 0 ? uri.slice(slashIndex + 1) : uri;
if (basename.length === 0) {
return uri;
}
return decodeFileName(basename).replace(UNIQUE_PREFIX_PATTERN, "");
}

let fileNameCounter = 0;

export function sanitizeAudioFileName(name: string | null | undefined): string {
const base = (name ?? "")
.replace(/[\\/]+/g, "_")
.replace(/\.\./g, "_")
.trim();
const extMatch = base.match(/(\.[A-Za-z0-9]+)$/);
const stem = extMatch ? base.slice(0, -extMatch[1].length) : base;
const ext = extMatch ? extMatch[1] : "";
const safeStem = stem.length > 0 ? stem : "alarm_sound";
const unique = `${Date.now()}${fileNameCounter++}`;
return `${unique}_${safeStem}${ext}`;
}

export function buildSoundSelection(
name: string | undefined,
uri: string,
): SoundSelection {
const trimmed = name?.trim();
return {
alarmSoundSelected:
trimmed && trimmed.length > 0 ? trimmed : soundLabelFor(uri),
alarmSoundUri: uri,
};
}

export function deleteCustomSoundFile(uri: string | null): void {
if (isDefaultSound(uri)) return;
try {
const file = new File(uri as string);
if (file.exists) {
file.delete();
}
} catch (e) {
console.warn("Failed to delete custom sound file", uri, e);
}
}

export function isSoundFileReferenced(uri: string | null): boolean {
if (isDefaultSound(uri)) return false;
return useAlarmStore.getState().alarms.some((a) => a.sound === uri);
}
29 changes: 29 additions & 0 deletions src/alarms/pickAlarmSound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as DocumentPicker from "expo-document-picker";
import { Directory, File, Paths } from "expo-file-system";
import {
buildSoundSelection,
sanitizeAudioFileName,
type SoundSelection,
} from "./audioSelection";

const ALARM_SOUNDS_DIR = "alarm_sounds";

export async function pickAlarmSoundFromDevice(): Promise<SoundSelection | null> {
const result = await DocumentPicker.getDocumentAsync({
type: "audio/*",
copyToCacheDirectory: true,
});
if (result.canceled) {
return null;
}
const asset = result.assets[0];
if (!asset) {
return null;
}
const source = new File(asset.uri);
const directory = new Directory(Paths.document, ALARM_SOUNDS_DIR);
directory.create({ idempotent: true, intermediates: true });
const destination = new File(directory, sanitizeAudioFileName(asset.name));
await source.copy(destination);
return buildSoundSelection(asset.name, destination.uri);
}
99 changes: 81 additions & 18 deletions src/components/CreateAlarmForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Lucide } from "@react-native-vector-icons/lucide";
import {
ActivityIndicator,
Platform,
Pressable,
ScrollView,
Expand Down Expand Up @@ -33,6 +34,8 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
]}
accessibilityRole="button"
accessibilityLabel="Back"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={form.handleCancel}
>
<Lucide name="chevron-left" size={22} color={colors.text} />
Expand All @@ -51,6 +54,7 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
<TimeWheelPicker
hour24={form.hourSelected}
minute={form.minuteSelected}
disabled={form.saving}
onChange={(n) => {
form.setHour(n.hour24);
form.setMinute(n.minute);
Expand All @@ -64,9 +68,11 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
<Pressable
style={({ pressed }) => [
styles.selectButton,
pressed && styles.selectButtonPressed,
pressed && !form.saving && styles.selectButtonPressed,
]}
accessibilityRole="button"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={form.selectAllDays}
>
<Text style={styles.selectButtonText}>Every day</Text>
Expand All @@ -78,6 +84,7 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
key={day}
weekday={day}
selected={form.weekdaysSelected.includes(day)}
disabled={form.saving}
onToggle={form.toggleWeekday}
/>
))}
Expand All @@ -91,9 +98,11 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
<Pressable
style={({ pressed }) => [
styles.dropdownHeader,
pressed && styles.dropdownHeaderPressed,
pressed && !form.saving && styles.dropdownHeaderPressed,
]}
accessibilityRole="button"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={() => form.expandTaskSelector(!form.taskSelectorExpanded)}
>
<Text style={styles.sectionLabel}>Task</Text>
Expand All @@ -113,9 +122,11 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
key={task}
style={({ pressed }) => [
styles.dropdownItem,
pressed && styles.dropdownItemPressed,
pressed && !form.saving && styles.dropdownItemPressed,
]}
accessibilityRole="button"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={() => form.setTask(task)}
>
<Text
Expand Down Expand Up @@ -149,7 +160,7 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
step={1}
value={form.roundsSelected}
onChange={form.setRounds}
disabled={!form.taskConfigurable}
disabled={!form.taskConfigurable || form.saving}
/>
</View>

Expand All @@ -163,7 +174,7 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
key={d}
label={d}
selected={form.difficultySelected === d}
disabled={!form.taskConfigurable}
disabled={!form.taskConfigurable || form.saving}
onSelect={() => form.setDifficulty(d)}
/>
))}
Expand All @@ -173,16 +184,43 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
<View style={styles.card}>
<View style={styles.rowBetween}>
<Text style={styles.sectionLabel}>Sound</Text>
<Pressable
style={({ pressed }) => [
styles.selectButton,
pressed && styles.selectButtonPressed,
]}
accessibilityRole="button"
onPress={form.pickSound}
>
<Text style={styles.selectButtonText}>Select</Text>
</Pressable>
<View style={styles.actionsRow}>
<Pressable
style={({ pressed }) => [
styles.selectButton,
pressed && !form.saving && styles.selectButtonPressed,
]}
accessibilityRole="button"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={form.pickSound}
>
<Text style={styles.selectButtonText}>Select</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.selectButton,
form.alarmSoundUri == null && styles.selectButtonDisabled,
pressed && !form.saving && styles.selectButtonPressed,
]}
accessibilityRole="button"
accessibilityState={{
disabled: form.alarmSoundUri == null || form.saving,
}}
disabled={form.alarmSoundUri == null || form.saving}
onPress={form.setToDefault}
>
<Text
style={[
styles.selectButtonText,
form.alarmSoundUri == null &&
styles.selectButtonTextDisabled,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]}
>
Set to default
</Text>
</Pressable>
</View>
</View>
<Text style={styles.helperText}>{form.alarmSoundSelected}</Text>
</View>
Expand All @@ -193,6 +231,7 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
<Switch
value={form.snoozeEnabled}
onValueChange={form.toggleSnooze}
disabled={form.saving}
trackColor={{ false: colors.surface, true: colors.primary }}
thumbColor={
Platform.OS === "android"
Expand All @@ -212,9 +251,12 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
style={({ pressed }) => [
styles.footerButton,
styles.footerButtonSecondary,
pressed && styles.footerButtonPressed,
pressed && !form.saving && styles.footerButtonPressed,
form.saving && styles.footerButtonDisabled,
]}
accessibilityRole="button"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={form.handleCancel}
>
<Text style={styles.footerButtonTextSecondary}>Cancel</Text>
Expand All @@ -223,12 +265,19 @@ export function CreateAlarmForm({ title, form }: CreateAlarmFormProps) {
style={({ pressed }) => [
styles.footerButton,
styles.footerButtonPrimary,
pressed && styles.footerButtonPressed,
pressed && !form.saving && styles.footerButtonPressed,
form.saving && styles.footerButtonDisabled,
]}
accessibilityRole="button"
accessibilityState={{ disabled: form.saving }}
disabled={form.saving}
onPress={form.handleConfirm}
>
<Text style={styles.footerButtonTextPrimary}>Confirm</Text>
{form.saving ? (
<ActivityIndicator color={colors.primaryFg} />
) : (
<Text style={styles.footerButtonTextPrimary}>Confirm</Text>
)}
</Pressable>
</View>
</View>
Expand Down Expand Up @@ -352,6 +401,11 @@ const styles = StyleSheet.create({
flexDirection: "row",
gap: spacing.lg,
},
actionsRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
selectButton: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
Expand All @@ -361,11 +415,17 @@ const styles = StyleSheet.create({
selectButtonPressed: {
backgroundColor: colors.border,
},
selectButtonDisabled: {
opacity: 0.5,
},
selectButtonText: {
...typography.caption,
color: colors.primary,
fontWeight: "600",
},
selectButtonTextDisabled: {
color: colors.textMuted,
},
footer: {
flexDirection: "row",
gap: spacing.sm,
Expand All @@ -392,6 +452,9 @@ const styles = StyleSheet.create({
footerButtonPressed: {
opacity: 0.8,
},
footerButtonDisabled: {
opacity: 0.6,
},
footerButtonTextPrimary: {
...typography.bodyEmphasis,
color: colors.primaryFg,
Expand Down
Loading