Compare commits
2
Commits
98061d7dee
...
2b95addd51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b95addd51 | ||
|
|
00869a1be9 |
@@ -135,4 +135,5 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.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
|
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