diff --git a/src/data/entities.rs b/src/data/entities.rs index 43cc726..b0fabff 100644 --- a/src/data/entities.rs +++ b/src/data/entities.rs @@ -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") } } diff --git a/src/navigation.rs b/src/navigation.rs index a7ac953..c581a31 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -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(), }; } diff --git a/src/pages/exam.rs b/src/pages/exam.rs index 2516883..4a1d918 100644 --- a/src/pages/exam.rs +++ b/src/pages/exam.rs @@ -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, - pub state: AppState, - pub entries : Vec, +pub struct ExamState { + id: Id, + state: AppState, + entries: Vec, + total_count: u32, + open: bool, + custom_fields_cache: Vec, + current_word: WordData, + current_exam_entry_handle: EntityHandle, + deck: DeckSettings, + can_play: bool, + sink: Arc>, } #[derive(Clone)] pub enum ExamMessage { Back, Open, - Answer(bool) + Answer(bool), + Play, + PlayFinished, } -impl NavigatedPage for ExamPage { +impl NavigatedPage for ExamState { fn navigate(&mut self, message: &ExamMessage) -> Option { 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 { 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, + ) } -} \ No newline at end of file +} + +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 { + self.entries.pop().unwrap(); + if self.entries.is_empty() { + return Task::done(RootMessage::Exam(Back)); + } + + let entry = self.entries.last().unwrap(); + let handle: EntityHandle = 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 { + 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() + } +} diff --git a/src/pages/exams.rs b/src/pages/exams.rs index 6ac07c6..009ac38 100644 --- a/src/pages/exams.rs +++ b/src/pages/exams.rs @@ -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 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 { 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( diff --git a/src/pages/repetition.rs b/src/pages/repetition.rs index 5e11614..9098d9c 100644 --- a/src/pages/repetition.rs +++ b/src/pages/repetition.rs @@ -50,7 +50,7 @@ impl NavigatedPage 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>, text: String) -> MaybeError { +pub async fn play_sound(sink: Arc>, text: String) -> MaybeError { let data = get_voice(text.as_str()).await?; spawn_blocking(move || { if let Some(sink) = sink.as_ref()