From 79c42d0feb41d8d35698037cd5aa4c42e1cf0fb2 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Sat, 3 Oct 2026 14:49:19 +0300 Subject: [PATCH] Exams page more code --- sql/exam_entry.sql | 4 +- src/data_provider/exam.rs | 8 +- src/navigation.rs | 14 +- src/pages.rs | 1 + src/pages/exams.rs | 359 ++++++++++++++++++++++++++++++++++++++ src/pages/repetitions.rs | 22 ++- 6 files changed, 391 insertions(+), 17 deletions(-) create mode 100644 src/pages/exams.rs diff --git a/sql/exam_entry.sql b/sql/exam_entry.sql index 99d5923..7f57588 100644 --- a/sql/exam_entry.sql +++ b/sql/exam_entry.sql @@ -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 ); \ No newline at end of file diff --git a/src/data_provider/exam.rs b/src/data_provider/exam.rs index f5ef353..1b4f5cb 100644 --- a/src/data_provider/exam.rs +++ b/src/data_provider/exam.rs @@ -64,19 +64,19 @@ pub struct ExamEntryQuerySet; impl TableAccessQuerySet 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 { diff --git a/src/navigation.rs b/src/navigation.rs index 05c9600..a7ac953 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -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); diff --git a/src/pages.rs b/src/pages.rs index 63d28c0..a4b7bb1 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -11,3 +11,4 @@ pub mod selector; pub mod sync; pub mod word; pub mod writing; +pub mod exams; diff --git a/src/pages/exams.rs b/src/pages/exams.rs new file mode 100644 index 0000000..1729265 --- /dev/null +++ b/src/pages/exams.rs @@ -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, + deck: DeckSettings, + selected_exam_id: Option>, + exams: BTreeMap, ExamExtended>, +} + +#[derive(Clone, Eq, PartialEq)] +pub enum ExamsMessage { + Back, + SelectExam(EntityHandle), + DeleteExam(EntityHandle), + CreateExam, + GotoExam, +} + +impl NavigatedPage for ExamsState { + fn navigate(&mut self, message: &ExamsMessage) -> Option { + 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 { + 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, 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) -> 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::>(); + + self.state + .exam_entries + .write() + .create_now_many(entries.as_mut_slice())?; + + Ok(()) + } + + fn get_exams_list(state: &AppState, deck_id: Id) -> BTreeMap, 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 + } +} diff --git a/src/pages/repetitions.rs b/src/pages/repetitions.rs index 81ff63e..73938f7 100644 --- a/src/pages/repetitions.rs +++ b/src/pages/repetitions.rs @@ -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 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 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), }