Exam page works

This commit is contained in:
micialware committed 2026-10-03 22:47:33 +03:00
1 parent 0b23da9eda
commit 7ad44110c3
5 files changed
+253 -38

No files matched your search

+1 -1
View File
@@ -146,7 +146,7 @@ impl DeckSettings {
ast.is_ok() ast.is_ok()
} }
pub fn require_speech(&self) -> bool { pub fn require_speech(&self) -> bool {
self.forward == "speech" || self.backward == "speech" self.forward.contains("speech") || self.backward.contains("speech")
} }
} }
+10 -3
View File
@@ -33,6 +33,7 @@ use iced::{Element, Task};
use iced_core::Length::Fill; use iced_core::Length::Fill;
use log::{debug, info}; use log::{debug, info};
use std::time::Instant; use std::time::Instant;
use crate::pages::exam::{ExamMessage, ExamState};
impl Default for ScreenState { impl Default for ScreenState {
fn default() -> Self { fn default() -> Self {
@@ -61,6 +62,7 @@ pub enum RootMessage {
RepetitionSettings(RepetitionSettingsMessage), RepetitionSettings(RepetitionSettingsMessage),
Import(ImportMessage), Import(ImportMessage),
Exams(ExamsMessage), Exams(ExamsMessage),
Exam(ExamMessage),
Keyboard(Event), Keyboard(Event),
PushNotification(NotificationRequest), PushNotification(NotificationRequest),
Notification(NotificationMessage), Notification(NotificationMessage),
@@ -83,6 +85,7 @@ pub enum Page {
RepetitionSettings(RepetitionSettingsState), RepetitionSettings(RepetitionSettingsState),
Import(ImportState), Import(ImportState),
Exams(ExamsState), Exams(ExamsState),
Exam(ExamState),
PreviousPage, PreviousPage,
} }
@@ -186,7 +189,8 @@ impl ScreenState {
History, History,
RepetitionSettings, RepetitionSettings,
Import, Import,
Exams Exams,
Exam
); );
} }
debug!("Update took {:?}", time.elapsed()); debug!("Update took {:?}", time.elapsed());
@@ -210,7 +214,8 @@ impl ScreenState {
History, History,
RepetitionSettings, RepetitionSettings,
Import, Import,
Exams Exams,
Exam
) )
} }
@@ -231,7 +236,8 @@ impl ScreenState {
History, History,
RepetitionSettings, RepetitionSettings,
Import, Import,
Exams Exams,
Exam
); );
let notifications = self.notification_manager.draw().map(Notification); let notifications = self.notification_manager.draw().map(Notification);
@@ -245,6 +251,7 @@ impl ScreenState {
return match page { return match page {
Repetition(page) => page.press(&message), Repetition(page) => page.press(&message),
Dictionary(page) => page.press(&message), Dictionary(page) => page.press(&message),
Exam(page) => page.press(&message),
_ => Task::none(), _ => Task::none(),
}; };
} }
+225 -18
View File
@@ -1,48 +1,255 @@
use iced::{Element, Task};
use crate::app::AppState; use crate::app::AppState;
use crate::data::entities::{EntityHandle, Exam, ExamEntry}; use crate::core::deck::DeckData;
use crate::navigation::{NavigatedPage, Page, RootMessage}; use crate::core::srs::WordOpenMode;
use crate::data::Id;
use crate::data::entities::{CustomField, DeckSettings, EntityHandle, Exam, ExamEntry, WordData};
use crate::data::repositories::Repository;
use crate::navigation::Page::PreviousPage; use crate::navigation::Page::PreviousPage;
use crate::styling::back_overlay; use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
use iced::widget::{button, row, column, text};
use crate::pages::exam::ExamMessage::*; use crate::pages::exam::ExamMessage::*;
use crate::pages::repetition::{RepetitionMessage, play_sound};
use crate::styling::*;
use crate::{push_error, push_error_task};
use iced::widget::space::horizontal;
use iced::widget::{button, column, container, progress_bar, row, text, tooltip};
use iced::{Element, Task};
use iced_core::Alignment::Center;
use iced_core::Length::Fill;
use iced_core::keyboard::Event;
use iced_core::keyboard::key::Physical::Code;
use iced_core::widget::text::Style;
use iced_core::{Theme, keyboard};
use rand::prelude::SliceRandom;
use rand::rng;
use rodio::MixerDeviceSink;
use std::sync::Arc;
pub struct ExamPage pub struct ExamState {
{ id: Id,
pub id: EntityHandle<Exam>, state: AppState,
pub state: AppState, entries: Vec<ExamEntry>,
pub entries : Vec<ExamEntry>, total_count: u32,
open: bool,
custom_fields_cache: Vec<CustomField>,
current_word: WordData,
current_exam_entry_handle: EntityHandle<ExamEntry>,
deck: DeckSettings,
can_play: bool,
sink: Arc<Option<MixerDeviceSink>>,
} }
#[derive(Clone)] #[derive(Clone)]
pub enum ExamMessage { pub enum ExamMessage {
Back, Back,
Open, Open,
Answer(bool) Answer(bool),
Play,
PlayFinished,
} }
impl NavigatedPage<ExamMessage> for ExamPage { impl NavigatedPage<ExamMessage> for ExamState {
fn navigate(&mut self, message: &ExamMessage) -> Option<Page> { fn navigate(&mut self, message: &ExamMessage) -> Option<Page> {
if let Back = message { if let Back = message {
return Some(PreviousPage) return Some(PreviousPage);
} }
None None
} }
fn navigated(&mut self) { fn navigated(&mut self) {}
}
fn update(&mut self, message: ExamMessage) -> Task<RootMessage> { fn update(&mut self, message: ExamMessage) -> Task<RootMessage> {
match message { match message {
Back => {} Back => {}
Open => {} Open => {
Answer(answer) => {} if self.open {
return Task::none();
}
self.open = true;
}
Answer(answer) => {
if !self.open {
return Task::none();
}
let id = self.current_exam_entry_handle.id;
let mut repo = self.state.exam_entries.write();
repo.set(
&mut self.current_exam_entry_handle,
ExamEntry {
id,
exam_id: self.id,
word_id: self.current_word.id,
status: Some(answer),
},
);
if let Err(err) = repo.save(&mut self.current_exam_entry_handle) {
return push_error_task!(err);
}
drop(repo);
self.open = false;
return self.next();
}
Play => {
if !self.can_play {
return Task::none();
}
self.can_play = false;
let value = self.current_word.key.clone();
if self.deck.require_speech() {
return Task::perform(play_sound(self.sink.clone(), value), |r| match r {
Ok(_) => RootMessage::Exam(PlayFinished),
Err(err) => push_error!(err),
});
}
}
PlayFinished => {
self.can_play = true;
}
} }
Task::none() Task::none()
} }
fn view(&self) -> Element<'_, ExamMessage> { fn view(&self) -> Element<'_, ExamMessage> {
back_overlay(column![].into(), Back) back_overlay(
column![
column![
progress_bar(
0.0..=(self.total_count as f32),
(self.total_count - self.entries.len() as u32) as f32
),
row![
text!("Решено: {}", self.total_count - self.entries.len() as u32),
horizontal().width(Fill),
text!("Осталось: {}", self.entries.len())
],
],
crate::widgets::card_display::word_display(
&self.deck,
&self.current_word,
self.open,
Play,
self.custom_fields_cache.as_slice()
),
container(self.answer_bar())
.width(Fill)
.align_x(Center)
.height(80),
]
.into(),
Back,
)
}
}
impl ExamState {
fn answer_bar(&self) -> Element<'_, ExamMessage> {
if !self.open {
return text!("Нажмите Пробел для открытия карточки")
.style(|theme: &Theme| Style {
color: Some(theme.palette().text.scale_alpha(0.3)),
})
.into();
}
row![
tooltip(
button("Не знаю").on_press(Answer(false)),
"Клавиша 1",
tooltip::Position::Top
),
tooltip(
button("Знаю").on_press(Answer(true)),
"Клавиша 2",
tooltip::Position::Top
),
]
.spacing(QUARTER_SPACING)
.into()
}
}
impl ExamState {
pub fn new(exam_id: Id, deck: DeckSettings, state: &AppState) -> Self {
let custom_fields_cache = state.custom_fields.read().iter().cloned().collect();
let sink_handle = if deck.require_speech() {
rodio::DeviceSinkBuilder::open_default_sink().ok()
} else {
None
};
let mut entries: Vec<_> = state
.exam_entries
.read()
.iter()
.filter(|e| e.exam_id == exam_id)
.cloned()
.collect();
let total_count = entries.len() as u32;
entries.retain(|e| e.status.is_none());
entries.shuffle(&mut rng());
let word = state
.dictionary
.read()
.get_static(EntityHandle::from_id(entries.last().unwrap().word_id))
.unwrap();
Self {
id: exam_id,
state: state.clone(),
current_word: word,
current_exam_entry_handle: EntityHandle::from_id(entries.last().unwrap().id),
entries,
total_count,
open: false,
custom_fields_cache,
deck,
can_play: true,
sink: Arc::new(sink_handle),
}
}
fn next(&mut self) -> Task<RootMessage> {
self.entries.pop().unwrap();
if self.entries.is_empty() {
return Task::done(RootMessage::Exam(Back));
}
let entry = self.entries.last().unwrap();
let handle: EntityHandle<ExamEntry> = EntityHandle::from_id(entry.id);
let word = self
.state
.dictionary
.read()
.get_static(EntityHandle::from_id(entry.word_id))
.unwrap();
self.current_word = word;
self.current_exam_entry_handle = handle;
Task::none()
}
}
impl KeyPressedPage for ExamState {
fn press(&mut self, message: &Event) -> Task<RootMessage> {
if let Event::KeyPressed {
key: _,
modified_key: _,
physical_key: pk,
location: _,
modifiers: _,
text: _,
repeat: _,
} = message
&& let Code(code) = pk
{
return match code {
keyboard::key::Code::Space => Task::done(RootMessage::Exam(Open)),
keyboard::key::Code::Digit1 => Task::done(RootMessage::Exam(Answer(false))),
keyboard::key::Code::Digit2 => Task::done(RootMessage::Exam(Answer(true))),
_ => Task::none(),
};
}
Task::none()
} }
} }
+15 -14
View File
@@ -1,19 +1,19 @@
use crate::app::AppState; use crate::app::AppState;
use crate::data::Id;
use crate::data::entities::{DeckSettings, EntityHandle, Exam, ExamEntry}; use crate::data::entities::{DeckSettings, EntityHandle, Exam, ExamEntry};
use crate::data::repositories::{BatchedRepository, Repository}; use crate::data::repositories::{BatchedRepository, Repository};
use crate::data::Id;
use crate::navigation::Page::PreviousPage; use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage}; use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::MaybeError; use crate::notification::MaybeError;
use crate::pages::exam::ExamState;
use crate::pages::exams::ExamsMessage::*; use crate::pages::exams::ExamsMessage::*;
use crate::pages::repetitions::Style; use crate::pages::repetitions::Style;
use crate::push_error; use crate::push_error;
use crate::styling::{back_overlay, cta_button, BUTTON_RADIUS, DEFAULT_SPACING, HALF_SPACING}; use crate::styling::{BUTTON_RADIUS, DEFAULT_SPACING, HALF_SPACING, back_overlay, cta_button};
use crate::{jl_button, push_error_task}; use crate::{jl_button, push_error_task};
use chrono::Utc; use chrono::{Local, Utc};
use iced::widget::button::Status; use iced::widget::button::Status;
use iced::widget::rule::horizontal; use iced::widget::{Column, button, center_x, column, container, row, rule, scrollable, space, text, tooltip};
use iced::widget::{button, center_x, column, container, row, rule, scrollable, space, text, tooltip, Column};
use iced::{Element, Task}; use iced::{Element, Task};
use iced_core::Alignment::*; use iced_core::Alignment::*;
use iced_core::Length::{Fill, FillPortion}; use iced_core::Length::{Fill, FillPortion};
@@ -46,12 +46,14 @@ impl NavigatedPage<ExamsMessage> for ExamsState {
if let Some(selected_exam_id) = self.selected_exam_id if let Some(selected_exam_id) = self.selected_exam_id
&& GotoExam == *message && GotoExam == *message
{ {
return Some(PreviousPage); return Some(Page::Exam(ExamState::new(selected_exam_id.id, self.deck.clone(), &self.state)));
} }
None None
} }
fn navigated(&mut self) {} fn navigated(&mut self) {
self.exams = Self::get_exams_list(&self.state, self.deck_id.id);
}
fn update(&mut self, message: ExamsMessage) -> Task<RootMessage> { fn update(&mut self, message: ExamsMessage) -> Task<RootMessage> {
match message { match message {
@@ -170,7 +172,7 @@ impl ExamsState {
for (handle, deck) in self.exams.iter() { for (handle, deck) in self.exams.iter() {
column = column.push( column = column.push(
jl_button!(text!("{}", &deck.timestamp.format("%d/%m/%Y %H:%M"))) jl_button!(text!("{}", &deck.timestamp.with_timezone(&Local).format("%d/%m/%Y %H:%M")))
.on_press_with(move || SelectExam(*handle)) .on_press_with(move || SelectExam(*handle))
.style(move |_x: &Theme, status: Status| Style { .style(move |_x: &Theme, status: Status| Style {
background: if status == Status::Hovered { background: if status == Status::Hovered {
@@ -178,7 +180,7 @@ impl ExamsState {
} else { } else {
None None
}, },
text_color: _x.palette().primary, text_color: if deck.finished() { _x.palette().success } else { _x.palette().primary},
border: Border { border: Border {
color: Default::default(), color: Default::default(),
width: 0.0, width: 0.0,
@@ -197,7 +199,7 @@ impl ExamsState {
return column![ return column![
center_x(text!( center_x(text!(
"Экзамен от {}", "Экзамен от {}",
exam.timestamp.format("%d/%m/%Y %H:%M") exam.timestamp.with_timezone(&Local).format("%d/%m/%Y %H:%M")
)), )),
row![text!("{} карточек", exam.total), Self::exam_bar(exam)] row![text!("{} карточек", exam.total), Self::exam_bar(exam)]
.height(30) .height(30)
@@ -217,10 +219,9 @@ impl ExamsState {
fn exam_bar(exam: &ExamExtended) -> Element<'_, ExamsMessage> { fn exam_bar(exam: &ExamExtended) -> Element<'_, ExamsMessage> {
let total = 1000.0; let total = 1000.0;
let answered = exam.answered as f64 / exam.total as f64 * total; let answered = (exam.answered as f64 / exam.total as f64 * total).round();
let unknown = exam.unknown as f64 / exam.total as f64 * total; let unknown = (exam.unknown as f64 / exam.total as f64 * total).round();
let remaining = exam.remaining() as f64 / exam.total as f64 * total; let remaining = (exam.remaining() as f64 / exam.total as f64 * total).round();
println!("{} {} {}", remaining, answered, unknown);
let mut row = row![]; let mut row = row![];
if answered > 0.0 { if answered > 0.0 {
row = row.push(tooltip( row = row.push(tooltip(
+2 -2
View File
@@ -50,7 +50,7 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
RepetitionMessage::Back => {} RepetitionMessage::Back => {}
RepetitionMessage::Next => return self.next(), RepetitionMessage::Next => return self.next(),
RepetitionMessage::Answer(m) => return self.answer(m), RepetitionMessage::Answer(m) => return self.answer(m),
RepetitionMessage::Play => { Play => {
if !self.can_play { if !self.can_play {
return Task::none(); return Task::none();
} }
@@ -242,7 +242,7 @@ pub enum RepetitionMessage {
PlayFinished, PlayFinished,
} }
async fn play_sound(sink: Arc<Option<MixerDeviceSink>>, text: String) -> MaybeError { pub async fn play_sound(sink: Arc<Option<MixerDeviceSink>>, text: String) -> MaybeError {
let data = get_voice(text.as_str()).await?; let data = get_voice(text.as_str()).await?;
spawn_blocking(move || { spawn_blocking(move || {
if let Some(sink) = sink.as_ref() if let Some(sink) = sink.as_ref()