diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -1,1237 +1,988 @@
-
-
-
-
-
-
-
-
-
-
76Notions
-
0Maîtrisées
-
0En cours
-
0À revoir
-
—Score Quiz
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
CLIQUER POUR RÉVÉLER ↗
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
score final
-
-
-
-
-
-
-
-
-
-
-
-
Cliquez sur un numéro pour aller directement à la carte correspondante.
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+STATUS_LABELS = {None: "Non vu", 0: "À revoir", 1: "Hésitant", 2: "Maîtrisé"}
+
+WRONG_POOL = [
+ "Cristallisation fractionnée", "Plan de Wadati-Benioff", "Décompression adiabatique",
+ "Fusion partielle", "Serpentinisation", "Dorsale médio-atlantique", "Glaucophane",
+ "Faille inverse", "Faille normale", "Basalte en coussin", "Jadéite", "Grenat",
+ "Hornblende", "Chlorite", "Actinote", "Gabbro", "Andésite", "Diorite",
+ "Schiste bleu", "Schiste vert", "Éclogite", "Slab pull", "Coin mantellique",
+ "Chambre magmatique", "Isotherme 1300°C", "Solidus", "Géotherme",
+ "Arc insulaire", "Cordillère", "Nuée ardente", "5 à 7 km", "10 à 16 cm/an",
+ "1 à 5 cm/an", "700 km de profondeur", "50 km de profondeur",
+ "Fumeurs noirs", "Micaschiste", "Gneiss", "Péridotite", "Pyroxène",
+ "Plagioclase", "Serpentine", "Nappe de charriage", "Racine crustale",
+]
+
+# ══════════════════════════════════════════════════════════
+# SAUVEGARDE
+# ══════════════════════════════════════════════════════════
+def load_progress():
+ if os.path.exists(SAVE_FILE):
+ try:
+ with open(SAVE_FILE) as f:
+ data = json.load(f)
+ return {int(k): v for k, v in data.items()}
+ except Exception:
+ pass
+ return {}
+
+def save_progress(status):
+ try:
+ with open(SAVE_FILE, "w") as f:
+ json.dump(status, f)
+ except Exception:
+ pass
+
+# ══════════════════════════════════════════════════════════
+# UTILITAIRES CURSES
+# ══════════════════════════════════════════════════════════
+def init_colors():
+ curses.start_color()
+ curses.use_default_colors()
+ # Paires : (numéro, fg, bg)
+ curses.init_pair(1, curses.COLOR_WHITE, -1) # normal
+ curses.init_pair(2, curses.COLOR_CYAN, -1) # titre/accent
+ curses.init_pair(3, curses.COLOR_GREEN, -1) # maîtrisé
+ curses.init_pair(4, curses.COLOR_YELLOW, -1) # hésitant / ch.3
+ curses.init_pair(5, curses.COLOR_RED, -1) # à revoir
+ curses.init_pair(6, curses.COLOR_MAGENTA, -1) # ch.4 / hint
+ curses.init_pair(7, curses.COLOR_BLUE, -1) # ch.5
+ curses.init_pair(8, curses.COLOR_WHITE, curses.COLOR_BLUE) # sélectionné
+ curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_GREEN) # correct
+ curses.init_pair(10, curses.COLOR_WHITE, curses.COLOR_RED) # faux
+ curses.init_pair(11, curses.COLOR_CYAN, curses.COLOR_BLACK) # header bg
+ curses.init_pair(12, curses.COLOR_WHITE, curses.COLOR_CYAN) # bouton actif
+
+C_NORMAL = lambda: curses.color_pair(1)
+C_TITLE = lambda: curses.color_pair(2) | curses.A_BOLD
+C_GREEN = lambda: curses.color_pair(3) | curses.A_BOLD
+C_YELLOW = lambda: curses.color_pair(4) | curses.A_BOLD
+C_RED = lambda: curses.color_pair(5) | curses.A_BOLD
+C_MAGENTA = lambda: curses.color_pair(6)
+C_BLUE = lambda: curses.color_pair(7)
+C_SEL = lambda: curses.color_pair(8) | curses.A_BOLD
+C_OK = lambda: curses.color_pair(9) | curses.A_BOLD
+C_FAIL = lambda: curses.color_pair(10) | curses.A_BOLD
+C_HEADER = lambda: curses.color_pair(11) | curses.A_BOLD
+C_BTN = lambda: curses.color_pair(12) | curses.A_BOLD
+
+def ch_color(ch):
+ return {
+ "3": C_YELLOW(),
+ "4": C_MAGENTA(),
+ "5": C_BLUE(),
+ "synth": C_GREEN(),
+ }.get(ch, C_NORMAL())
+
+def safe_addstr(win, y, x, text, attr=0):
+ h, w = win.getmaxyx()
+ if y < 0 or y >= h or x < 0:
+ return
+ max_len = w - x - 1
+ if max_len <= 0:
+ return
+ try:
+ win.addstr(y, x, text[:max_len], attr)
+ except curses.error:
+ pass
+
+def draw_box(win, y, x, h, w, title="", color=None):
+ attr = color or C_NORMAL()
+ h2, w2 = win.getmaxyx()
+ # top
+ try:
+ safe_addstr(win, y, x, "╔" + "═" * (w-2) + "╗", attr)
+ for i in range(1, h-1):
+ if y+i < h2:
+ safe_addstr(win, y+i, x, "║", attr)
+ safe_addstr(win, y+i, x+w-1, "║", attr)
+ safe_addstr(win, y+h-1, x, "╚" + "═" * (w-2) + "╝", attr)
+ if title:
+ t = f" {title} "
+ tx = x + (w - len(t)) // 2
+ safe_addstr(win, y, tx, t, attr | curses.A_BOLD)
+ except curses.error:
+ pass
+
+def draw_header(win, title):
+ h, w = win.getmaxyx()
+ try:
+ win.attron(C_HEADER())
+ win.addstr(0, 0, " " * w)
+ safe_addstr(win, 0, 0, f" GÉO-RÉVISION │ {title}", C_HEADER())
+ win.attroff(C_HEADER())
+ except curses.error:
+ pass
+
+def wrap_text(text, width):
+ lines = []
+ for paragraph in text.split("\n"):
+ if len(paragraph) <= width:
+ lines.append(paragraph)
+ else:
+ wrapped = textwrap.wrap(paragraph, width)
+ lines.extend(wrapped if wrapped else [""])
+ return lines
+
+def get_stats(status):
+ good = sum(1 for v in status.values() if v == 2)
+ ok = sum(1 for v in status.values() if v == 1)
+ bad = sum(1 for v in status.values() if v == 0)
+ unseen = 76 - good - ok - bad
+ return good, ok, bad, unseen
+
+def draw_stats_bar(win, y, status):
+ good, ok, bad, unseen = get_stats(status)
+ h, w = win.getmaxyx()
+ bar = (
+ f" ✓ Maîtrisés:{good:3} "
+ f"~ Hésitants:{ok:3} "
+ f"✗ À revoir:{bad:3} "
+ f"? Non vus:{unseen:3} "
+ f"Total: 76"
+ )
+ safe_addstr(win, y, 0, bar.ljust(w), C_NORMAL())
+
+# ══════════════════════════════════════════════════════════
+# ÉCRAN MENU PRINCIPAL
+# ══════════════════════════════════════════════════════════
+def menu_screen(stdscr, status):
+ options = [
+ ("1", "Flashcards", "Révision active par carte — toutes chapitres"),
+ ("2", "Flashcards CH.3", "Chapitre 3 : Mobilité horizontale"),
+ ("3", "Flashcards CH.4", "Chapitre 4 : Zones de divergence"),
+ ("4", "Flashcards CH.5", "Chapitre 5 : Zones de convergence"),
+ ("5", "Flashcards Synthèse", "Cycle lithosphérique complet"),
+ ("6", "Flashcards À revoir", "Seulement les cartes non maîtrisées"),
+ ("7", "Quiz QCM", "Entraînement avec 4 propositions"),
+ ("8", "Récapitulatif", "Toutes les 76 notions en lecture"),
+ ("9", "Carte de progression", "Visualisation de l'avancement"),
+ ("0", "Réinitialiser", "Effacer la progression"),
+ ("Q", "Quitter", ""),
+ ]
+ sel = 0
+ while True:
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, "MENU PRINCIPAL")
+
+ # Logo
+ logo = [
+ "╔═══════════════════════════════════════════════════╗",
+ "║ GÉO-RÉVISION — Tectonique des Plaques ║",
+ "║ 76 notions · CH.3 · CH.4 · CH.5 · Synthèse ��",
+ "║ Méthode : Ebbinghaus + Testing Effect ║",
+ "╚═══════════════════════════════════════════════════╝",
+ ]
+ for i, line in enumerate(logo):
+ safe_addstr(stdscr, 2+i, max(0,(w-len(line))//2), line, C_TITLE())
+
+ # Stats
+ draw_stats_bar(stdscr, 8, status)
+
+ # Options
+ for i, (key, name, desc) in enumerate(options):
+ y = 10 + i
+ attr = C_SEL() if i == sel else C_NORMAL()
+ prefix = "▶ " if i == sel else " "
+ line = f"{prefix}[{key}] {name:<30} {desc}"
+ safe_addstr(stdscr, y, 2, line[:w-4], attr)
+
+ safe_addstr(stdscr, h-2, 2, "↑↓ pour naviguer · ENTRÉE pour sélectionner", C_MAGENTA())
+
+ k = stdscr.getch()
+ if k == curses.KEY_UP:
+ sel = (sel - 1) % len(options)
+ elif k == curses.KEY_DOWN:
+ sel = (sel + 1) % len(options)
+ elif k in (curses.KEY_ENTER, 10, 13):
+ key = options[sel][0]
+ return key
+ elif k in range(256):
+ c = chr(k).upper()
+ for i, (key, _, _) in enumerate(options):
+ if c == key.upper():
+ return key
+
+# ══════════════════════════════════════════════════════════
+# FLASHCARD
+# ══════════════════════════════════════════════════════════
+def flashcard_screen(stdscr, status, cards, title="Flashcards"):
+ if not cards:
+ stdscr.clear()
+ draw_header(stdscr, title)
+ safe_addstr(stdscr, 5, 4, "Aucune carte dans ce filtre.", C_YELLOW())
+ safe_addstr(stdscr, 7, 4, "Appuie sur une touche pour revenir.", C_NORMAL())
+ stdscr.getch()
+ return status
+
+ deck = cards[:]
+ random.shuffle(deck)
+ idx = 0
+ flipped = False
+
+ while True:
+ card = deck[idx]
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, f"{title} [{idx+1}/{len(deck)}]")
+ draw_stats_bar(stdscr, 1, status)
+
+ # Barre progression
+ pct = (idx + 1) / len(deck)
+ bar_w = min(w - 4, 60)
+ filled = int(pct * bar_w)
+ bar = "█" * filled + "░" * (bar_w - filled)
+ safe_addstr(stdscr, 2, 2, f"[{bar}] {int(pct*100)}%", C_CYAN() if hasattr(curses, 'COLOR_CYAN') else C_NORMAL())
+
+ # Encart chapitre
+ safe_addstr(stdscr, 4, 2, f"■ {card['section']}", ch_color(card["ch"]))
+
+ # Statut de la carte
+ s = status.get(card["id"])
+ s_colors = {None: C_NORMAL(), 0: C_RED(), 1: C_YELLOW(), 2: C_GREEN()}
+ s_label = STATUS_LABELS.get(s, "Non vu")
+ safe_addstr(stdscr, 4, w-20, f"[#{card['id']:02d}] {s_label}", s_colors.get(s, C_NORMAL()))
+
+ if not flipped:
+ # Recto : question
+ draw_box(stdscr, 5, 1, min(12, h-14), w-2, "QUESTION", C_TITLE())
+ q_lines = wrap_text(card["q"], w-6)
+ for i, line in enumerate(q_lines[:6]):
+ safe_addstr(stdscr, 7+i, 4, line, C_NORMAL() | curses.A_BOLD)
+ if card.get("hint"):
+ safe_addstr(stdscr, 13, 4, f"💡 {card['hint']}", C_MAGENTA())
+ safe_addstr(stdscr, 15, 4, "[ ESPACE ] Révéler la réponse", C_TITLE())
+ safe_addstr(stdscr, 16, 4, "[ ← / → ] Carte précédente / suivante [ M ] Mélanger [ Q ] Menu", C_MAGENTA())
+ else:
+ # Verso : réponse
+ draw_box(stdscr, 5, 1, 3, w-2, "QUESTION", C_YELLOW())
+ q_short = card["q"][:w-6]
+ safe_addstr(stdscr, 6, 4, q_short, C_YELLOW())
+
+ draw_box(stdscr, 8, 1, min(11, h-16), w-2, "RÉPONSE", C_GREEN())
+ a_lines = wrap_text(card["a"], w-6)
+ for i, line in enumerate(a_lines[:7]):
+ safe_addstr(stdscr, 10+i, 4, line, C_NORMAL())
+
+ # Boutons notation
+ y_btn = h - 6
+ safe_addstr(stdscr, y_btn, 2, "[ 1 ] ✗ À REVOIR", C_RED())
+ safe_addstr(stdscr, y_btn, 25, "[ 2 ] ~ HÉSITANT", C_YELLOW())
+ safe_addstr(stdscr, y_btn, 50, "[ 3 ] ✓ MAÎTRISÉ", C_GREEN())
+ safe_addstr(stdscr, y_btn+1, 2, "[ ← / → ] Naviguer [ ESPACE ] Retourner [ Q ] Menu", C_MAGENTA())
+
+ k = stdscr.getch()
+
+ if k == ord(' '):
+ flipped = not flipped
+ elif k == curses.KEY_RIGHT or k == ord('n') or k == ord('N'):
+ idx = (idx + 1) % len(deck)
+ flipped = False
+ elif k == curses.KEY_LEFT or k == ord('p') or k == ord('P'):
+ idx = (idx - 1) % len(deck)
+ flipped = False
+ elif k == ord('m') or k == ord('M'):
+ random.shuffle(deck)
+ idx = 0
+ flipped = False
+ elif k == ord('1') and flipped:
+ status[card["id"]] = 0
+ save_progress(status)
+ flipped = False
+ idx = (idx + 1) % len(deck)
+ elif k == ord('2') and flipped:
+ status[card["id"]] = 1
+ save_progress(status)
+ flipped = False
+ idx = (idx + 1) % len(deck)
+ elif k == ord('3') and flipped:
+ status[card["id"]] = 2
+ save_progress(status)
+ flipped = False
+ idx = (idx + 1) % len(deck)
+ elif k == ord('q') or k == ord('Q'):
+ break
+
+ return status
+
+def C_CYAN():
+ return curses.color_pair(2)
+
+# ══════════════════════════════════════════════════════════
+# QUIZ QCM
+# ══════════════════════════════════════════════════════════
+def build_quiz_question(card):
+ """Crée une question QCM à partir d'une carte."""
+ first_line = card["a"].split("\n")[0].strip()
+ # Nettoie les annotations type "1. "
+ correct = re.sub(r"^\d+\.\s*", "", first_line).strip()
+ if len(correct) > 70:
+ correct = correct[:70] + "..."
+
+ # Génère 3 mauvaises réponses différentes
+ pool = [w for w in WRONG_POOL if w.lower() not in correct.lower()]
+ random.shuffle(pool)
+ wrongs = pool[:3]
+
+ options = wrongs + [correct]
+ random.shuffle(options)
+ return {
+ "question": card["q"],
+ "correct": correct,
+ "options": options,
+ "explanation": card["a"],
+ "section": card["section"],
+ "ch": card["ch"],
+ }
+
+def quiz_screen(stdscr, status, cards=None):
+ if cards is None:
+ cards = CARDS[:]
+ random.shuffle(cards)
+ score = 0
+ total = len(cards)
+ wrong_ids = []
+
+ for qi, card in enumerate(cards):
+ qdata = build_quiz_question(card)
+ sel = 0
+ answered = False
+ chosen = None
+
+ while True:
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, f"QUIZ [{qi+1}/{total}] Score: {score}/{qi}")
+ draw_stats_bar(stdscr, 1, status)
+
+ # Section
+ safe_addstr(stdscr, 3, 2, f"■ {qdata['section']}", ch_color(qdata["ch"]))
+
+ # Question
+ draw_box(stdscr, 4, 1, 4, w-2, "QUESTION", C_TITLE())
+ q_lines = wrap_text(qdata["question"], w-6)
+ for i, line in enumerate(q_lines[:2]):
+ safe_addstr(stdscr, 5+i, 4, line, C_NORMAL() | curses.A_BOLD)
+
+ # Options
+ for i, opt in enumerate(qdata["options"]):
+ y = 9 + i * 2
+ prefix = " "
+ if not answered:
+ attr = C_SEL() if i == sel else C_NORMAL()
+ prefix = "▶ " if i == sel else " "
+ else:
+ if opt == qdata["correct"]:
+ attr = C_GREEN()
+ prefix = "✓ "
+ elif opt == chosen and chosen != qdata["correct"]:
+ attr = C_RED()
+ prefix = "✗ "
+ else:
+ attr = C_NORMAL()
+ prefix = " "
+
+ opt_short = opt[:w-10]
+ safe_addstr(stdscr, y, 2, f"{prefix}[{'ABCD'[i]}] {opt_short}", attr)
+
+ if answered:
+ # Explication
+ exp_y = 18
+ if chosen == qdata["correct"]:
+ safe_addstr(stdscr, exp_y, 2, "✓ BONNE RÉPONSE !", C_GREEN())
+ else:
+ safe_addstr(stdscr, exp_y, 2, f"✗ Réponse correcte : {qdata['correct'][:w-30]}", C_RED())
+ exp_lines = wrap_text(qdata["explanation"], w-6)
+ for i, line in enumerate(exp_lines[:3]):
+ safe_addstr(stdscr, exp_y+1+i, 4, line, C_MAGENTA())
+ safe_addstr(stdscr, h-2, 2, "[ ENTRÉE ou ESPACE ] Question suivante [ Q ] Arrêter", C_TITLE())
+ else:
+ safe_addstr(stdscr, h-2, 2, "[ ↑↓ ] Naviguer [ ENTRÉE ] Valider [ Q ] Arrêter", C_TITLE())
+
+ k = stdscr.getch()
+
+ if not answered:
+ if k == curses.KEY_UP:
+ sel = (sel - 1) % 4
+ elif k == curses.KEY_DOWN:
+ sel = (sel + 1) % 4
+ elif k in range(ord('a'), ord('e')):
+ sel = k - ord('a')
+ elif k in range(ord('A'), ord('E')):
+ sel = k - ord('A')
+ elif k in (curses.KEY_ENTER, 10, 13, ord(' ')):
+ answered = True
+ chosen = qdata["options"][sel]
+ if chosen == qdata["correct"]:
+ score += 1
+ status[card["id"]] = max(status.get(card["id"], 0) or 0, 2)
+ else:
+ wrong_ids.append(card["id"])
+ status[card["id"]] = 0
+ save_progress(status)
+ elif k == ord('q') or k == ord('Q'):
+ break
+ else:
+ if k in (curses.KEY_ENTER, 10, 13, ord(' ')):
+ break
+ elif k == ord('q') or k == ord('Q'):
+ # Montre résultat intermédiaire
+ qi = total
+ break
+
+ if k == ord('q') or k == ord('Q'):
+ break
+
+ # Résultat final
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, "RÉSULTAT DU QUIZ")
+
+ pct = int(score / total * 100) if total > 0 else 0
+ color = C_GREEN() if pct >= 80 else C_YELLOW() if pct >= 50 else C_RED()
+
+ draw_box(stdscr, 3, w//2-20, 12, 40, "SCORE FINAL", C_TITLE())
+ score_str = f"{pct}%"
+ safe_addstr(stdscr, 6, w//2 - len(score_str)//2, score_str, color | curses.A_BOLD)
+ detail = f"{score} bonnes réponses sur {total}"
+ safe_addstr(stdscr, 8, w//2 - len(detail)//2, detail, C_NORMAL())
+
+ msg = ("🏆 Excellent ! Chapitres maîtrisés !" if pct >= 80 else
+ "👍 Bien ! Quelques révisions restent utiles." if pct >= 60 else
+ "⚠ Des lacunes — retourne aux flashcards !" if pct >= 40 else
+ "📚 Reprends les flashcards et révise tout !")
+ safe_addstr(stdscr, 10, w//2 - len(msg)//2, msg[:w-4], C_TITLE())
+
+ if wrong_ids:
+ safe_addstr(stdscr, 16, 2, f"Cartes à revoir : {', '.join(f'#{i}' for i in wrong_ids[:15])}", C_RED())
+
+ safe_addstr(stdscr, h-2, 2, "Appuie sur une touche pour revenir au menu.", C_MAGENTA())
+ stdscr.getch()
+ return status
+
+# ══════════════════════════════════════════════════════════
+# RÉCAPITULATIF
+# ══════════════════════════════════════════════════════════
+def recap_screen(stdscr, status):
+ lines = []
+ groups = [
+ ("3", "═══ CHAPITRE 3 — Mobilité horizontale des plaques ═══"),
+ ("4", "═══ CHAPITRE 4 — Dynamique des zones de divergence ═══"),
+ ("5", "═══ CHAPITRE 5 — Dynamique des zones de convergence ═══"),
+ ("synth", "═══ SYNTHÈSE — Cycle de la lithosphère océanique ═══"),
+ ]
+ for ch, title in groups:
+ lines.append(("title", ch, title))
+ for c in CARDS:
+ if c["ch"] == ch:
+ lines.append(("section", ch, f" ─ {c['section']}"))
+ lines.append(("question", ch, f" #{c['id']:02d} Q: {c['q']}"))
+ for al in c["a"].split("\n"):
+ lines.append(("answer", ch, f" → {al}"))
+ lines.append(("sep", ch, ""))
+
+ scroll = 0
+ while True:
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, "RÉCAPITULATIF COMPLET — 76 notions")
+ draw_stats_bar(stdscr, 1, status)
+
+ visible = h - 5
+ for i, (ltype, ch, text) in enumerate(lines[scroll:scroll+visible]):
+ y = 3 + i
+ text_trunc = text[:w-2]
+ if ltype == "title":
+ safe_addstr(stdscr, y, 0, text_trunc.ljust(w-1), ch_color(ch) | curses.A_BOLD)
+ elif ltype == "section":
+ safe_addstr(stdscr, y, 0, text_trunc, C_CYAN())
+ elif ltype == "question":
+ safe_addstr(stdscr, y, 0, text_trunc, C_NORMAL() | curses.A_BOLD)
+ elif ltype == "answer":
+ safe_addstr(stdscr, y, 0, text_trunc, C_MAGENTA())
+ else:
+ pass
+
+ pct_scroll = int(scroll / max(1, len(lines) - visible) * 100)
+ safe_addstr(stdscr, h-1, 2, f"↑↓/PgUp/PgDn : défiler ({pct_scroll}%) Q : menu", C_TITLE())
+
+ k = stdscr.getch()
+ if k == curses.KEY_UP:
+ scroll = max(0, scroll - 1)
+ elif k == curses.KEY_DOWN:
+ scroll = min(len(lines) - visible, scroll + 1)
+ elif k == curses.KEY_PPAGE:
+ scroll = max(0, scroll - visible)
+ elif k == curses.KEY_NPAGE:
+ scroll = min(len(lines) - visible, scroll + visible)
+ elif k == ord('q') or k == ord('Q'):
+ break
+
+# ══════════════════════════════════════════════════════════
+# CARTE DE PROGRESSION
+# ══════════════════════════════════════════════════════════
+def map_screen(stdscr, status):
+ while True:
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, "CARTE DE PROGRESSION")
+ draw_stats_bar(stdscr, 1, status)
+
+ safe_addstr(stdscr, 3, 2, "Légende :", C_NORMAL() | curses.A_BOLD)
+ safe_addstr(stdscr, 3, 14, "[██] Non vu", C_NORMAL())
+ safe_addstr(stdscr, 3, 28, "[██] À revoir", C_RED())
+ safe_addstr(stdscr, 3, 44, "[~~] Hésitant", C_YELLOW())
+ safe_addstr(stdscr, 3, 60, "[OK] Maîtrisé", C_GREEN())
+
+ # Grille
+ cols = min(19, (w - 4) // 5)
+ for c in CARDS:
+ idx = c["id"] - 1
+ row = idx // cols
+ col = idx % cols
+ y = 5 + row * 2
+ x = 2 + col * 5
+ if y >= h - 3:
+ break
+ s = status.get(c["id"])
+ if s == 2:
+ attr = C_GREEN()
+ label = f"OK "
+ elif s == 1:
+ attr = C_YELLOW()
+ label = f"~~ "
+ elif s == 0:
+ attr = C_RED()
+ label = f"✗ "
+ else:
+ attr = C_NORMAL()
+ label = f" "
+ safe_addstr(stdscr, y, x, f"{c['id']:02d}", attr | curses.A_BOLD)
+ safe_addstr(stdscr, y+1, x, label, attr)
+
+ # Résumé par chapitre
+ y_sum = h - 8
+ safe_addstr(stdscr, y_sum, 2, "Par chapitre :", C_TITLE())
+ for ch in ["3", "4", "5", "synth"]:
+ ch_cards = [c for c in CARDS if c["ch"] == ch]
+ ch_good = sum(1 for c in ch_cards if status.get(c["id"]) == 2)
+ ch_ok = sum(1 for c in ch_cards if status.get(c["id"]) == 1)
+ line = f"{CH_LABELS[ch]:20s} ✓{ch_good:3d} ~{ch_ok:3d} /{len(ch_cards)}"
+ safe_addstr(stdscr, y_sum+1 + list(["3","4","5","synth"]).index(ch),
+ 4, line, ch_color(ch))
+
+ safe_addstr(stdscr, h-1, 2, "Q : revenir au menu", C_MAGENTA())
+
+ k = stdscr.getch()
+ if k == ord('q') or k == ord('Q'):
+ break
+
+# ══════════════════════════════════════════════════════════
+# RÉINITIALISATION
+# ══════════════════════════════════════════════════════════
+def confirm_reset(stdscr, status):
+ stdscr.clear()
+ h, w = stdscr.getmaxyx()
+ draw_header(stdscr, "RÉINITIALISATION")
+ safe_addstr(stdscr, 5, 4, "⚠ Effacer toute la progression ?", C_RED())
+ safe_addstr(stdscr, 7, 4, "[ O ] Oui, effacer [ N ] Annuler", C_NORMAL())
+ k = stdscr.getch()
+ if k in (ord('o'), ord('O')):
+ status.clear()
+ if os.path.exists(SAVE_FILE):
+ os.remove(SAVE_FILE)
+ safe_addstr(stdscr, 9, 4, "Progression effacée.", C_GREEN())
+ stdscr.refresh()
+ curses.napms(1000)
+ return status
+
+# ══════════════════════════════════════════════════════════
+# MAIN
+# ══════════════════════════════════════════════════════════
+def main(stdscr):
+ curses.curs_set(0)
+ stdscr.keypad(True)
+ init_colors()
+ status = load_progress()
+
+ while True:
+ choice = menu_screen(stdscr, status)
+
+ if choice == "1":
+ status = flashcard_screen(stdscr, status, CARDS[:], "Flashcards — Tout")
+ elif choice == "2":
+ cards = [c for c in CARDS if c["ch"] == "3"]
+ status = flashcard_screen(stdscr, status, cards, "Flashcards — CH.3")
+ elif choice == "3":
+ cards = [c for c in CARDS if c["ch"] == "4"]
+ status = flashcard_screen(stdscr, status, cards, "Flashcards — CH.4")
+ elif choice == "4":
+ cards = [c for c in CARDS if c["ch"] == "5"]
+ status = flashcard_screen(stdscr, status, cards, "Flashcards — CH.5")
+ elif choice == "5":
+ cards = [c for c in CARDS if c["ch"] == "synth"]
+ status = flashcard_screen(stdscr, status, cards, "Flashcards — Synthèse")
+ elif choice == "6":
+ cards = [c for c in CARDS if status.get(c["id"]) in (None, 0)]
+ if not cards:
+ cards = CARDS[:]
+ status = flashcard_screen(stdscr, status, cards, "Flashcards — À revoir")
+ elif choice == "7":
+ status = quiz_screen(stdscr, status)
+ elif choice == "8":
+ recap_screen(stdscr, status)
+ elif choice == "9":
+ map_screen(stdscr, status)
+ elif choice == "0":
+ status = confirm_reset(stdscr, status)
+ elif choice in ("Q", "q"):
+ break
+
+ save_progress(status)
+
+if __name__ == "__main__":
+ curses.wrapper(main)
\ No newline at end of file