Configure Expo mobile project and update Android/iOS bundles

Initialize iOS and Android native projects for the Time to Leave app.
Rename Android package to com.floegger.timetoleave and add iOS
project files. Add FontAwesome icons, calendar types, and
dependencies.
This commit is contained in:
2026-05-15 18:26:51 +02:00
parent b3bba8cf38
commit 9d6efdd43b
38 changed files with 1815 additions and 58 deletions
+4 -1
View File
@@ -13,6 +13,8 @@
"test": "jest"
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-native-fontawesome": "^1.0.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.2.4",
"@react-navigation/native-stack": "^7.14.14",
@@ -27,7 +29,8 @@
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
"react-native-screens": "~4.16.0",
"react-native-svg": "^15.15.5"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+97 -3
View File
@@ -1,8 +1,11 @@
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Image, Text, View } from 'react-native';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import { faBars } from '@fortawesome/free-solid-svg-icons/faBars';
import { Image, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { EventListScreen } from '../screens/EventListScreen';
import { EventDetailScreen } from '../screens/EventDetailScreen';
import { AddEventScreen } from '../screens/AddEventScreen';
@@ -14,6 +17,57 @@ import navLogo from '../../assets/nav-logo.png';
// ── Root Stack ──
const Root = createNativeStackNavigator<RootStack>();
const byPrefixAndName = { fas: { bars: faBars } };
type HeaderMenuProps = {
navigation: {
navigate: (screen: 'CalendarImport' | 'Settings') => void;
};
};
function HeaderMenu({ navigation }: HeaderMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const navigateTo = (screen: 'CalendarImport' | 'Settings') => {
setIsOpen(false);
navigation.navigate(screen);
};
return (
<>
<Pressable
accessibilityLabel="Open menu"
accessibilityRole="button"
hitSlop={12}
onPress={() => setIsOpen(true)}
style={({ pressed }) => [styles.menuButton, pressed && styles.menuButtonPressed]}
>
<FontAwesomeIcon icon={byPrefixAndName.fas['bars']} color="#F4F1EA" size={22} />
</Pressable>
<Modal animationType="fade" transparent visible={isOpen} onRequestClose={() => setIsOpen(false)}>
<Pressable style={styles.menuOverlay} onPress={() => setIsOpen(false)}>
<View style={styles.menuPanel}>
<Pressable
accessibilityRole="menuitem"
onPress={() => navigateTo('CalendarImport')}
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
>
<Text style={styles.menuItemText}>Calendar</Text>
</Pressable>
<Pressable
accessibilityRole="menuitem"
onPress={() => navigateTo('Settings')}
style={({ pressed }) => [styles.menuItem, pressed && styles.menuItemPressed]}
>
<Text style={styles.menuItemText}>Settings</Text>
</Pressable>
</View>
</Pressable>
</Modal>
</>
);
}
/**
* Root native stack navigator for the app.
@@ -27,16 +81,17 @@ export default function AppNavigator() {
<NavigationContainer>
<Root.Navigator
initialRouteName="EventList"
screenOptions={{
screenOptions={({ navigation }) => ({
headerStyle: { backgroundColor: '#17112A' },
headerTintColor: '#F4F1EA',
headerRight: () => <HeaderMenu navigation={navigation} />,
headerTitle: () => (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Image source={navLogo} style={{ width: 28, height: 28, borderRadius: 6 }} resizeMode="contain" />
<Text style={{ color: '#F4F1EA', fontSize: 18, fontWeight: '600' }}>Time To Leave</Text>
</View>
),
}}
})}
>
<Root.Screen name="EventList" component={EventListScreen} />
<Root.Screen name="AddEvent" component={AddEventScreen} />
@@ -49,3 +104,42 @@ export default function AppNavigator() {
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
menuButton: {
alignItems: 'center',
height: 40,
justifyContent: 'center',
width: 40,
},
menuButtonPressed: {
opacity: 0.65,
},
menuOverlay: {
alignItems: 'flex-end',
backgroundColor: 'rgba(9, 8, 22, 0.45)',
flex: 1,
paddingRight: 12,
paddingTop: 72,
},
menuPanel: {
backgroundColor: '#17112A',
borderColor: '#3B3157',
borderRadius: 8,
borderWidth: 1,
minWidth: 168,
overflow: 'hidden',
},
menuItem: {
paddingHorizontal: 18,
paddingVertical: 14,
},
menuItemPressed: {
backgroundColor: '#2A2140',
},
menuItemText: {
color: '#F4F1EA',
fontSize: 16,
fontWeight: '600',
},
});
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
@@ -11,9 +11,9 @@ import {
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import { api } from '../services/api';
import { fetchNativeEvents } from '../services/calendar';
import { addEvent, loadEvents } from '../store/eventStore';
import type { Event as CalendarEvent } from '@timetoleave/core';
import { fetchNativeEvents, getSelectableCalendars, groupCalendarsByType } from '../services/calendar';
import { addEvent, getSelectedCalendarIds, hasCalendarSelection, loadEvents, saveSelectedCalendarIds } from '../store/eventStore';
import type { Event as CalendarEvent, CalendarAccountType, SelectableCalendar } from '@timetoleave/core';
import type { RootStack } from '../types/navigation';
import { useColors } from '../hooks/useColors';
@@ -22,9 +22,81 @@ type ScreenProps = {
route: RouteProp<RootStack, 'CalendarImport'>;
};
/**
* Display order for calendar source type groups.
*/
const GROUP_ORDER: CalendarAccountType[] = [
'caldav',
'mobileme',
'google',
'exchange',
'subscriptions',
'local',
'carddav',
'activesync',
'other',
'none',
];
/** Badge color for each account type. */
const BADGE_COLORS: Record<string, string> = {
caldav: '#34C759',
mobileme: '#FF9500',
google: '#4285F4',
exchange: '#0078D4',
subscriptions: '#AF52DE',
local: '#8E8E93',
carddav: '#5AC8FA',
activesync: '#FF2D55',
other: '#8E8E93',
none: '#8E8E93',
};
/** Calendar checkbox row component. */
function CalendarCheckbox({
calendar,
selected,
onToggle,
colors,
}: {
calendar: SelectableCalendar;
selected: boolean;
onToggle: (id: string) => void;
colors: ReturnType<typeof useColors>;
}) {
const badgeColor = BADGE_COLORS[calendar.accountType] ?? BADGE_COLORS.other;
return (
<TouchableOpacity
style={[styles.calendarRow, { borderColor: selected ? colors.accent : colors.border }]}
onPress={() => onToggle(calendar.id)}
accessibilityRole="button"
accessibilityLabel={`${calendar.name} ${calendar.sourceInfo.label}`}
>
<View style={[styles.checkBox, selected && styles.checkBoxSelected]}>
{selected && <Text style={styles.checkMark}></Text>}
</View>
<View style={styles.calendarInfo}>
<Text style={[styles.calendarName, { color: colors.text }]}>
{calendar.sourceInfo.emoji} {calendar.name}
</Text>
<View style={styles.badgeRow}>
<View style={[styles.badge, { backgroundColor: badgeColor }]}>
<Text style={styles.badgeText}>{calendar.sourceInfo.badge}</Text>
</View>
<Text style={[styles.accountTypeText, { color: colors.subtext }]}>
{calendar.sourceInfo.label}
</Text>
</View>
</View>
</TouchableOpacity>
);
}
/**
* Screen for importing events from either an ICS calendar URL or the device's
* native calendar. Deduplicates against already-imported events by ID.
* native calendar. Includes calendar selection so the user can choose which
* calendars to sync. Deduplicates against already-imported events by ID.
*/
export function CalendarImportScreen({ navigation }: ScreenProps) {
const colors = useColors();
@@ -33,6 +105,57 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
const [error, setError] = useState<string | null>(null);
const [count, setCount] = useState<number | null>(null);
// Calendar selection state
const [availableCalendars, setAvailableCalendars] = useState<SelectableCalendar[]>([]);
const [selectedCalendarIds, setSelectedCalendarIds] = useState<Set<string>>(new Set());
const [calendarsLoaded, setCalendarsLoaded] = useState(false);
const [hasSelection, setHasSelection] = useState(false);
const initialLoadRef = useRef(false);
// Load available calendars and persisted selection on mount
useEffect(() => {
if (initialLoadRef.current) return;
initialLoadRef.current = true;
(async () => {
const [calendars, persistedIds, hasSel] = await Promise.all([
getSelectableCalendars(),
getSelectedCalendarIds(),
hasCalendarSelection(),
]);
setAvailableCalendars(calendars);
setSelectedCalendarIds(new Set(persistedIds));
setHasSelection(hasSel);
setCalendarsLoaded(true);
})();
}, []);
const toggleCalendar = useCallback((id: string) => {
setSelectedCalendarIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedCalendarIds(new Set(availableCalendars.map((c) => c.id)));
}, [availableCalendars]);
const deselectAll = useCallback(() => {
setSelectedCalendarIds(new Set());
}, []);
const saveSelection = useCallback(async () => {
const ids = Array.from(selectedCalendarIds);
await saveSelectedCalendarIds(ids);
setHasSelection(ids.length > 0);
}, [selectedCalendarIds]);
const handleImport = async () => {
if (!url.trim()) {
setError('Bitte ICS-URL eingeben');
@@ -77,11 +200,19 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
setCount(null);
try {
// Save the current selection before syncing
await saveSelection();
// Fetch events from the next 30 days
const now = new Date();
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater);
// If a selection was made, sync only selected calendars
const calendarIds = hasSelection
? Array.from(selectedCalendarIds)
: undefined;
const nativeEvents = await fetchNativeEvents(now, thirtyDaysLater, calendarIds);
// Load existing events to avoid duplicates
const existing = await loadEvents();
@@ -103,6 +234,9 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
}
};
// Group calendars by account type for display
const grouped = groupCalendarsByType(availableCalendars);
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.content}>
@@ -111,6 +245,7 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
Importiere Termine über eine ICS-URL oder sync mit dem Geräte-Kalender.
</Text>
{/* ── ICS URL Import ── */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>ICS-URL Import</Text>
@@ -137,6 +272,75 @@ export function CalendarImportScreen({ navigation }: ScreenProps) {
</TouchableOpacity>
</View>
{/* ── Calendar Selection ── */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Kalender auswählen</Text>
<View style={styles.selectionActions}>
<TouchableOpacity onPress={selectAll} accessibilityLabel="Alle auswählen">
<Text style={[styles.linkText, { color: colors.accent }]}>Alle</Text>
</TouchableOpacity>
<Text style={[styles.dividerText, { color: colors.subtext }]}> · </Text>
<TouchableOpacity onPress={deselectAll} accessibilityLabel="Alle abwählen">
<Text style={[styles.linkText, { color: colors.accent }]}>Keine</Text>
</TouchableOpacity>
</View>
</View>
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
Wähle die Kalender aus, die gesynct werden sollen. Ohne Auswahl werden alle Kalender verwendet.
{'\n'}
CalDAV-Quellen (DAVx5, Apple Calendar, etc.) werden automatisch erkannt.
</Text>
{!calendarsLoaded ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.accent} />
<Text style={[styles.loadingText, { color: colors.subtext }]}>Kalender werden geladen</Text>
</View>
) : availableCalendars.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.subtext }]}>
Keine Kalender auf diesem Gerät gefunden.
</Text>
</View>
) : (
<>
{/* Render groups in defined order, then any remaining types */}
{GROUP_ORDER.map((type) => {
const group = grouped.get(type);
if (!group || group.length === 0) return null;
return (
<View key={type} style={styles.group}>
<Text style={[styles.groupLabel, { color: colors.subtext }]}>
{group[0].sourceInfo.emoji} {group[0].sourceInfo.label} ({group.length})
</Text>
{group.map((cal) => (
<CalendarCheckbox
key={cal.id}
calendar={cal}
selected={selectedCalendarIds.has(cal.id)}
onToggle={toggleCalendar}
colors={colors}
/>
))}
</View>
);
})}
{hasSelection && (
<View style={[styles.selectionInfo, { backgroundColor: colors.highlight }]}>
<Text style={[styles.selectionInfoText, { color: colors.text }]}>
{selectedCalendarIds.size} von {availableCalendars.length} Kalendern ausgewählt
</Text>
</View>
)}
</>
)}
</View>
{/* ── Device Calendar Sync ── */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text }]}>Geräte-Kalender Sync</Text>
<Text style={[styles.sectionDesc, { color: colors.subtext }]}>
@@ -183,6 +387,12 @@ const styles = StyleSheet.create({
heading: { fontSize: 22, fontWeight: '700', marginBottom: 4 },
description: { fontSize: 14, marginBottom: 20, lineHeight: 20 },
section: { marginBottom: 24 },
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
sectionTitle: { fontSize: 16, fontWeight: '600', marginBottom: 8 },
sectionDesc: { fontSize: 13, marginBottom: 12, lineHeight: 18 },
input: {
@@ -210,4 +420,107 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
backBtnText: { fontSize: 15 },
// Calendar selection
selectionActions: {
flexDirection: 'row',
alignItems: 'center',
},
linkText: {
fontSize: 13,
fontWeight: '600',
},
dividerText: {
fontSize: 13,
marginHorizontal: 4,
},
group: {
marginBottom: 12,
},
groupLabel: {
fontSize: 12,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
marginTop: 4,
},
calendarRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1.5,
marginBottom: 6,
},
checkBox: {
width: 24,
height: 24,
borderRadius: 6,
borderWidth: 2,
borderColor: '#8E8E93',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
checkBoxSelected: {
backgroundColor: '#8B5CF6',
borderColor: '#8B5CF6',
},
checkMark: {
color: '#fff',
fontSize: 14,
fontWeight: '700',
},
calendarInfo: {
flex: 1,
},
calendarName: {
fontSize: 15,
fontWeight: '500',
marginBottom: 2,
},
badgeRow: {
flexDirection: 'row',
alignItems: 'center',
},
badge: {
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 4,
marginRight: 6,
},
badgeText: {
color: '#fff',
fontSize: 10,
fontWeight: '700',
},
accountTypeText: {
fontSize: 11,
},
loadingContainer: {
padding: 20,
alignItems: 'center',
},
loadingText: {
marginTop: 8,
fontSize: 13,
},
emptyContainer: {
padding: 20,
alignItems: 'center',
},
emptyText: {
fontSize: 13,
textAlign: 'center',
},
selectionInfo: {
padding: 10,
borderRadius: 8,
marginTop: 4,
},
selectionInfoText: {
fontSize: 13,
textAlign: 'center',
},
});
+145 -12
View File
@@ -1,5 +1,5 @@
import * as Calendar from 'expo-calendar';
import type { Event as CoreEvent } from '@timetoleave/core';
import type { Event as CoreEvent, CalendarAccountType, CalendarSourceInfo, SelectableCalendar } from '@timetoleave/core';
/**
* Native calendar integration for the mobile app.
@@ -7,33 +7,166 @@ import type { Event as CoreEvent } from '@timetoleave/core';
*
* Only events that have a non-empty `location` field are included, since the
* app requires a destination to compute routes.
*
* Supports calendar selection so the user can pick which calendars to sync.
* CalDAV sources (DAVx5 on Android, Apple Calendar on iOS) are detected and
* labelled automatically.
*/
// ── Source type metadata ──
/** Maps expo-calendar account types to human-readable info. */
const SOURCE_INFO: Record<string, CalendarSourceInfo> = {
caldav: {
label: 'CalDAV / DAVx',
badge: 'CalDAV',
emoji: '🔗',
},
mobileme: {
label: 'Apple Calendar',
badge: 'Apple',
emoji: '🍎',
},
google: {
label: 'Google Calendar',
badge: 'Google',
emoji: '📅',
},
exchange: {
label: 'Microsoft Exchange',
badge: 'Exchange',
emoji: '🏢',
},
subscriptions: {
label: 'Subscribed',
badge: 'Sub',
emoji: '📡',
},
local: {
label: 'On My Device',
badge: 'Local',
emoji: '📱',
},
carddav: {
label: 'CardDAV',
badge: 'CardDAV',
emoji: '👤',
},
activesync: {
label: 'ActiveSync',
badge: 'Sync',
emoji: '🔄',
},
};
const DEFAULT_SOURCE_INFO: CalendarSourceInfo = {
label: 'Other',
badge: 'Other',
emoji: '📆',
};
/**
* Resolve the account type string from expo-calendar into a known
* CalendarAccountType. Returns 'other' for unknown types.
*/
function resolveAccountType(type: string | undefined | null): CalendarAccountType {
if (!type) return 'other';
const lower = type.toLowerCase();
if (lower in SOURCE_INFO) return lower as CalendarAccountType;
return 'other';
}
/**
* Get source info for an expo-calendar source object.
* DAVx5 on Android appears as `caldav`, Apple Calendar on iOS as `mobileme`.
*/
function sourceInfoFor(source: { type?: string } | undefined | null): CalendarSourceInfo {
if (!source || !source.type) return DEFAULT_SOURCE_INFO;
return SOURCE_INFO[source.type.toLowerCase()] ?? DEFAULT_SOURCE_INFO;
}
// ── Permission ──
/** Requests calendar read permission and verifies the calendar service is available. */
export async function ensureCalendarPermission(): Promise<boolean> {
const { status } = await Calendar.requestCalendarPermissionsAsync();
if (status !== 'granted')
return false;
if (status !== 'granted') return false;
return Calendar.isAvailableAsync();
}
// ── Calendar listing ──
/**
* Fetch events from native calendars within a date range.
* Returns events converted to our internal Event format.
* Get all available calendars on the device, converted to a minimal format
* suitable for the selection UI. Grouped by account type.
*
* DAVx5 calendars (Android) will show as CalDAV type.
* Apple Calendar (iOS) will show as mobileme type.
*/
export async function fetchNativeEvents(
startDate: Date,
endDate: Date,
): Promise<CoreEvent[]> {
export async function getSelectableCalendars(): Promise<SelectableCalendar[]> {
const available = await ensureCalendarPermission();
if (!available) return [];
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
return calendars.map((cal) => {
const accountType = resolveAccountType(cal.source?.type);
const sourceInfo = sourceInfoFor(cal.source);
return {
id: cal.id,
name: cal.name ?? 'Unnamed Calendar',
accountType,
sourceInfo,
editable: cal.allowsModifications ?? false,
};
});
}
/**
* Group calendars by account type for display in the selection UI.
*/
export function groupCalendarsByType(calendars: SelectableCalendar[]): Map<CalendarAccountType, SelectableCalendar[]> {
const groups = new Map<CalendarAccountType, SelectableCalendar[]>();
for (const cal of calendars) {
const existing = groups.get(cal.accountType) ?? [];
existing.push(cal);
groups.set(cal.accountType, existing);
}
return groups;
}
// ── Event fetching ──
/**
* Fetch events from native calendars within a date range.
* Returns events converted to our internal Event format.
*
* @param startDate - Start of the date range.
* @param endDate - End of the date range.
* @param calendarIds - Optional list of calendar IDs to fetch. If omitted,
* fetches from all calendars.
*/
export async function fetchNativeEvents(
startDate: Date,
endDate: Date,
calendarIds?: string[],
): Promise<CoreEvent[]> {
const available = await ensureCalendarPermission();
if (!available) return [];
let calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT);
if (calendarIds && calendarIds.length > 0) {
calendars = calendars.filter((c) => calendarIds.includes(c.id));
}
if (calendars.length === 0) return [];
const calendarIds = calendars.map((c) => c.id);
const events = await Calendar.getEventsAsync(calendarIds, startDate, endDate);
const ids = calendarIds ?? calendars.map((c) => c.id);
const events = await Calendar.getEventsAsync(ids, startDate, endDate);
return events
.filter((evt) => evt.location && evt.location.trim().length > 0)
+35 -2
View File
@@ -16,6 +16,7 @@ import { SchedulableTriggerInputTypes } from 'expo-notifications';
const EVENTS_KEY = '@timetoleave_events';
const ORIGIN_KEY = '@timetoleave_origin';
const NOTIFICATIONS_KEY = '@timetoleave_notifications';
const SELECTED_CALENDARS_KEY = '@timetoleave_selected_calendars';
// ── Default notification settings ─────────────────────────────
@@ -190,8 +191,40 @@ export async function saveNotificationSettings(
}
// ───────────────────────────────────────────────────────────────
// Reschedule all notifications (for origin/setting changes)
// ───────────────────────────────────────────────────────────────
// Calendar Selection
// ───────────────────────────────────────────────────────────────
/**
* Get the list of calendar IDs the user has chosen to sync.
* Returns an empty array if no selection has been made yet (meaning
* all calendars should be synced).
*/
export async function getSelectedCalendarIds(): Promise<string[]> {
const json = await AsyncStorage.getItem(SELECTED_CALENDARS_KEY);
if (!json) return [];
try {
return JSON.parse(json) as string[];
} catch {
return [];
}
}
/**
* Save the list of calendar IDs the user wants to sync.
* Pass an empty array to reset to "sync all calendars".
*/
export async function saveSelectedCalendarIds(ids: string[]): Promise<void> {
await AsyncStorage.setItem(SELECTED_CALENDARS_KEY, JSON.stringify(ids));
}
/**
* Check whether the user has made an explicit calendar selection.
* Returns true if a non-empty list is stored.
*/
export async function hasCalendarSelection(): Promise<boolean> {
const ids = await getSelectedCalendarIds();
return ids.length > 0;
}
export async function rescheduleAllNotifications(): Promise<void> {
const [events, settings] = await Promise.all([loadEvents(), loadNotificationSettings()]);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 528 B