Merge branch 'develop'

This commit is contained in:
Arthur Belleville 2025-07-27 14:42:10 +02:00
commit b285c213c8
No known key found for this signature in database
36 changed files with 3687 additions and 1166 deletions

View file

@ -1,12 +1,18 @@
repos:
- repo: local
hooks:
- id: no-commit-to-main
name: Don't commit to main branch
entry: sh -c 'if [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ]; then echo "Direct commits to main branch are not allowed!"; exit 1; fi'
language: system
pass_filenames: false
always_run: true
- id: test-ui
name: Test Frontend
entry: just test-frontend
language: python
pass_filenames: false
files: \.ts*
files: ^ui/.*\.(ts|tsx|js|jsx)$
- id: typecheck
name: Typecheck Frontend
entry: just typecheck

View file

@ -275,3 +275,25 @@ tabloRouter.get("/members/:tablo_id", async (c) => {
})),
});
});
tabloRouter.post("/leave", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const { tablo_id } = await c.req.json();
const channel = streamServerClient.channel("messaging", tablo_id);
await channel.removeMembers([user.id]);
const { error } = await supabase
.from("tablo_access")
.update({ is_active: false })
.eq("tablo_id", tablo_id)
.eq("user_id", user.id);
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ message: "Tablo left successfully" });
});

View file

@ -23,10 +23,13 @@ update-types:
npx supabase gen types typescript --project-id "mhcafqvzbrrwvahpvvzd" --schema public > ui/src/types/database.types.ts && cp ui/src/types/database.types.ts api/src/database.types.ts && cp ui/src/types/database.types.ts xtablo-expo/lib/database.types.ts
expo-install-all:
cd xtablo-expo && npx expo install -- --legacy-peer-deps
cd xtablo-expo && npx expo install
expo-install +package:
cd xtablo-expo && npx expo install {{package}} -- --legacy-peer-deps
cd xtablo-expo && npx expo install {{package}}
expo-start *args:
cd xtablo-expo && npx expo start {{args}}
build-expo:
cd xtablo-expo && eas build --platform all

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTablosList } from "@ui/hooks/tablos";
import { useEventsByTablo, useDeleteEvent } from "@ui/hooks/events";
import {
@ -9,6 +9,7 @@ import {
SelectListItem,
} from "@ui/ui-library/select";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { generateICSFromEvents, downloadICSFile } from "@ui/utils/helpers";
type ViewType = "month" | "week" | "day";
@ -31,6 +32,65 @@ export const PlanningPage = () => {
const deleteEvent = useDeleteEvent();
// Keyboard shortcuts for view switching
useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => {
// Only trigger shortcuts when not typing in input fields
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement
) {
return;
}
// Prevent default behavior and switch views
switch (event.key.toLowerCase()) {
case "m":
case "1":
event.preventDefault();
setCurrentView("month");
break;
case "w":
case "s":
case "2":
event.preventDefault();
setCurrentView("week");
break;
case "d":
case "j":
case "3":
event.preventDefault();
setCurrentView("day");
break;
}
};
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, []);
const handleExportICS = () => {
if (!tabloEvents || tabloEvents.length === 0) {
return;
}
const calendarName =
selectedTabloId === "all"
? "Planning - Tous les tablos"
: tablos?.find((t) => t.id === selectedTabloId)?.name || "Planning";
const icsContent = generateICSFromEvents(tabloEvents, calendarName);
const filename =
selectedTabloId === "all"
? "planning-tous-tablos.ics"
: `planning-${
tablos?.find((t) => t.id === selectedTabloId)?.name || "tablo"
}.ics`;
downloadICSFile(icsContent, filename);
};
const navigateToCreateEvent = (date: Date, tablo_id: string) => {
if (tablo_id === "all") {
navigate({
@ -732,11 +792,45 @@ export const PlanningPage = () => {
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleExportICS}
disabled={!tabloEvents || tabloEvents.length === 0}
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
title="Exporter en format ICS"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
<span>Exporter</span>
</button>
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
{(["month", "week", "day"] as ViewType[]).map((view) => (
<button
key={view}
onClick={() => setCurrentView(view)}
title={`${
view === "month"
? "Mois"
: view === "week"
? "Semaine"
: "Jour"
} (${
view === "month"
? "M ou 1"
: view === "week"
? "W ou 2"
: "D ou 3"
})`}
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
currentView === view
? "bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm"
@ -748,6 +842,13 @@ export const PlanningPage = () => {
: view === "week"
? "Semaine"
: "Jour"}
<span className="ml-1 text-xs opacity-60">
{view === "month"
? "(M)"
: view === "week"
? "(S)"
: "(J)"}
</span>
</button>
))}
</div>

View file

@ -1,4 +1,5 @@
import { Database } from "@ui/types/database.types";
import { EventAndTablo } from "@ui/types/events.types";
import jsPDF from "jspdf";
export const calculateTax = (amount: number, taxRate: number) => {
@ -79,3 +80,89 @@ export const exportDevisToPdf = (devis: Devis) => {
export const isStaging = import.meta.env.MODE === "staging";
export const isProd = import.meta.env.MODE === "production";
// ICS Export functionality
export const generateICSFromEvents = (
events: EventAndTablo[],
calendarName: string = "Planning"
) => {
const formatDate = (date: string, time: string) => {
// Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC
const dateTime = new Date(`${date}T${time}`);
return dateTime.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
};
const escapeICSText = (text: string) => {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n")
.replace(/\r/g, "");
};
const generateUID = (eventId: string) => {
return `${eventId}@xtablo.com`;
};
let icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//XTablo//Planning Export//EN",
`X-WR-CALNAME:${escapeICSText(calendarName)}`,
"X-WR-TIMEZONE:Europe/Paris",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
].join("\r\n");
events.forEach((event) => {
if (!event.start_date || !event.start_time || !event.title) return;
console.log("event", event);
const startDateTime = formatDate(event.start_date, event.start_time);
const endDateTime = event.end_time
? formatDate(event.start_date, event.end_time)
: formatDate(event.start_date, event.start_time); // Default to start time if no end time
const eventLines = [
"",
"BEGIN:VEVENT",
`UID:${generateUID(event.event_id)}`,
`DTSTART:${startDateTime}`,
`DTEND:${endDateTime}`,
`SUMMARY:${escapeICSText(event.title)}`,
`DESCRIPTION:${escapeICSText(
`Tablo: ${event.tablo_name}\n${event.description || ""}`
)}`,
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`LAST-MODIFIED:${
new Date().toISOString().replace(/[-:]/g, "").split(".")[0]
}Z`,
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"END:VEVENT",
].filter((line) => line !== ""); // Remove empty lines
icsContent += "\r\n" + eventLines.join("\r\n");
});
icsContent += "\r\n" + "END:VCALENDAR";
return icsContent;
};
export const downloadICSFile = (
icsContent: string,
filename: string = "planning.ics"
) => {
console.log("icsContent", icsContent);
const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
};

View file

@ -1,7 +1,7 @@
{
"expo": {
"name": "xtablo",
"slug": "xtablo",
"slug": "xtablo-expo",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",

View file

@ -8,9 +8,8 @@ import { useColorScheme } from "@/hooks/useColorScheme";
import {
MessageCircle,
Calendar,
List,
Home,
Grid3X3,
Settings,
} from "lucide-react-native";
export default function TabLayout() {
@ -76,6 +75,7 @@ export default function TabLayout() {
/>
),
tabBarLabel: "Discussions",
// tabBarBadge: 10, // TODO: Add badge for notifications
}}
/>
<Tabs.Screen
@ -108,6 +108,21 @@ export default function TabLayout() {
tabBarBadge: undefined, // You can set this to a number for notifications
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Paramètres",
tabBarIcon: ({ focused, color, size }) => (
<Settings
size={focused ? 24 : 22}
color={color}
strokeWidth={focused ? 2.2 : 2}
/>
),
tabBarLabel: "Paramètres",
tabBarBadge: undefined,
}}
/>
</Tabs>
);
}

View file

@ -0,0 +1,865 @@
import { router } from "expo-router";
import {
ChannelList,
ChannelPreviewMessenger,
ChannelPreviewMessengerProps,
DefaultStreamChatGenerics,
} from "stream-chat-expo";
import { ChannelSort, Channel } from "stream-chat";
import { useUser } from "@/providers/UserProvider";
import { View, Text, StyleSheet, StatusBar } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Search } from "lucide-react-native";
import React, { useMemo } from "react";
import { useSharedValue } from "react-native-reanimated";
import { useTablosList } from "@/hooks/tablos";
import { ColorMap } from "@/constants/colors";
import { UserTablo } from "@/types/tablos.types";
import { SwipeableChannelPreview } from "@/components/SwipeableChannelPreview";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
// Custom Avatar Component for Channel List
// Custom Title Component for bigger channel names
const CustomChannelTitle = ({ channel }: { channel: Channel }) => {
const channelName = channel?.data?.name || channel?.id || "Channel";
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
return (
<Text
style={[styles.customChannelTitle, { color: textColor }]}
numberOfLines={1}
>
{channelName}
</Text>
);
};
// Custom Preview Component with Swipe to Archive
// Swipe left on any channel to reveal the archive button
const CustomChannelPreview = (
props: ChannelPreviewMessengerProps<DefaultStreamChatGenerics>
) => {
return (
<SwipeableChannelPreview channel={props.channel}>
<ChannelPreviewMessenger {...props} />
</SwipeableChannelPreview>
);
};
export default function HomeScreen() {
const user = useUser();
const { data: tablos, isLoading } = useTablosList();
const colorScheme = useColorScheme();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
const subtitleColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
const cardBackgroundColor = useThemeColor(
{ light: "#ffffff", dark: "#1f2937" },
"background"
);
const borderColor = colorScheme === "dark" ? "#374151" : "#e5e7eb";
const placeholderColor = useThemeColor(
{ light: "#9ca3af", dark: "#6b7280" },
"text"
);
const iconColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
const emptyTextColor = useThemeColor(
{ light: "#4b5563", dark: "#d1d5db" },
"text"
);
const emptyIconColor = useThemeColor(
{ light: "#d1d5db", dark: "#6b7280" },
"text"
);
// Theme-aware gradient colors
const gradientColors: [string, string, string] =
colorScheme === "dark"
? ["#1f2937", "#374151", "#4b5563"]
: ["#1e3a8a", "#3b82f6", "#60a5fa"];
const loadingColors = {
container: colorScheme === "dark" ? "#1f2937" : "#f8fafc",
item: colorScheme === "dark" ? "#374151" : "#e5e7eb",
itemSecondary: colorScheme === "dark" ? "#4b5563" : "#f3f4f6",
};
// const animations = useSharedValue<Record<string, number>>({});
// Search animation state
// const [isSearchVisible, setIsSearchVisible] = useState(false);
// const [searchQuery, setSearchQuery] = useState("");
// const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const searchAnimation = useSharedValue(0);
// const searchInputRef = useRef<TextInput>(null);
// // Debounce search query for better performance
// useEffect(() => {
// const timer = setTimeout(() => {
// setDebouncedSearchQuery(searchQuery);
// }, 300);
// return () => clearTimeout(timer);
// }, [searchQuery]);
// Create filters based on search query
const filters = useMemo(() => {
const baseFilters = {
members: { $in: [user.id] },
type: "messaging",
};
// Add name filter when searching
// if (debouncedSearchQuery.trim()) {
// return {
// ...baseFilters,
// name: { $autocomplete: debouncedSearchQuery.trim() },
// };
// }
return baseFilters;
}, [user.id]);
const sort: ChannelSort = { last_updated: -1 };
const options = {
state: true,
watch: true,
limit: 20,
};
const CustomChannelAvatar = ({
channel,
tablos,
}: {
channel: Channel;
tablos: UserTablo[];
}) => {
const tabloId = channel?.id || "";
const tablo = tablos?.find((t) => t.id === tabloId);
const tabloColor = tablo?.color || "bg-blue-500";
const tabloName = tablo?.name || channel?.data?.name || "Tablo";
// Get members info
const members = channel?.state?.members || {};
const memberCount = Object.keys(members).length;
// Generate initials from tablo name
const getInitials = (name: string) => {
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
};
// // Create gradient colors based on tablo color
const getTabloGradientColors = (colorKey: string): [string, string] => {
const baseColor = ColorMap[colorKey] || ColorMap["bg-blue-500"];
// Create a lighter version for gradient effect
const lightenColor = (hex: string, percent: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
const B = Math.min(255, Math.max(0, (num & 0x0000ff) + amt));
return (
"#" + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)
);
};
// Create a darker version for gradient effect
const darkenColor = (hex: string, percent: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, Math.max(0, (num >> 16) - amt));
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) - amt));
const B = Math.min(255, Math.max(0, (num & 0x0000ff) - amt));
return (
"#" + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1)
);
};
const lightColor = lightenColor(baseColor, 15);
const darkColor = darkenColor(baseColor, 10);
return [lightColor, darkColor];
};
const initials = getInitials(tabloName);
const gradientColors = getTabloGradientColors(tabloColor);
return (
<View style={styles.avatarContainer}>
<LinearGradient
colors={gradientColors}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.avatarInitials}>{initials}</Text>
{/* Member count indicator for group channels */}
{memberCount > 2 && (
<View style={styles.memberCountBadge}>
<Text style={styles.memberCountText}>{memberCount}</Text>
</View>
)}
</LinearGradient>
{/* Decorative ring */}
<View
style={[
styles.avatarRing,
{ borderColor: `${ColorMap[tabloColor]}30` },
]}
/>
{/* Status indicator (online/active) */}
<View
style={[styles.statusIndicator, { borderColor: cardBackgroundColor }]}
/>
</View>
);
};
// Toggle search animation
// const toggleSearch = () => {
// const toValue = isSearchVisible ? 0 : 1;
// searchAnimation.value = withTiming(toValue, {
// duration: 300,
// });
// if (toValue === 1) {
// // Focus input when animation starts
// setTimeout(() => searchInputRef.current?.focus(), 150);
// } else {
// // Clear search when hiding
// setSearchQuery("");
// setDebouncedSearchQuery("");
// }
// setIsSearchVisible(!isSearchVisible);
// };
// Close search when keyboard is dismissed
// useEffect(() => {
// const keyboardDidHideListener = Keyboard.addListener(
// "keyboardDidHide",
// () => {
// if (isSearchVisible && searchQuery === "") {
// toggleSearch();
// }
// }
// );
// return () => {
// keyboardDidHideListener?.remove();
// };
// }, [isSearchVisible, searchQuery]);
// Animated styles using react-native-reanimated
// const animatedSearchStyle = useAnimatedStyle(() => {
// const height = interpolate(
// searchAnimation.value,
// [0, 1],
// [0, 80],
// Extrapolate.CLAMP
// );
// const opacity = interpolate(
// searchAnimation.value,
// [0, 0.5, 1],
// [0, 0, 1],
// Extrapolate.CLAMP
// );
// return {
// height,
// opacity,
// };
// });
// Simple search header component - no memoization
// const SearchHeader = () => (
// <Animated.View style={[styles.searchHeaderContainer, animatedSearchStyle]}>
// <View style={styles.searchInputContainer}>
// <Search size={18} color="#6b7280" style={styles.searchIcon} />
// <TextInput
// ref={searchInputRef}
// style={styles.searchInput}
// placeholder="Rechercher une conversation..."
// placeholderTextColor="#9ca3af"
// value={searchQuery}
// onChangeText={setSearchQuery}
// returnKeyType="search"
// autoCapitalize="none"
// autoCorrect={false}
// onSubmitEditing={() => {
// console.log("Searching for:", searchQuery);
// Keyboard.dismiss();
// }}
// />
// {searchQuery.length > 0 && (
// <TouchableOpacity
// onPress={() => {
// setSearchQuery("");
// setDebouncedSearchQuery("");
// searchInputRef.current?.focus();
// }}
// style={styles.clearButton}
// >
// <X size={16} color="#6b7280" />
// </TouchableOpacity>
// )}
// </View>
// {/* Search Results Info */}
// {debouncedSearchQuery.trim() && (
// <View style={styles.searchInfoContainer}>
// <Text style={styles.searchInfoText}>
// Recherche: "{debouncedSearchQuery}"
// </Text>
// </View>
// )}
// </Animated.View>
// );
if (isLoading) {
return (
<View style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === "dark" ? "light-content" : "light-content"}
backgroundColor={gradientColors[0]}
/>
{/* Loading Header */}
<LinearGradient
colors={gradientColors}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<View style={styles.headerBottom}>
<View style={styles.titleContainer}>
<Text style={styles.headerTitle}>Discussions</Text>
<Text style={styles.headerSubtitle}>
Chargement de vos conversations...
</Text>
</View>
</View>
</View>
{/* Decorative Elements */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</LinearGradient>
{/* Loading Content */}
<View
style={[
styles.loadingContentContainer,
{ backgroundColor: loadingColors.container },
]}
>
{/* Loading Skeleton Items */}
{[1, 2, 3, 4, 5].map((item) => (
<View key={item} style={styles.loadingItem}>
<View
style={[
styles.loadingAvatar,
{ backgroundColor: loadingColors.item },
]}
/>
<View style={styles.loadingTextContainer}>
<View
style={[
styles.loadingTitle,
{ backgroundColor: loadingColors.item },
]}
/>
<View
style={[
styles.loadingSubtitle,
{ backgroundColor: loadingColors.itemSecondary },
]}
/>
</View>
</View>
))}
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === "dark" ? "light-content" : "light-content"}
backgroundColor={gradientColors[0]}
/>
{/* Beautiful Header */}
<LinearGradient
colors={gradientColors}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<View style={styles.headerBottom}>
<View style={styles.titleContainer}>
<Text style={styles.headerTitle}>Discussions</Text>
<Text style={styles.headerSubtitle}>
{/* {debouncedSearchQuery.trim()
? `Recherche: "${debouncedSearchQuery}"`
: "Gérez les conversations de vos tablos"} */}
Gérez les conversations de vos tablos
</Text>
</View>
{/* <TouchableOpacity
style={styles.searchButton}
onPress={toggleSearch}
>
{isSearchVisible ? (
<X size={20} color="#3b82f6" />
) : (
<Search size={20} color="#3b82f6" />
)}
</TouchableOpacity> */}
</View>
</View>
{/* Decorative Elements */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</LinearGradient>
{/* Channel List with animated search */}
<View style={[styles.channelListContainer, { backgroundColor }]}>
{/* <SearchHeader /> */}
<ChannelList
filters={filters}
onSelect={(channel) => {
// Close search when selecting a channel
// if (isSearchVisible) {
// toggleSearch();
// }
router.push(`/channel/${channel.cid}`);
}}
sort={sort}
options={options}
Preview={(props) => (
<CustomChannelPreview
{...props}
// animations={animations}
// cancelOtherAnimations={(id) => {
// animations.value = {
// [id]: 0,
// };
// }}
/>
)}
PreviewAvatar={(props) => (
<CustomChannelAvatar
channel={props.channel}
tablos={tablos || []}
/>
)}
PreviewTitle={CustomChannelTitle}
// ListHeaderComponent={SearchHeader}
// Show loading state during search
LoadingIndicator={() => (
<View style={styles.searchLoadingContainer}>
<Text
style={[styles.searchLoadingText, { color: subtitleColor }]}
>
{/* {debouncedSearchQuery
? "Recherche en cours..."
: "Chargement..."} */}
Chargement...
</Text>
</View>
)}
// Show empty state when no results
EmptyStateIndicator={() => (
<View style={styles.emptySearchContainer}>
<Search size={48} color={emptyIconColor} />
<Text
style={[styles.emptySearchTitle, { color: emptyTextColor }]}
>
{/* {debouncedSearchQuery
? "Aucun résultat"
: "Aucune conversation"} */}
Aucune conversation
</Text>
<Text
style={[styles.emptySearchMessage, { color: subtitleColor }]}
>
{/* {debouncedSearchQuery
? `Aucune conversation trouvée pour "${debouncedSearchQuery}"`
: "Vous n'avez pas encore de conversations"} */}
Vous n'avez pas encore de conversations
</Text>
</View>
)}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
// backgroundColor is set dynamically
},
headerGradient: {
paddingTop: 50,
paddingBottom: 25,
paddingHorizontal: 20,
position: "relative",
overflow: "hidden",
},
headerContent: {
zIndex: 10,
},
headerTop: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
userInfo: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
avatar: {
marginRight: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
borderWidth: 3,
borderColor: "rgba(255, 255, 255, 0.3)",
},
greetingContainer: {
flex: 1,
},
greeting: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.9)",
fontWeight: "500",
},
userName: {
fontSize: 20,
color: "white",
fontWeight: "bold",
marginTop: 2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: 15,
},
actionButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255, 255, 255, 0.2)",
justifyContent: "center",
alignItems: "center",
position: "relative",
},
notificationBadge: {
position: "absolute",
top: -2,
right: -2,
backgroundColor: "#ef4444",
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "white",
},
badgeText: {
color: "white",
fontSize: 12,
fontWeight: "bold",
},
headerBottom: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
},
titleContainer: {
flex: 1,
},
headerTitle: {
fontSize: 28,
color: "white",
fontWeight: "bold",
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.8)",
fontWeight: "400",
},
searchButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "white",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
},
decorativeCircle1: {
position: "absolute",
top: -50,
right: -30,
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
decorativeCircle2: {
position: "absolute",
bottom: -20,
left: -20,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
channelListContainer: {
flex: 1,
// backgroundColor is set dynamically
marginTop: -10,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
paddingTop: 10,
},
// Custom Avatar Styles
avatarContainer: {
position: "relative",
width: 56,
height: 56,
marginRight: 12,
},
avatarGradient: {
width: 56,
height: 56,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 6,
position: "relative",
},
avatarInitials: {
fontSize: 18,
fontWeight: "bold",
color: "white",
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
avatarRing: {
position: "absolute",
top: -2,
left: -2,
width: 60,
height: 60,
borderRadius: 18,
borderWidth: 2,
borderColor: "rgba(59, 130, 246, 0.2)",
backgroundColor: "transparent",
},
statusIndicator: {
position: "absolute",
bottom: 2,
right: 2,
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: "#10b981",
borderWidth: 3,
// borderColor is set dynamically to match background
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
memberCountBadge: {
position: "absolute",
top: -4,
right: -4,
backgroundColor: "#3b82f6",
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "white",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
memberCountText: {
color: "white",
fontSize: 11,
fontWeight: "bold",
},
// Custom Channel Title Styles
customChannelTitle: {
fontSize: 18,
fontWeight: "bold",
// color is set dynamically
},
// Search Header Styles
searchHeaderContainer: {
// backgroundColor is set dynamically
borderBottomWidth: 1,
// borderBottomColor is set dynamically
overflow: "hidden",
paddingHorizontal: 20,
},
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
// backgroundColor is set dynamically
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
borderWidth: 1,
// borderColor is set dynamically
marginTop: 15,
},
searchIcon: {
marginRight: 10,
},
searchInput: {
flex: 1,
fontSize: 16,
// color is set dynamically
paddingVertical: 0,
fontWeight: "500",
},
clearButton: {
padding: 5,
},
searchInfoContainer: {
paddingTop: 10,
paddingBottom: 15,
},
searchInfoText: {
fontSize: 14,
// color is set dynamically
},
searchLoadingContainer: {
paddingVertical: 20,
alignItems: "center",
},
searchLoadingText: {
fontSize: 16,
// color is set dynamically
},
emptySearchContainer: {
paddingVertical: 40,
alignItems: "center",
},
emptySearchTitle: {
fontSize: 20,
// color is set dynamically
marginTop: 10,
},
emptySearchMessage: {
fontSize: 16,
// color is set dynamically
marginTop: 5,
textAlign: "center",
paddingHorizontal: 20,
},
// Loading Skeleton Styles
loadingContentContainer: {
flex: 1,
// backgroundColor is set dynamically
marginTop: -10,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
paddingTop: 20,
paddingHorizontal: 20,
},
loadingItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
marginBottom: 8,
},
loadingAvatar: {
width: 56,
height: 56,
borderRadius: 16,
// backgroundColor is set dynamically
marginRight: 12,
},
loadingTextContainer: {
flex: 1,
},
loadingTitle: {
height: 20,
// backgroundColor is set dynamically
borderRadius: 4,
marginBottom: 8,
width: "70%",
},
loadingSubtitle: {
height: 16,
// backgroundColor is set dynamically
borderRadius: 4,
width: "50%",
},
});

View file

@ -31,6 +31,8 @@ import { EventAndTablo, EventInsert } from "@/types/events.types";
import { useTablosList } from "@/hooks/tablos";
import { UserTablo } from "@/types/tablos.types";
import { ColorMap } from "@/constants/colors";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
type ViewMode = "month" | "week";
@ -74,6 +76,33 @@ export default function PlanningScreen() {
const { data: events } = useEventsByTablo(null);
const { data: tablos } = useTablosList();
const createEvent = useCreateEvent();
const colorScheme = useColorScheme();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const cardBackgroundColor = useThemeColor(
{ light: "#ffffff", dark: "#1f2937" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
const subtitleColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
const borderColor = colorScheme === "dark" ? "#374151" : "#e5e7eb";
const modalOverlayColor =
colorScheme === "dark" ? "rgba(0, 0, 0, 0.8)" : "rgba(0, 0, 0, 0.5)";
const inactiveDayColor = colorScheme === "dark" ? "#4b5563" : "#d1d5db";
const emptyIconColor = colorScheme === "dark" ? "#6b7280" : "#d1d5db";
const viewModeToggleColor = colorScheme === "dark" ? "#374151" : "#f3f4f6";
const weekHeaderBorderColor = colorScheme === "dark" ? "#374151" : "#e5e7eb";
const selectedOptionBgColor = colorScheme === "dark" ? "#1e3a8a" : "#eff6ff";
const filteredEvents: EventAndTablo[] =
(selectedTablo === null
@ -166,7 +195,6 @@ export default function PlanningScreen() {
Alert.alert("Erreur", "Veuillez sélectionner un tablo");
return;
}
console.log({ newEvent });
createEvent({
...newEvent,
start_date: newEvent.start_date,
@ -177,7 +205,7 @@ export default function PlanningScreen() {
const renderTabloOption = ({ item }: { item: UserTablo }) => (
<TouchableOpacity
style={styles.tabloOption}
style={[styles.tabloOption, { borderBottomColor: borderColor }]}
onPress={() => selectTablo(item)}
>
<View style={styles.tabloOptionLeft}>
@ -190,7 +218,9 @@ export default function PlanningScreen() {
]}
/>
<View style={styles.tabloOptionContent}>
<Text style={styles.tabloOptionName}>{item.name}</Text>
<Text style={[styles.tabloOptionName, { color: textColor }]}>
{item.name}
</Text>
</View>
</View>
{selectedTablo?.id === item.id && <Check size={20} color="#3b82f6" />}
@ -198,22 +228,31 @@ export default function PlanningScreen() {
);
const renderEvent = ({ item }: { item: EventAndTablo }) => (
<TouchableOpacity style={styles.eventCard}>
<TouchableOpacity
style={[
styles.eventCard,
{ backgroundColor: cardBackgroundColor, borderColor },
]}
>
<View
style={[styles.eventColorBar, { backgroundColor: item.tablo_color }]}
/>
<View style={styles.eventContent}>
<Text style={styles.eventTitle}>{item.title}</Text>
<Text style={[styles.eventTitle, { color: textColor }]}>
{item.title}
</Text>
<View style={styles.eventDetails}>
<View style={styles.eventDetailItem}>
<Clock size={14} color="#6b7280" />
<Text style={styles.eventDetailText}>
<Clock size={14} color={subtitleColor} />
<Text style={[styles.eventDetailText, { color: subtitleColor }]}>
{item.start_time?.substring(0, 5)}
</Text>
</View>
<View style={styles.eventDetailItem}>
<MapPin size={14} color="#6b7280" />
<Text style={styles.eventDetailText}>{item.tablo_name}</Text>
<MapPin size={14} color={subtitleColor} />
<Text style={[styles.eventDetailText, { color: subtitleColor }]}>
{item.tablo_name}
</Text>
</View>
</View>
</View>
@ -234,7 +273,8 @@ export default function PlanningScreen() {
<Text
style={[
styles.dayText,
!isCurrentMonth(date) && styles.inactiveDayText,
{ color: textColor },
!isCurrentMonth(date) && { color: inactiveDayColor },
isSelected(date) && styles.selectedDayText,
isToday(date) && !isSelected(date) && styles.todayDayText,
]}
@ -253,6 +293,7 @@ export default function PlanningScreen() {
<TouchableOpacity
style={[
styles.weekDayHeader,
{ backgroundColor: cardBackgroundColor },
isToday(date) && styles.todayWeekDay,
isSelected(date) && styles.selectedWeekDay,
isToday(date) && isSelected(date) && styles.todaySelectedWeekDay,
@ -262,6 +303,7 @@ export default function PlanningScreen() {
<Text
style={[
styles.weekDayName,
{ color: subtitleColor },
isSelected(date) && { color: "white" },
isToday(date) && isSelected(date) && { color: "white" },
]}
@ -271,6 +313,7 @@ export default function PlanningScreen() {
<Text
style={[
styles.weekDayNumber,
{ color: textColor },
isSelected(date) && styles.selectedWeekDayText,
isToday(date) && !isSelected(date) && styles.todayWeekDayText,
isToday(date) &&
@ -301,8 +344,15 @@ export default function PlanningScreen() {
/>
))}
{dayEvents.length > 6 && (
<View style={styles.weekEventMoreCircle}>
<Text style={styles.weekEventMoreText}>
<View
style={[
styles.weekEventMoreCircle,
{ backgroundColor: borderColor },
]}
>
<Text
style={[styles.weekEventMoreText, { color: subtitleColor }]}
>
+{dayEvents.length - 6}
</Text>
</View>
@ -319,14 +369,16 @@ export default function PlanningScreen() {
return (
<View style={styles.selectedDayEventsContainer}>
<View style={styles.selectedDayHeader}>
<Text style={styles.selectedDayTitle}>
<Text style={[styles.selectedDayTitle, { color: textColor }]}>
{selectedDate.toLocaleDateString("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
})}
</Text>
<Text style={styles.selectedDayEventsCount}>
<Text
style={[styles.selectedDayEventsCount, { color: subtitleColor }]}
>
{selectedDayEvents.length} événement
{selectedDayEvents.length > 1 ? "s" : ""}
</Text>
@ -343,9 +395,11 @@ export default function PlanningScreen() {
/>
) : (
<View style={styles.selectedDayEmptyState}>
<CalendarIcon size={40} color="#d1d5db" />
<Text style={styles.emptyStateTitle}>Aucun événement</Text>
<Text style={styles.emptyStateText}>
<CalendarIcon size={40} color={emptyIconColor} />
<Text style={[styles.emptyStateTitle, { color: textColor }]}>
Aucun événement
</Text>
<Text style={[styles.emptyStateText, { color: subtitleColor }]}>
Vous n'avez aucun événement prévu pour cette date.
</Text>
</View>
@ -359,12 +413,17 @@ export default function PlanningScreen() {
const todayEvents = getEventsForDate(selectedDate);
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<ScrollView
style={[styles.container, { backgroundColor }]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.headerTitle}>Planning</Text>
<Text style={styles.headerSubtitle}>
<Text style={[styles.headerTitle, { color: textColor }]}>
Planning
</Text>
<Text style={[styles.headerSubtitle, { color: subtitleColor }]}>
{viewMode === "month"
? months[currentMonth.getMonth()] +
" " +
@ -387,7 +446,12 @@ export default function PlanningScreen() {
{/* View Mode Toggle */}
<View style={styles.viewModeContainer}>
<View style={styles.viewModeToggle}>
<View
style={[
styles.viewModeToggle,
{ backgroundColor: viewModeToggleColor },
]}
>
<TouchableOpacity
style={[
styles.viewModeButton,
@ -397,11 +461,12 @@ export default function PlanningScreen() {
>
<Grid3x3
size={18}
color={viewMode === "month" ? "white" : "#6b7280"}
color={viewMode === "month" ? "white" : subtitleColor}
/>
<Text
style={[
styles.viewModeText,
{ color: viewMode === "month" ? "white" : subtitleColor },
viewMode === "month" && styles.activeViewModeText,
]}
>
@ -417,11 +482,12 @@ export default function PlanningScreen() {
>
<Rows3
size={18}
color={viewMode === "week" ? "white" : "#6b7280"}
color={viewMode === "week" ? "white" : subtitleColor}
/>
<Text
style={[
styles.viewModeText,
{ color: viewMode === "week" ? "white" : subtitleColor },
viewMode === "week" && styles.activeViewModeText,
]}
>
@ -434,35 +500,48 @@ export default function PlanningScreen() {
{/* Tablo Selector */}
<View style={styles.tabloSelectorContainer}>
<TouchableOpacity
style={styles.tabloSelector}
style={[
styles.tabloSelector,
{ backgroundColor: cardBackgroundColor, borderColor },
]}
onPress={() => setShowTabloSelector(true)}
>
<View style={styles.tabloSelectorLeft}>
<Table size={20} color="#6b7280" />
<Table size={20} color={subtitleColor} />
<View style={styles.tabloSelectorContent}>
<Text style={styles.tabloSelectorLabel}>Tablo actuel</Text>
<Text
style={[styles.tabloSelectorLabel, { color: subtitleColor }]}
>
Tablo actuel
</Text>
<View style={styles.tabloSelectorRow}>
<View
style={[
styles.tabloColorDot,
{
backgroundColor:
ColorMap[selectedTablo?.color ?? "bg-gray-500"],
backgroundColor: selectedTablo?.color
? ColorMap[selectedTablo.color]
: subtitleColor,
},
]}
/>
<Text style={styles.tabloSelectorName}>
<Text style={[styles.tabloSelectorName, { color: textColor }]}>
{selectedTablo?.name ?? "Tous les tablos"}
</Text>
</View>
</View>
</View>
<ChevronDown size={20} color="#6b7280" />
<ChevronDown size={20} color={subtitleColor} />
</TouchableOpacity>
</View>
{/* Calendar/Week View */}
<Card containerStyle={styles.calendarCard}>
<Card
containerStyle={[
styles.calendarCard,
{ backgroundColor: cardBackgroundColor },
]}
>
<View style={styles.calendarHeader}>
<TouchableOpacity
onPress={() =>
@ -473,7 +552,7 @@ export default function PlanningScreen() {
>
<ChevronLeft size={24} color="#3b82f6" />
</TouchableOpacity>
<Text style={styles.calendarTitle}>
<Text style={[styles.calendarTitle, { color: textColor }]}>
{viewMode === "month"
? `${
months[currentMonth.getMonth()]
@ -495,9 +574,17 @@ export default function PlanningScreen() {
{viewMode === "month" ? (
<>
<View style={styles.weekHeader}>
<View
style={[
styles.weekHeader,
{ borderBottomColor: weekHeaderBorderColor },
]}
>
{daysOfWeek.map((day, i) => (
<Text key={`${day}-${i}`} style={styles.weekDay}>
<Text
key={`${day}-${i}`}
style={[styles.weekDay, { color: subtitleColor }]}
>
{day}
</Text>
))}
@ -524,7 +611,7 @@ export default function PlanningScreen() {
<View style={styles.eventsSection}>
<View style={styles.eventsSectionHeader}>
<CalendarIcon size={20} color="#3b82f6" />
<Text style={styles.eventsSectionTitle}>
<Text style={[styles.eventsSectionTitle, { color: textColor }]}>
Événements du jour ({todayEvents.length})
</Text>
</View>
@ -539,9 +626,11 @@ export default function PlanningScreen() {
/>
) : (
<View style={styles.selectedDayEmptyState}>
<CalendarIcon size={40} color="#d1d5db" />
<Text style={styles.emptyStateTitle}>Aucun événement</Text>
<Text style={styles.emptyStateText}>
<CalendarIcon size={40} color={emptyIconColor} />
<Text style={[styles.emptyStateTitle, { color: textColor }]}>
Aucun événement
</Text>
<Text style={[styles.emptyStateText, { color: subtitleColor }]}>
Vous n'avez aucun événement prévu pour cette date.
</Text>
</View>
@ -557,13 +646,22 @@ export default function PlanningScreen() {
onRequestClose={() => setShowTabloSelector(false)}
>
<TouchableOpacity
style={styles.modalOverlay}
style={[styles.modalOverlay, { backgroundColor: modalOverlayColor }]}
activeOpacity={1}
onPress={() => setShowTabloSelector(false)}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Choisir un tablo</Text>
<View
style={[
styles.modalContent,
{ backgroundColor: cardBackgroundColor, borderColor },
]}
>
<View
style={[styles.modalHeader, { borderBottomColor: borderColor }]}
>
<Text style={[styles.modalTitle, { color: textColor }]}>
Choisir un tablo
</Text>
</View>
<FlatList
data={tablos ?? []}
@ -572,7 +670,10 @@ export default function PlanningScreen() {
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<TouchableOpacity
style={styles.tabloOption}
style={[
styles.tabloOption,
{ borderBottomColor: borderColor },
]}
onPress={() => selectTablo(null)}
>
<View style={styles.tabloOptionLeft}>
@ -580,12 +681,14 @@ export default function PlanningScreen() {
style={[
styles.tabloColorDot,
{
backgroundColor: "#6b7280",
backgroundColor: subtitleColor,
},
]}
/>
<View style={styles.tabloOptionContent}>
<Text style={styles.tabloOptionName}>
<Text
style={[styles.tabloOptionName, { color: textColor }]}
>
Tous les tablos
</Text>
</View>
@ -606,15 +709,34 @@ export default function PlanningScreen() {
animationType="slide"
onRequestClose={() => setShowCreateEventModal(false)}
>
<View style={styles.createEventModalOverlay}>
<View style={styles.createEventModalContent}>
<View style={styles.createEventModalHeader}>
<Text style={styles.createEventModalTitle}>Nouvel événement</Text>
<View
style={[
styles.createEventModalOverlay,
{ backgroundColor: modalOverlayColor },
]}
>
<View
style={[
styles.createEventModalContent,
{ backgroundColor: cardBackgroundColor },
]}
>
<View
style={[
styles.createEventModalHeader,
{ borderBottomColor: borderColor },
]}
>
<Text
style={[styles.createEventModalTitle, { color: textColor }]}
>
Nouvel événement
</Text>
<TouchableOpacity
onPress={() => setShowCreateEventModal(false)}
style={styles.createEventCloseButton}
>
<X size={24} color="#6b7280" />
<X size={24} color={subtitleColor} />
</TouchableOpacity>
</View>
@ -624,57 +746,92 @@ export default function PlanningScreen() {
>
{/* Title Field */}
<View style={styles.formField}>
<Text style={styles.formLabel}>Titre *</Text>
<Text style={[styles.formLabel, { color: subtitleColor }]}>
Titre *
</Text>
<TextInput
style={styles.formInput}
style={[
styles.formInput,
{
backgroundColor: cardBackgroundColor,
borderColor,
color: textColor,
},
]}
value={newEvent.title}
onChangeText={(text) =>
setNewEvent({ ...newEvent, title: text })
}
placeholder="Titre de l'événement"
placeholderTextColor="#9ca3af"
placeholderTextColor={subtitleColor}
/>
</View>
{/* Date Field */}
<View style={styles.formField}>
<Text style={styles.formLabel}>Date</Text>
<Text style={[styles.formLabel, { color: subtitleColor }]}>
Date
</Text>
<TextInput
style={styles.formInput}
style={[
styles.formInput,
{
backgroundColor: cardBackgroundColor,
borderColor,
color: textColor,
},
]}
value={newEvent.start_date}
onChangeText={(text) =>
setNewEvent({ ...newEvent, start_date: text })
}
placeholder="YYYY-MM-DD"
placeholderTextColor="#9ca3af"
placeholderTextColor={subtitleColor}
/>
</View>
{/* Time Field */}
<View style={styles.formField}>
<Text style={styles.formLabel}>Heure</Text>
<Text style={[styles.formLabel, { color: subtitleColor }]}>
Heure
</Text>
<TextInput
style={styles.formInput}
style={[
styles.formInput,
{
backgroundColor: cardBackgroundColor,
borderColor,
color: textColor,
},
]}
value={newEvent.start_time}
onChangeText={(text) =>
setNewEvent({ ...newEvent, start_time: text })
}
placeholder="HH:MM"
placeholderTextColor="#9ca3af"
placeholderTextColor={subtitleColor}
/>
</View>
{/* Tablo Selector */}
<View style={styles.formField}>
<Text style={styles.formLabel}>Tablo *</Text>
<Text style={[styles.formLabel, { color: subtitleColor }]}>
Tablo *
</Text>
<FlatList
style={[
styles.tabloListInForm,
{ backgroundColor: cardBackgroundColor, borderColor },
]}
data={tablos ?? []}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.tabloOptionInForm,
newEvent.tablo_id === item.id &&
styles.selectedTabloOptionInForm,
{ borderBottomColor: borderColor },
newEvent.tablo_id === item.id && {
backgroundColor: selectedOptionBgColor,
},
]}
onPress={() =>
setNewEvent({ ...newEvent, tablo_id: item.id })
@ -693,6 +850,12 @@ export default function PlanningScreen() {
<Text
style={[
styles.tabloOptionNameInForm,
{
color:
newEvent.tablo_id === item.id
? "#3b82f6"
: textColor,
},
newEvent.tablo_id === item.id &&
styles.selectedTabloOptionNameInForm,
]}
@ -708,17 +871,23 @@ export default function PlanningScreen() {
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
scrollEnabled={false}
style={styles.tabloListInForm}
/>
</View>
</ScrollView>
<View style={styles.createEventModalActions}>
<View
style={[
styles.createEventModalActions,
{ borderTopColor: borderColor },
]}
>
<TouchableOpacity
style={styles.cancelButton}
style={[styles.cancelButton, { borderColor }]}
onPress={() => setShowCreateEventModal(false)}
>
<Text style={styles.cancelButtonText}>Annuler</Text>
<Text style={[styles.cancelButtonText, { color: textColor }]}>
Annuler
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveButton}
@ -738,7 +907,7 @@ export default function PlanningScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f8fafc",
// backgroundColor is set dynamically
},
header: {
flexDirection: "row",
@ -751,12 +920,12 @@ const styles = StyleSheet.create({
headerTitle: {
fontSize: 28,
fontWeight: "bold",
color: "#1f2937",
// color is set dynamically
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: "#6b7280",
// color is set dynamically
textTransform: "capitalize",
},
addButton: {
@ -778,7 +947,7 @@ const styles = StyleSheet.create({
},
viewModeToggle: {
flexDirection: "row",
backgroundColor: "#f3f4f6",
// backgroundColor is set dynamically
borderRadius: 12,
padding: 4,
},
@ -798,7 +967,7 @@ const styles = StyleSheet.create({
viewModeText: {
fontSize: 14,
fontWeight: "500",
color: "#6b7280",
// color is set dynamically
},
activeViewModeText: {
color: "white",
@ -808,7 +977,7 @@ const styles = StyleSheet.create({
marginBottom: 20,
},
tabloSelector: {
backgroundColor: "white",
// backgroundColor is set dynamically
borderRadius: 12,
padding: 16,
flexDirection: "row",
@ -831,7 +1000,7 @@ const styles = StyleSheet.create({
},
tabloSelectorLabel: {
fontSize: 12,
color: "#6b7280",
// color is set dynamically
marginBottom: 2,
},
tabloSelectorRow: {
@ -841,7 +1010,7 @@ const styles = StyleSheet.create({
tabloSelectorName: {
fontSize: 16,
fontWeight: "600",
color: "#1f2937",
// color is set dynamically
marginLeft: 8,
},
tabloColorDot: {
@ -870,7 +1039,7 @@ const styles = StyleSheet.create({
calendarTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
// color is set dynamically
},
weekHeader: {
flexDirection: "row",
@ -878,12 +1047,12 @@ const styles = StyleSheet.create({
marginBottom: 16,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
// borderBottomColor is set dynamically
},
weekDay: {
fontSize: 14,
fontWeight: "500",
color: "#6b7280",
// color is set dynamically
textAlign: "center",
width: 40,
},
@ -900,11 +1069,11 @@ const styles = StyleSheet.create({
},
dayText: {
fontSize: 16,
color: "#1f2937",
// color is set dynamically
fontWeight: "500",
},
inactiveDayText: {
color: "#d1d5db",
// color is set dynamically
},
selectedDay: {
backgroundColor: "#3b82f6",
@ -947,7 +1116,7 @@ const styles = StyleSheet.create({
marginHorizontal: 2,
},
weekDayHeader: {
backgroundColor: "#f8fafc",
// backgroundColor is set dynamically
borderRadius: 8,
padding: 8,
alignItems: "center",
@ -964,12 +1133,12 @@ const styles = StyleSheet.create({
},
weekDayName: {
fontSize: 12,
color: "#6b7280",
// color is set dynamically
fontWeight: "500",
},
weekDayNumber: {
fontSize: 16,
color: "#1f2937",
// color is set dynamically
fontWeight: "600",
marginTop: 2,
},
@ -1037,14 +1206,14 @@ const styles = StyleSheet.create({
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "#e5e7eb",
// backgroundColor is set dynamically
justifyContent: "center",
alignItems: "center",
margin: 2,
},
weekEventMoreText: {
fontSize: 10,
color: "#6b7280",
// color is set dynamically
},
weekEmptySlot: {
flex: 1,
@ -1069,11 +1238,10 @@ const styles = StyleSheet.create({
eventsSectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
// color is set dynamically
marginLeft: 8,
},
eventCard: {
backgroundColor: "white",
borderRadius: 12,
marginBottom: 12,
flexDirection: "row",
@ -1082,6 +1250,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
borderWidth: 1,
},
eventColorBar: {
width: 4,
@ -1095,7 +1264,6 @@ const styles = StyleSheet.create({
eventTitle: {
fontSize: 16,
fontWeight: "600",
color: "#1f2937",
marginBottom: 8,
},
eventDetails: {
@ -1108,7 +1276,6 @@ const styles = StyleSheet.create({
},
eventDetailText: {
fontSize: 14,
color: "#6b7280",
},
emptyState: {
display: "flex",
@ -1121,25 +1288,24 @@ const styles = StyleSheet.create({
emptyStateTitle: {
fontSize: 18,
fontWeight: "600",
color: "#6b7280",
// color is set dynamically
marginTop: 8,
marginBottom: 8,
},
emptyStateText: {
fontSize: 14,
color: "#9ca3af",
// color is set dynamically
textAlign: "center",
lineHeight: 20,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
// backgroundColor is set dynamically
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
},
modalContent: {
backgroundColor: "white",
borderRadius: 16,
width: "100%",
maxWidth: 400,
@ -1149,16 +1315,15 @@ const styles = StyleSheet.create({
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
borderWidth: 1,
},
modalHeader: {
padding: 20,
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
},
modalTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
textAlign: "center",
},
tabloOption: {
@ -1167,7 +1332,6 @@ const styles = StyleSheet.create({
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
tabloOptionLeft: {
flexDirection: "row",
@ -1181,7 +1345,6 @@ const styles = StyleSheet.create({
tabloOptionName: {
fontSize: 16,
fontWeight: "500",
color: "#1f2937",
marginBottom: 2,
},
tabloOptionCount: {
@ -1204,11 +1367,11 @@ const styles = StyleSheet.create({
selectedDayTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
// color is set dynamically
},
selectedDayEventsCount: {
fontSize: 14,
color: "#6b7280",
// color is set dynamically
},
selectedDayEventsList: {
paddingBottom: 10,
@ -1222,11 +1385,10 @@ const styles = StyleSheet.create({
// Create Event Modal Styles
createEventModalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
// backgroundColor is set dynamically
justifyContent: "flex-end",
},
createEventModalContent: {
backgroundColor: "white",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: "90%",
@ -1242,12 +1404,10 @@ const styles = StyleSheet.create({
alignItems: "center",
padding: 20,
borderBottomWidth: 1,
borderBottomColor: "#e5e7eb",
},
createEventModalTitle: {
fontSize: 20,
fontWeight: "600",
color: "#1f2937",
},
createEventCloseButton: {
padding: 4,
@ -1262,23 +1422,17 @@ const styles = StyleSheet.create({
formLabel: {
fontSize: 16,
fontWeight: "500",
color: "#374151",
marginBottom: 8,
},
formInput: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 8,
padding: 12,
fontSize: 16,
color: "#1f2937",
backgroundColor: "white",
},
tabloListInForm: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 8,
backgroundColor: "white",
maxHeight: 150,
},
tabloOptionInForm: {
@ -1287,15 +1441,13 @@ const styles = StyleSheet.create({
alignItems: "center",
padding: 12,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
selectedTabloOptionInForm: {
backgroundColor: "#eff6ff",
// backgroundColor is set dynamically
},
tabloOptionNameInForm: {
fontSize: 16,
fontWeight: "500",
color: "#1f2937",
marginLeft: 8,
},
selectedTabloOptionNameInForm: {
@ -1306,7 +1458,6 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
padding: 20,
borderTopWidth: 1,
borderTopColor: "#e5e7eb",
gap: 12,
},
cancelButton: {
@ -1315,14 +1466,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 20,
borderRadius: 8,
borderWidth: 1,
borderColor: "#d1d5db",
alignItems: "center",
justifyContent: "center",
},
cancelButtonText: {
fontSize: 16,
fontWeight: "500",
color: "#6b7280",
},
saveButton: {
flex: 1,

View file

@ -0,0 +1,504 @@
import React, { useState } from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
ScrollView,
Switch,
Alert,
Linking,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { useAuthStore } from "@/stores/auth";
import { useUser } from "@/providers/UserProvider";
import {
User,
Bell,
Moon,
Shield,
HelpCircle,
Info,
MessageSquare,
LogOut,
ChevronRight,
Smartphone,
Globe,
Lock,
Heart,
} from "lucide-react-native";
import { router } from "expo-router";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
export default function SettingsScreen() {
const signOut = useAuthStore((state) => state.signOut);
const user = useUser();
const colorScheme = useColorScheme();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const cardBackgroundColor = useThemeColor(
{ light: "#ffffff", dark: "#1f2937" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
const subtitleColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
const borderColor = colorScheme === "dark" ? "#374151" : "#e5e7eb";
// Theme-aware gradient colors
const gradientColors: [string, string, string] =
colorScheme === "dark"
? ["#1f2937", "#374151", "#4b5563"]
: ["#1e3a8a", "#3b82f6", "#60a5fa"];
// Settings state
const [pushNotifications, setPushNotifications] = useState(true);
const [emailNotifications, setEmailNotifications] = useState(true);
const [biometricAuth, setBiometricAuth] = useState(false);
const handleSignOut = () => {
Alert.alert("Déconnexion", "Êtes-vous sûr de vouloir vous déconnecter ?", [
{
text: "Annuler",
style: "cancel",
},
{
text: "Se déconnecter",
style: "destructive",
onPress: signOut,
},
]);
};
const handleContactSupport = () => {
Linking.openURL("mailto:support@xtablo.com?subject=Support XTablo");
};
const handleRateApp = () => {
// Replace with your actual app store URL
Alert.alert(
"Évaluer l'application",
"Vous aimez XTablo ? Laissez-nous un avis sur l'App Store !",
[
{ text: "Plus tard", style: "cancel" },
{ text: "Évaluer", onPress: () => console.log("Rate app") },
]
);
};
const renderSettingsSection = (title: string, children: React.ReactNode) => (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: subtitleColor }]}>
{title}
</Text>
<View
style={[
styles.sectionContent,
{ backgroundColor: cardBackgroundColor, borderColor },
]}
>
{children}
</View>
</View>
);
const renderSettingsItem = (
icon: React.ReactNode,
title: string,
subtitle?: string,
onPress?: () => void,
rightComponent?: React.ReactNode,
showArrow: boolean = true
) => (
<TouchableOpacity
style={[styles.settingsItem, { borderBottomColor: borderColor }]}
onPress={onPress}
disabled={!onPress}
activeOpacity={onPress ? 0.7 : 1}
>
<View style={styles.settingsItemLeft}>
<View style={styles.iconContainer}>{icon}</View>
<View style={styles.settingsItemContent}>
<Text style={[styles.settingsItemTitle, { color: textColor }]}>
{title}
</Text>
{subtitle && (
<Text
style={[styles.settingsItemSubtitle, { color: subtitleColor }]}
>
{subtitle}
</Text>
)}
</View>
</View>
<View style={styles.settingsItemRight}>
{rightComponent}
{showArrow && onPress && (
<ChevronRight
size={20}
color={subtitleColor}
style={{ marginLeft: 8 }}
/>
)}
</View>
</TouchableOpacity>
);
const renderSwitchItem = (
icon: React.ReactNode,
title: string,
subtitle: string,
value: boolean,
onValueChange: (value: boolean) => void
) =>
renderSettingsItem(
icon,
title,
subtitle,
undefined,
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{
false: colorScheme === "dark" ? "#374151" : "#e5e7eb",
true: "#3b82f6",
}}
thumbColor={value ? "#ffffff" : "#ffffff"}
ios_backgroundColor={colorScheme === "dark" ? "#374151" : "#e5e7eb"}
/>,
false
);
return (
<View style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === "dark" ? "light-content" : "light-content"}
backgroundColor={gradientColors[0]}
/>
{/* Header */}
<LinearGradient
colors={gradientColors}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<Text style={styles.headerTitle}>Paramètres</Text>
<Text style={styles.headerSubtitle}>
Gérez vos préférences et votre compte
</Text>
</View>
{/* Decorative Elements */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</LinearGradient>
<ScrollView
style={[styles.content, { backgroundColor }]}
showsVerticalScrollIndicator={false}
>
{renderSettingsSection(
"Compte",
<>
{renderSettingsItem(
<User size={20} color="#3b82f6" />,
"Profil utilisateur",
`${user.name || "Non défini"}${user.email}`,
() => router.push("/user"),
undefined,
true
)}
</>
)}
{/* {renderSettingsSection(
"Notifications",
<>
{renderSwitchItem(
<Bell size={20} color="#10b981" />,
"Notifications push",
"Recevoir des notifications sur votre appareil",
pushNotifications,
setPushNotifications
)}
{renderSwitchItem(
<Smartphone size={20} color="#10b981" />,
"Notifications par email",
"Recevoir des notifications par email",
emailNotifications,
setEmailNotifications
)}
</>
)} */}
{/* Appearance Section */}
{renderSettingsSection(
"Apparence",
<>
{renderSwitchItem(
<Moon size={20} color="#6366f1" />,
"Mode sombre",
"Thème système automatique",
colorScheme === "dark",
() => {
Alert.alert(
"Thème automatique",
"Le thème suit automatiquement les préférences de votre appareil. Modifiez le thème dans les paramètres de votre appareil."
);
}
)}
</>
)}
{/* {renderSettingsSection(
"Sécurité et confidentialité",
<>
{renderSwitchItem(
<Lock size={20} color="#ef4444" />,
"Authentification biométrique",
"Utiliser votre empreinte ou Face ID",
biometricAuth,
setBiometricAuth
)}
{renderSettingsItem(
<Shield size={20} color="#ef4444" />,
"Politique de confidentialité",
"Consulter notre politique de confidentialité",
() => Linking.openURL("https://xtablo.com/privacy-policy"),
undefined,
true
)}
</>
)} */}
{/* Help & Support Section */}
{renderSettingsSection(
"Aide et support",
<>
{/* {renderSettingsItem(
<HelpCircle size={20} color="#f59e0b" />,
"Centre d'aide",
"FAQ et guides d'utilisation",
() => Linking.openURL("https://xtablo.com/help"),
undefined,
true
)} */}
{renderSettingsItem(
<MessageSquare size={20} color="#f59e0b" />,
"Contacter le support",
"Envoyez-nous un email",
handleContactSupport,
undefined,
true
)}
{/* {renderSettingsItem(
<Heart size={20} color="#ec4899" />,
"Évaluer l'application",
"Aidez-nous à améliorer XTablo",
handleRateApp,
undefined,
true
)} */}
</>
)}
{/* About Section */}
{renderSettingsSection(
"À propos",
<>
{renderSettingsItem(
<Info size={20} color="#6b7280" />,
"Version de l'application",
"1.0.0 (Build 1)",
undefined,
undefined,
false
)}
{/* {renderSettingsItem(
<Globe size={20} color="#6b7280" />,
"Site web",
"Visitez notre site web",
() => Linking.openURL("https://app.xtablo.com"),
undefined,
true
)} */}
</>
)}
{/* Sign Out Section */}
<View style={styles.signOutSection}>
<TouchableOpacity
style={styles.signOutButton}
onPress={handleSignOut}
activeOpacity={0.8}
>
<LinearGradient
colors={["#ef4444", "#dc2626"]}
style={styles.signOutGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<LogOut size={20} color="white" />
<Text style={styles.signOutText}>Se déconnecter</Text>
</LinearGradient>
</TouchableOpacity>
</View>
{/* Bottom Spacing */}
<View style={styles.bottomSpacing} />
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerGradient: {
paddingTop: 50,
paddingBottom: 25,
paddingHorizontal: 20,
position: "relative",
overflow: "hidden",
},
headerContent: {
zIndex: 10,
},
headerTitle: {
fontSize: 28,
color: "white",
fontWeight: "bold",
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.8)",
fontWeight: "400",
},
decorativeCircle1: {
position: "absolute",
top: -50,
right: -30,
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
decorativeCircle2: {
position: "absolute",
bottom: -20,
left: -20,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
content: {
flex: 1,
marginTop: -10,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
paddingTop: 20,
},
section: {
marginBottom: 24,
paddingHorizontal: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
marginBottom: 12,
marginLeft: 4,
},
sectionContent: {
borderRadius: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
overflow: "hidden",
borderWidth: 1,
},
settingsItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
},
settingsItemLeft: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(0, 0, 0, 0.05)",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
settingsItemContent: {
flex: 1,
},
settingsItemTitle: {
fontSize: 16,
fontWeight: "500",
marginBottom: 2,
},
settingsItemSubtitle: {
fontSize: 14,
},
settingsItemRight: {
flexDirection: "row",
alignItems: "center",
},
signOutSection: {
paddingHorizontal: 20,
marginTop: 20,
marginBottom: 20,
},
signOutButton: {
borderRadius: 16,
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
signOutGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 16,
},
signOutText: {
color: "white",
fontSize: 16,
fontWeight: "600",
marginLeft: 8,
},
bottomSpacing: {
height: 100,
},
});

View file

@ -16,7 +16,7 @@ import {
Platform,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { useCreateTablo, useTablosList } from "@/hooks/tablos";
import { useCreateTablo, useTablosList, useDeleteTablo } from "@/hooks/tablos";
import { UserTablo } from "@/types/tablos.types";
import {
Plus,
@ -30,7 +30,8 @@ import {
} from "lucide-react-native";
import { router } from "expo-router";
import { AVAILABLE_COLORS, ColorMap } from "@/constants/colors";
import { useAuth } from "@/stores/auth";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
const { width } = Dimensions.get("window");
const numColumns = 2;
@ -46,6 +47,33 @@ export default function TablosScreen() {
const [refreshing, setRefreshing] = useState(false);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const { mutate: createTablo } = useCreateTablo();
const { mutate: deleteTablo } = useDeleteTablo();
const colorScheme = useColorScheme();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const cardBackgroundColor = useThemeColor(
{ light: "#ffffff", dark: "#1f2937" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
const subtitleColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
const borderColor = colorScheme === "dark" ? "#374151" : "#e5e7eb";
// Theme-aware gradient colors
const gradientColors: [string, string, string] =
colorScheme === "dark"
? ["#1f2937", "#374151", "#4b5563"]
: ["#1e3a8a", "#3b82f6", "#60a5fa"];
const [newTablo, setNewTablo] = useState<{
name: string;
@ -156,6 +184,33 @@ export default function TablosScreen() {
});
};
const handleDeleteTablo = (tablo: UserTablo) => {
// Only allow admin users to delete tablos
if (!tablo.is_admin) {
Alert.alert(
"Non autorisé",
"Seuls les administrateurs peuvent supprimer ce tablo."
);
return;
}
Alert.alert(
"Supprimer le tablo",
`Êtes-vous sûr de vouloir supprimer "${tablo.name}" ? Cette action est irréversible.`,
[
{
text: "Annuler",
style: "cancel",
},
{
text: "Supprimer",
style: "destructive",
onPress: () => deleteTablo(tablo.id),
},
]
);
};
const renderTabloCard = ({ item: tablo }: { item: UserTablo }) => {
const initials = tablo.name?.charAt(0)?.toUpperCase() || "T";
@ -163,9 +218,14 @@ export default function TablosScreen() {
<TouchableOpacity
style={[
styles.tabloCard,
{ width: viewMode === "grid" ? itemWidth : "100%" },
{
width: viewMode === "grid" ? itemWidth : "100%",
backgroundColor: cardBackgroundColor,
borderColor: borderColor,
},
]}
onPress={() => navigateToTablo(tablo)}
onLongPress={() => handleDeleteTablo(tablo)}
activeOpacity={0.8}
>
{/* Tablo Image/Color Header */}
@ -188,7 +248,10 @@ export default function TablosScreen() {
{/* Tablo Info */}
<View style={styles.tabloInfo}>
<View style={styles.nameRow}>
<Text style={styles.tabloName} numberOfLines={2}>
<Text
style={[styles.tabloName, { color: textColor }]}
numberOfLines={2}
>
{tablo.name}
</Text>
<View
@ -229,13 +292,13 @@ export default function TablosScreen() {
router.push(`/channel/${getChannelCid(tablo.id)}`)
}
>
<MessageCircle size={16} color="#6b7280" />
<MessageCircle size={16} color={subtitleColor} />
</TouchableOpacity>
<TouchableOpacity
style={styles.cardActionButton}
onPress={() => router.push("/planning")}
>
<Calendar size={16} color="#6b7280" />
<Calendar size={16} color={subtitleColor} />
</TouchableOpacity>
</View>
</View>
@ -247,8 +310,12 @@ export default function TablosScreen() {
const renderListItem = ({ item: tablo }: { item: UserTablo }) => {
return (
<TouchableOpacity
style={styles.listItem}
style={[
styles.listItem,
{ backgroundColor: cardBackgroundColor, borderColor: borderColor },
]}
onPress={() => navigateToTablo(tablo)}
onLongPress={() => handleDeleteTablo(tablo)}
activeOpacity={0.8}
>
<View
@ -265,7 +332,9 @@ export default function TablosScreen() {
<View style={styles.listItemContent}>
<View style={styles.listNameRow}>
<View style={styles.listNameContainer}>
<Text style={styles.listName}>{tablo.name}</Text>
<Text style={[styles.listName, { color: textColor }]}>
{tablo.name}
</Text>
</View>
<View
style={[
@ -304,13 +373,13 @@ export default function TablosScreen() {
router.push(`/channel/${getChannelCid(tablo.id)}`)
}
>
<MessageCircle size={16} color="#6b7280" />
<MessageCircle size={16} color={subtitleColor} />
</TouchableOpacity>
<TouchableOpacity
style={styles.listActionButton}
onPress={() => router.push("/planning")}
>
<Calendar size={16} color="#6b7280" />
<Calendar size={16} color={subtitleColor} />
</TouchableOpacity>
</View>
</View>
@ -340,12 +409,15 @@ export default function TablosScreen() {
}
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={[styles.container, { backgroundColor }]}>
<StatusBar
barStyle={colorScheme === "dark" ? "light-content" : "light-content"}
backgroundColor={gradientColors[0]}
/>
{/* Beautiful Header */}
<LinearGradient
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
colors={gradientColors}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
@ -394,10 +466,12 @@ export default function TablosScreen() {
</LinearGradient>
{/* Content */}
<View style={styles.contentContainer}>
<View style={[styles.contentContainer, { backgroundColor }]}>
{isLoading && !refreshing ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Chargement de vos tablos...</Text>
<Text style={[styles.loadingText, { color: subtitleColor }]}>
Chargement de vos tablos...
</Text>
</View>
) : filteredTablos && filteredTablos.length > 0 ? (
<FlatList
@ -414,8 +488,10 @@ export default function TablosScreen() {
/>
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>Aucun tablo trouvé</Text>
<Text style={styles.emptyMessage}>
<Text style={[styles.emptyTitle, { color: textColor }]}>
Aucun tablo trouvé
</Text>
<Text style={[styles.emptyMessage, { color: subtitleColor }]}>
{filterStatus === "all"
? "Vous n'avez encore aucun tablo. Créez votre premier tablo pour commencer !"
: `Aucun tablo avec le statut "${getStatusLabel(
@ -456,20 +532,37 @@ export default function TablosScreen() {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View
style={[
styles.modalContent,
{ backgroundColor: cardBackgroundColor, borderColor },
]}
>
<TouchableOpacity
style={styles.modalCloseButton}
onPress={() => setIsCreateModalVisible(false)}
>
<X size={24} color="#6b7280" />
<X size={24} color={subtitleColor} />
</TouchableOpacity>
<Text style={styles.modalTitle}>Nouveau Tablo</Text>
<Text style={[styles.modalTitle, { color: textColor }]}>
Nouveau Tablo
</Text>
<ScrollView>
<View style={styles.modalFormGroup}>
<Text style={styles.modalLabel}>Nom du Tablo</Text>
<Text style={[styles.modalLabel, { color: subtitleColor }]}>
Nom du Tablo
</Text>
<TextInput
style={styles.modalInput}
style={[
styles.modalInput,
{
backgroundColor: cardBackgroundColor,
borderColor,
color: textColor,
},
]}
placeholder="Ex: Projet React Native"
placeholderTextColor={subtitleColor}
value={newTablo.name}
onChangeText={(text) =>
setNewTablo({ ...newTablo, name: text })
@ -477,7 +570,9 @@ export default function TablosScreen() {
/>
</View>
<View style={styles.modalFormGroup}>
<Text style={styles.modalLabel}>Couleur</Text>
<Text style={[styles.modalLabel, { color: subtitleColor }]}>
Couleur
</Text>
<View style={styles.colorPicker}>
{AVAILABLE_COLORS.map((color) => (
<TouchableOpacity
@ -493,7 +588,9 @@ export default function TablosScreen() {
</View>
</View>
<View style={styles.modalFormGroup}>
<Text style={styles.modalLabel}>Statut</Text>
<Text style={[styles.modalLabel, { color: subtitleColor }]}>
Statut
</Text>
<View style={styles.statusPicker}>
{(["todo", "in_progress", "done"] as const).map((status) => (
<TouchableOpacity
@ -663,8 +760,8 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: "#f8fafc",
marginTop: -10,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
paddingTop: 10,
},
listContent: {
@ -949,7 +1046,6 @@ const styles = StyleSheet.create({
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
modalContent: {
backgroundColor: "white",
borderRadius: 20,
padding: 20,
width: "90%",
@ -959,6 +1055,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3,
shadowRadius: 10,
elevation: 10,
borderWidth: 1,
},
modalCloseButton: {
alignSelf: "flex-end",
@ -967,7 +1064,6 @@ const styles = StyleSheet.create({
modalTitle: {
fontSize: 24,
fontWeight: "bold",
color: "#1f2937",
marginBottom: 20,
},
modalFormGroup: {
@ -975,16 +1071,13 @@ const styles = StyleSheet.create({
},
modalLabel: {
fontSize: 16,
color: "#6b7280",
marginBottom: 8,
},
modalInput: {
borderWidth: 1,
borderColor: "#e5e7eb",
borderRadius: 12,
padding: 12,
fontSize: 16,
color: "#1f2937",
},
colorPicker: {
flexDirection: "row",

View file

@ -17,6 +17,8 @@ import {
import { MessageCircle, Users, Smile } from "lucide-react-native";
import { useEffect, useState } from "react";
import { useHeaderHeight } from "@react-navigation/elements";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
export default function ChannelScreen() {
const { cid } = useLocalSearchParams<{ cid: string }>();
@ -25,9 +27,32 @@ export default function ChannelScreen() {
const channel = client.channel(type, id);
const [hasMessages, setHasMessages] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const colorScheme = useColorScheme();
const headerHeight = useHeaderHeight();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
const subtitleColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
const iconColor = useThemeColor(
{ light: "#d1d5db", dark: "#6b7280" },
"icon"
);
const iconSecondaryColor = useThemeColor(
{ light: "#e5e7eb", dark: "#4b5563" },
"icon"
);
useEffect(() => {
if (channel) {
const checkMessages = async () => {
@ -64,31 +89,35 @@ export default function ChannelScreen() {
const EmptyState = () => (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<MessageCircle size={64} color="#d1d5db" strokeWidth={1.5} />
<MessageCircle size={64} color={iconColor} strokeWidth={1.5} />
<View style={styles.decorativeElements}>
<View style={styles.floatingIcon1}>
<Users size={20} color="#e5e7eb" />
<Users size={20} color={iconSecondaryColor} />
</View>
<View style={styles.floatingIcon2}>
<Smile size={18} color="#e5e7eb" />
<Smile size={18} color={iconSecondaryColor} />
</View>
</View>
</View>
<Text style={styles.emptyTitle}>Commencez la conversation</Text>
<Text style={styles.emptyMessage}>
<Text style={[styles.emptyTitle, { color: textColor }]}>
Commencez la conversation
</Text>
<Text style={[styles.emptyMessage, { color: subtitleColor }]}>
Soyez le premier à envoyer un message dans ce canal !
</Text>
</View>
);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: "#f8fafc" }}>
<SafeAreaView style={{ flex: 1, backgroundColor }}>
<Channel channel={channel} keyboardVerticalOffset={headerHeight}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.loadingText}>Chargement des messages...</Text>
<Text style={[styles.loadingText, { color: subtitleColor }]}>
Chargement des messages...
</Text>
</View>
) : hasMessages ? (
<MessageList />
@ -112,12 +141,10 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f8fafc",
gap: 16,
},
loadingText: {
fontSize: 16,
color: "#6b7280",
fontWeight: "500",
},
emptyContainer: {
@ -125,7 +152,6 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 40,
backgroundColor: "#f8fafc",
},
emptyIconContainer: {
position: "relative",
@ -144,7 +170,7 @@ const styles = StyleSheet.create({
position: "absolute",
top: 10,
right: 5,
backgroundColor: "white",
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 15,
padding: 6,
shadowColor: "#000",
@ -157,7 +183,7 @@ const styles = StyleSheet.create({
position: "absolute",
bottom: 15,
left: 8,
backgroundColor: "white",
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 12,
padding: 5,
shadowColor: "#000",
@ -169,24 +195,22 @@ const styles = StyleSheet.create({
emptyTitle: {
fontSize: 24,
fontWeight: "bold",
color: "#1f2937",
marginBottom: 12,
textAlign: "center",
},
emptyMessage: {
fontSize: 16,
color: "#6b7280",
textAlign: "center",
lineHeight: 24,
marginBottom: 32,
},
emptyHint: {
backgroundColor: "white",
backgroundColor: "rgba(255, 255, 255, 0.9)",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 20,
borderWidth: 1,
borderColor: "#e5e7eb",
borderColor: "rgba(0, 0, 0, 0.1)",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
@ -195,14 +219,10 @@ const styles = StyleSheet.create({
},
emptyHintText: {
fontSize: 14,
color: "#9ca3af",
fontWeight: "500",
textAlign: "center",
},
keyboardContainer: {
backgroundColor: "#f8fafc",
},
messageInputContainer: {
backgroundColor: "#f8fafc",
flexShrink: 0,
},
});

View file

@ -0,0 +1,328 @@
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import { useAuthStore } from "@/stores/auth";
import { Avatar, Input } from "@rn-vui/themed";
import { Card } from "@rn-vui/themed";
import { useState } from "react";
import { useUser } from "@/providers/UserProvider";
import { LinearGradient } from "expo-linear-gradient";
import {
User,
Mail,
Edit3,
Check,
LogOut,
Settings,
Shield,
} from "lucide-react-native";
import { Stack } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function ProfileScreen() {
const signOut = useAuthStore((state) => state.signOut);
const user = useUser();
const insets = useSafeAreaInsets();
// const [displayName, setDisplayName] = useState(user.name || "");
// const [isEditing, setIsEditing] = useState(false);
// const handleSaveDisplayName = () => {
// // TODO: Implémenter la fonctionnalité de sauvegarde
// setIsEditing(false);
// };
return (
<View style={styles.container}>
<Stack.Screen options={{ headerShown: false }} />
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<LinearGradient
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
style={[styles.headerGradient, { paddingTop: insets.top + 60 }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<Avatar
size={120}
rounded
source={user.avatar_url ? { uri: user.avatar_url } : undefined}
icon={
!user.avatar_url
? { name: "user", type: "font-awesome" }
: undefined
}
containerStyle={styles.avatar}
/>
<Text style={styles.userName}>{user.name || "Utilisateur"}</Text>
<Text style={styles.userEmail}>{user.email}</Text>
</View>
</LinearGradient>
{/* Contenu principal */}
<View style={styles.content}>
{/* Carte d'informations personnelles */}
<Card containerStyle={styles.mainCard}>
<View style={styles.cardHeader}>
<User size={20} color="#3b82f6" />
<Text style={styles.cardHeaderTitle}>
Informations personnelles
</Text>
</View>
<View style={styles.infoItem}>
<View style={styles.infoIconContainer}>
<Mail size={18} color="#6b7280" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Adresse e-mail</Text>
<Text style={styles.infoValue}>{user.email}</Text>
</View>
</View>
<View style={styles.divider} />
<View style={styles.infoItem}>
<View style={styles.infoIconContainer}>
<User size={18} color="#6b7280" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Nom d'affichage</Text>
<Text style={styles.infoValue}>{user.name}</Text>
{/* {isEditing ? (
<Input
placeholder="Entrez le nom d'affichage"
value={displayName}
onChangeText={setDisplayName}
containerStyle={styles.inputContainer}
inputStyle={styles.inputText}
autoFocus
/>
) : (
<Text style={styles.infoValue}>
{user.name || "Non défini"}
</Text>
)} */}
</View>
{/* <TouchableOpacity
style={styles.editButton}
onPress={() => {
if (isEditing) {
handleSaveDisplayName();
} else {
setDisplayName(user.name ?? "");
setIsEditing(true);
}
}}
>
{isEditing ? (
<Check size={18} color="#10b981" />
) : (
<Edit3 size={18} color="#6b7280" />
)}
</TouchableOpacity> */}
</View>
</Card>
{/* Bouton de déconnexion */}
<TouchableOpacity style={styles.signOutContainer} onPress={signOut}>
<LinearGradient
colors={["#ef4444", "#dc2626"]}
style={styles.signOutGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<LogOut size={20} color="white" />
<Text style={styles.signOutText}>Se déconnecter</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f8fafc",
},
headerGradient: {
paddingBottom: 40,
paddingHorizontal: 20,
},
headerContent: {
alignItems: "center",
},
avatar: {
marginBottom: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderWidth: 4,
borderColor: "white",
},
userName: {
fontSize: 24,
fontWeight: "bold",
color: "white",
marginBottom: 4,
textAlign: "center",
},
userEmail: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.9)",
textAlign: "center",
},
content: {
padding: 20,
marginTop: -20,
},
mainCard: {
borderRadius: 16,
padding: 0,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
borderWidth: 0,
},
menuCard: {
borderRadius: 16,
padding: 0,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
borderWidth: 0,
},
cardHeader: {
flexDirection: "row",
alignItems: "center",
padding: 20,
paddingBottom: 16,
},
cardHeaderTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
marginLeft: 12,
},
infoItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
},
infoIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
infoContent: {
flex: 1,
},
infoLabel: {
fontSize: 14,
color: "#6b7280",
marginBottom: 4,
fontWeight: "500",
},
infoValue: {
fontSize: 16,
color: "#1f2937",
fontWeight: "500",
},
editButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
},
inputContainer: {
paddingHorizontal: 0,
height: 40,
marginTop: 4,
},
inputText: {
fontSize: 16,
color: "#1f2937",
},
divider: {
height: 1,
backgroundColor: "#e5e7eb",
marginLeft: 72,
},
menuItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
},
menuIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
menuContent: {
flex: 1,
},
menuTitle: {
fontSize: 16,
fontWeight: "500",
color: "#1f2937",
marginBottom: 2,
},
menuSubtitle: {
fontSize: 14,
color: "#6b7280",
},
menuArrow: {
fontSize: 20,
color: "#9ca3af",
fontWeight: "300",
},
signOutContainer: {
marginTop: 10,
marginBottom: 40,
},
signOutGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 12,
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
signOutText: {
color: "white",
fontSize: 16,
fontWeight: "600",
marginLeft: 8,
},
});

View file

@ -1,26 +0,0 @@
import { Redirect, Slot } from "expo-router";
import { useAuth } from "@/stores/auth";
import { ActivityIndicator } from "react-native";
import { useGetUser } from "@/hooks/user";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
export default function AuthLayout() {
const { loading, initialize } = useAuth();
const queryClient = useQueryClient();
const { user, isLoading: isUserLoading } = useGetUser();
const isLoading = loading || isUserLoading;
useEffect(() => {
initialize(queryClient);
}, []);
if (isLoading) {
return <ActivityIndicator />;
}
if (user) {
return <Redirect href="/(home)/(tabs)" />;
}
return <Slot />;
}

View file

@ -1,396 +0,0 @@
import { router } from "expo-router";
import { ChannelList } from "stream-chat-expo";
import { ChannelSort } from "stream-chat";
import { useUser } from "@/providers/UserProvider";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Search } from "lucide-react-native";
import React from "react";
import { useTablosList } from "@/hooks/tablos";
import { ColorMap } from "@/constants/colors";
import { UserTablo } from "@/types/tablos.types";
// Custom Avatar Component for Channel List
const CustomChannelAvatar = ({
channel,
tablos,
}: {
channel: any;
tablos: UserTablo[];
}) => {
const tabloId = channel?.id || "";
const tablo = tablos?.find((t) => t.id === tabloId);
const tabloColor = tablo?.color || "bg-blue-500";
const tabloName = tablo?.name || channel?.data?.name || "Tablo";
// Get members info
const members = channel?.state?.members || {};
const memberCount = Object.keys(members).length;
// Generate initials from tablo name
const getInitials = (name: string) => {
return name
.split(" ")
.map((word) => word.charAt(0))
.join("")
.toUpperCase()
.slice(0, 2);
};
// // Create gradient colors based on tablo color
const getTabloGradientColors = (colorKey: string): [string, string] => {
const baseColor = ColorMap[colorKey] || ColorMap["bg-blue-500"];
// Create a lighter version for gradient effect
const lightenColor = (hex: string, percent: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, Math.max(0, (num >> 16) + amt));
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) + amt));
const B = Math.min(255, Math.max(0, (num & 0x0000ff) + amt));
return "#" + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1);
};
// Create a darker version for gradient effect
const darkenColor = (hex: string, percent: number): string => {
const num = parseInt(hex.replace("#", ""), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, Math.max(0, (num >> 16) - amt));
const G = Math.min(255, Math.max(0, ((num >> 8) & 0x00ff) - amt));
const B = Math.min(255, Math.max(0, (num & 0x0000ff) - amt));
return "#" + ((1 << 24) + (R << 16) + (G << 8) + B).toString(16).slice(1);
};
const lightColor = lightenColor(baseColor, 15);
const darkColor = darkenColor(baseColor, 10);
return [lightColor, darkColor];
};
const initials = getInitials(tabloName);
const gradientColors = getTabloGradientColors(tabloColor);
return (
<View style={styles.avatarContainer}>
<LinearGradient
colors={gradientColors}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.avatarInitials}>{initials}</Text>
{/* Member count indicator for group channels */}
{memberCount > 2 && (
<View style={styles.memberCountBadge}>
<Text style={styles.memberCountText}>{memberCount}</Text>
</View>
)}
</LinearGradient>
{/* Decorative ring */}
<View
style={[
styles.avatarRing,
{ borderColor: `${ColorMap[tabloColor]}30` },
]}
/>
{/* Status indicator (online/active) */}
<View style={styles.statusIndicator} />
</View>
);
};
export default function HomeScreen() {
const user = useUser();
const { data: tablos } = useTablosList();
const filters = {
members: { $in: [user.id] },
type: "messaging",
};
const sort: ChannelSort = { last_updated: -1 };
const options = {
state: true,
watch: true,
};
// Create a wrapper component for the avatar that has access to tablos data
const AvatarWithTablos = ({ channel }: { channel: any }) => (
<CustomChannelAvatar channel={channel} tablos={tablos || []} />
);
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" />
{/* Beautiful Header */}
<LinearGradient
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<View style={styles.headerBottom}>
<View style={styles.titleContainer}>
<Text style={styles.headerTitle}>Discussions</Text>
<Text style={styles.headerSubtitle}>
Gérez les conversations de vos tablos
</Text>
</View>
<TouchableOpacity style={styles.searchButton}>
<Search size={20} color="#3b82f6" />
</TouchableOpacity>
</View>
</View>
{/* Decorative Elements
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} /> */}
</LinearGradient>
{/* Channel List */}
<View style={styles.channelListContainer}>
<ChannelList
filters={filters}
onSelect={(channel) => {
router.push(`/channel/${channel.cid}`);
}}
sort={sort}
options={options}
PreviewAvatar={AvatarWithTablos}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f8fafc",
},
headerGradient: {
paddingTop: 50,
paddingBottom: 25,
paddingHorizontal: 20,
position: "relative",
overflow: "hidden",
},
headerContent: {
zIndex: 10,
},
headerTop: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
userInfo: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
avatar: {
marginRight: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
elevation: 5,
borderWidth: 3,
borderColor: "rgba(255, 255, 255, 0.3)",
},
greetingContainer: {
flex: 1,
},
greeting: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.9)",
fontWeight: "500",
},
userName: {
fontSize: 20,
color: "white",
fontWeight: "bold",
marginTop: 2,
},
headerActions: {
flexDirection: "row",
alignItems: "center",
gap: 15,
},
actionButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255, 255, 255, 0.2)",
justifyContent: "center",
alignItems: "center",
position: "relative",
},
notificationBadge: {
position: "absolute",
top: -2,
right: -2,
backgroundColor: "#ef4444",
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "white",
},
badgeText: {
color: "white",
fontSize: 12,
fontWeight: "bold",
},
headerBottom: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
},
titleContainer: {
flex: 1,
},
headerTitle: {
fontSize: 28,
color: "white",
fontWeight: "bold",
marginBottom: 4,
},
headerSubtitle: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.8)",
fontWeight: "400",
},
searchButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "white",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
},
decorativeCircle1: {
position: "absolute",
top: -50,
right: -30,
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
decorativeCircle2: {
position: "absolute",
bottom: -20,
left: -20,
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
channelListContainer: {
flex: 1,
backgroundColor: "#f8fafc",
marginTop: -10,
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
paddingTop: 10,
},
// Custom Avatar Styles
avatarContainer: {
position: "relative",
width: 56,
height: 56,
marginRight: 12,
},
avatarGradient: {
width: 56,
height: 56,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 6,
position: "relative",
},
avatarInitials: {
fontSize: 18,
fontWeight: "bold",
color: "white",
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
avatarRing: {
position: "absolute",
top: -2,
left: -2,
width: 60,
height: 60,
borderRadius: 18,
borderWidth: 2,
borderColor: "rgba(59, 130, 246, 0.2)",
backgroundColor: "transparent",
},
statusIndicator: {
position: "absolute",
bottom: 2,
right: 2,
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: "#10b981",
borderWidth: 3,
borderColor: "white",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
memberCountBadge: {
position: "absolute",
top: -4,
right: -4,
backgroundColor: "#3b82f6",
borderRadius: 10,
minWidth: 20,
height: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "white",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
memberCountText: {
color: "white",
fontSize: 11,
fontWeight: "bold",
},
});

View file

@ -1,361 +0,0 @@
import {
View,
StyleSheet,
ScrollView,
Text,
TouchableOpacity,
} from "react-native";
import { useAuth } from "@/stores/auth";
import { Avatar, Input } from "@rn-vui/themed";
import { Card } from "@rn-vui/themed";
import { useState } from "react";
import { useUser } from "@/providers/UserProvider";
import { LinearGradient } from "expo-linear-gradient";
import {
User,
Mail,
Edit3,
Check,
LogOut,
Settings,
Shield,
} from "lucide-react-native";
export default function ProfileScreen() {
const signOut = useAuth((state) => state.signOut);
const user = useUser();
const [displayName, setDisplayName] = useState(user.name || "");
const [isEditing, setIsEditing] = useState(false);
const handleSaveDisplayName = () => {
// TODO: Implémenter la fonctionnalité de sauvegarde
setIsEditing(false);
};
const menuItems = [
{
icon: Settings,
title: "Paramètres du compte",
subtitle: "Gérez vos préférences de compte",
onPress: () => console.log("Paramètres"),
},
{
icon: Shield,
title: "Confidentialité et sécurité",
subtitle: "Contrôlez vos paramètres de confidentialité",
onPress: () => console.log("Confidentialité"),
},
];
return (
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
<LinearGradient
colors={["#1e3a8a", "#3b82f6", "#60a5fa"]}
style={styles.headerGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.headerContent}>
<Avatar
size={120}
rounded
source={user.avatar_url ? { uri: user.avatar_url } : undefined}
icon={
!user.avatar_url
? { name: "user", type: "font-awesome" }
: undefined
}
containerStyle={styles.avatar}
/>
<Text style={styles.userName}>{user.name || "Utilisateur"}</Text>
<Text style={styles.userEmail}>{user.email}</Text>
</View>
</LinearGradient>
{/* Contenu principal */}
<View style={styles.content}>
{/* Carte d'informations personnelles */}
<Card containerStyle={styles.mainCard}>
<View style={styles.cardHeader}>
<User size={20} color="#3b82f6" />
<Text style={styles.cardHeaderTitle}>
Informations personnelles
</Text>
</View>
<View style={styles.infoItem}>
<View style={styles.infoIconContainer}>
<Mail size={18} color="#6b7280" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Adresse e-mail</Text>
<Text style={styles.infoValue}>{user.email}</Text>
</View>
</View>
<View style={styles.divider} />
<View style={styles.infoItem}>
<View style={styles.infoIconContainer}>
<User size={18} color="#6b7280" />
</View>
<View style={styles.infoContent}>
<Text style={styles.infoLabel}>Nom d'affichage</Text>
{isEditing ? (
<Input
placeholder="Entrez le nom d'affichage"
value={displayName}
onChangeText={setDisplayName}
containerStyle={styles.inputContainer}
inputStyle={styles.inputText}
autoFocus
/>
) : (
<Text style={styles.infoValue}>
{user.name || "Non défini"}
</Text>
)}
</View>
<TouchableOpacity
style={styles.editButton}
onPress={() => {
if (isEditing) {
handleSaveDisplayName();
} else {
setDisplayName(user.name ?? "");
setIsEditing(true);
}
}}
>
{isEditing ? (
<Check size={18} color="#10b981" />
) : (
<Edit3 size={18} color="#6b7280" />
)}
</TouchableOpacity>
</View>
</Card>
{/* Éléments de menu */}
<Card containerStyle={styles.menuCard}>
<View style={styles.cardHeader}>
<Settings size={20} color="#3b82f6" />
<Text style={styles.cardHeaderTitle}>Préférences</Text>
</View>
{menuItems.map((item, index) => (
<View key={index}>
<TouchableOpacity style={styles.menuItem} onPress={item.onPress}>
<View style={styles.menuIconContainer}>
<item.icon size={20} color="#6b7280" />
</View>
<View style={styles.menuContent}>
<Text style={styles.menuTitle}>{item.title}</Text>
<Text style={styles.menuSubtitle}>{item.subtitle}</Text>
</View>
<Text style={styles.menuArrow}></Text>
</TouchableOpacity>
{index < menuItems.length - 1 && <View style={styles.divider} />}
</View>
))}
</Card>
{/* Bouton de déconnexion */}
<TouchableOpacity style={styles.signOutContainer} onPress={signOut}>
<LinearGradient
colors={["#ef4444", "#dc2626"]}
style={styles.signOutGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<LogOut size={20} color="white" />
<Text style={styles.signOutText}>Se déconnecter</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f8fafc",
},
headerGradient: {
paddingTop: 60,
paddingBottom: 40,
paddingHorizontal: 20,
},
headerContent: {
alignItems: "center",
},
avatar: {
marginBottom: 16,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
borderWidth: 4,
borderColor: "white",
},
userName: {
fontSize: 24,
fontWeight: "bold",
color: "white",
marginBottom: 4,
textAlign: "center",
},
userEmail: {
fontSize: 16,
color: "rgba(255, 255, 255, 0.9)",
textAlign: "center",
},
content: {
padding: 20,
marginTop: -20,
},
mainCard: {
borderRadius: 16,
padding: 0,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
borderWidth: 0,
},
menuCard: {
borderRadius: 16,
padding: 0,
marginBottom: 20,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
borderWidth: 0,
},
cardHeader: {
flexDirection: "row",
alignItems: "center",
padding: 20,
paddingBottom: 16,
},
cardHeaderTitle: {
fontSize: 18,
fontWeight: "600",
color: "#1f2937",
marginLeft: 12,
},
infoItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
},
infoIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
infoContent: {
flex: 1,
},
infoLabel: {
fontSize: 14,
color: "#6b7280",
marginBottom: 4,
fontWeight: "500",
},
infoValue: {
fontSize: 16,
color: "#1f2937",
fontWeight: "500",
},
editButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
},
inputContainer: {
paddingHorizontal: 0,
height: 40,
marginTop: 4,
},
inputText: {
fontSize: 16,
color: "#1f2937",
},
divider: {
height: 1,
backgroundColor: "#e5e7eb",
marginLeft: 72,
},
menuItem: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 20,
paddingVertical: 16,
},
menuIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#f3f4f6",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
menuContent: {
flex: 1,
},
menuTitle: {
fontSize: 16,
fontWeight: "500",
color: "#1f2937",
marginBottom: 2,
},
menuSubtitle: {
fontSize: 14,
color: "#6b7280",
},
menuArrow: {
fontSize: 20,
color: "#9ca3af",
fontWeight: "300",
},
signOutContainer: {
marginTop: 10,
marginBottom: 40,
},
signOutGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 16,
paddingHorizontal: 24,
borderRadius: 12,
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
signOutText: {
color: "white",
fontSize: 16,
fontWeight: "600",
marginLeft: 8,
},
});

View file

@ -3,17 +3,17 @@ import {
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useColorScheme } from "@/hooks/useColorScheme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cloneDeep } from "lodash";
import { ActivityIndicator } from "react-native";
import { SplashScreenController } from "@/components/Splash";
import { useInitializeApp } from "@/hooks/auth";
import { LoadingView } from "@/components/LoadingView";
window.structuredClone = cloneDeep;
@ -33,19 +33,6 @@ const queryClient = new QueryClient({
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return <ActivityIndicator />;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
@ -53,14 +40,34 @@ export default function RootLayout() {
<ThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<Stack>
<Stack.Screen name="(home)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<SplashScreenController />
<RootNavigator />
<StatusBar style="auto" />
</ThemeProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
const RootNavigator = () => {
const { isLoading, isLoggedIn } = useInitializeApp();
if (isLoading) {
return <LoadingView />;
}
return (
<Stack>
<Stack.Protected guard={isLoggedIn}>
<Stack.Screen name="(app)" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Protected guard={!isLoggedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} />
<Stack.Screen name="signup" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Screen name="+not-found" />
</Stack>
);
};

View file

@ -1,25 +1,60 @@
import React, { useState } from "react";
import { StyleSheet, View, Text, Image } from "react-native";
import {
StyleSheet,
View,
Text,
Image,
ImageSourcePropType,
} from "react-native";
import { Button, Input } from "@rn-vui/themed";
import { useAuth } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
import { Link } from "expo-router";
import { Mail, Lock } from "lucide-react-native";
import { GoogleLoginButton } from "@/components/GoogleLoginButton";
import { AppleLoginButton } from "@/components/AppleLoginButton";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
export default function Auth() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const login = useAuth((state) => state.login);
const authLoading = useAuth((state) => state.loading);
const performOAuth = useAuth((state) => state.performOAuth);
const login = useAuthStore((state) => state.login);
const authLoading = useAuthStore((state) => state.loading);
const performOAuth = useAuthStore((state) => state.performOAuth);
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f5f5f5", dark: "#111827" },
"background"
);
const textColor = useThemeColor({ light: "#333", dark: "#f9fafb" }, "text");
const subtitleColor = useThemeColor(
{ light: "#666", dark: "#9ca3af" },
"text"
);
const separatorColor = useThemeColor(
{ light: "#ddd", dark: "#374151" },
"text"
);
const linkColor = useThemeColor(
{ light: "#3b82f6", dark: "#60a5fa" },
"text"
);
const dark = useColorScheme() === "dark";
const logo = dark
? require("@/assets/images/logo_white.png")
: require("@/assets/images/logo.png");
return (
<View style={styles.container}>
<Image source={require("@/assets/images/logo.png")} style={styles.logo} />
<Text style={styles.title}>Connexion XTablo</Text>
<Text style={styles.subtitle}>Connectez-vous à votre compte</Text>
<View style={[styles.container, { backgroundColor }]}>
<Image source={logo} style={styles.logo} />
<Text style={[styles.title, { color: textColor }]}>Connexion XTablo</Text>
<Text style={[styles.subtitle, { color: subtitleColor }]}>
Connectez-vous à votre compte
</Text>
<View style={[styles.verticallySpaced, styles.mt10]}>
<Input
label="Adresse email"
@ -51,9 +86,9 @@ export default function Auth() {
/>
</View>
<View style={styles.separatorContainer}>
<View style={styles.separator} />
<Text style={styles.separatorText}>ou</Text>
<View style={styles.separator} />
<View style={[styles.separator, { backgroundColor: separatorColor }]} />
<Text style={[styles.separatorText, { color: subtitleColor }]}>ou</Text>
<View style={[styles.separator, { backgroundColor: separatorColor }]} />
</View>
<View style={styles.verticallySpaced}>
<GoogleLoginButton onPress={() => performOAuth("google")} />
@ -62,8 +97,10 @@ export default function Auth() {
<AppleLoginButton onPress={() => performOAuth("apple")} />
</View>
<View style={styles.linkContainer}>
<Text style={styles.linkText}>Pas encore de compte ? </Text>
<Link href="/signup" style={styles.link}>
<Text style={[styles.linkText, { color: subtitleColor }]}>
Pas encore de compte ?{" "}
</Text>
<Link href="/signup" style={[styles.link, { color: linkColor }]}>
S'inscrire
</Link>
</View>
@ -76,7 +113,6 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: "center",
padding: 16,
backgroundColor: "#f5f5f5", // Light grey background
},
logo: {
width: 80,
@ -90,13 +126,11 @@ const styles = StyleSheet.create({
fontWeight: "bold",
textAlign: "center",
marginBottom: 3,
color: "#333",
},
subtitle: {
fontSize: 14,
textAlign: "center",
marginBottom: 16,
color: "#666",
},
verticallySpaced: {
paddingTop: 2,
@ -132,11 +166,9 @@ const styles = StyleSheet.create({
separator: {
flex: 1,
height: 1,
backgroundColor: "#ddd",
},
separatorText: {
marginHorizontal: 15,
color: "#666",
fontSize: 14,
},
linkContainer: {
@ -144,11 +176,8 @@ const styles = StyleSheet.create({
justifyContent: "center",
marginTop: 12,
},
linkText: {
color: "#666",
},
linkText: {},
link: {
color: "#007bff",
fontWeight: "bold",
},
});

View file

@ -1,9 +1,11 @@
import React, { useState } from "react";
import { StyleSheet, View, Text, Image } from "react-native";
import { Button, Input } from "@rn-vui/themed";
import { useAuth } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
import { Link } from "expo-router";
import { Mail, Lock, User, Building2 } from "lucide-react-native";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
export default function SignUp() {
const [firstName, setFirstName] = useState("");
@ -12,14 +14,39 @@ export default function SignUp() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const signUp = useAuth((state) => state.signUp);
const authLoading = useAuth((state) => state.loading);
const signUp = useAuthStore((state) => state.signUp);
const authLoading = useAuthStore((state) => state.loading);
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f5f5f5", dark: "#111827" },
"background"
);
const textColor = useThemeColor({ light: "#333", dark: "#f9fafb" }, "text");
const subtitleColor = useThemeColor(
{ light: "#666", dark: "#9ca3af" },
"text"
);
const linkColor = useThemeColor(
{ light: "#3b82f6", dark: "#60a5fa" },
"text"
);
const dark = useColorScheme() === "dark";
const logo = dark
? require("@/assets/images/logo_white.png")
: require("@/assets/images/logo.png");
return (
<View style={styles.container}>
<Image source={require("@/assets/images/logo.png")} style={styles.logo} />
<Text style={styles.title}>Créer un compte XTablo</Text>
<Text style={styles.subtitle}>Rejoignez-nous !</Text>
<View style={[styles.container, { backgroundColor }]}>
<Image source={logo} style={styles.logo} />
<Text style={[styles.title, { color: textColor }]}>
Créer un compte XTablo
</Text>
<Text style={[styles.subtitle, { color: subtitleColor }]}>
Rejoignez-nous !
</Text>
<View style={[styles.verticallySpaced, styles.mt10]}>
<Input
label="Prénom"
@ -84,8 +111,10 @@ export default function SignUp() {
/>
</View>
<View style={styles.linkContainer}>
<Text style={styles.linkText}>Vous avez déjà un compte ? </Text>
<Link href="/login" style={styles.link}>
<Text style={[styles.linkText, { color: subtitleColor }]}>
Vous avez déjà un compte ?{" "}
</Text>
<Link href="/login" style={[styles.link, { color: linkColor }]}>
Se connecter
</Link>
</View>
@ -99,7 +128,6 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: "center",
padding: 16,
backgroundColor: "#f5f5f5", // Light grey background
},
logo: {
width: 80,
@ -113,13 +141,11 @@ const styles = StyleSheet.create({
fontWeight: "bold",
textAlign: "center",
marginBottom: 3,
color: "#333",
},
subtitle: {
fontSize: 14,
textAlign: "center",
marginBottom: 16,
color: "#666",
},
verticallySpaced: {
paddingTop: 2,
@ -167,11 +193,8 @@ const styles = StyleSheet.create({
justifyContent: "center",
marginTop: 12,
},
linkText: {
color: "#666",
},
linkText: {},
link: {
color: "#007bff",
fontWeight: "bold",
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -1,6 +1,6 @@
import React from "react";
import { StyleSheet, View, Text, TouchableOpacity } from "react-native";
import { useAuth } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
import { Svg, Path } from "react-native-svg";
const AppleIcon = ({ color = "#fff", size = 20 }) => (
@ -17,7 +17,7 @@ const AppleIcon = ({ color = "#fff", size = 20 }) => (
);
export const AppleLoginButton = ({ onPress }: { onPress: () => void }) => {
const authLoading = useAuth((state) => state.loading);
const authLoading = useAuthStore((state) => state.loading);
return (
<TouchableOpacity

View file

@ -1,9 +1,9 @@
import React from "react";
import { StyleSheet, View, Text, TouchableOpacity, Image } from "react-native";
import { useAuth } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
export const GoogleLoginButton = ({ onPress }: { onPress: () => void }) => {
const authLoading = useAuth((state) => state.loading);
const authLoading = useAuthStore((state) => state.loading);
return (
<TouchableOpacity

View file

@ -0,0 +1,105 @@
import { StyleSheet, View } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
useDerivedValue,
Easing,
} from "react-native-reanimated";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { useThemeColor } from "@/hooks/useThemeColor";
export const LoadingView = () => {
const rotation = useSharedValue(0);
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
const subtitleColor = useThemeColor(
{ light: "#6b7280", dark: "#9ca3af" },
"text"
);
rotation.value = withRepeat(
withTiming(360, { easing: Easing.linear, duration: 2000 }),
-1,
false
);
const rotationDeg = useDerivedValue(() => {
return `${rotation.value}deg`;
});
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ rotate: rotationDeg.value }],
};
});
return (
<ThemedView style={[styles.loadingContainer, { backgroundColor }]}>
<View style={styles.loadingContent}>
<View style={styles.logoContainer}>
<Animated.Image
source={require("@/assets/images/logo.png")}
style={[styles.logo, animatedStyle]}
/>
</View>
<ThemedText type="title" style={[styles.title, { color: textColor }]}>
XTablo
</ThemedText>
<ThemedText
type="subtitle"
style={[styles.subtitle, { color: subtitleColor }]}
>
Initialisation de l'application...
</ThemedText>
</View>
</ThemedView>
);
};
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
loadingContent: {
alignItems: "center",
paddingHorizontal: 40,
},
logoContainer: {
alignItems: "center",
width: 130,
height: 130,
},
logo: {
width: 100,
height: 100,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
},
title: {
fontSize: 28,
fontWeight: "bold",
textAlign: "center",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: "center",
marginBottom: 32,
opacity: 0.8,
},
});

View file

@ -0,0 +1,17 @@
import { SplashScreen } from "expo-router";
import { useInitializeApp } from "@/hooks/auth";
import { useFonts } from "expo-font";
export function SplashScreenController() {
const { isLoading } = useInitializeApp();
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
if (!isLoading && loaded) {
SplashScreen.hideAsync();
}
return null;
}

View file

@ -0,0 +1,217 @@
import React from "react";
import { View, Text, StyleSheet, Alert } from "react-native";
import {
GestureDetector,
Gesture,
Pressable,
} from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
runOnJS,
withSpring,
interpolate,
Extrapolation,
FadeOut,
useDerivedValue,
SharedValue,
} from "react-native-reanimated";
import { Archive, Trash } from "lucide-react-native";
import { Channel } from "stream-chat";
import { DefaultStreamChatGenerics } from "stream-chat-expo";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
interface SwipeableChannelPreviewProps {
channel: Channel<DefaultStreamChatGenerics>;
children: React.ReactNode;
}
const SWIPE_THRESHOLD = -80;
const ACTION_WIDTH = 80;
export const SwipeableChannelPreview: React.FC<
SwipeableChannelPreviewProps
> = ({ channel, children }) => {
const translateX = useSharedValue(0);
const colorScheme = useColorScheme();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#ffffff", dark: "#1f1f1f" },
"background"
);
const textColor = useThemeColor(
{ light: "#ffffff", dark: "#ffffff" },
"text"
);
const deleteButtonColor = colorScheme === "dark" ? "#c2410c" : "#ea580c";
const iconColor = "#ffffff";
// const handleDeleteChannel = async () => {
// try {
// // Show confirmation dialog
// Alert.alert(
// "Quitter la conversation",
// "Êtes-vous sûr de vouloir quitter cette conversation ? Vous n'aurez plus accès au tablo associé.",
// [
// {
// text: "Annuler",
// style: "cancel",
// onPress: () => {
// // Close the swipe action
// translateX.value = withSpring(0);
// },
// },
// {
// text: "Quitter",
// style: "destructive",
// onPress: async () => {
// try {
// // Hide the channel for the current user
// await channel.delete({ hard_delete: false });
// // Close the swipe action
// translateX.value = withSpring(0);
// // // Show success message
// // Alert.alert(
// // "Succès",
// // "La conversation a été supprimée avec succès",
// // [{ text: "OK" }]
// // );
// } catch (error) {
// console.error("Error deleting channel:", error);
// Alert.alert("Erreur", "Impossible de quitter la conversation");
// }
// },
// },
// ],
// { cancelable: true }
// );
// } catch (error) {
// console.error("Error showing archive dialog:", error);
// }
// };
// const gestureHandler = Gesture.Pan()
// .onStart((context) => {
// // cancelOtherAnimations(id);
// context.translationX = translateX.value;
// })
// .onUpdate((event) => {
// // Only allow swiping left (negative values)
// translateX.value = Math.min(
// 0,
// Math.max(SWIPE_THRESHOLD, event.translationX)
// );
// })
// .onEnd((event) => {
// const shouldOpen = translateX.value < SWIPE_THRESHOLD / 2;
// if (shouldOpen) {
// translateX.value = withSpring(SWIPE_THRESHOLD);
// } else {
// translateX.value = withSpring(0);
// }
// });
const channelAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
};
});
const actionAnimatedStyle = useAnimatedStyle(() => {
const scale = interpolate(
translateX.value,
[SWIPE_THRESHOLD, 0],
[1, 0],
Extrapolation.CLAMP
);
const opacity = interpolate(
translateX.value,
[SWIPE_THRESHOLD, 0],
[1, 0],
Extrapolation.CLAMP
);
return {
transform: [{ scale }],
opacity,
};
});
// const onDeletePress = () => {
// runOnJS(handleDeleteChannel)();
// };
return (
<View style={styles.container}>
{/* Right Actions Background */}
{/* <View style={styles.rightActionsContainer}>
<Pressable
style={[styles.deleteButton, { backgroundColor: deleteButtonColor }]}
onPress={onDeletePress}
>
<Animated.View style={[styles.actionContent, actionAnimatedStyle]}>
<Trash size={24} color={iconColor} />
<Text style={[styles.actionText, { color: textColor }]}>
Quitter
</Text>
</Animated.View>
</Pressable>
</View> */}
{/* Channel Content */}
{/* <GestureDetector gesture={gestureHandler}> */}
<View
style={[
styles.channelContainer,
channelAnimatedStyle,
{ backgroundColor },
]}
>
{children}
</View>
{/* </GestureDetector> */}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
position: "relative",
},
channelContainer: {
flex: 1,
},
rightActionsContainer: {
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: ACTION_WIDTH,
justifyContent: "center",
alignItems: "center",
},
deleteButton: {
justifyContent: "center",
alignItems: "center",
width: ACTION_WIDTH,
height: "100%",
paddingHorizontal: 10,
},
actionContent: {
justifyContent: "center",
alignItems: "center",
gap: 4,
},
actionText: {
fontSize: 12,
fontWeight: "600",
textAlign: "center",
},
});

18
xtablo-expo/hooks/auth.ts Normal file
View file

@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth";
import { useGetUser } from "@/hooks/user";
export const useInitializeApp = () => {
const { loading, initialize } = useAuthStore();
const queryClient = useQueryClient();
const { user, isLoading: isUserLoading } = useGetUser();
const isLoading = loading || isUserLoading;
useEffect(() => {
initialize(queryClient);
}, []);
return { isLoading, isLoggedIn: !!user };
};

View file

@ -3,7 +3,7 @@ import { useUser } from "@/providers/UserProvider";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { TabloInsert, UserTablo } from "@/types/tablos.types";
import { api } from "@/lib/api";
import { useAuth } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
import { Alert } from "react-native";
// type TabloInsert = Tablo["Insert"];
@ -28,7 +28,7 @@ export const useTablosList = () => {
};
export const useCreateTablo = () => {
const session = useAuth((state) => state.session);
const session = useAuthStore((state) => state.session);
const queryClient = useQueryClient();
return useMutation({
@ -50,3 +50,30 @@ export const useCreateTablo = () => {
},
});
};
// Delete tablo (soft delete)
export const useDeleteTablo = () => {
const session = useAuthStore((state) => state.session);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete("/api/v1/tablos/delete", {
data: { id },
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tablos"] });
},
onError: (error) => {
console.error(error);
Alert.alert(
"Erreur",
"Impossible de supprimer le tablo. Veuillez réessayer."
);
},
});
};

View file

@ -1,14 +1,10 @@
import { api } from "@/lib/api";
import { Tables } from "@/types/database.types";
import { useAuth } from "@/stores/auth";
import { useAuthStore } from "@/stores/auth";
import { useQuery } from "@tanstack/react-query";
type User = Tables<"profiles"> & {
streamToken: string | null;
};
import { User } from "@/types/user.types";
export const useGetUser = (): { user: User | null; isLoading: boolean } => {
const session = useAuth((state) => state.session);
const session = useAuthStore((state) => state.session);
const { data, isLoading } = useQuery<User | null>({
queryKey: ["user"],
queryFn: async () => {

File diff suppressed because it is too large Load diff

View file

@ -25,13 +25,16 @@
"@tanstack/react-query": "^5.75.2",
"aes-js": "^3.1.2",
"expo": "^53.0.19",
"expo-auth-session": "~6.2.1",
"expo-av": "~15.1.7",
"expo-blur": "~14.1.5",
"expo-constants": "~17.1.5",
"expo-crypto": "~14.1.5",
"expo-font": "~13.3.2",
"expo-haptics": "~14.1.4",
"expo-image-manipulator": "~13.1.7",
"expo-image-picker": "~16.1.4",
"expo-linear-gradient": "~14.1.5",
"expo-linking": "~7.1.4",
"expo-router": "~5.1.3",
"expo-secure-store": "~14.2.3",
@ -56,9 +59,7 @@
"react-native-webview": "13.13.5",
"stream-chat-expo": "^6.7.3",
"zustand": "^5.0.4",
"expo-crypto": "~14.1.5",
"expo-auth-session": "~6.2.1",
"expo-linear-gradient": "~14.1.5"
"expo-dev-client": "~5.2.4"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@ -66,10 +67,8 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.13",
"@types/react": "~19.0.10",
"@types/react-test-renderer": "^19.0.0",
"jest": "^29.2.1",
"jest-expo": "~53.0.9",
"react-test-renderer": "18.3.1",
"typescript": "^5.3.3"
},
"private": true

View file

@ -2,6 +2,10 @@ import { ActivityIndicator, View } from "react-native";
import { Chat, OverlayProvider, useCreateChatClient } from "stream-chat-expo";
import { useUser } from "@/providers/UserProvider";
import { Text } from "react-native";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useColorScheme } from "@/hooks/useColorScheme";
import { useMemo } from "react";
import type { DeepPartial, Theme } from "stream-chat-expo";
export default function ChatProvider({
children,
@ -9,6 +13,92 @@ export default function ChatProvider({
children: React.ReactNode;
}) {
const user = useUser();
const colorScheme = useColorScheme();
// Theme-aware colors
const backgroundColor = useThemeColor(
{ light: "#f8fafc", dark: "#111827" },
"background"
);
const textColor = useThemeColor(
{ light: "#1f2937", dark: "#f9fafb" },
"text"
);
// Aggressive Stream Chat theme to force correct backgrounds
const streamChatTheme: DeepPartial<Theme> = useMemo(
() => ({
colors: {
// Force ALL background-related colors to match page background
white: backgroundColor,
white_snow: backgroundColor,
white_smoke: backgroundColor,
grey_whisper: backgroundColor,
// Keep text colors appropriate
black: colorScheme === "dark" ? "#f9fafb" : "#1f2937",
grey: colorScheme === "dark" ? "#9ca3af" : "#6b7280",
grey_gainsboro: colorScheme === "dark" ? "#4b5563" : "#9ca3af",
// Accent colors
accent_blue: "#3b82f6",
accent_green: "#10b981",
accent_red: "#ef4444",
// Overlays
overlay:
colorScheme === "dark" ? "rgba(0, 0, 0, 0.8)" : "rgba(0, 0, 0, 0.3)",
// Border colors
border: colorScheme === "dark" ? "#374151" : "#e5e7eb",
},
// Force channel list backgrounds
channelListMessenger: {
flatList: {
backgroundColor: backgroundColor,
},
flatListContent: {
backgroundColor: backgroundColor,
},
container: {
backgroundColor: backgroundColor,
},
},
// Force channel preview backgrounds
channelPreview: {
container: {
backgroundColor: backgroundColor,
},
title: {
color: colorScheme === "dark" ? "#f9fafb" : "#1f2937",
},
message: {
color: colorScheme === "dark" ? "#9ca3af" : "#6b7280",
},
date: {
color: colorScheme === "dark" ? "#6b7280" : "#9ca3af",
},
},
// Force message backgrounds
messageSimple: {
content: {
container: {
backgroundColor: backgroundColor,
},
},
},
// Force input backgrounds
messageInput: {
container: {
backgroundColor: backgroundColor,
borderTopColor: colorScheme === "dark" ? "#374151" : "#e5e7eb",
},
},
}),
[colorScheme, backgroundColor]
);
const client = useCreateChatClient({
apiKey: process.env.EXPO_PUBLIC_STREAM_CHAT_API_KEY as string,
@ -22,8 +112,15 @@ export default function ChatProvider({
if (!user.streamToken) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<Text>Chat Indisponible</Text>
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor,
}}
>
<Text style={{ color: textColor }}>Chat Indisponible</Text>
</View>
);
}
@ -33,7 +130,7 @@ export default function ChatProvider({
}
return (
<OverlayProvider>
<OverlayProvider value={{ style: streamChatTheme }}>
<Chat client={client}>{children}</Chat>
</OverlayProvider>
);

View file

@ -1,13 +1,8 @@
import { createStore, StoreApi, useStore } from "zustand";
import React from "react";
import { Tables } from "@/types/database.types";
import { ActivityIndicator } from "react-native";
import { Redirect } from "expo-router";
import { useGetUser } from "@/hooks/user";
type User = Tables<"profiles"> & {
streamToken: string | null;
};
import { User } from "@/types/user.types";
const UserStoreContext = React.createContext<StoreApi<User> | null>(null);
@ -23,7 +18,7 @@ export const UserStoreProvider = ({
}
if (!user) {
return <Redirect href="/(auth)/login" />;
return null;
}
const store = createStore<User>()(() => user);

View file

@ -10,6 +10,7 @@ import { QueryClient } from "@tanstack/react-query";
interface AuthState {
session: Session | null;
loading: boolean;
initialized: boolean;
initialize: (queryClient: QueryClient) => Promise<void>;
setSession: (session: Session | null) => void;
login: (email: string, password: string) => Promise<void>;
@ -28,8 +29,9 @@ interface AuthState {
WebBrowser.maybeCompleteAuthSession();
const redirectTo = makeRedirectUri({ path: "/(home)/(tabs)" });
export const useAuth = create<AuthState>((set, get) => ({
export const useAuthStore = create<AuthState>((set, get) => ({
loading: true,
initialized: false,
session: null,
setSession: (session: Session | null) => set({ session }),
initialize: async (queryClient: QueryClient) => {
@ -61,6 +63,8 @@ export const useAuth = create<AuthState>((set, get) => ({
console.error("Auth initialization error:", error);
} finally {
set({ loading: false });
set({ initialized: true });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
login: async (email: string, password: string) => {

View file

@ -0,0 +1,9 @@
import { Tables } from "./database.types";
import { RemoveNullFromObject } from "./removeNull";
export type User = RemoveNullFromObject<
Tables<"profiles"> & {
streamToken: string | null;
},
"email" | "name"
>;