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
+254 -39

No files matched your search

+1 -1
View File
@@ -146,7 +146,7 @@ impl DeckSettings {
ast.is_ok()
}
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 log::{debug, info};
use std::time::Instant;
use crate::pages::exam::{ExamMessage, ExamState};
impl Default for ScreenState {
fn default() -> Self {
@@ -61,6 +62,7 @@ pub enum RootMessage {
RepetitionSettings(RepetitionSettingsMessage),
Import(ImportMessage),
Exams(ExamsMessage),
Exam(ExamMessage),
Keyboard(Event),
PushNotification(NotificationRequest),
Notification(NotificationMessage),
@@ -83,6 +85,7 @@ pub enum Page {
RepetitionSettings(RepetitionSettingsState),
Import(ImportState),
Exams(ExamsState),
Exam(ExamState),
PreviousPage,
}
@@ -186,7 +189,8 @@ impl ScreenState {
History,
RepetitionSettings,
Import,
Exams
Exams,
Exam
);
}
debug!("Update took {:?}", time.elapsed());
@@ -210,7 +214,8 @@ impl ScreenState {
History,
RepetitionSettings,
Import,
Exams
Exams,
Exam
)
}
@@ -231,7 +236,8 @@ impl ScreenState {
History,
RepetitionSettings,
Import,
Exams
Exams,
Exam
);
let notifications = self.notification_manager.draw().map(Notification);
@@ -245,6 +251,7 @@ impl ScreenState {
return match page {
Repetition(page) => page.press(&message),
Dictionary(page) => page.press(&message),
Exam(page) => page.press(&message),
_ => Task::none(),
};
}
+226 -19
View File
@@ -1,48 +1,255 @@
use iced::{Element, Task};
use crate::app::AppState;
use crate::data::entities::{EntityHandle, Exam, ExamEntry};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::core::deck::DeckData;
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::styling::back_overlay;
use iced::widget::{button, row, column, text};
use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
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 id: EntityHandle<Exam>,
pub state: AppState,
pub entries : Vec<ExamEntry>,
pub struct ExamState {
id: Id,
state: AppState,
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)]
pub enum ExamMessage {
Back,
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> {
if let Back = message {
return Some(PreviousPage)
return Some(PreviousPage);
}
None
}
fn navigated(&mut self) {
}
fn navigated(&mut self) {}
fn update(&mut self, message: ExamMessage) -> Task<RootMessage> {
match message {
Back => {}
Open => {}
Answer(answer) => {}
Open => {
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()
}
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::data::Id;
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::exam::ExamState;
use crate::pages::exams::ExamsMessage::*;
use crate::pages::repetitions::Style;
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 chrono::Utc;
use chrono::{Local, Utc};
use iced::widget::button::Status;
use iced::widget::rule::horizontal;
use iced::widget::{button, center_x, column, container, row, rule, scrollable, space, text, tooltip, Column};
use iced::widget::{Column, button, center_x, column, container, row, rule, scrollable, space, text, tooltip};
use iced::{Element, Task};
use iced_core::Alignment::*;
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
&& GotoExam == *message
{
return Some(PreviousPage);
return Some(Page::Exam(ExamState::new(selected_exam_id.id, self.deck.clone(), &self.state)));
}
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> {
match message {
@@ -170,7 +172,7 @@ impl ExamsState {
for (handle, deck) in self.exams.iter() {
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))
.style(move |_x: &Theme, status: Status| Style {
background: if status == Status::Hovered {
@@ -178,7 +180,7 @@ impl ExamsState {
} else {
None
},
text_color: _x.palette().primary,
text_color: if deck.finished() { _x.palette().success } else { _x.palette().primary},
border: Border {
color: Default::default(),
width: 0.0,
@@ -197,7 +199,7 @@ impl ExamsState {
return column![
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)]
.height(30)
@@ -217,10 +219,9 @@ impl ExamsState {
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;
println!("{} {} {}", remaining, answered, unknown);
let answered = (exam.answered as f64 / exam.total as f64 * total).round();
let unknown = (exam.unknown as f64 / exam.total as f64 * total).round();
let remaining = (exam.remaining() as f64 / exam.total as f64 * total).round();
let mut row = row![];
if answered > 0.0 {
row = row.push(tooltip(
+2 -2
View File
@@ -50,7 +50,7 @@ impl NavigatedPage<RepetitionMessage> for RepetitionState {
RepetitionMessage::Back => {}
RepetitionMessage::Next => return self.next(),
RepetitionMessage::Answer(m) => return self.answer(m),
RepetitionMessage::Play => {
Play => {
if !self.can_play {
return Task::none();
}
@@ -242,7 +242,7 @@ pub enum RepetitionMessage {
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?;
spawn_blocking(move || {
if let Some(sink) = sink.as_ref()