notification system implemented

need refinements
This commit is contained in:
dimitar committed 2026-03-11 06:07:16 +01:00
1 parent 612259f020
commit 1143f8ca02
33 files changed
+2221 -222

No files matched your search

@@ -0,0 +1,434 @@
import React, { useEffect } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
FlatList,
ActivityIndicator,
Alert,
StatusBar,
Platform,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import { theme } from "../styles/theme";
import { useNotifications } from "../contexts/NotificationsContext";
import type { Notification } from "../api/notifications";
import log from "../utils/logger";
interface NotificationsModalProps {
visible: boolean;
onClose: () => void;
}
export function NotificationsModal({
visible,
onClose,
}: NotificationsModalProps) {
const {
notifications,
loading,
markNotificationAsRead,
deleteNotificationAction,
markAllAsReadAction,
} = useNotifications();
useEffect(() => {
if (visible) {
log.debug("NotificationsModal opened", {
notificationCount: notifications.length,
loading,
});
}
}, [visible, notifications.length, loading]);
const handleMarkAsRead = async (id: string) => {
await markNotificationAsRead(id);
};
const handleDelete = (id: string) => {
Alert.alert("Delete Notification", "Are you sure?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteNotificationAction(id),
},
]);
};
const handleMarkAllAsRead = () => {
if (notifications.filter((n) => !n.read).length === 0) return;
Alert.alert("Mark All as Read", "Mark all notifications as read?", [
{ text: "Cancel", style: "cancel" },
{
text: "Mark All",
onPress: () => markAllAsReadAction(),
},
]);
};
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={onClose}
transparent={false}
statusBarTranslucent={false}
>
<View style={styles.modalWrapper}>
<StatusBar barStyle="light-content" />
<View style={styles.container}>
{/* Header */}
<LinearGradient
colors={theme.gradients.primary}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<View style={styles.headerContent}>
<View>
<Text style={styles.headerTitle}>Notifications</Text>
<Text style={styles.headerSubtitle}>
{notifications.filter((n) => !n.read).length} unread
</Text>
</View>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
</View>
</LinearGradient>
{/* Actions Bar */}
{notifications.length > 0 && (
<View style={styles.actionsBar}>
<TouchableOpacity
onPress={handleMarkAllAsRead}
disabled={notifications.filter((n) => !n.read).length === 0}
>
<Text
style={[
styles.actionText,
notifications.filter((n) => !n.read).length === 0 &&
styles.actionTextDisabled,
]}
>
Mark all as read
</Text>
</TouchableOpacity>
</View>
)}
{/* Notifications List */}
{loading ? (
<View style={styles.centered}>
<ActivityIndicator size="large" color={theme.colors.primary} />
</View>
) : notifications.length === 0 ? (
<View style={styles.emptyState}>
<LinearGradient
colors={["rgba(59, 130, 246, 0.1)", "rgba(59, 130, 246, 0.05)"]}
style={styles.emptyCard}
>
<Ionicons
name="notifications-off-outline"
size={64}
color={theme.colors.primary}
/>
<Text style={styles.emptyTitle}>No Notifications</Text>
<Text style={styles.emptyText}>
You're all caught up! New notifications will appear here.
</Text>
</LinearGradient>
</View>
) : (
<FlatList
data={notifications}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<NotificationItem
notification={item}
onMarkAsRead={handleMarkAsRead}
onDelete={handleDelete}
/>
)}
contentContainerStyle={styles.listContent}
/>
)}
</View>
</View>
</Modal>
);
}
interface NotificationItemProps {
notification: Notification;
onMarkAsRead: (id: string) => void;
onDelete: (id: string) => void;
}
function NotificationItem({
notification,
onMarkAsRead,
onDelete,
}: NotificationItemProps) {
const getIcon = (type: Notification["type"]) => {
switch (type) {
case "payment_reminder":
return "card-outline";
case "attendance":
return "checkmark-circle-outline";
case "promotion":
return "megaphone-outline";
case "system":
default:
return "information-circle-outline";
}
};
const getIconColor = (type: Notification["type"]) => {
switch (type) {
case "payment_reminder":
return theme.colors.warning;
case "attendance":
return theme.colors.success;
case "promotion":
return theme.colors.purple;
case "system":
default:
return theme.colors.primary;
}
};
const formatTime = (date: Date) => {
const now = new Date();
const diff = now.getTime() - new Date(date).getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(date).toLocaleDateString();
};
return (
<TouchableOpacity
onPress={() => !notification.read && onMarkAsRead(notification.id)}
activeOpacity={0.7}
>
<View
style={[styles.notificationCard, !notification.read && styles.unread]}
>
<View style={styles.notificationContent}>
{/* Icon */}
<View
style={[
styles.iconCircle,
{ backgroundColor: `${getIconColor(notification.type)}20` },
]}
>
<Ionicons
name={getIcon(notification.type)}
size={24}
color={getIconColor(notification.type)}
/>
</View>
{/* Content */}
<View style={styles.textContent}>
<Text
style={[
styles.notificationTitle,
!notification.read && styles.notificationTitleUnread,
]}
>
{notification.title}
</Text>
<Text style={styles.notificationMessage}>
{notification.message}
</Text>
<Text style={styles.notificationTime}>
{formatTime(notification.createdAt)}
</Text>
</View>
{/* Actions */}
<TouchableOpacity
onPress={() => onDelete(notification.id)}
style={styles.deleteButton}
>
<Ionicons
name="trash-outline"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
{/* Unread Indicator */}
{!notification.read && <View style={styles.unreadDot} />}
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
modalWrapper: {
flex: 1,
width: "100%",
height: "100%",
},
container: {
flex: 1,
backgroundColor: theme.colors.background,
margin: 0,
padding: 0,
},
header: {
paddingTop: Platform.OS === "android" ? 50 : 60,
paddingBottom: 20,
paddingHorizontal: 20,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
},
headerContent: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
headerTitle: {
fontSize: theme.typography.fontSize["3xl"],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
},
headerSubtitle: {
fontSize: theme.typography.fontSize.base,
color: "rgba(255, 255, 255, 0.9)",
marginTop: 4,
},
closeButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255, 255, 255, 0.2)",
justifyContent: "center",
alignItems: "center",
},
actionsBar: {
flexDirection: "row",
justifyContent: "flex-end",
paddingHorizontal: 20,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: theme.colors.gray200,
},
actionText: {
fontSize: theme.typography.fontSize.sm,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.primary,
},
actionTextDisabled: {
color: theme.colors.gray400,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
emptyState: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 40,
},
emptyCard: {
borderRadius: theme.borderRadius["2xl"],
padding: 40,
alignItems: "center",
width: "100%",
},
emptyTitle: {
fontSize: theme.typography.fontSize.xl,
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray700,
marginTop: 16,
marginBottom: 8,
},
emptyText: {
fontSize: theme.typography.fontSize.base,
color: theme.colors.gray500,
textAlign: "center",
lineHeight: 24,
},
listContent: {
paddingHorizontal: 20,
paddingVertical: 12,
},
notificationCard: {
backgroundColor: theme.colors.white,
borderRadius: theme.borderRadius.xl,
padding: 16,
marginBottom: 12,
position: "relative",
...theme.shadows.subtle,
},
unread: {
backgroundColor: "rgba(59, 130, 246, 0.05)",
borderLeftWidth: 3,
borderLeftColor: theme.colors.primary,
},
notificationContent: {
flexDirection: "row",
alignItems: "flex-start",
},
iconCircle: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
textContent: {
flex: 1,
marginRight: 8,
},
notificationTitle: {
fontSize: theme.typography.fontSize.base,
fontWeight: theme.typography.fontWeight.semibold,
color: theme.colors.gray700,
marginBottom: 4,
},
notificationTitleUnread: {
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.gray900,
},
notificationMessage: {
fontSize: theme.typography.fontSize.sm,
color: theme.colors.gray600,
lineHeight: 20,
marginBottom: 6,
},
notificationTime: {
fontSize: theme.typography.fontSize.xs,
color: theme.colors.gray400,
},
deleteButton: {
padding: 4,
},
unreadDot: {
position: "absolute",
top: 16,
right: 16,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.primary,
},
});
@@ -0,0 +1,124 @@
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import { theme } from "../styles/theme";
interface NutritionWidgetProps {
current: number; // in kcal
goal: number; // in kcal
}
export function NutritionWidget({ current, goal }: NutritionWidgetProps) {
const percentage = Math.min(Math.max(current / goal, 0), 1);
return (
<View style={styles.container}>
<LinearGradient
colors={["#fef3c7", "#fde68a"]} // Light yellow background
style={[styles.card, theme.shadows.subtle]}
>
<View style={styles.content}>
<View style={styles.iconContainer}>
<LinearGradient colors={theme.gradients.sunset} style={styles.icon}>
<Ionicons name="nutrition" size={24} color="#fff" />
</LinearGradient>
</View>
<View style={styles.info}>
<Text style={styles.title}>Nutrition</Text>
<Text style={styles.subtitle}>
{current} / {goal} kcal
</Text>
</View>
<View style={styles.percentageContainer}>
<Text style={styles.percentage}>
{Math.round(percentage * 100)}%
</Text>
</View>
</View>
<View style={styles.progressContainer}>
<View style={styles.progressBarBg}>
<LinearGradient
colors={theme.gradients.sunset}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[
styles.progressBarFill,
{ width: `${percentage * 100}%` },
]}
/>
</View>
</View>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 20,
marginBottom: 24,
},
card: {
borderRadius: 24,
padding: 20,
borderWidth: 1,
borderColor: "#fcd34d",
},
content: {
flexDirection: "row",
alignItems: "center",
marginBottom: 16,
},
iconContainer: {
marginRight: 16,
},
icon: {
width: 48,
height: 48,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
},
info: {
flex: 1,
},
title: {
fontSize: 16,
fontWeight: "700",
color: theme.colors.gray900,
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: theme.colors.gray600,
fontWeight: "500",
},
percentageContainer: {
backgroundColor: "#fff",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
percentage: {
fontSize: 14,
fontWeight: "700",
color: theme.colors.primary,
},
progressContainer: {
height: 8,
},
progressBarBg: {
height: 8,
backgroundColor: "rgba(255, 255, 255, 0.5)",
borderRadius: 4,
overflow: "hidden",
},
progressBarFill: {
height: "100%",
borderRadius: 4,
},
});
+129 -107
View File
@@ -1,123 +1,145 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { theme } from '../styles/theme';
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Dimensions,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import { theme } from "../styles/theme";
const { width } = Dimensions.get('window');
const { width } = Dimensions.get("window");
const ITEM_WIDTH = (width - 40 - 16) / 2; // (Screen width - padding - gap) / 2
interface QuickActionProps {
icon: keyof typeof Ionicons.glyphMap;
label: string;
gradient: readonly [string, string, ...string[]];
onPress?: () => void;
icon: keyof typeof Ionicons.glyphMap;
label: string;
gradient: readonly [string, string, ...string[]];
onPress?: () => void;
}
function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
return (
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={styles.itemContainer}>
<LinearGradient
colors={['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.6)']}
style={[styles.item, theme.shadows.subtle]}
>
<LinearGradient
colors={gradient}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon} size={24} color="#fff" />
</LinearGradient>
<Text style={styles.label}>{label}</Text>
<Ionicons name="chevron-forward" size={16} color={theme.colors.gray400} style={styles.arrow} />
</LinearGradient>
</TouchableOpacity>
);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={styles.itemContainer}
>
<LinearGradient
colors={["rgba(255, 255, 255, 0.8)", "rgba(255, 255, 255, 0.6)"]}
style={[styles.item, theme.shadows.subtle]}
>
<LinearGradient
colors={gradient}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon} size={24} color="#fff" />
</LinearGradient>
<Text style={styles.label}>{label}</Text>
<Ionicons
name="chevron-forward"
size={16}
color={theme.colors.gray400}
style={styles.arrow}
/>
</LinearGradient>
</TouchableOpacity>
);
}
interface QuickActionGridProps {
onTrackMealPress?: () => void;
onAddWaterPress?: () => void;
onScanFoodPress?: () => void;
onTrackMealPress?: () => void;
onAddWaterPress?: () => void;
onScanFoodPress?: () => void;
onLogWorkoutPress?: () => void;
}
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.grid}>
<QuickActionItem
icon="barbell"
label="Log Workout"
gradient={theme.gradients.primary}
/>
<QuickActionItem
icon="restaurant"
label="Track Meal"
gradient={theme.gradients.success}
onPress={onTrackMealPress}
/>
<QuickActionItem
icon="water"
label="Add Water"
gradient={theme.gradients.ocean}
onPress={onAddWaterPress}
/>
<QuickActionItem
icon="scan"
label="Scan Food"
gradient={theme.gradients.purple}
onPress={onScanFoodPress}
/>
</View>
</View>
);
export function QuickActionGrid({
onTrackMealPress,
onAddWaterPress,
onScanFoodPress,
onLogWorkoutPress,
}: QuickActionGridProps) {
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.grid}>
<QuickActionItem
icon="barbell"
label="Log Workout"
gradient={theme.gradients.primary}
onPress={onLogWorkoutPress}
/>
<QuickActionItem
icon="restaurant"
label="Track Meal"
gradient={theme.gradients.success}
onPress={onTrackMealPress}
/>
<QuickActionItem
icon="water"
label="Add Water"
gradient={theme.gradients.ocean}
onPress={onAddWaterPress}
/>
<QuickActionItem
icon="scan"
label="Scan Food"
gradient={theme.gradients.purple}
onPress={onScanFoodPress}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 20,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: theme.colors.gray900,
marginBottom: 16,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
},
itemContainer: {
width: ITEM_WIDTH,
},
item: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 20,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.6)',
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
label: {
fontSize: 14,
fontWeight: '600',
color: theme.colors.gray800,
flex: 1,
},
arrow: {
opacity: 0.5,
},
container: {
paddingHorizontal: 20,
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: "700",
color: theme.colors.gray900,
marginBottom: 16,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: 16,
},
itemContainer: {
width: ITEM_WIDTH,
},
item: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderRadius: 20,
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.6)",
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
label: {
fontSize: 14,
fontWeight: "600",
color: theme.colors.gray800,
flex: 1,
},
arrow: {
opacity: 0.5,
},
});
@@ -5,6 +5,7 @@ import { Ionicons } from "@expo/vector-icons";
import { theme } from "../styles/theme";
import { useStatistics } from "../contexts/StatisticsContext";
import type { WeeklyTrendData } from "../api/types";
import log from "../utils/logger";
export function WeeklyProgressWidget() {
const { statistics, loading, refetchStatistics } = useStatistics();
@@ -16,9 +17,24 @@ export function WeeklyProgressWidget() {
useEffect(() => {
if (statistics?.weeklyTrend) {
log.debug("WeeklyProgressWidget - Processing weekly trend", {
weeklyTrendLength: statistics.weeklyTrend.length,
weeklyTrend: statistics.weeklyTrend,
statisticsKeys: Object.keys(statistics),
});
// Get last 4 weeks for compact display
const last4Weeks = statistics.weeklyTrend.slice(-4);
setWeeklyData(last4Weeks);
log.debug("WeeklyProgressWidget - Set weekly data", {
last4Weeks,
});
} else {
log.debug("WeeklyProgressWidget - No weekly trend data", {
hasStatistics: !!statistics,
statistics,
});
}
}, [statistics]);