Exams page more code

This commit is contained in:
micialware committed 2026-10-03 14:49:19 +03:00
1 parent 4b9e257cdf
commit 79c42d0feb
6 files changed
+391 -17

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
create table exam_entry
create table exam_entries
(
id integer primary key,
exam_id integer not null references exam on delete cascade,
exam_id integer not null references exams on delete cascade,
word_id integer not null references words on delete cascade,
status integer default null
);
+4 -4
View File
@@ -64,19 +64,19 @@ pub struct ExamEntryQuerySet;
impl TableAccessQuerySet<ExamEntry> for ExamEntryQuerySet {
fn load() -> &'static str {
"SELECT id, exam_id, word_id, status FROM exam_entry"
"SELECT id, exam_id, word_id, status FROM exam_entries"
}
fn save() -> &'static str {
"UPDATE exam_entry set status = $1 WHERE id = $2"
"UPDATE exam_entries set status = $1 WHERE id = $2"
}
fn add() -> &'static str {
"INSERT into exam_entry (exam_id, word_id, status) VALUES ($1, $2, $3)"
"INSERT into exam_entries (exam_id, word_id, status) VALUES ($1, $2, $3)"
}
fn name() -> &'static str {
"exam_entry"
"exam_entries"
}
fn from_sql(row: &Row) -> Result<ExamEntry, Error> {
+10 -4
View File
@@ -1,4 +1,4 @@
use crate::app::{AppState, fill_state};
use crate::app::{fill_state, AppState};
use crate::data_provider::sqlite::create_db;
use crate::data_provider::web_api::{
get_local_version, get_web_version, load_data, set_local_version,
@@ -12,6 +12,7 @@ use crate::notification::{
};
use crate::pages::dictionary::{DictionaryMessage, DictionaryState};
use crate::pages::dictionary_quiz::{DictionaryQuizMessage, DictionaryQuizState};
use crate::pages::exams::{ExamsMessage, ExamsState};
use crate::pages::history::{HistoryMessage, HistoryState};
use crate::pages::import::{ImportMessage, ImportState};
use crate::pages::quiz::{QuizMessage, QuizState};
@@ -59,6 +60,7 @@ pub enum RootMessage {
History(HistoryMessage),
RepetitionSettings(RepetitionSettingsMessage),
Import(ImportMessage),
Exams(ExamsMessage),
Keyboard(Event),
PushNotification(NotificationRequest),
Notification(NotificationMessage),
@@ -80,6 +82,7 @@ pub enum Page {
History(HistoryState),
RepetitionSettings(RepetitionSettingsState),
Import(ImportState),
Exams(ExamsState),
PreviousPage,
}
@@ -182,7 +185,8 @@ impl ScreenState {
Sync,
History,
RepetitionSettings,
Import
Import,
Exams
);
}
debug!("Update took {:?}", time.elapsed());
@@ -205,7 +209,8 @@ impl ScreenState {
Sync,
History,
RepetitionSettings,
Import
Import,
Exams
)
}
@@ -225,7 +230,8 @@ impl ScreenState {
Sync,
History,
RepetitionSettings,
Import
Import,
Exams
);
let notifications = self.notification_manager.draw().map(Notification);
+1
View File
@@ -11,3 +11,4 @@ pub mod selector;
pub mod sync;
pub mod word;
pub mod writing;
pub mod exams;
+359
View File
@@ -0,0 +1,359 @@
use crate::app::AppState;
use crate::data::entities::{DeckSettings, EntityHandle, Exam, ExamEntry};
use crate::data::repositories::{BatchedRepository, Repository};
use crate::data::Id;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::MaybeError;
use crate::pages::exams::ExamsMessage::*;
use crate::pages::repetitions::Style;
use crate::push_error;
use crate::styling::{back_overlay, BUTTON_RADIUS, DEFAULT_SPACING, HALF_SPACING};
use crate::{jl_button, push_error_task};
use chrono::Utc;
use iced::widget::button::Status;
use iced::widget::{
button, center_x, column, container, row, scrollable, space, text, tooltip, Column,
};
use iced::{Element, Task};
use iced_core::Alignment::*;
use iced_core::Length::{Fill, FillPortion};
use iced_core::{Background, Border, Color, Shadow, Theme};
use std::collections::BTreeMap;
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
pub struct ExamsState {
state: AppState,
deck_id: EntityHandle<DeckSettings>,
deck: DeckSettings,
selected_exam_id: Option<EntityHandle<Exam>>,
exams: BTreeMap<EntityHandle<Exam>, ExamExtended>,
}
#[derive(Clone, Eq, PartialEq)]
pub enum ExamsMessage {
Back,
SelectExam(EntityHandle<Exam>),
DeleteExam(EntityHandle<Exam>),
CreateExam,
GotoExam,
}
impl NavigatedPage<ExamsMessage> for ExamsState {
fn navigate(&mut self, message: &ExamsMessage) -> Option<Page> {
if let Back = message {
return Some(PreviousPage);
}
if let Some(selected_exam_id) = self.selected_exam_id
&& GotoExam == *message
{
return Some(PreviousPage);
}
None
}
fn navigated(&mut self) {}
fn update(&mut self, message: ExamsMessage) -> Task<RootMessage> {
match message {
Back => {}
GotoExam => {}
SelectExam(id) => {
self.selected_exam_id = Some(id);
}
DeleteExam(id) => {
self.selected_exam_id = None;
}
CreateExam => {
let exam = Exam {
id: Default::default(),
timestamp: Utc::now(),
deck_id: self.deck_id.id,
};
let mut exam_repo = self.state.exam.write();
let handle = exam_repo.create_now(exam);
if let Ok(handle) = handle {
drop(exam_repo);
if let Err(err) = self.append_exam_entries(handle) {
return push_error_task!(err);
}
self.exams = Self::get_exams_list(&self.state, self.deck_id.id);
self.selected_exam_id = Some(handle);
} else {
return push_error_task!(handle.err().unwrap());
}
}
}
Task::none()
}
fn view(&self) -> Element<'_, ExamsMessage> {
back_overlay(
column![
text(format!("Экзамены для колоды {}", self.deck.name)),
row![row![
column![
scrollable(self.exams_list()).height(Fill),
jl_button!("Добавить").width(Fill).on_press(CreateExam),
]
.spacing(DEFAULT_SPACING)
.width(FillPortion(1)),
self.selected_exam_view(),
]]
]
.into(),
Back,
)
}
}
impl ExamsState {
pub fn new(deck_id: EntityHandle<DeckSettings>, state: AppState) -> Self {
let deck = state.decks.read().get_static(deck_id).unwrap();
Self {
exams: Self::get_exams_list(&state, deck_id.id),
state,
deck_id,
deck,
selected_exam_id: None,
}
}
fn selected_exam(&self) -> Option<&ExamExtended> {
self.exams.get(&self.selected_exam_id?)
}
fn selected_exam_mut(&mut self) -> Option<&mut ExamExtended> {
self.exams.get_mut(&self.selected_exam_id?)
}
fn append_exam_entries(&mut self, handle: EntityHandle<Exam>) -> MaybeError {
let deck_id = self.deck_id.id;
let words: Vec<_> = self
.state
.card_states
.read()
.iter()
.filter(|state| state.deck_id == deck_id)
.map(|state| state.word_id)
.collect();
let mut entries = words
.iter()
.map(|word_id| ExamEntry {
id: Default::default(),
exam_id: handle.id,
word_id: *word_id,
status: None,
})
.collect::<Vec<_>>();
self.state
.exam_entries
.write()
.create_now_many(entries.as_mut_slice())?;
Ok(())
}
fn get_exams_list(state: &AppState, deck_id: Id) -> BTreeMap<EntityHandle<Exam>, ExamExtended> {
let mut query = state
.exam
.read()
.query(Arc::new(move |exam| exam.deck_id == deck_id));
let handles: Vec<_> = query.handles().collect();
query
.iter(&state.exam.read())
.cloned()
.zip(handles)
.map(|(e, h)| (h, ExamExtended::from(e, &state)))
.collect()
}
}
impl ExamsState {
pub fn exams_list(&self) -> Column<'_, ExamsMessage> {
let mut column = Column::new();
for (handle, deck) in self.exams.iter() {
column = column.push(
button(text!("{}", &deck.timestamp.format("%d/%m/%Y %H:%M")))
.on_press_with(move || SelectExam(*handle))
.style(move |_x: &Theme, status: Status| Style {
background: if status == Status::Hovered {
Some(Background::Color(Color::WHITE.scale_alpha(0.2)))
} else {
None
},
text_color: _x.palette().primary,
border: Border {
color: Default::default(),
width: 0.0,
radius: BUTTON_RADIUS.into(),
},
shadow: Shadow::default(),
snap: false,
}),
);
}
column
}
pub fn selected_exam_view(&self) -> Element<'_, ExamsMessage> {
if let Some(exam) = self.selected_exam() {
return column![
center_x(text!(
"Экзамен от {}",
exam.timestamp.format("%d/%m/%Y %H:%M")
)),
row![text!("{} карточек", exam.total), Self::exam_bar(exam)]
.height(30)
.align_y(Center)
.width(Fill).spacing(HALF_SPACING),
self.exam_variable_view(exam),
]
.spacing(DEFAULT_SPACING)
.width(FillPortion(2))
.into();
}
space().width(FillPortion(3)).into()
}
fn exam_bar(exam: &ExamExtended) -> Element<'_, ExamsMessage> {
let total = 1000.0;
let answered = exam.answered as f64 / exam.total as f64 * total;
let unknown = exam.unknown as f64 / exam.total as f64 * total;
let remaining = exam.remaining() as f64 / exam.total as f64 * total;
row![
tooltip(
container(space())
.height(Fill)
.width(FillPortion(answered as u16))
.style(|theme: &Theme| {
container::Style {
background: Some(Background::Color(theme.palette().success)),
text_color: Default::default(),
border: Default::default(),
shadow: Default::default(),
snap: false,
}
}),
text!("Правильно: {}%", answered / 10.0),
tooltip::Position::Bottom
),
tooltip(
container(space())
.height(Fill)
.width(FillPortion(unknown as u16))
.style(|theme: &Theme| {
container::Style {
background: Some(Background::Color(theme.palette().danger)),
text_color: Default::default(),
border: Default::default(),
shadow: Default::default(),
snap: false,
}
}),
text!("Не получилось: {}%", unknown / 10.0),
tooltip::Position::Bottom
),
tooltip(
container(space())
.height(Fill)
.width(FillPortion(remaining as u16))
.style(|theme: &Theme| {
container::Style {
background: Some(Background::Color(theme.palette().primary)),
text_color: Default::default(),
border: Default::default(),
shadow: Default::default(),
snap: false,
}
}),
text!("Осталось {}%", remaining / 10.0),
tooltip::Position::Bottom
)
]
.width(Fill)
.height(Fill)
.into()
}
fn exam_variable_view(&self, exam: &ExamExtended) -> Element<'_, ExamsMessage> {
if exam.finished() {
self.exam_finished_view(exam)
} else {
self.exam_ongoing_view(exam)
}
}
fn exam_finished_view(&self, exam: &ExamExtended) -> Element<'_, ExamsMessage> {
column![].spacing(DEFAULT_SPACING).into()
}
fn exam_ongoing_view(&self, exam: &ExamExtended) -> Element<'_, ExamsMessage> {
center_x(button("Продолжить экзамен").on_press(GotoExam)).into()
}
}
struct ExamExtended {
exam: Exam,
total: u32,
answered: u32,
unknown: u32,
}
impl ExamExtended {
fn from(exam: Exam, state: &AppState) -> Self {
let entries = state.exam_entries.read();
let mut total = 0;
let mut answered = 0;
let mut unknown = 0;
entries.iter().filter(|e| e.exam_id == e.id).for_each(|e| {
total += 1;
match e.status {
None => {}
Some(s) => {
if s {
answered += 1;
} else {
unknown += 1;
}
}
}
});
Self {
exam,
total,
answered,
unknown,
}
}
fn solved(&self) -> u32 {
self.answered + self.unknown
}
fn finished(&self) -> bool {
self.solved() == self.total
}
fn remaining(&self) -> u32 {
self.total - self.solved()
}
}
impl Deref for ExamExtended {
type Target = Exam;
fn deref(&self) -> &Self::Target {
&self.exam
}
}
impl DerefMut for ExamExtended {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.exam
}
}
+15 -7
View File
@@ -3,9 +3,10 @@ use crate::core::deck::DeckData;
use crate::core::srs::{AppendMode, OrderMode};
use crate::data::entities::{CardState, DeckSettings, EntityHandle, WordData};
use crate::data::repositories::{BatchedRepository, Repository};
use crate::navigation::Page::{History, PreviousPage, Repetition, RepetitionSettings};
use crate::navigation::Page::{Exams, History, PreviousPage, Repetition, RepetitionSettings};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::MaybeError;
use crate::pages::exams::ExamsState;
use crate::pages::history::HistoryState;
use crate::pages::repetition::RepetitionState;
use crate::pages::repetition_settings::RepetitionSettingsState;
@@ -16,17 +17,16 @@ use crate::styling::*;
use crate::{jl_button, jl_text_input};
use chrono::{Days, Local, Utc};
use hashbrown::HashSet;
use iced::widget::button::{danger, Status};
pub use iced::widget::button::{Catalog, Style};
use iced::widget::button::{Status, danger};
use iced::widget::tooltip::Position::Top;
use iced::widget::{
Column, Row, button, column, container, lazy, radio, row, scrollable, space, svg, text,
text_input, tooltip,
button, column, container, lazy, radio, row, scrollable, space, svg, text, tooltip, Column, Row,
};
use iced::{Background, Border, Center, Color, Element, Fill, Length, Shadow, Task, Theme};
use iced_core::Padding;
use iced_core::border::Radius;
use iced_core::svg::Handle;
use iced_core::Padding;
use std::collections::BTreeMap;
use std::ops::Deref;
use std::sync::Arc;
@@ -74,7 +74,10 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
self.selected_deck_handle.unwrap(),
self.state.clone(),
)))
} else {
} else if let GoToExams = message {
Some(Exams(ExamsState::new(self.selected_deck_handle.unwrap(), self.state.clone())))
} else
{
None
}
}
@@ -219,6 +222,7 @@ impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
return push_error_task!(err);
}
}
GoToExams => {}
}
Task::none()
}
@@ -355,7 +359,10 @@ impl RepetitionsState {
Some(deck.open_mode),
SetOpenMode
),
jl_button!("История").on_press(GoToHistory),
row![
jl_button!("История").on_press(GoToHistory),
jl_button!("Экзамены").on_press(GoToExams),
].spacing(DEFAULT_SPACING),
]
.spacing(HALF_SPACING)
}
@@ -630,6 +637,7 @@ pub enum RepetitionsMessage {
SetOpenMode(OrderMode),
SetAppendMode(AppendMode),
GoToHistory,
GoToExams,
GoToSettings,
AppendWords(usize),
}