MIL-005: Step 4 - Save progress

Review record RC-027 (Go, autonomous mode, not independent).

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
Task: MIL-005#5
This commit is contained in:
2026-10-14 22:29:58 +08:00
parent da4e9382cf
commit 8dbaaa3059
22 changed files with 1367 additions and 669 deletions
@@ -32,6 +32,21 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
)
def _load_image(root: tk.Tk, name: str) -> tk.PhotoImage:
"""Load an asset image.
The bytes are read here and handed to Tk as data, so the image does not
depend on Tcl opening the file: Tcl keeps the standard handles it saw at
start, and a process that swaps them (pytest fd capture) makes the first
Tcl file open fail with an empty error.
@param root The Tk root window that owns the image.
@param name The file name in the assets folder.
@return The image; the caller must keep a reference.
"""
return tk.PhotoImage(master=root, data=(ASSETS_DIR / name).read_bytes())
class FlashCardView:
"""The flash card window: a card and the wrong and right buttons."""
@@ -50,13 +65,13 @@ class FlashCardView:
# Kept as attributes: Tkinter drops an image nothing refers to.
## @brief Card front image, kept so Tk does not drop it.
self._card_front_image = tk.PhotoImage(file=ASSETS_DIR / CARD_FRONT_IMAGE_NAME)
self._card_front_image = _load_image(root, CARD_FRONT_IMAGE_NAME)
## @brief Card back image, kept so Tk does not drop it.
self._card_back_image = tk.PhotoImage(file=ASSETS_DIR / CARD_BACK_IMAGE_NAME)
self._card_back_image = _load_image(root, CARD_BACK_IMAGE_NAME)
## @brief Wrong button image, kept so Tk does not drop it.
self._wrong_image = tk.PhotoImage(file=ASSETS_DIR / WRONG_IMAGE_NAME)
self._wrong_image = _load_image(root, WRONG_IMAGE_NAME)
## @brief Right button image, kept so Tk does not drop it.
self._right_image = tk.PhotoImage(file=ASSETS_DIR / RIGHT_IMAGE_NAME)
self._right_image = _load_image(root, RIGHT_IMAGE_NAME)
## @brief Canvas that draws the card.
self.canvas = tk.Canvas(
@@ -103,7 +118,7 @@ class FlashCardView:
image=self._right_image,
highlightthickness=0,
bd=0,
command=self.next_card,
command=self.is_known,
)
self.right_button.grid(row=1, column=1)
@@ -137,6 +152,16 @@ class FlashCardView:
self._show_card()
self._start_flip_timer()
def is_known(self) -> None:
"""Mark the card as known, show the next card front, restart the timer.
@return Nothing.
"""
self._cancel_flip_timer()
self._view_model.is_known()
self._show_card()
self._start_flip_timer()
def flip_card(self) -> None:
"""Show the back of the current card.
@@ -1,104 +1,119 @@
"""View model of the flash card screen.
@file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import.
"""
import enum
from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class Side(enum.Enum):
"""The side of a card that is shown."""
## @brief The front: the word to learn.
FRONT = enum.auto()
## @brief The back: the translation.
BACK = enum.auto()
class FlashCardViewModel:
"""Data the flash card view shows."""
def __init__(
self,
session: StudySession,
language_names: Mapping[str, str] = LANGUAGE_NAMES,
) -> None:
"""Create the view model and draw the first card.
@param session The study session that supplies the cards.
@param language_names Display names by language code.
"""
## @brief The study session that supplies the cards.
self._session = session
## @brief Display names by language code.
self._language_names = language_names
## @brief The card shown now, or None when none is left.
self._card: CardDto | None = session.next_card()
## @brief The side of the card shown.
self._side = Side.FRONT
@property
def is_finished(self) -> bool:
"""Tell whether no card is left to show.
@return True when there is no card.
"""
return self._card is None
@property
def side(self) -> Side:
"""Return the side of the card shown.
@return FRONT or BACK; FRONT when no card is left.
"""
return self._side
@property
def language_name(self) -> str:
"""Return the language name shown on the card.
@return The display name of the language of the side shown, upper case
code when unknown, or an empty string when no card is left.
"""
if self._card is None:
return ""
if self._side is Side.BACK:
code = self._card.back_language
else:
code = self._card.front_language
return self._language_names.get(code, code.upper())
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The text of the side shown, or the finished message when no
card is left.
"""
if self._card is None:
return FINISHED_MESSAGE
if self._side is Side.BACK:
return self._card.back_text
return self._card.front_text
def next_card(self) -> None:
"""Draw the next card and show its front.
@return Nothing.
"""
self._card = self._session.next_card()
self._side = Side.FRONT
def flip_card(self) -> None:
"""Show the back of the card; does nothing when finished or flipped.
@return Nothing.
"""
if self._card is not None:
self._side = Side.BACK
"""View model of the flash card screen.
@file flash_card_view_model.py
@brief Exposes the texts shown on the card; free of any Tkinter import.
"""
import enum
from collections.abc import Mapping
from flash_card.application.dto.card_dto import CardDto
from flash_card.application.use_cases.study_session import StudySession
from flash_card.constants import FINISHED_MESSAGE, LANGUAGE_NAMES
class Side(enum.Enum):
"""The side of a card that is shown."""
## @brief The front: the word to learn.
FRONT = enum.auto()
## @brief The back: the translation.
BACK = enum.auto()
class FlashCardViewModel:
"""Data the flash card view shows."""
def __init__(
self,
session: StudySession,
language_names: Mapping[str, str] = LANGUAGE_NAMES,
) -> None:
"""Create the view model and draw the first card.
@param session The study session that supplies the cards.
@param language_names Display names by language code.
"""
## @brief The study session that supplies the cards.
self._session = session
## @brief Display names by language code.
self._language_names = language_names
## @brief The card shown now, or None when none is left.
self._card: CardDto | None = session.next_card()
## @brief The side of the card shown.
self._side = Side.FRONT
@property
def is_finished(self) -> bool:
"""Tell whether no card is left to show.
@return True when there is no card.
"""
return self._card is None
@property
def side(self) -> Side:
"""Return the side of the card shown.
@return FRONT or BACK; FRONT when no card is left.
"""
return self._side
@property
def language_name(self) -> str:
"""Return the language name shown on the card.
@return The display name of the language of the side shown, upper case
code when unknown, or an empty string when no card is left.
"""
if self._card is None:
return ""
if self._side is Side.BACK:
code = self._card.back_language
else:
code = self._card.front_language
return self._language_names.get(code, code.upper())
@property
def text(self) -> str:
"""Return the word shown on the card.
@return The text of the side shown, or the finished message when no
card is left.
"""
if self._card is None:
return FINISHED_MESSAGE
if self._side is Side.BACK:
return self._card.back_text
return self._card.front_text
def next_card(self) -> None:
"""Draw the next card and show its front; does nothing when finished.
@return Nothing.
"""
if self._card is None:
return
self._card = self._session.next_card()
self._side = Side.FRONT
def is_known(self) -> None:
"""Mark the card as known, save the progress and show the next card.
Does nothing when finished.
@return Nothing.
"""
if self._card is None:
return
self._session.mark_known()
self._card = self._session.next_card()
self._side = Side.FRONT
def flip_card(self) -> None:
"""Show the back of the card; does nothing when finished or flipped.
@return Nothing.
"""
if self._card is not None:
self._side = Side.BACK