Compare commits
2
Commits
98061d7dee
...
2b95addd51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b95addd51 | ||
|
|
00869a1be9 |
@@ -135,4 +135,5 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.expo
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,48 @@
|
||||
# tirages
|
||||
# Tirage Classes — application mobile (Expo / React Native)
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
Generated
+8028
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 };
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
Reference in New Issue
Block a user