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
+85 -77
View File
@@ -1,77 +1,85 @@
"""Entry point and composition root of the flash card program.
@file app.py
@brief Creates the window, its view model and view, and runs the main loop.
"""
import argparse
import logging
import random
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from typing import NoReturn
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import DEFAULT_DATA_DIR, VOCABULARY_FILE_NAME
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
def create_application(
data_dir: Path | None = None,
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
@param data_dir Folder of the data files, or None for the default folder.
@return The root window and the view built on it.
@throws VocabularyError When the word list cannot be loaded.
"""
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
session = open_study_session(repository, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
return root, view
def main(argv: Sequence[str] | None = None) -> int:
"""Run the program and return its exit code.
@param argv Command line arguments, or None for those of the process.
@return The exit code, 0 on success and 1 when the word list is unusable.
"""
parser = argparse.ArgumentParser(description="Flash card program.")
parser.add_argument(
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR,
help="folder of the data files",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir)
except VocabularyError as error:
_logger.error("%s", error)
return 1
root.mainloop()
return 0
def run() -> NoReturn:
"""Run the program and exit the interpreter with its exit code.
@return Never returns; the function always raises SystemExit.
@throws SystemExit Always, with the exit code of main.
"""
raise SystemExit(main())
"""Entry point and composition root of the flash card program.
@file app.py
@brief Creates the window, its view model and view, and runs the main loop.
"""
import argparse
import logging
import random
import tkinter as tk
from collections.abc import Sequence
from pathlib import Path
from typing import NoReturn
from flash_card.application.use_cases.open_study_session import open_study_session
from flash_card.constants import (
DEFAULT_DATA_DIR,
PROGRESS_FILE_NAME,
VOCABULARY_FILE_NAME,
)
from flash_card.domain.errors.vocabulary_errors import VocabularyError
from flash_card.infrastructure.persistence.csv_progress_repository import (
CsvProgressRepository,
)
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
CsvVocabularyRepository,
)
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
from flash_card.presentation.features.flash_card.flash_card_view_model import (
FlashCardViewModel,
)
## @brief Logger of the entry point.
_logger = logging.getLogger(__name__)
def create_application(
data_dir: Path | None = None,
) -> tuple[tk.Tk, FlashCardView]:
"""Create the root window and the flash card view.
@param data_dir Folder of the data files, or None for the default folder.
@return The root window and the view built on it.
@throws VocabularyError When the word list or saved progress is unusable.
"""
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
session = open_study_session(repository, progress, random.choice)
view_model = FlashCardViewModel(session)
root = tk.Tk()
view = FlashCardView(root, view_model)
return root, view
def main(argv: Sequence[str] | None = None) -> int:
"""Run the program and return its exit code.
@param argv Command line arguments, or None for those of the process.
@return The exit code, 0 on success and 1 when the word list is unusable.
"""
parser = argparse.ArgumentParser(description="Flash card program.")
parser.add_argument(
"--data-dir",
type=Path,
default=DEFAULT_DATA_DIR,
help="folder of the data files",
)
args = parser.parse_args(argv)
try:
root, _view = create_application(args.data_dir)
except VocabularyError as error:
_logger.error("%s", error)
return 1
root.mainloop()
return 0
def run() -> NoReturn:
"""Run the program and exit the interpreter with its exit code.
@return Never returns; the function always raises SystemExit.
@throws SystemExit Always, with the exit code of main.
"""
raise SystemExit(main())
@@ -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