Compare commits

...
2 Commits
Author SHA1 Message Date
cspaier 2b95addd51 initial commit 2026-09-04 14:37:40 +02:00
cspaier 00869a1be9 maj gitignore 2026-09-04 14:37:29 +02:00
13 changed files with 8891 additions and 2 deletions
+1
View File
@@ -135,4 +135,5 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.expo
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import ListeClassesScreen from './src/screens/ListeClassesScreen';
import FormClasseScreen from './src/screens/FormClasseScreen';
import DetailClasseScreen from './src/screens/DetailClasseScreen';
import { couleurs } from './src/theme';
const Stack = createNativeStackNavigator();
export default function App() {
return (
<>
<StatusBar style="dark" />
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: couleurs.carte },
headerTintColor: couleurs.texte,
headerTitleStyle: { fontWeight: '700' },
contentStyle: { backgroundColor: couleurs.fond },
}}
>
<Stack.Screen
name="ListeClasses"
component={ListeClassesScreen}
options={{ title: 'Mes classes' }}
/>
<Stack.Screen
name="FormClasse"
component={FormClasseScreen}
options={{ title: 'Classe' }}
/>
<Stack.Screen
name="DetailClasse"
component={DetailClasseScreen}
options={{ title: 'Classe' }}
/>
</Stack.Navigator>
</NavigationContainer>
</>
);
}
+47 -2
View File
@@ -1,3 +1,48 @@
# tirages
# Tirage Classes — application mobile (Expo / React Native)
tirage aléatoire avec pondération par l'ancienneté du dernier tirage
tirage aléatoire avec pondération par l'ancienneté du dernier tirage
Application 100% locale et hors-ligne (aucun compte, aucun serveur) : les classes
et l'historique des tirages sont stockés sur l'appareil via AsyncStorage.
> **Prérequis :** Node.js ≥ 20.19 (requis par Expo SDK 54).
## Installation rapide (test avec Expo Go)
```bash
npm install
npx expo install --fix # aligne les versions des dépendances avec le SDK Expo
npx expo start
```
Scannez le QR code avec l'app **Expo Go** (iOS ou Android) : l'appli se lance
instantanément sur votre téléphone, sans Xcode ni Android Studio.
## Générer une appli installable (sans Expo Go), pour iOS et Android
Utilisez EAS Build (gratuit, nécessite un compte Expo) :
```bash
npm install -g eas-cli
eas login
eas build:configure
eas build --profile preview --platform all
```
Vous obtenez un lien de téléchargement direct (.apk pour Android à installer
directement, .ipa pour iOS via TestFlight ou un profil ad-hoc).
## Fonctionnalités
- Créer / modifier / supprimer des classes (nom, nombre d'élèves, pondération max)
- Tirer un élève au sort, pondéré par l'ancienneté du dernier tirage
- Bouton **Re-tirer** : annule le dernier tirage et en refait un immédiatement
- Historique complet des tirages par classe
- Aucune authentification, aucun réseau requis après installation
## Structure
- `src/models.js` — classes `Classe` et `Tirage` (POO) : logique du tirage pondéré encapsulée en méthodes (`classe.tirer()`, `classe.reTirer()`, `classe.calculerPoids()`), sérialisation JSON (`toJSON()`/`fromJSON()`). Testable indépendamment de React Native.
- `src/storage.js` — persistance locale (AsyncStorage), manipule des instances `Classe`
- `src/screens/` — écrans (liste, formulaire, détail/tirage)
- `App.js` — navigation
+24
View File
@@ -0,0 +1,24 @@
{
"expo": {
"name": "Tirage Classes",
"slug": "tirage-classes",
"version": "1.0.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"backgroundColor": "#2563eb"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "fr.lycee.tirageclasses"
},
"android": {
"package": "fr.lycee.tirageclasses",
"adaptiveIcon": {
"backgroundColor": "#2563eb"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+8028
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "tirage-classes",
"version": "1.0.0",
"main": "node_modules/expo/AppEntry.js",
"private": true,
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios"
},
"dependencies": {
"expo": "~54.0.0",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"react-native-screens": "~4.16.0",
"react-native-safe-area-context": "~5.6.0",
"@react-native-async-storage/async-storage": "2.2.0"
},
"devDependencies": {
"babel-preset-expo": "~54.0.10"
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Modèles orientés objet, sans dépendance React Native (testables avec Node).
*
* Tirage : un tirage au sort effectué pour un élève à une date donnée.
* Classe : une classe scolaire, avec son historique de tirages et la
* logique de tirage pondéré qui va avec.
*/
function genererId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
class Tirage {
constructor({ id, numeroEleve, dateTirage } = {}) {
this.id = id ?? genererId();
this.numeroEleve = numeroEleve;
this.dateTirage = dateTirage ?? new Date().toISOString();
}
toJSON() {
return { id: this.id, numeroEleve: this.numeroEleve, dateTirage: this.dateTirage };
}
static fromJSON(obj) {
return new Tirage(obj);
}
}
class Classe {
constructor({ id, nom, nombreEleves, ponderationMax, dateCreation, tirages } = {}) {
this.id = id ?? genererId();
this.nom = nom;
this.nombreEleves = Number(nombreEleves);
this.ponderationMax = Math.max(Number(ponderationMax) || 2, 1);
this.dateCreation = dateCreation ?? new Date().toISOString();
this.tirages = (tirages || []).map((t) =>
t instanceof Tirage ? t : Tirage.fromJSON(t)
);
}
/** Numéros d'élèves de la classe : [1, 2, ..., nombreEleves] */
numerosEleves() {
return Array.from({ length: this.nombreEleves }, (_, i) => i + 1);
}
/**
* Calcule le poids de chaque numéro d'élève pour le tirage pondéré.
* Plus un élève a été tiré il y a longtemps (ou jamais), plus son poids
* est élevé. Le rapport entre le poids max et le poids min est plafonné
* à this.ponderationMax (2 par défaut), pour éviter qu'un élève non tiré
* depuis très longtemps devienne quasi certain d'être tiré indéfiniment.
*/
calculerPoids(maintenant = new Date()) {
const dernierTirageParNumero = {};
// this.tirages est trié du plus récent au plus ancien (insertion en tête)
for (const tirage of this.tirages) {
if (!(tirage.numeroEleve in dernierTirageParNumero)) {
dernierTirageParNumero[tirage.numeroEleve] = new Date(tirage.dateTirage);
}
}
const dateCreation = new Date(this.dateCreation);
const numeros = this.numerosEleves();
const anciennetes = numeros.map((n) => {
const derniereDate = dernierTirageParNumero[n] || dateCreation;
return (maintenant.getTime() - derniereDate.getTime()) / 1000; // secondes
});
const ancienneteMin = Math.min(...anciennetes);
const ancienneteMax = Math.max(...anciennetes);
let poids;
if (ancienneteMax === ancienneteMin) {
poids = numeros.map(() => 1);
} else {
const etendue = ancienneteMax - ancienneteMin;
poids = anciennetes.map(
(a) => 1 + ((a - ancienneteMin) / etendue) * (this.ponderationMax - 1)
);
}
return { numeros, poids };
}
/** Tirage aléatoire pondéré (sans effet de bord), retourne juste un numéro. */
choisirNumeroPondere() {
const { numeros, poids } = this.calculerPoids();
const total = poids.reduce((a, b) => a + b, 0);
let r = Math.random() * total;
for (let i = 0; i < numeros.length; i++) {
r -= poids[i];
if (r <= 0) return numeros[i];
}
return numeros[numeros.length - 1];
}
/** Effectue un tirage pondéré et l'ajoute en tête de l'historique. */
tirer() {
const nouveauTirage = new Tirage({ numeroEleve: this.choisirNumeroPondere() });
this.tirages = [nouveauTirage, ...this.tirages];
return nouveauTirage;
}
/**
* Annule le dernier tirage puis en effectue immédiatement un nouveau
* (bouton "Re-tirer"). Le tirage annulé est simplement retiré de
* l'historique : l'élève concerné redevient éligible normalement, au même
* titre que n'importe quel autre élève, pour ce nouveau tirage pondéré.
*/
reTirer() {
if (this.tirages.length === 0) {
throw new Error('Aucun tirage à annuler');
}
this.tirages = this.tirages.slice(1);
return this.tirer();
}
get dernierTirage() {
return this.tirages[0] || null;
}
toJSON() {
return {
id: this.id,
nom: this.nom,
nombreEleves: this.nombreEleves,
ponderationMax: this.ponderationMax,
dateCreation: this.dateCreation,
tirages: this.tirages.map((t) => t.toJSON()),
};
}
static fromJSON(obj) {
return new Classe(obj);
}
}
module.exports = { Classe, Tirage, genererId };
+203
View File
@@ -0,0 +1,203 @@
import React, { useCallback, useRef, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { obtenirClasse, effectuerTirage, reTirer } from '../storage';
import { couleurs } from '../theme';
function formaterDate(iso) {
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(
d.getHours()
)}:${pad(d.getMinutes())}`;
}
export default function DetailClasseScreen({ route, navigation }) {
const { classeId } = route.params;
const [classe, setClasse] = useState(null);
const [enCours, setEnCours] = useState(false);
// Ref en plus du state : le state ne se répercute qu'au prochain rendu,
// ce qui laisse une fenêtre où un second tap très rapide (avant que le
// bouton soit visuellement désactivé) pourrait lancer un 2e tirage pendant
// que le premier écrit encore dans le stockage. La ref, elle, est lue et
// mise à jour de façon synchrone, donc elle bloque le double-tap immédiatement.
const enCoursRef = useRef(false);
const recharger = useCallback(async () => {
const c = await obtenirClasse(classeId);
setClasse(c);
if (c) navigation.setOptions({ title: c.nom });
}, [classeId]);
useFocusEffect(
useCallback(() => {
recharger();
}, [recharger])
);
const tirer = async () => {
if (enCoursRef.current) return;
enCoursRef.current = true;
setEnCours(true);
try {
const c = await effectuerTirage(classeId);
setClasse(c);
} finally {
enCoursRef.current = false;
setEnCours(false);
}
};
const reTirerAction = async () => {
if (enCoursRef.current) return;
enCoursRef.current = true;
setEnCours(true);
try {
const c = await reTirer(classeId);
setClasse(c);
} finally {
enCoursRef.current = false;
setEnCours(false);
}
};
if (!classe) {
return (
<View style={[styles.container, styles.centre]}>
<ActivityIndicator />
</View>
);
}
const dernierTirage = classe.tirages[0];
return (
<View style={styles.container}>
<Text style={styles.sousTitre}>
{classe.nombreEleves} élèves · pondération max ×{classe.ponderationMax}
</Text>
<View style={styles.cardTirage}>
<TouchableOpacity
style={[styles.bouton, enCours && styles.boutonDesactive]}
onPress={tirer}
disabled={enCours}
>
<Text style={styles.boutonTexte}>🎲 Tirer un élève au sort</Text>
</TouchableOpacity>
{dernierTirage && (
<>
<Text style={styles.numeroTire}>n°{dernierTirage.numeroEleve}</Text>
<TouchableOpacity
style={[styles.boutonSecondaire, enCours && styles.boutonDesactive]}
onPress={reTirerAction}
disabled={enCours}
>
<Text style={styles.boutonSecondaireTexte}>
🔁 Re-tirer (annule et retire)
</Text>
</TouchableOpacity>
</>
)}
</View>
<Text style={styles.titreHistorique}>Historique des tirages</Text>
<FlatList
data={classe.tirages}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 24 }}
ListEmptyComponent={
<Text style={styles.texteVide}>Aucun tirage pour le moment.</Text>
}
renderItem={({ item, index }) => (
<View style={styles.ligneHistorique}>
<View style={styles.badge}>
<Text style={styles.badgeTexte}>{item.numeroEleve}</Text>
</View>
<Text style={styles.dateHistorique}>{formaterDate(item.dateTirage)}</Text>
{index === 0 && <Text style={styles.etiquetteRecent}>dernier</Text>}
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: couleurs.fond, padding: 16 },
centre: { justifyContent: 'center', alignItems: 'center' },
sousTitre: { color: couleurs.texteSecondaire, marginBottom: 14, fontSize: 14 },
cardTirage: {
backgroundColor: couleurs.carte,
borderRadius: 14,
padding: 20,
alignItems: 'center',
marginBottom: 22,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
bouton: {
backgroundColor: couleurs.primaire,
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
width: '100%',
},
boutonDesactive: { opacity: 0.6 },
boutonTexte: { color: 'white', fontSize: 16, fontWeight: '600' },
numeroTire: {
fontSize: 52,
fontWeight: '800',
color: couleurs.primaire,
marginVertical: 14,
},
boutonSecondaire: {
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 20,
alignItems: 'center',
width: '100%',
backgroundColor: couleurs.fond,
borderWidth: 1,
borderColor: couleurs.bordure,
},
boutonSecondaireTexte: { color: couleurs.texte, fontSize: 15, fontWeight: '600' },
titreHistorique: { fontSize: 16, fontWeight: '700', marginBottom: 10, color: couleurs.texte },
texteVide: { color: couleurs.texteSecondaire },
ligneHistorique: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: couleurs.bordure,
},
badge: {
backgroundColor: couleurs.primaire,
borderRadius: 14,
width: 28,
height: 28,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
badgeTexte: { color: 'white', fontWeight: '700', fontSize: 13 },
dateHistorique: { color: couleurs.texteSecondaire, flex: 1 },
etiquetteRecent: {
fontSize: 11,
color: couleurs.succes,
fontWeight: '700',
textTransform: 'uppercase',
},
});
+144
View File
@@ -0,0 +1,144 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
Alert,
} from 'react-native';
import { creerClasse, modifierClasse, obtenirClasse } from '../storage';
import { couleurs } from '../theme';
export default function FormClasseScreen({ route, navigation }) {
const classeId = route.params?.classeId;
const modeEdition = !!classeId;
const [nom, setNom] = useState('');
const [nombreEleves, setNombreEleves] = useState('');
const [ponderationMax, setPonderationMax] = useState('2');
const [chargement, setChargement] = useState(modeEdition);
useEffect(() => {
navigation.setOptions({
title: modeEdition ? 'Modifier la classe' : 'Nouvelle classe',
});
if (modeEdition) {
obtenirClasse(classeId).then((classe) => {
if (classe) {
setNom(classe.nom);
setNombreEleves(String(classe.nombreEleves));
setPonderationMax(String(classe.ponderationMax));
}
setChargement(false);
});
}
}, [classeId]);
const enregistrer = async () => {
const nomNettoye = nom.trim();
const n = parseInt(nombreEleves, 10);
const p = parseFloat(ponderationMax.replace(',', '.'));
if (!nomNettoye) {
Alert.alert('Champ requis', 'Le nom de la classe est obligatoire.');
return;
}
if (!Number.isInteger(n) || n < 1) {
Alert.alert('Valeur invalide', "Le nombre d'élèves doit être un entier ≥ 1.");
return;
}
if (isNaN(p) || p < 1) {
Alert.alert('Valeur invalide', 'La pondération maximale doit être ≥ 1.');
return;
}
if (modeEdition) {
await modifierClasse(classeId, { nom: nomNettoye, nombreEleves: n, ponderationMax: p });
} else {
await creerClasse({ nom: nomNettoye, nombreEleves: n, ponderationMax: p });
}
navigation.goBack();
};
if (chargement) return <View style={styles.container} />;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={styles.contenu} keyboardShouldPersistTaps="handled">
<View style={styles.champ}>
<Text style={styles.label}>Nom de la classe</Text>
<TextInput
style={styles.input}
value={nom}
onChangeText={setNom}
placeholder="ex : 3A"
autoFocus
/>
</View>
<View style={styles.champ}>
<Text style={styles.label}>Nombre d'élèves</Text>
<TextInput
style={styles.input}
value={nombreEleves}
onChangeText={setNombreEleves}
keyboardType="number-pad"
placeholder="ex : 24"
/>
</View>
<View style={styles.champ}>
<Text style={styles.label}>Pondération maximale</Text>
<TextInput
style={styles.input}
value={ponderationMax}
onChangeText={setPonderationMax}
keyboardType="decimal-pad"
placeholder="2"
/>
<Text style={styles.aide}>
Rapport maximal entre la probabilité de l'élève le moins tiré et
celle de l'élève le plus récemment tiré. 1 = équiprobable, 2 par
défaut.
</Text>
</View>
<TouchableOpacity style={styles.bouton} onPress={enregistrer}>
<Text style={styles.boutonTexte}>Enregistrer</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: couleurs.fond },
contenu: { padding: 20 },
champ: { marginBottom: 20 },
label: { fontSize: 14, fontWeight: '600', color: couleurs.texte, marginBottom: 6 },
input: {
backgroundColor: couleurs.carte,
borderWidth: 1,
borderColor: couleurs.bordure,
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
},
aide: { fontSize: 12, color: couleurs.texteSecondaire, marginTop: 6, lineHeight: 17 },
bouton: {
backgroundColor: couleurs.primaire,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
marginTop: 10,
},
boutonTexte: { color: 'white', fontSize: 16, fontWeight: '600' },
});
+149
View File
@@ -0,0 +1,149 @@
import React, { useCallback, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { useFocusEffect } from '@react-navigation/native';
import { chargerClasses, supprimerClasse } from '../storage';
import { couleurs } from '../theme';
export default function ListeClassesScreen({ navigation }) {
const [classes, setClasses] = useState([]);
const [chargement, setChargement] = useState(true);
const recharger = useCallback(async () => {
setChargement(true);
const c = await chargerClasses();
setClasses(c.sort((a, b) => a.nom.localeCompare(b.nom)));
setChargement(false);
}, []);
// Recharge à chaque fois que l'écran redevient visible (retour d'un autre écran)
useFocusEffect(
useCallback(() => {
recharger();
}, [recharger])
);
const confirmerSuppression = (classe) => {
Alert.alert(
'Supprimer la classe',
`Confirmez-vous la suppression de "${classe.nom}" ? Cette action est irréversible.`,
[
{ text: 'Annuler', style: 'cancel' },
{
text: 'Supprimer',
style: 'destructive',
onPress: async () => {
await supprimerClasse(classe.id);
recharger();
},
},
]
);
};
const renderItem = ({ item }) => (
<TouchableOpacity
style={styles.carte}
onPress={() => navigation.navigate('DetailClasse', { classeId: item.id })}
onLongPress={() => confirmerSuppression(item)}
>
<View style={{ flex: 1 }}>
<Text style={styles.nomClasse}>{item.nom}</Text>
<Text style={styles.details}>{item.nombreEleves} élèves</Text>
</View>
<TouchableOpacity
style={styles.boutonModifier}
onPress={() =>
navigation.navigate('FormClasse', { classeId: item.id })
}
>
<Text style={styles.boutonModifierTexte}>Modifier</Text>
</TouchableOpacity>
</TouchableOpacity>
);
return (
<View style={styles.container}>
<FlatList
data={classes}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={
classes.length === 0 ? styles.listeVide : styles.liste
}
refreshing={chargement}
onRefresh={recharger}
ListEmptyComponent={
!chargement && (
<Text style={styles.texteVide}>
Aucune classe pour le moment.{'\n'}Appuyez sur + pour en créer une.
</Text>
)
}
/>
<TouchableOpacity
style={styles.fab}
onPress={() => navigation.navigate('FormClasse')}
>
<Text style={styles.fabTexte}>+</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: couleurs.fond },
liste: { padding: 16 },
listeVide: { flex: 1, justifyContent: 'center', padding: 16 },
texteVide: {
textAlign: 'center',
color: couleurs.texteSecondaire,
fontSize: 16,
lineHeight: 24,
},
carte: {
backgroundColor: couleurs.carte,
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 4,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
nomClasse: { fontSize: 17, fontWeight: '600', color: couleurs.texte },
details: { fontSize: 14, color: couleurs.texteSecondaire, marginTop: 2 },
boutonModifier: {
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: couleurs.fond,
},
boutonModifierTexte: { color: couleurs.primaire, fontWeight: '600' },
fab: {
position: 'absolute',
right: 20,
bottom: 30,
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: couleurs.primaire,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 6,
shadowOffset: { width: 0, height: 2 },
elevation: 4,
},
fabTexte: { color: 'white', fontSize: 30, lineHeight: 32, marginTop: -2 },
});
+70
View File
@@ -0,0 +1,70 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Classe } from './models';
const STORAGE_KEY = '@tirage_classes:classes';
/** Charge toutes les classes depuis le stockage local (offline), sous forme d'instances Classe. */
export async function chargerClasses() {
const brut = await AsyncStorage.getItem(STORAGE_KEY);
if (!brut) return [];
try {
const donnees = JSON.parse(brut);
return donnees.map((c) => Classe.fromJSON(c));
} catch (e) {
console.warn('Données locales corrompues, réinitialisation.', e);
return [];
}
}
async function sauvegarderClasses(classes) {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(classes.map((c) => c.toJSON())));
}
export async function obtenirClasse(id) {
const classes = await chargerClasses();
return classes.find((c) => c.id === id) || null;
}
export async function creerClasse({ nom, nombreEleves, ponderationMax }) {
const classes = await chargerClasses();
const nouvelle = new Classe({ nom: nom.trim(), nombreEleves, ponderationMax });
classes.push(nouvelle);
await sauvegarderClasses(classes);
return nouvelle;
}
export async function modifierClasse(id, { nom, nombreEleves, ponderationMax }) {
const classes = await chargerClasses();
const classe = classes.find((c) => c.id === id);
if (!classe) throw new Error('Classe introuvable');
classe.nom = nom.trim();
classe.nombreEleves = Number(nombreEleves);
classe.ponderationMax = Number(ponderationMax) || 2;
await sauvegarderClasses(classes);
return classe;
}
export async function supprimerClasse(id) {
const classes = await chargerClasses();
await sauvegarderClasses(classes.filter((c) => c.id !== id));
}
/** Effectue un tirage pondéré sur la classe et persiste le résultat. */
export async function effectuerTirage(classeId) {
const classes = await chargerClasses();
const classe = classes.find((c) => c.id === classeId);
if (!classe) throw new Error('Classe introuvable');
classe.tirer();
await sauvegarderClasses(classes);
return classe;
}
/** Annule le dernier tirage et en effectue immédiatement un nouveau (bouton "Re-tirer"). */
export async function reTirer(classeId) {
const classes = await chargerClasses();
const classe = classes.find((c) => c.id === classeId);
if (!classe) throw new Error('Classe introuvable');
classe.reTirer();
await sauvegarderClasses(classes);
return classe;
}
+11
View File
@@ -0,0 +1,11 @@
export const couleurs = {
primaire: '#2563eb',
primaireFonce: '#1d4ed8',
danger: '#dc2626',
fond: '#f7f7fb',
carte: '#ffffff',
bordure: '#e5e7eb',
texte: '#1c1c1e',
texteSecondaire: '#6b7280',
succes: '#16a34a',
};