Splitting kana set

This commit is contained in:
micialware committed 2026-08-24 01:22:00 +03:00
1 parent 36e5693564
commit c74a0eb311
25 files changed
+389 -354

No files matched your search

+632
View File
@@ -0,0 +1,632 @@
use crate::data_provider::words::{delete_group, delete_word, update_group, update_word};
use crate::pages::dictionary::DictionaryMessage::*;
use crate::pages::dictionary_quiz::DictionaryQuizState;
use crate::pages::import::ImportState;
use crate::lang::{WordData, WordGroup};
use crate::navigation::Page::{Import, Word};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
use crate::pages::word::WordState;
use chrono::{DateTime, TimeDelta, Utc};
use hashbrown::{HashMap, HashSet};
use iced::alignment::Vertical::Center;
use iced::widget::button::Style;
use iced::widget::button::{danger, text};
use iced::widget::space::horizontal;
use iced::widget::text_input::default;
use iced::widget::*;
use iced::{Border, Color, Shadow, Task};
use iced_core::Length::Fill;
use parking_lot::Mutex;
use rand::random_range;
use std::fs;
use std::ops::Add;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use log::info;
use crate::app::AppState;
#[derive(Clone)]
pub struct DictionaryState {
state: Arc<Mutex<AppState>>,
include_map: Vec<bool>,
tag_map: HashMap<String, bool>,
reverse: bool,
search: String,
no_typing: bool,
selected_group_index: usize,
reverse_list: bool,
auto_save_queue: HashMap<usize, DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub enum DictionaryMessage {
Back,
SetTags(usize, String),
SetKey(usize, String),
SetValue(usize, String),
SubmitWord(usize),
WordAction(usize),
NewWord,
Include(usize, bool),
IncludeTag(String, bool),
Test,
ResetTags,
SetReverse(bool),
Search(String),
SetTyping(bool),
CreateGroup,
EditGroup(String),
SaveGroup,
SelectGroup(usize),
DeleteGroup,
ChangeDirection,
TrySave(usize),
ToImport,
}
impl NavigatedPage<DictionaryMessage> for DictionaryState {
fn navigate(&mut self, message: &DictionaryMessage) -> Option<Page> {
if let Back = message {
return Some(Page::PreviousPage);
}
if let Test = message
&& self.include_map.iter().any(|x| *x)
{
let mut words = vec![];
let dict = &self.state.lock().dictionary;
words = self
.include_map
.iter()
.zip(0..self.include_map.len())
.filter(|(flag, _)| **flag)
.map(|(_, index)| dict[index].clone())
.collect();
return Some(Page::DictionaryQuiz(DictionaryQuizState::new(
words,
self.reverse,
self.no_typing,
)));
}
if let WordAction(index) = message {
let word: WordData;
{
let state = self.state.lock();
let dict = &state.dictionary;
word = dict[*index].clone();
}
if word.id.is_valid() {
return Some(Word(WordState::new(word, *index, self.state.clone())));
}
}
if let ToImport = message {
return Some(Import(ImportState::new(self.state.clone())));
}
None
}
fn navigated(&mut self) {
let len = self.state.lock().dictionary.len();
self.include_map = vec![false; len];
self.tag_map = Default::default();
self.update_tags();
}
fn update(&mut self, message: DictionaryMessage) -> Task<RootMessage> {
match message {
NewWord => {
let mut state = self.state.lock();
let mut word = WordData::new();
word.group_id = state.word_groups[self.selected_group_index].id;
let dict = &mut state.dictionary;
dict.push(word);
self.include_map.push(false);
}
SetKey(i, v) => {
{
let dict = &mut self.state.lock().dictionary;
dict[i].key = v;
}
return self.launch_auto_save_offset(i);
}
SetValue(i, v) => {
{
let dict = &mut self.state.lock().dictionary;
dict[i].value = v
}
return self.launch_auto_save_offset(i);
}
SetTags(i, mut v) => {
{
let dict = &mut self.state.lock().dictionary;
let current_tags_value = dict[i].tags.clone();
if v.ends_with(", ") && v.len() < current_tags_value.len() {
v = v[..v.len() - 2].to_string()
}
while let Some(index) = v.find(",,") {
v.remove(index);
}
dict[i].tags = v;
}
self.update_tags();
return self.launch_auto_save_offset(i);
}
WordAction(i) => {
let state = &mut self.state.lock();
let dict = &mut state.dictionary;
let word = dict.remove(i);
self.include_map.remove(i);
self.auto_save_queue.remove(&i);
delete_word(&word, &state.connection)
}
Include(i, b) => self.include_map[i] = b,
IncludeTag(t, v) => {
let index;
{
let state = self.state.lock();
index = state.word_groups[self.selected_group_index].id;
}
self.tag_map.insert(t, v);
self.update_words_include(index)
}
ResetTags => {
self.tag_map.iter_mut().for_each(|(_, v)| *v = false);
self.include_map.iter_mut().for_each(|x| *x = false)
}
SetReverse(v) => self.reverse = v,
Search(s) => {
self.search = s;
}
SetTyping(b) => self.no_typing = b,
SubmitWord(i) => self.save_word(i),
Back => {}
Test => {}
CreateGroup => {
let state = &mut self.state.lock();
state.word_groups.push(WordGroup {
id: 0.into(),
name: format!("Группа слов {}", random_range(100..1000)),
});
}
EditGroup(new) => {
let state = &mut self.state.lock();
let group = state.word_groups.get_mut(self.selected_group_index);
if let Some(group) = group {
group.name = new.clone();
}
}
SaveGroup => {
let state = &mut self.state.lock();
let connection = &state.connection;
let mut group = state.word_groups[self.selected_group_index].clone();
update_group(&mut group, connection);
state.word_groups[self.selected_group_index] = group;
}
SelectGroup(i) => {
self.selected_group_index = i;
let index;
{
let state = self.state.lock();
index = state.word_groups[i].id;
}
self.update_words_include(index)
}
DeleteGroup => {
if self.selected_group_index == 0 {
return Task::none();
}
{
let state = &mut self.state.lock();
if let Some(group) = state.word_groups.get(self.selected_group_index) {
let remove_group_id = group.id;
let connection = &state.connection;
delete_group(group, connection);
state.word_groups.remove(self.selected_group_index);
state
.dictionary
.retain(|word| word.group_id != remove_group_id);
self.selected_group_index = 0;
}
}
self.update_tags()
}
ChangeDirection => {
self.reverse_list = !self.reverse_list;
}
TrySave(word_index) => {
let now = Utc::now();
if !self.auto_save_queue.contains_key(&word_index) {
return Task::none();
}
if self.auto_save_queue[&word_index] <= now {
self.auto_save_queue.remove(&word_index);
self.save_word(word_index);
}
}
ToImport => {}
}
Task::none()
}
fn view(&self) -> iced::Element<'_, DictionaryMessage> {
back_overlay(
row![
iced::widget::column![
self.groups_panel(),
row![horizontal().width(8), self.words_list(),],
row![
self.add_word_button(),
horizontal().width(Fill),
button("Импорт").style(text).on_press(ToImport),
]
]
.spacing(5),
self.filters(),
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
impl DictionaryState {
pub fn new(state: Arc<Mutex<AppState>>) -> Self {
let len = state.lock().dictionary.len();
let mut result = DictionaryState {
include_map: vec![false; len],
selected_group_index: 0,
state,
tag_map: HashMap::new(),
reverse: false,
search: "".to_string(),
no_typing: true,
reverse_list: true,
auto_save_queue: HashMap::new(),
};
result.update_tags();
result
}
fn save_word(&mut self, i: usize) {
let state = &mut self.state.lock();
let connection = &state.connection;
let word = &mut state.dictionary[i].clone();
update_word(word, connection);
state.dictionary[i] = word.clone();
}
fn launch_auto_save_offset(&mut self, index: usize) -> Task<RootMessage> {
let save_time = Utc::now().add(TimeDelta::milliseconds(900));
self.auto_save_queue.insert(index, save_time);
let message = RootMessage::Dictionary(TrySave(index));
Task::perform(
async { tokio::time::sleep(Duration::from_secs(1)).await },
|_| message,
)
}
fn words_list(&self) -> iced::Element<'_, DictionaryMessage> {
let time = Instant::now();
let mut col = Column::new().width(Fill);
let state = self.state.lock();
let group_id = state.word_groups[self.selected_group_index].id;
let dict = &state.dictionary;
let mut index = 0;
for access_index in 0..dict.len() {
let mut i = access_index;
if self.reverse_list {
i = dict.len() - access_index - 1;
}
let word = &dict[i];
if word.group_id != group_id {
continue;
}
if !self.search.is_empty()
&& !word.key.contains(&self.search)
&& !word.value.contains(&self.search)
&& !word.tags.contains(&self.search)
{
continue;
}
let word_line_data = WordLineState {
is_included: self.include_map[i],
key: word.key.clone(),
value: word.value.clone(),
tags: word.tags.clone(),
id: word.id,
index: i,
};
index += 1;
let lazy_line = lazy(word_line_data, move |data| {
let index = data.index;
let mut line = Row::new().width(Fill).align_y(Center);
line = line.push(
checkbox(data.is_included)
.label("")
.spacing(15)
.on_toggle(move |b| Include(index, b)),
);
line = line.push(
text_input("Слово", &data.key)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.on_input(move |string| SetKey(index, string))
.on_submit(SubmitWord(index))
.style(|x, status| {
let mut default_style = default(x, status);
default_style.border.radius = 0.0.into();
default_style
}),
);
line = line.push(
text_input("Перевод", &data.value)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.on_input(move |string| SetValue(index, string))
.on_submit(SubmitWord(index))
.style(|x, status| {
let mut default_style = default(x, status);
default_style.border.radius = 0.0.into();
default_style
}),
);
line = line.push(
text_input("Теги", &data.tags)
.size(ACCENT_FONT_SIZE)
.width(Fill)
.on_input(move |string| SetTags(index, string))
.on_submit(SubmitWord(index))
.style(|x, status| {
let mut default_style = default(x, status);
default_style.border.radius = 0.0.into();
default_style
}),
);
let line_button = || {
let action = WordAction(index);
if !data.id.is_valid() {
return button("-").on_press(action).style(|_x, _status| Style {
background: None,
text_color: Color::BLACK,
border: Border::default(),
shadow: Shadow::default(),
snap: false,
});
}
button("").on_press(WordAction(index)).width(15)
};
line = line.push(line_button()).push(space().width(10));
line
});
col = col.push(lazy_line);
}
info!("Drawing {index} lines");
info!("Words rendering time: {:?}", time.elapsed());
scrollable(col).height(Fill).into()
}
fn filters(&self) -> iced::Element<'_, DictionaryMessage> {
let dict = &self.state.lock().dictionary;
iced::widget::column![
text_input("Поиск", &self.search)
.on_input(Search)
.width(Fill),
text!("Всего слов: {}", dict.len()),
text!(
"Выбрано слов: {}",
self.include_map.iter().filter(|i| **i).count()
),
self.tags_selector(),
toggler(self.no_typing)
.label("Без набора")
.on_toggle(SetTyping),
toggler(self.reverse)
.label("Обратный тест")
.on_toggle(SetReverse),
button(text!("Тест").center().width(Fill))
.style(cta_button)
.on_press(Test)
.width(Fill),
]
.width(250)
.spacing(DEFAULT_SPACING)
.into()
}
fn tags_selector(&self) -> iced::Element<'_, DictionaryMessage> {
let mut col = Column::new().width(Fill);
col = col.push(
button("Сбросить")
.on_press(ResetTags)
.style(|x: &Theme, _status| Style {
background: None,
text_color: x.palette().primary,
border: Border::default(),
shadow: Shadow::default(),
snap: false,
}),
);
let mut sorted_tags = self.tag_map.iter().collect::<Vec<_>>();
sorted_tags.sort();
for tag in sorted_tags {
col = col.push(
checkbox(*tag.1)
.label(tag.0)
.on_toggle(|x1| IncludeTag(tag.0.clone(), x1)),
)
}
container(scrollable(col)).height(Fill).into()
}
fn update_tags(&mut self) {
let mut tags_list: HashSet<String> = HashSet::new();
let dict = &self.state.lock().dictionary;
dict.iter().for_each(|element| {
split_with_coma(element.tags.as_str())
.iter()
.for_each(|tag| {
tags_list.insert(tag.clone());
});
});
let current_tags = self
.tag_map
.keys()
.map(|k| k.clone().to_string())
.collect::<Vec<String>>();
current_tags
.iter()
.filter(|tag| !tags_list.contains(*tag))
.for_each(|tag| {
self.tag_map.remove(tag);
});
tags_list.iter().for_each(|tag| {
self.tag_map.insert(tag.clone(), false);
});
}
fn update_words_include(&mut self, group_id: crate::lang::Id) {
let include_tags = self
.tag_map
.iter()
.filter(|(_, value)| **value)
.map(|(t, _)| t.clone())
.collect::<Vec<String>>();
if include_tags.is_empty() {
self.include_map.iter_mut().for_each(|x| *x = false);
return;
}
let dict = &self.state.lock().dictionary;
let time = Instant::now();
self.include_map = dict
.iter()
.map(|word| (split_with_coma(word.tags.as_str()), word.group_id))
.map(|(tags, word_group_id)| {
!tags.is_empty()
&& tags.iter().all(|t| include_tags.contains(t))
&& word_group_id == group_id
})
.collect();
info!("Time {}", time.elapsed().as_micros());
}
fn groups_panel(&self) -> iced::Element<'_, DictionaryMessage> {
let mut row = Row::new();
row = row.push(button("+").style(text).on_press(CreateGroup));
let state = &self.state.lock();
let groups = &state.word_groups;
for (index, group) in groups.iter().enumerate() {
row = row.push(
button(text!("{}", group.name.clone()))
.style(text)
.on_press(SelectGroup(index)),
);
}
let group = state.word_groups[self.selected_group_index].clone();
iced::widget::column![
scrollable(row).width(Fill).horizontal(),
row![
button("⇳").on_press(ChangeDirection).style(jl_button),
text_input("Название группы слов", &group.name)
.on_input(EditGroup)
.width(250)
.on_submit(SaveGroup),
horizontal(),
self.group_delete_button(),
]
.spacing(DEFAULT_SPACING / 2.0)
]
.into()
}
fn group_delete_button(&self) -> iced::Element<'_, DictionaryMessage> {
if self.selected_group_index != 0 {
button("Удалить").style(danger).on_press(DeleteGroup).into()
} else {
space().into()
}
}
fn add_word_button(&self) -> iced::Element<'_, DictionaryMessage> {
let state = self.state.lock();
let group_id = state.word_groups[self.selected_group_index].id;
let button = button("Добавить слово").style(jl_button);
if group_id.is_valid() {
return button.on_press(NewWord).into();
}
button.into()
}
}
pub fn split_with_coma(ts: &str) -> Vec<String> {
ts.split(',')
.map(|ts| ts.to_lowercase().trim().to_string())
.filter(|t| !t.is_empty())
.collect::<Vec<String>>()
}
#[derive(Hash, PartialEq, Eq)]
struct WordLineState {
is_included: bool,
key: String,
value: String,
tags: String,
id: crate::lang::Id,
index: usize,
}
+224
View File
@@ -0,0 +1,224 @@
use crate::navigation::RootMessage;
use crate::pages::dictionary::split_with_coma;
use crate::pages::dictionary_quiz::DictionaryQuizMessage::*;
use crate::lang::WordData;
use crate::navigation::Page::PreviousPage;
use crate::navigation::*;
use crate::pages::quiz::Score;
use crate::styling::*;
use iced::Background::Color;
use iced::border::Radius;
use iced::widget::container::Style;
use iced::widget::{Row, button, container, row, space, text, text_input};
use iced::{Border, Element, Fill, Task, Theme, alignment};
use rand::prelude::SliceRandom;
#[derive(Debug, Clone)]
pub struct DictionaryQuizState {
words: Vec<WordData>,
current_set: Vec<WordData>,
answer: String,
view: String,
correct: String,
score: Score,
is_help: bool,
reverse: bool,
laps: u32,
no_typing: bool,
}
#[derive(Debug, Clone)]
pub enum DictionaryQuizMessage {
Back,
AnswerChanged(String),
SubmitAnswer,
Appeal,
}
impl NavigatedPage<DictionaryQuizMessage> for DictionaryQuizState {
fn navigate(&mut self, message: &DictionaryQuizMessage) -> Option<Page> {
match message {
Back => Some(PreviousPage),
_ => None,
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: DictionaryQuizMessage) -> Task<RootMessage> {
match message {
Back => {}
AnswerChanged(c) => self.answer = c.clone(),
SubmitAnswer => self.submit(),
Appeal => self.appeal_answer(),
}
Task::none()
}
fn view(&self) -> Element<'_, DictionaryQuizMessage> {
container(
iced::widget::column![
self.laps(),
iced::widget::column![
text!("{}", self.view).size(54),
text!(
"{}",
if self.is_help {
self.correct.clone()
} else {
String::new()
}
)
.size(ACCENT_FONT_SIZE)
.align_y(alignment::Vertical::Center),
]
.align_x(alignment::Horizontal::Center)
.spacing(5),
text_input("Перевод", &self.answer)
.size(28)
.width(250)
.on_input(AnswerChanged)
.on_submit(SubmitAnswer),
row![
text!("{}", self.score.total.to_string()).size(25),
text!("{}", self.score.correct.to_string())
.size(25)
.color(iced::Color::from_rgb8(60, 170, 60)),
text!("{}", self.score.fail.to_string())
.color(iced::Color::from_rgb8(255, 79, 0))
.size(25),
]
.spacing(DEFAULT_SPACING),
row![
button("Закончить").style(jl_button).on_press(Back),
self.appeal_button()
]
.spacing(DEFAULT_SPACING),
]
.spacing(DEFAULT_SPACING)
.align_x(alignment::Horizontal::Center),
)
.center_y(Fill)
.center_x(Fill)
.into()
}
}
impl DictionaryQuizState {
pub fn new(words: Vec<WordData>, reverse: bool, no_typing: bool) -> DictionaryQuizState {
DictionaryQuizState {
words,
current_set: Vec::new(),
answer: "".to_string(),
view: "---".to_string(),
correct: "".to_string(),
score: Default::default(),
is_help: false,
reverse,
laps: 0,
no_typing,
}
}
fn submit(&mut self) {
if self.view == "---" {
self.show_next();
return;
}
if self.no_typing {
self.no_type_submit();
} else {
self.default_submit();
}
}
fn no_type_submit(&mut self) {
if self.is_help {
self.is_help = false;
self.score.total += 1;
self.show_next()
} else {
self.is_help = true;
}
}
fn default_submit(&mut self) {
if !self.is_help {
self.score.total += 1;
}
if self.answer.to_lowercase() == self.correct.to_lowercase()
|| split_with_coma(self.correct.to_lowercase().as_str()).contains(&self.answer.to_lowercase())
{
if !self.is_help {
self.score.correct += 1;
}
self.show_next()
} else {
self.score.fail += 1;
self.is_help = true;
}
}
fn update_set(&mut self) {
self.current_set.append(&mut self.words.clone());
self.current_set.shuffle(&mut rand::rng());
if self.score.total != 0 {
self.laps += 1;
}
}
fn show_next(&mut self) {
self.is_help = false;
self.answer = String::new();
let next: WordData = match self.current_set.pop() {
Some(word) => word,
None => {
self.update_set();
self.current_set.pop().expect("Empty set")
}
};
if self.reverse {
self.view = next.value.clone();
self.correct = next.key.clone();
} else {
self.view = next.key.clone();
self.correct = next.value.clone();
}
}
fn laps(&self) -> Element<'_, DictionaryQuizMessage> {
let mut col = Row::new();
for _ in 0..self.laps {
col = col.push(iced::widget::container(space().height(15).width(15)).style(
|x: &Theme| Style {
text_color: None,
background: Some(Color(x.palette().primary)),
border: Border {
color: Default::default(),
width: 0.0,
radius: Radius::new(5),
},
shadow: Default::default(),
snap: false,
},
));
}
col.spacing(DEFAULT_SPACING).into()
}
fn appeal_button(&self) -> Element<'_, DictionaryQuizMessage> {
if self.is_help && !self.no_typing {
return button("Апелляция").style(jl_button).on_press(Appeal).into();
}
space().into()
}
fn appeal_answer(&mut self) {
self.score.correct += 1;
self.score.fail -= 1;
self.answer = self.correct.clone();
self.show_next()
}
}
+111
View File
@@ -0,0 +1,111 @@
use crate::data_provider::history::{get_history_of_set, HistoryItem};
use crate::lang::{WordData, INVALID_ID};
use crate::navigation::*;
use crate::styling::*;
use chrono::Local;
use iced::alignment::Horizontal::Center;
use iced::widget::space::horizontal;
use iced::widget::*;
use iced::{Element, Fill, FillPortion, Task};
use parking_lot::Mutex;
use std::sync::Arc;
use HistoryMessage::Back;
use crate::app::AppState;
#[derive(Clone)]
pub struct HistoryState {
list: Vec<HistoryItem>,
words: Vec<WordData>,
}
impl NavigatedPage<HistoryMessage> for HistoryState {
fn navigate(&mut self, message: &HistoryMessage) -> Option<Page> {
match message {
Back => Some(Page::PreviousPage),
}
}
fn navigated(&mut self) {}
fn update(&mut self, _: HistoryMessage) -> Task<RootMessage> {
Task::none()
}
fn view(&self) -> Element<'_, HistoryMessage> {
back_overlay(
iced::widget::row![
horizontal().width(FillPortion(1)),
scrollable(self.history_lines().padding(DEFAULT_SPACING))
.height(Fill)
.width(FillPortion(5)),
horizontal().width(FillPortion(1))
]
.height(Fill)
.width(Fill)
.into(),
Back,
)
}
}
impl HistoryState {
pub fn new(id: crate::lang::Id, state: Arc<Mutex<AppState>>) -> Self {
let state = state.lock();
let history = get_history_of_set(id).unwrap_or_default();
let words = history
.iter()
.map(
|item| match state.dictionary.iter().find(|w| w.id == item.word_id) {
None => WordData {
id,
key: "Удаленное слово".to_string(),
value: "Удаленное слово".to_string(),
tags: "Удаленное слово".to_string(),
additional: Default::default(),
group_id: INVALID_ID,
},
Some(word) => word.clone(),
},
)
.collect();
Self {
list: history,
words,
}
}
fn history_lines(&self) -> Column<'_, HistoryMessage> {
let mut column = Column::new();
for (index, item) in self.list.iter().enumerate() {
column = column.push(
row![
iced::widget::column![
text!("{}", self.words[index].key)
.width(Fill)
.align_x(Center),
text!("{}", self.words[index].value)
.width(Fill)
.align_x(Center),
]
.align_x(Center),
iced::widget::column![
text!("{} ➞ {}", item.before, item.after),
text!(
"{}",
item.timestamp.with_timezone(&Local).format("%d.%m %H:%M")
)
]
.spacing(5)
.align_x(Center)
]
.spacing(5),
);
column = column.push(rule::horizontal(1));
}
column.spacing(5)
}
}
#[derive(Clone)]
pub enum HistoryMessage {
Back,
}
+557
View File
@@ -0,0 +1,557 @@
use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups};
use crate::data_provider::words::{add_group, add_words};
use crate::pages::import::ImportMessage::*;
use crate::lang::{WordData, WordGroup};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::notification::NotificationData::OpenFileError;
use crate::notification::{NotificationData, NotificationRequest};
use crate::styling::*;
use iced::widget::button::danger;
use iced::widget::container::success;
use iced::widget::{
Row, button, checkbox, column, container, progress_bar, row, rule, text, text_input,
};
use iced::widget::{scrollable, space};
use iced::{Element, Task};
use iced_core::Alignment::Center;
use iced_core::Length::Fill;
use iced_core::Padding;
use parking_lot::Mutex;
use rfd::AsyncFileDialog;
use rusqlite::Connection;
use std::fs::{File, OpenOptions};
use std::io::BufReader;
use std::path::PathBuf;
use std::sync::Arc;
use log::info;
use tokio::task::spawn_blocking;
use zip::ZipArchive;
use crate::app::AppState;
use crate::fs::*;
const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"];
#[derive(Clone)]
pub struct ImportState {
state: Arc<Mutex<AppState>>,
path: Option<PathBuf>,
import_data: Option<ImportData>,
selected_index: usize,
selected_property: Option<String>,
custom_property_name: String,
skip_empty: bool,
separator: String,
progress: Option<Arc<Mutex<f32>>>,
}
#[derive(Clone)]
pub enum ImportMessage {
Back,
SelectFile,
UpdateFile(PathBuf),
UpdateImport(ImportData),
NextGroup,
PreviousGroup,
OpenPropertyMapper(String),
EditCustomProperty(String),
SetupCustomProperty,
SetupDefaultProperty(String),
SetupDirect,
RemoveMapping(String),
SwitchSkipEmpty(bool),
EditSeparator(String),
StartImport,
NextProgress,
ImportFinished,
}
impl NavigatedPage<ImportMessage> for ImportState {
fn navigate(&mut self, message: &ImportMessage) -> Option<Page> {
if self.progress.is_some() {
return None;
}
if let Back = message {
return Some(Page::PreviousPage);
}
None
}
fn navigated(&mut self) {}
fn update(&mut self, message: ImportMessage) -> Task<RootMessage> {
match message {
Back => {}
UpdateFile(path) => {
self.path = Some(path);
self.import_data = None;
return self.load_package();
}
SelectFile => return Self::select_import_file(),
UpdateImport(import) => {
self.selected_index = 0;
self.import_data = Some(import);
}
NextGroup => {
self.selected_index += 1;
self.selected_property = None;
self.custom_property_name.clear();
}
PreviousGroup => {
self.selected_index -= 1;
self.selected_property = None;
self.custom_property_name.clear();
}
OpenPropertyMapper(prop) => self.selected_property = Some(prop),
EditCustomProperty(prop_name) => self.custom_property_name = prop_name,
SetupCustomProperty => {
self.setup_mapping(self.custom_property_name.clone());
}
SetupDefaultProperty(prop_name) => {
self.setup_mapping(prop_name);
}
SetupDirect => {
self.setup_mapping(self.selected_property.clone().unwrap());
}
RemoveMapping(pro_name) => {
self.remove_mapping(pro_name);
}
SwitchSkipEmpty(skip) => {
self.skip_empty = skip;
}
EditSeparator(separator) => {
self.separator = separator.clone();
}
StartImport => {
self.progress = Some(Arc::new(Mutex::new(0.0)));
return Task::batch([self.start_import(), self.next_progress()]);
}
NextProgress => {
if self.progress.is_none() {
return Task::none();
}
return self.next_progress();
}
ImportFinished => {
self.progress = None;
let group = &mut self.import_data.as_mut().unwrap().0[self.selected_index];
group.imported = true;
}
}
Task::none()
}
fn view(&self) -> Element<'_, ImportMessage> {
back_overlay(
{
if let Some(value) = &self.progress {
column![progress_bar(0f32..=1f32, *value.lock()),]
.spacing(QUARTER_SPACING)
.into()
} else {
scrollable(
column![
row![
button("Выбрать файл").style(jl_button).on_press(SelectFile),
text!("{}", {
if let Some(path) = &self.path {
path.to_string_lossy().to_string()
} else {
"Файл не выбран".to_string()
}
})
]
.align_y(Center)
.spacing(DEFAULT_SPACING),
self.selected_group()
]
.spacing(DEFAULT_SPACING),
)
.into()
}
},
Back,
)
}
}
impl ImportState {
fn selected_group(&self) -> Element<'_, ImportMessage> {
if let Some(data) = &self.import_data {
let group = &data.0[self.selected_index];
return column![
row![
self.back_button(),
space().width(Fill),
text!("{}", group.name.clone()),
space().width(Fill),
self.next_button()
]
.width(Fill),
text!("Количество карточек: {}", group.length),
self.property_mapper(group),
self.import_settings(),
self.import_button(group),
]
.spacing(DEFAULT_SPACING)
.into();
}
space().into()
}
fn import_button(&self, group: &ImportGroup) -> Element<'_, ImportMessage> {
if group.imported {
return container(text!("Группа слов успешно импортирована"))
.padding(HALF_SPACING)
.align_x(Center)
.style(success)
.width(Fill)
.into();
}
button("Начать импорт")
.style(cta_button)
.on_press(StartImport)
.into()
}
fn property_mapper(&self, group: &ImportGroup) -> Element<'_, ImportMessage> {
column![
self.property_list(group),
rule::horizontal(2),
self.property_selector()
]
.spacing(HALF_SPACING)
.into()
}
fn property_list(&self, group: &ImportGroup) -> Element<'_, ImportMessage> {
let mut row = Row::new();
for prop in &group.fields {
let primary_button_name = (*prop).clone();
if let Some(mapped) = group.mapping.get(prop.clone().as_str()) {
row = row.push(
column![
button(text!("{}", primary_button_name)),
"↕",
button(text!("{}", mapped))
.style(danger)
.on_press(RemoveMapping(mapped.as_str().to_string())),
]
.align_x(Center),
);
} else {
row = row.push(
button(text!("{}", primary_button_name))
.on_press(OpenPropertyMapper(prop.clone())),
);
}
}
scrollable(row.spacing(HALF_SPACING).padding(Padding {
top: 0.0,
right: 0.0,
bottom: DEFAULT_SPACING,
left: 0.0,
}))
.horizontal()
.into()
}
fn property_selector(&self) -> Element<'_, ImportMessage> {
if self.selected_property.is_none() {
return space().into();
}
column![
self.available_properties(),
button("Добавить напрямую").on_press(SetupDirect),
row![
text_input("Пользовательское поле", &self.custom_property_name)
.on_input(EditCustomProperty),
button("Установить привязку").on_press(SetupCustomProperty)
]
.spacing(QUARTER_SPACING)
]
.spacing(HALF_SPACING)
.into()
}
fn available_properties(&self) -> Element<'_, ImportMessage> {
let mut row = Row::new();
for default in DEFAULT_FIELDS {
row = row.push(
button(text!("{default}")).on_press(SetupDefaultProperty(default.to_string())),
);
}
row.spacing(HALF_SPACING).into()
}
fn import_settings(&self) -> Element<'_, ImportMessage> {
column![
checkbox(self.skip_empty)
.label("Пропускать пустые строки")
.on_toggle(SwitchSkipEmpty),
column![
text!("Разделитель при множественном объединении"),
text_input("", &self.separator).on_input(EditSeparator)
]
]
.spacing(DEFAULT_SPACING)
.into()
}
fn back_button(&self) -> Element<'_, ImportMessage> {
let mut button = button("←").style(button::text);
if self.selected_index > 0 {
button = button.on_press(PreviousGroup);
}
button.into()
}
fn next_button(&self) -> Element<'_, ImportMessage> {
let mut button = button("→").style(button::text);
if self.selected_index < self.import_data.as_ref().unwrap().0.len() - 1 {
button = button.on_press(NextGroup);
}
button.into()
}
}
impl ImportState {
pub fn new(state: Arc<Mutex<AppState>>) -> ImportState {
ImportState {
state,
path: None,
import_data: None,
selected_index: 0,
selected_property: None,
custom_property_name: "".to_string(),
skip_empty: true,
separator: ", ".to_string(),
progress: None,
}
}
fn select_import_file() -> Task<RootMessage> {
Task::perform(
async {
let file = AsyncFileDialog::new()
.add_filter("Anki", &["apkg"])
.pick_file()
.await;
if let Some(file) = file {
return Some(file.path().into());
}
None
},
|result| {
if let Some(path) = result {
RootMessage::Import(UpdateFile(path))
} else {
RootMessage::None
}
},
)
}
pub fn load_package(&self) -> Task<RootMessage> {
let path = self.path.clone().unwrap();
Task::perform(
async {
spawn_blocking(move || {
Self::extract_import_file(path)?;
let data = Self::read_import_file()?;
Ok(data)
})
.await
.unwrap()
},
|result: Result<ImportData, NotificationData>| match result {
Ok(import) => RootMessage::Import(UpdateImport(import)),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err)),
},
)
}
fn extract_import_file(path: PathBuf) -> Result<(), NotificationData> {
let file = File::open(path).map_err(|_| OpenFileError)?;
let reader = BufReader::new(file);
let mut archive = ZipArchive::new(reader).map_err(|_| OpenFileError)?;
let temp_file_path = temp_import_path();
let mut temp_file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&temp_file_path)
.map_err(|_| OpenFileError)?;
if let Ok(mut file) = archive.by_name("collection.anki21") {
std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?;
Ok(())
} else if let Ok(mut file) = archive.by_name("collection.anki2") {
std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?;
Ok(())
} else {
Err(OpenFileError)
}
}
fn read_import_file() -> Result<ImportData, NotificationData> {
let temp_file_path = temp_import_path();
let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?;
let data = load_groups(&connection)?;
Ok(data)
}
fn setup_mapping(&mut self, property_name: String) {
let group = self.import_data.as_mut().unwrap();
let group = &mut group.0[self.selected_index];
group
.mapping
.insert(self.selected_property.clone().unwrap(), property_name);
self.selected_property = None;
self.custom_property_name.clear();
}
fn remove_mapping(&mut self, property_name: String) {
let group = self.import_data.as_mut().unwrap();
let group = &mut group.0[self.selected_index];
let remove_key = group
.mapping
.keys()
.find(|key| group.mapping[*key] == property_name)
.unwrap();
group.mapping.remove(remove_key.clone().as_str());
self.selected_property = None;
self.custom_property_name.clear();
}
fn start_import(&self) -> Task<RootMessage> {
let state = self.state.clone();
let separator = self.separator.clone();
let skip_empty = self.skip_empty;
let group = self.import_data.as_ref().unwrap().0[self.selected_index].clone();
let progress = self.progress.clone().unwrap();
info!("{}", group.name);
Task::perform(
async move {
spawn_blocking(move || {
let temp_file_path = temp_import_path();
let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?;
let import_list = get_words_of_group(&connection, group.id)?;
let mut words_list = Vec::with_capacity(import_list.len());
let map_indices = Self::get_mapping_indices(&group);
let mut group_entity = WordGroup {
id: 0.into(),
name: group.name.clone(),
};
{
let state = state.lock();
add_group(&mut group_entity, &state.connection);
}
let group_id = group_entity.id;
for import in import_list {
let mut word = WordData::new();
word.tags = import.tags.trim().replace(" ", ", ");
word.group_id = group_id;
for (dest, indices) in &map_indices {
let collected_string = Self::collect_strings(
&import.fields,
indices,
&separator,
skip_empty,
);
match dest.as_str() {
"key" => {
word.key = collected_string;
}
"value" => {
word.value = collected_string;
}
"tags" => {
word.tags = collected_string;
}
&_ => {
word.additional.insert((*dest).clone(), collected_string);
}
}
}
words_list.push(word);
}
let mut state = state.lock();
let connection = &mut state.connection;
let mut index = 0;
let total_len = words_list.len() as f32 / 1024.0;
for word in &mut words_list.chunks_mut(1024) {
add_words(word, connection);
index += 1;
let mut progress = progress.lock();
*progress = (index as f32 / total_len).min(1.0);
}
state.dictionary.append(&mut words_list);
state.word_groups.push(group_entity);
Ok(())
})
.await
.unwrap()
},
|r: Result<(), NotificationData>| match r {
Ok(_) => RootMessage::Import(ImportFinished),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err))
}
)
}
fn get_mapping_indices(group: &ImportGroup) -> Vec<(String, Vec<usize>)> {
let mut final_props = group.mapping.values().cloned().collect::<Vec<_>>();
final_props.sort();
final_props.dedup();
let mut result = final_props
.iter()
.map(|name| (name.clone(), Vec::<usize>::with_capacity(1)))
.collect::<Vec<_>>();
for (key, endpoint) in &group.mapping {
let property_index = group
.fields
.iter()
.position(|f| f == key)
.expect("Unknown property");
let group_index = result
.iter()
.position(|(name, _)| name == endpoint)
.unwrap();
result[group_index].1.push(property_index);
}
result
}
fn collect_strings(
properties: &[String],
indices: &[usize],
separator: &str,
skip_empty: bool,
) -> String {
let mut working_words = Vec::with_capacity(indices.len());
for index in indices {
let str = &properties[*index];
if skip_empty && str.is_empty() {
continue;
}
working_words.push(str.as_str());
}
working_words.join(separator)
}
fn next_progress(&self) -> Task<RootMessage> {
Task::perform(
async {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
},
|_| RootMessage::Import(NextProgress),
)
}
}
+164
View File
@@ -0,0 +1,164 @@
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::pages::quiz::QuizMessage::*;
use crate::styling::*;
use iced::widget::*;
use iced::{Element, Fill, Task, alignment};
use rand::seq::SliceRandom;
use crate::app::USER_FONT;
use crate::core::kana_set::KanaSet;
#[derive(Clone, Debug)]
pub struct QuizState {
kana: String,
current_roman: String,
correct_roman: String,
pub(crate) set: KanaSet,
queue: Vec<(String, String)>,
score: Score,
is_help: bool,
}
impl NavigatedPage<QuizMessage> for QuizState {
fn navigate(&mut self, message: &QuizMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: QuizMessage) -> Task<RootMessage> {
match message {
ContentChanged(content) => {
if content.contains("`") {
self.is_help = true;
self.score.fail += 1;
return Task::none();
}
self.current_roman = content;
if self.correct_roman == self.current_roman {
if !self.is_help {
self.score.correct += 1;
}
self.is_help = false;
self.score.total += 1;
self.update_showed()
} else if self.current_roman.len() == self.correct_roman.len() {
self.score.total += 1;
self.score.fail += 1;
self.update_showed()
}
}
Back => {}
}
Task::none()
}
fn view(&self) -> Element<'_, QuizMessage> {
container(
iced::widget::column![
row![
text!("{}", self.kana.to_lowercase())
.size(54)
.font(USER_FONT),
text!(
"{}",
if self.is_help {
self.correct_roman.clone()
} else {
String::new()
}
),
]
.align_y(alignment::Vertical::Center)
.spacing(20),
text_input("Романдзи", &self.current_roman)
.size(28)
.width(150)
.on_input(ContentChanged),
score_display(&self.score),
button("Закончить").style(jl_button).on_press(Back),
]
.spacing(DEFAULT_SPACING)
.align_x(alignment::Horizontal::Center),
)
.center_y(Fill)
.center_x(Fill)
.into()
}
}
fn score_display<'a, T: 'a>(score: &Score) -> Element<'a, T> {
row![
text!("{}", score.total.to_string()).size(25),
text!("{}", score.correct.to_string())
.size(25)
.color(iced::Color::from_rgb8(60, 170, 60)),
text!("{}", score.fail.to_string())
.color(iced::Color::from_rgb8(255, 79, 0))
.size(25),
]
.spacing(DEFAULT_SPACING)
.into()
}
impl QuizState {
pub(crate) fn new() -> QuizState {
QuizState {
kana: "ぁ".to_string(),
correct_roman: "a".to_string(),
is_help: false,
current_roman: "".to_string(),
set: KanaSet::hiragana(),
queue: Vec::new(),
score: Score {
total: 0,
correct: 0,
fail: 0,
},
}
}
}
impl Default for QuizState {
fn default() -> Self {
QuizState::new()
}
}
impl QuizState {
fn update_showed(&mut self) {
self.current_roman = String::new();
let queue = &mut self.queue;
if queue.is_empty() {
for pair in self.set.list() {
queue.push(pair);
}
queue.shuffle(&mut rand::rng());
}
let pair = self.queue.pop().unwrap();
self.kana = pair.0;
self.correct_roman = pair.1;
}
}
#[derive(Debug, Clone)]
pub enum QuizMessage {
ContentChanged(String),
Back,
}
#[derive(Default, Debug, Clone)]
pub struct Score {
pub(crate) total: i32,
pub(crate) correct: i32,
pub(crate) fail: i32,
}
+81
View File
@@ -0,0 +1,81 @@
use crate::navigation::RootMessage;
use crate::navigation::{NavigatedPage, Page};
use crate::pages::randomizer::RandomizerMessage::{Back, Edit, Start};
use crate::styling::{HALF_SPACING, back_overlay, jl_button};
use iced::Task;
use iced::widget::{button, text_editor};
use rand::prelude::SliceRandom;
#[derive(Clone, Debug)]
pub struct RandomizerState {
text: text_editor::Content,
list: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum RandomizerMessage {
Back,
Start,
Edit(text_editor::Action),
}
impl NavigatedPage<RandomizerMessage> for RandomizerState {
fn navigate(&mut self, message: &RandomizerMessage) -> Option<Page> {
if let Back = message {
return Some(Page::PreviousPage);
}
None
}
fn navigated(&mut self) {}
fn update(&mut self, message: RandomizerMessage) -> Task<RootMessage> {
match message {
Edit(action) => {
self.text.perform(action);
self.list = self
.text
.text()
.split("\n")
.map(|s| s.to_string())
.collect();
}
Start => {
self.list.shuffle(&mut rand::rng());
self.text = text_editor::Content::with_text(self.list.join("\n").as_str());
}
_ => {}
}
Task::none()
}
fn view(&self) -> iced::Element<'_, RandomizerMessage> {
back_overlay(
iced::widget::column![
text_editor(&self.text)
.on_action(Edit)
.width(400)
.height(400)
.placeholder("Каждый элемент с новой строки"),
button("Перемешать").style(jl_button).on_press(Start),
]
.spacing(HALF_SPACING)
.into(),
Back,
)
}
}
impl Default for RandomizerState {
fn default() -> Self {
Self::new()
}
}
impl RandomizerState {
pub fn new() -> RandomizerState {
RandomizerState {
text: Default::default(),
list: vec![],
}
}
}
+343
View File
@@ -0,0 +1,343 @@
use crate::app::AppState;
use crate::data_provider::voice::get_voice;
use crate::lang::Id;
use crate::lang::{CardStatistics, DeckData, DeckSettings, WordData, WordOpenMode};
use crate::navigation::Page::PreviousPage;
use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage};
use crate::notification::{NotificationData, NotificationRequest};
use crate::styling::*;
use chrono::Local;
use iced::alignment::Horizontal::Center;
use iced::keyboard::key::Physical::Code;
use iced::widget::text::Style;
use iced::widget::{Column, button, column, container, row, rule, space, text, tooltip};
use iced::{Element, Fill, Task, Theme, alignment, keyboard};
use parking_lot::Mutex;
use rodio::MixerDeviceSink;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::task::spawn_blocking;
pub struct RepetitionState {
state: Arc<Mutex<AppState>>,
pub settings: DeckSettings,
pub set: DeckData,
current_word: WordData,
current_statistic: CardStatistics,
open: bool,
can_play: bool,
sink: Arc<Option<MixerDeviceSink>>,
opened: HashSet<Id>,
}
impl NavigatedPage<RepetitionMessage> for RepetitionState {
fn navigate(&mut self, message: &RepetitionMessage) -> Option<Page> {
if let RepetitionMessage::Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: RepetitionMessage) -> Task<RootMessage> {
match message {
RepetitionMessage::Back => {}
RepetitionMessage::Next => return self.next(),
RepetitionMessage::Answer(m) => return self.answer(m),
RepetitionMessage::Play => {
if !self.can_play {
return Task::none();
}
self.can_play = false;
let value = self.current_word.key.clone();
if self.settings.require_speech() {
return Task::perform(play_sound(self.sink.clone(), value), |r| match r {
Ok(_) => RootMessage::Repetition(RepetitionMessage::PlayFinished),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err)),
});
}
}
RepetitionMessage::PlayFinished => {
self.can_play = true;
}
}
Task::none()
}
fn view(&self) -> Element<'_, RepetitionMessage> {
back_overlay(
column![
container(self.draw_forward())
.width(Fill)
.height(Fill)
.align_x(Center)
.align_y(alignment::Vertical::Center),
rule::horizontal(2),
container(self.draw_backward())
.width(Fill)
.height(Fill)
.align_x(Center)
.align_y(alignment::Vertical::Center),
container(self.answer_bar())
.width(Fill)
.align_x(Center)
.height(80),
text!(
"Затронуто слов {}, {}%",
self.opened.len(),
(self.opened.len() as f32 / self.set.len() as f32 * 10000.0).round() / 100.0
)
]
.height(Fill)
.width(Fill)
.into(),
RepetitionMessage::Back,
)
}
}
impl RepetitionState {
pub(crate) fn new(set: DeckSettings, state: Arc<Mutex<AppState>>) -> RepetitionState {
let mut card_set = DeckData::new(&set, state.clone());
let (word, stat) = card_set.next();
let sink_handle = if set.forward.contains("speech") || set.backward.contains("speech") {
rodio::DeviceSinkBuilder::open_default_sink().ok()
} else {
None
};
RepetitionState {
state,
settings: set,
set: card_set,
current_word: word,
current_statistic: stat,
open: false,
can_play: true,
sink: Arc::new(sink_handle),
opened: HashSet::new(),
}
}
}
impl RepetitionState {
fn next(&mut self) -> Task<RootMessage> {
if self.open {
self.answer(WordOpenMode::None)
} else {
self.open = true;
Task::none()
}
}
fn answer(&mut self, mode: WordOpenMode) -> Task<RootMessage> {
if !self.open {
return Task::none();
}
self.set.open(mode);
self.open = false;
self.opened.insert(self.current_word.id);
let next = self.set.next();
self.current_word = next.0;
self.current_statistic = next.1;
if self.settings.require_speech() && self.sink.is_some() {
let sink = self.sink.clone();
let task = play_sound(sink, self.current_word.key.clone());
return Task::perform(task, |r| match r {
Ok(_) => RootMessage::Repetition(RepetitionMessage::PlayFinished),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err)),
});
}
self.increment_counter();
Task::none()
}
fn increment_counter(&mut self) {
let mut state = self.state.lock();
let id = self.settings.id;
let activity = state.activity.get_mut(&id);
let now = Local::now().date_naive();
if let Some(activity) = activity {
let (last_day, counter) = activity.last_mut().unwrap();
if *last_day == now {
*counter += 1;
} else {
activity.push((now, 1));
}
} else {
state.activity.insert(id, vec![(now, 1)]);
}
}
fn draw_forward(&self) -> Element<'_, RepetitionMessage> {
self.draw_card_view(self.settings.forward.as_str())
}
fn draw_backward(&self) -> Element<'_, RepetitionMessage> {
if !self.open {
return container(text!("?").size(72))
.width(Fill)
.height(Fill)
.align_y(alignment::Vertical::Center)
.align_x(Center)
.into();
}
self.draw_card_view(self.settings.backward.as_str())
}
fn draw_card_view(&self, properties: &str) -> Element<'_, RepetitionMessage> {
let word = &self.current_word;
let mut col = Column::new();
for view_type in properties.split(" ") {
col = col.push(match view_type {
"key" => self.draw_key(word),
"value" => self.draw_value(word),
"speech" => self.draw_voice(),
"reading" => self.draw_reading(word),
"context" => self.draw_context(word),
"description" => self.draw_description(word),
_ => space().into(),
})
}
col.spacing(DEFAULT_SPACING).align_x(Center).into()
}
fn answer_bar(&self) -> Element<'_, RepetitionMessage> {
if !self.open {
return text!("Нажмите Пробел для открытия карточки")
.style(|theme: &Theme| Style {
color: Some(theme.palette().text.scale_alpha(0.3)),
})
.into();
}
column![
text!(
"{} очков",
self.current_statistic.calculated_score().round() as i32
),
row![
tooltip(
button("Не получилось").on_press(RepetitionMessage::Answer(WordOpenMode::None)),
"Клавиша 1",
tooltip::Position::Top
),
tooltip(
button("Трудно").on_press(RepetitionMessage::Answer(WordOpenMode::Hard)),
"Клавиша 2",
tooltip::Position::Top
),
tooltip(
button("Нормально").on_press(RepetitionMessage::Answer(WordOpenMode::Ok)),
"Клавиша 3",
tooltip::Position::Top
),
tooltip(
button("Легко").on_press(RepetitionMessage::Answer(WordOpenMode::Easy)),
"Клавиша 4",
tooltip::Position::Top
),
]
.spacing(QUARTER_SPACING)
]
.align_x(Center)
.spacing(DEFAULT_SPACING)
.into()
}
fn draw_key(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
text!("{}", word.key).size(36).into()
}
fn draw_value(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
text!("{}", word.value).size(24).into()
}
fn draw_voice(&self) -> Element<'_, RepetitionMessage> {
button("Воспроизвести")
.style(jl_button)
.on_press(RepetitionMessage::Play)
.into()
}
fn draw_reading(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
match word.additional.get("reading") {
None => space().into(),
Some(reading) => text!("{}", reading).size(24).into(),
}
}
fn draw_context(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
match word.additional.get("context") {
None => space().into(),
Some(context) => text!("{}", context).size(24).into(),
}
}
fn draw_description(&self, word: &WordData) -> Element<'_, RepetitionMessage> {
match word.additional.get("description") {
None => space().into(),
Some(context) => text!("{}", context).size(24).into(),
}
}
}
impl KeyPressedPage for RepetitionState {
fn press(&mut self, message: &keyboard::Event) -> Task<RootMessage> {
if let keyboard::Event::KeyPressed {
key: _,
modified_key: _,
physical_key: pk,
location: _,
modifiers: _,
text: _,
repeat: _,
} = message
&& let Code(code) = pk
{
return match code {
keyboard::key::Code::Space => self.next(),
keyboard::key::Code::Digit1 => self.answer(WordOpenMode::None),
keyboard::key::Code::Digit2 => self.answer(WordOpenMode::Hard),
keyboard::key::Code::Digit3 => self.answer(WordOpenMode::Ok),
keyboard::key::Code::Digit4 => self.answer(WordOpenMode::Easy),
_ => Task::none(),
};
}
Task::none()
}
}
#[derive(Clone)]
pub enum RepetitionMessage {
Next,
Back,
Answer(WordOpenMode),
Play,
PlayFinished,
}
async fn play_sound(
sink: Arc<Option<MixerDeviceSink>>,
text: String,
) -> Result<(), NotificationData> {
let data = get_voice(text.as_str()).await?;
spawn_blocking(move || {
if let Some(sink) = sink.as_ref()
&& let Ok(play) = rodio::play(sink.mixer(), data)
{
play.sleep_until_end()
}
})
.await
.unwrap();
Ok(())
}
+207
View File
@@ -0,0 +1,207 @@
use crate::data_provider::card_sets::{delete_set, update_deck};
use crate::data_provider::card_stats::{delete_stats, load_stats_of_deck};
use crate::lang::DeckSettings;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::pages::repetition_settings::RepetitionSettingsMessage::*;
use crate::styling::*;
use iced::widget::button::danger;
use iced::widget::text_input::Catalog;
use iced::widget::{button, column, row, scrollable, space, text, text_input};
use iced::{Element, Task};
use iced_core::Length::Fill;
use iced_core::{Padding, Theme, color};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use crate::app::AppState;
pub struct RepetitionSettingsState {
set: DeckSettings,
index: usize,
state: Arc<Mutex<AppState>>,
real_delete: bool,
correct_filter: bool,
filter_changed: bool,
}
impl RepetitionSettingsState {
pub(crate) fn new(index: usize, state: Arc<Mutex<AppState>>) -> RepetitionSettingsState {
let set;
{
let local_state = state.lock();
set = local_state.decks[index].clone();
}
let filter_state = set.check_filter();
RepetitionSettingsState {
index,
set,
state,
real_delete: false,
correct_filter: filter_state,
filter_changed: false,
}
}
}
impl NavigatedPage<RepetitionSettingsMessage> for RepetitionSettingsState {
fn navigate(&mut self, message: &RepetitionSettingsMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: RepetitionSettingsMessage) -> Task<RootMessage> {
match message {
Back => {}
Save => {
let mut state = self.state.lock();
update_deck(&mut self.set, &state.connection);
if self.filter_changed {
update_deck_stats(&self.set, &state);
}
state.decks[self.index] = self.set.clone();
}
SetName(new) => {
self.set.name = new;
}
SetForward(new) => {
self.set.forward = new;
}
SetBackward(backward) => {
self.set.backward = backward;
}
SetFilter(new) => {
self.set.filter = new;
self.correct_filter = self.set.check_filter();
self.filter_changed = true;
}
TryFilter => {
let state = self.state.lock();
let count = self.set.get_word_list(&state).len();
self.set.count = Some(count);
}
DeleteSet => {
if !self.real_delete {
self.real_delete = true;
return Task::future(async {
tokio::time::sleep(Duration::from_millis(3000)).await;
RootMessage::RepetitionSettings(RevertDeleteSet)
});
}
let mut state = self.state.lock();
delete_set(&self.set, &state.connection);
state.decks.remove(self.index);
return Task::done(RootMessage::RepetitionSettings(Back));
}
RevertDeleteSet => self.real_delete = false,
}
Task::none()
}
fn view(&self) -> Element<'_, RepetitionSettingsMessage> {
back_overlay(
iced::widget::column![
scrollable(
column![
column![
text!("Название набора"),
text_input("", &self.set.name).on_input(SetName),
]
.spacing(QUARTER_SPACING),
column![
text!("Передняя сторона"),
text_input("", &self.set.forward).on_input(SetForward),
]
.spacing(QUARTER_SPACING),
column![
text!("Задняя сторона"),
text_input("", &self.set.backward).on_input(SetBackward),
]
.spacing(QUARTER_SPACING),
column![
text!("Фильтр"),
text_input("", &self.set.filter)
.style(|x, status| {
let mut primary = Theme::default()(x, status);
if !self.correct_filter {
primary.border.color = color!(255, 255, 0);
}
primary
})
.on_input(SetFilter),
button("Проверить фильтр")
.style(jl_button)
.on_press(TryFilter),
self.count_view(&self.set),
]
.spacing(QUARTER_SPACING),
]
.spacing(DEFAULT_SPACING)
.padding(Padding {
top: 0.0,
right: DEFAULT_SPACING,
bottom: 0.0,
left: 0.0,
})
)
.height(Fill),
row![
button("Сохранить").style(jl_button).on_press(Save),
button(if self.real_delete {
"Вы уверены?"
} else {
"Удалить"
})
.style(danger)
.on_press(DeleteSet),
space().width(Fill),
]
.spacing(DEFAULT_SPACING),
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
fn update_deck_stats(deck: &DeckSettings, state: &AppState) {
let mut db_stats = load_stats_of_deck(deck, &state.connection);
let filtered_words = deck.get_word_list(state);
filtered_words.iter().for_each(|word| {
let word_id = state.dictionary[*word].id;
if let Some(index) = db_stats.iter().position(|stat| stat.word_id == word_id) {
db_stats.remove(index);
}
});
delete_stats(db_stats.as_slice(), &state.connection);
}
impl RepetitionSettingsState {
fn count_view(&self, set: &DeckSettings) -> Element<'_, RepetitionSettingsMessage> {
if let Some(count) = set.count {
return text!("Количество слов: {}", count).into();
}
space().into()
}
}
#[derive(Clone)]
pub enum RepetitionSettingsMessage {
Back,
Save,
SetName(String),
SetForward(String),
SetBackward(String),
SetFilter(String),
TryFilter,
DeleteSet,
RevertDeleteSet,
}
+667
View File
@@ -0,0 +1,667 @@
use crate::data_provider::card_sets::update_deck;
use crate::data_provider::card_stats::{add_stat_list, load_stats_of_deck};
use crate::pages::history::HistoryState;
use crate::lang::{AppendMode, CardStatistics, DeckSettings, Id, OrderMode};
use crate::navigation::Page::{History, PreviousPage, Repetition, RepetitionSettings};
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::pages::repetition::RepetitionState;
use crate::pages::repetition_settings::RepetitionSettingsState;
use crate::pages::repetitions::RepetitionsMessage::*;
use crate::styling::*;
use chrono::{Days, Local, Utc};
use hashbrown::{HashMap, HashSet};
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,
};
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 parking_lot::Mutex;
use std::ops::Deref;
use std::sync::Arc;
use crate::app::AppState;
#[derive(Clone)]
pub struct RepetitionsState {
word_id_index_map: HashMap<Id, usize>,
selected_deck_index: Option<usize>,
decks: Vec<DeckViewData>,
state: Arc<Mutex<AppState>>,
}
impl NavigatedPage<RepetitionsMessage> for RepetitionsState {
fn navigate(&mut self, message: &RepetitionsMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else if let GoToHistory = message {
Some(History(HistoryState::new(
self.selected_deck().unwrap().id,
self.state.clone(),
)))
} else if let GoToRepetition = message {
let clone = self.state.clone();
let selected_set = self.selected_deck_mut().unwrap();
if selected_set.append_mode == AppendMode::Full {
Self::append_all_words(&mut clone.lock(), selected_set);
selected_set.existing_words_indices = selected_set.available_words_indices.clone();
} else {
if selected_set
.available_words_indices
.as_ref()
.unwrap()
.is_empty()
|| selected_set
.existing_words_indices
.as_ref()
.unwrap()
.is_empty()
{
return None;
}
}
Some(Repetition(RepetitionState::new(
selected_set.general_settings.clone(),
clone,
)))
} else if let GoToSettings = message {
Some(RepetitionSettings(RepetitionSettingsState::new(
self.selected_deck().unwrap().index.unwrap(),
self.state.clone(),
)))
} else {
None
}
}
fn navigated(&mut self) {
let index = self.selected_deck_index.unwrap();
let state = self.state.lock();
if let Some(global_deck) = state.decks.get(index) {
if global_deck.id != self.selected_deck().unwrap().id {
drop(state);
self.decks.remove(index);
self.clear_selection();
} else {
self.decks[self.selected_deck_index.unwrap()].general_settings =
state.decks[index].clone();
drop(state);
self.fill_deck_word_indices();
}
} else {
drop(state);
self.decks.remove(index);
self.clear_selection()
}
}
fn update(&mut self, message: RepetitionsMessage) -> Task<RootMessage> {
match message {
Next => {}
Back => {}
GoToRepetition => {}
GoToHistory => {}
GoToSettings => {}
CreateSet => {
let index = self.decks.len() + 1;
self.decks.push(DeckViewData {
general_settings: DeckSettings::with_name(format!("Колода #{}", index)),
existing_words_indices: None,
available_words_indices: None,
append_mode: Default::default(),
index: None,
});
}
DeleteSet => {
let index = self.selected_deck_index.unwrap();
self.clear_selection();
let deck = self.decks.remove(index);
if deck.id.is_valid() {
let mut state = self.state.lock();
state.decks.remove(index);
self.decks = self
.decks
.clone()
.into_iter()
.map(|mut deck| {
deck.index = state.decks.iter().position(|d| d.id == deck.id);
deck
})
.collect();
}
}
SetName(value) => {
let deck = self.selected_deck_mut().unwrap();
deck.general_settings.name = value;
}
SelectSet(index) => {
self.select_deck(index);
let deck = self.selected_deck().unwrap();
if deck.existing_words_indices.is_none() {
self.fill_deck_word_indices();
}
}
Save => {
let mut deck = self.selected_deck().unwrap().clone();
let mut state = self.state.lock();
{
update_deck(&mut deck.general_settings, &state.connection);
if let Some(index) = deck.index {
state.decks[index] = deck.general_settings.clone();
} else {
state.decks.push(deck.general_settings.clone());
deck.index = Some(state.decks.len() - 1);
self.decks[self.selected_deck_index.unwrap()] = deck.clone();
}
}
}
SetForward(value) => {
let deck = self.selected_deck_mut().unwrap();
deck.general_settings.forward = value;
}
SetBackward(value) => {
let deck = self.selected_deck_mut().unwrap();
deck.general_settings.backward = value;
}
SetFilter(value) => {
let deck = self.selected_deck_mut().unwrap();
deck.general_settings.filter = value;
}
TryFilter => {
let state = self.state.lock();
let deck = self.selected_deck().unwrap();
let count = deck.get_word_list(&state).len();
drop(state);
let deck = self.selected_deck_mut().unwrap();
deck.general_settings.count = Some(count);
}
SetOpenMode(mode) => {
let deck = self.selected_deck_mut().unwrap();
deck.general_settings.open_mode = mode;
}
SetAppendMode(mode) => {
let deck = self.selected_deck_mut().unwrap();
deck.append_mode = mode;
}
AppendWords(count) => {
let adding: Vec<usize>;
{
{
let deck = self.selected_deck().unwrap();
let existing_words_indices = deck.existing_words_indices.as_ref().unwrap();
let mut created_set = HashSet::with_capacity(existing_words_indices.len());
existing_words_indices.iter().for_each(|c| {
created_set.insert(c);
});
adding = deck
.available_words_indices
.as_ref()
.unwrap()
.iter()
.filter(|c| !created_set.contains(c))
.take(count)
.cloned()
.collect();
}
let deck = self.selected_deck_mut().unwrap();
deck.existing_words_indices
.as_mut()
.unwrap()
.append(&mut adding.clone());
}
let deck = self.selected_deck().unwrap();
let mut state = self.state.lock();
Self::append_words(&mut state, &deck.general_settings, adding.into_iter());
}
}
Task::none()
}
fn view(&self) -> Element<'_, RepetitionsMessage> {
back_overlay(
row![
column![
scrollable(self.decks_list()).height(Fill),
button("Добавить")
.style(jl_button)
.width(Fill)
.on_press(CreateSet),
]
.spacing(DEFAULT_SPACING)
.width(Length::FillPortion(1)),
self.selected_deck_view(),
]
.align_y(Center)
.spacing(DEFAULT_SPACING)
.width(Fill)
.height(Fill)
.into(),
Back,
)
}
}
impl RepetitionsState {
pub(crate) fn new(state: Arc<Mutex<AppState>>) -> RepetitionsState {
let state_ = state.lock();
let mut map = HashMap::with_capacity(state_.dictionary.len());
state_
.dictionary
.iter()
.enumerate()
.for_each(|(index, word)| {
map.insert(word.id, index);
});
let decks = Self::get_decks_from_state(&state_);
drop(state_);
RepetitionsState {
word_id_index_map: map,
selected_deck_index: None,
decks,
state,
}
}
fn get_decks_from_state(state: &AppState) -> Vec<DeckViewData> {
state
.decks
.iter()
.enumerate()
.map(|(index, deck)| DeckViewData {
general_settings: deck.clone(),
existing_words_indices: None,
available_words_indices: None,
append_mode: Default::default(),
index: Some(index),
})
.collect()
}
}
impl RepetitionsState {
fn launch_delete_button(&self, deck: &DeckViewData) -> Element<'_, RepetitionsMessage> {
if deck.id.is_valid() {
if deck.append_mode == AppendMode::Manual
&& deck.existing_words_indices.as_ref().unwrap().is_empty()
{
return text!("Добавьте карточки или измените фильтр").into();
}
return row![
space().width(Fill),
button(text!("Начать повторение").width(200).center())
.style(cta_button)
.on_press(GoToRepetition)
]
.into();
}
button("Удалить").style(danger).on_press(DeleteSet).into()
}
fn selected_deck_view(&self) -> Element<'_, RepetitionsMessage> {
if let Some(deck) = &self.selected_deck() {
return column![
scrollable(
column![
column![
text!("Название набора"),
row![
text_input("", &deck.name).on_input(SetName),
button(svg(Handle::from_memory(SETTINGS_ICON)))
.width(43)
.on_press(GoToSettings),
]
.spacing(HALF_SPACING)
]
.spacing(QUARTER_SPACING),
{
if !deck.id.is_valid() {
column![
column![
text!("Передняя сторона"),
text_input("", &deck.forward).on_input(SetForward),
]
.spacing(QUARTER_SPACING),
column![
text!("Задняя сторона"),
text_input("", &deck.backward).on_input(SetBackward),
]
.spacing(QUARTER_SPACING),
column![
text!("Фильтр"),
text_input("", &deck.filter).on_input(SetFilter),
button("Проверить фильтр")
.style(jl_button)
.on_press(TryFilter),
self.count_view(deck),
]
.spacing(QUARTER_SPACING),
]
.spacing(DEFAULT_SPACING)
} else {
column![
self.filled_deck_data_view(deck),
self.word_append_panel(deck),
radio(
"Обычный режим",
OrderMode::Default,
Some(deck.open_mode),
SetOpenMode
),
radio(
"Начать с плохих слов",
OrderMode::TrainWorstFirst,
Some(deck.open_mode),
SetOpenMode
),
radio(
"Полностью случайно",
OrderMode::FullRandom,
Some(deck.open_mode),
SetOpenMode
),
button("История").style(jl_button).on_press(GoToHistory),
]
.spacing(HALF_SPACING)
}
},
// self.words_words_view(&set),
]
.spacing(DEFAULT_SPACING)
.padding(Padding {
top: 0.0,
right: DEFAULT_SPACING,
bottom: 0.0,
left: 0.0,
})
)
.height(Fill),
row![
button("Сохранить").style(jl_button).on_press(Save),
self.launch_delete_button(deck)
]
.spacing(DEFAULT_SPACING)
.align_y(Center),
]
.spacing(DEFAULT_SPACING)
.width(Length::FillPortion(2))
.into();
}
space().width(Length::FillPortion(2)).into()
}
fn filled_deck_data_view(&self, deck: &DeckSettings) -> Column<'_, RepetitionsMessage> {
column![self.activity_bar(deck), self.count_comparator_view()]
.align_x(Center)
.width(Fill)
.spacing(DEFAULT_SPACING)
}
fn activity_bar(&self, deck: &DeckSettings) -> Element<'_, RepetitionsMessage> {
const MAX_DAY_COUNT: f32 = 128.0;
let state = self.state.lock();
let history = state.activity.get(&deck.id);
let mut counts: Vec<u32> = vec![0; 30 * 7];
let now = Local::now().date_naive();
if let Some(history) = history {
for (date, activity) in history {
let distance = now - *date;
let index = counts.len() as i64 - distance.num_days() - 1;
if index < 0 {
continue;
}
counts[index as usize] = *activity;
}
}
lazy((counts, now), |(counts, now)| {
let mut row = Row::new()
.padding(Padding {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: DEFAULT_SPACING,
})
.spacing(QUARTER_SPACING);
let mut iter = counts.iter();
for i in 0..30 {
let mut column = Column::new().spacing(QUARTER_SPACING);
for j in 0..7 {
let value = *iter.next().unwrap() as f32;
let k = (value / MAX_DAY_COUNT).min(1.0) * 0.9 + 0.1;
let date = (*now)
.checked_sub_days(Days::new(30 * 7 - i * 7 - j - 1))
.unwrap();
column = column.push(tooltip(
iced::widget::container(space().height(15).width(15)).style(
move |x: &Theme| container::Style {
text_color: None,
background: Some(Background::Color(
x.palette().primary.scale_alpha(k),
)),
border: Border {
color: Default::default(),
width: 0.0,
radius: Radius::new(5),
},
shadow: Default::default(),
snap: false,
},
),
text!("{} {} карточки", date.format("%Y.%m.%d"), value),
Top,
));
}
row = row.push(column);
}
scrollable(
column!["Карта активности", row]
.spacing(QUARTER_SPACING)
.align_x(Center),
)
})
.into()
}
fn word_append_panel(&self, deck: &DeckViewData) -> Element<'_, RepetitionsMessage> {
column![
text!("Режим добавления карточек"),
row![
radio(
"Добавлять все доступные",
AppendMode::Full,
Some(deck.append_mode),
SetAppendMode
),
radio(
"Добавлять вручную",
AppendMode::Manual,
Some(deck.append_mode),
SetAppendMode
)
]
.spacing(HALF_SPACING),
self.adder_panel(deck),
]
.spacing(HALF_SPACING)
.into()
}
fn adder_panel(&self, deck: &DeckViewData) -> Element<'_, RepetitionsMessage> {
match deck.append_mode {
AppendMode::Full => space().into(),
AppendMode::Manual => row![
button("+5").on_press(AppendWords(5)),
button("+10").on_press(AppendWords(10)),
button("+15").on_press(AppendWords(15)),
button("+20").on_press(AppendWords(20))
]
.spacing(HALF_SPACING)
.into(),
}
}
fn count_comparator_view(&self) -> Element<'_, RepetitionsMessage> {
let selected_deck = self.selected_deck().unwrap();
let now = selected_deck.existing_words_indices.as_ref().unwrap().len();
let available = selected_deck
.available_words_indices
.as_ref()
.unwrap()
.len();
text!("{} слова добавлено из {}", now, available).into()
}
fn count_view(&self, deck: &DeckSettings) -> Element<'_, RepetitionsMessage> {
if let Some(count) = deck.count {
return text!("Количество слов: {}", count).into();
}
space().into()
}
fn decks_list(&self) -> Column<'_, RepetitionsMessage> {
let mut column = Column::new();
for (i, deck) in self.decks.iter().enumerate() {
column = column.push(
button(text!("{}", &deck.name))
.on_press_with(move || SelectSet(i))
.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
}
}
impl RepetitionsState {
fn append_all_words(state: &mut AppState, deck: &DeckViewData) {
let existing = deck.existing_words_indices.as_ref().unwrap();
let mut created_set = HashSet::with_capacity(existing.len());
existing.iter().for_each(|c| {
created_set.insert(c);
});
let required = deck
.available_words_indices
.as_ref()
.unwrap()
.iter()
.filter(|i| !created_set.contains(i))
.cloned();
Self::append_words(state, deck, required);
}
fn append_words(
state: &mut AppState,
deck: &DeckSettings,
indices: impl Iterator<Item = usize>,
) {
let words = &state.dictionary;
let stats = &mut indices
.map(|i| {
let word = &words[i];
CardStatistics {
id: 0.into(),
word_id: word.id,
last_open: Utc::now(),
score: 1,
set_id: deck.id,
}
})
.collect::<Vec<_>>();
if !stats.is_empty() {
add_stat_list(stats, &mut state.connection);
}
}
fn select_deck(&mut self, index: usize) {
if self.decks.get(index).is_some() {
self.selected_deck_index = Some(index);
}
}
fn selected_deck(&self) -> Option<&DeckViewData> {
self.decks.get(self.selected_deck_index?)
}
fn selected_deck_mut(&mut self) -> Option<&mut DeckViewData> {
self.decks.get_mut(self.selected_deck_index?)
}
fn clear_selection(&mut self) {
self.selected_deck_index = None;
}
fn fill_deck_word_indices(&mut self) {
let state = &self.state.lock();
let mut deck = self.selected_deck().unwrap().clone();
let added: Vec<_> = load_stats_of_deck(&deck, &state.connection)
.iter()
.map(|c| self.word_id_index_map[&c.word_id])
.collect();
let total = deck.get_word_list(state);
deck.existing_words_indices = Some(added);
deck.available_words_indices = Some(total);
self.decks[self.selected_deck_index.unwrap()] = deck;
}
}
#[derive(Clone)]
pub enum RepetitionsMessage {
Next,
Back,
GoToRepetition,
CreateSet,
DeleteSet,
SetName(String),
SelectSet(usize),
Save,
SetForward(String),
SetBackward(String),
SetFilter(String),
TryFilter,
SetOpenMode(OrderMode),
SetAppendMode(AppendMode),
GoToHistory,
GoToSettings,
AppendWords(usize),
}
#[derive(Clone)]
struct DeckViewData {
general_settings: DeckSettings,
existing_words_indices: Option<Vec<usize>>,
available_words_indices: Option<Vec<usize>>,
append_mode: AppendMode,
index: Option<usize>,
}
impl Deref for DeckViewData {
type Target = DeckSettings;
fn deref(&self) -> &Self::Target {
&self.general_settings
}
}
+166
View File
@@ -0,0 +1,166 @@
use crate::pages::dictionary::DictionaryState;
use crate::navigation::Page::*;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::pages::randomizer::RandomizerState;
use crate::pages::repetitions::RepetitionsState;
use crate::pages::selector::SelectorMessage::ChangeMode;
use crate::styling::*;
use crate::pages::sync::SyncState;
use crate::pages::writing::WritingState;
use iced::widget::button::{Status, Style};
use iced::widget::*;
use iced::{Element, Task, alignment};
use iced_core::Alignment::Center;
use iced_core::Background;
use iced_core::Color;
use parking_lot::Mutex;
use std::sync::Arc;
use crate::app::AppState;
use crate::core::kana_set::{KanaSet, KanaType};
use crate::pages::quiz::QuizState;
pub struct SelectorState {
pub set: KanaSet,
is_writing: bool,
state: Arc<Mutex<AppState>>,
}
#[derive(Debug, Clone)]
pub enum SelectorMessage {
Change,
Goto,
Check(usize, bool),
ChangeMode(bool),
ToDictionary,
ToRandomize,
ToRepetitions,
ToSync,
}
impl NavigatedPage<SelectorMessage> for SelectorState {
fn navigate(&mut self, message: &SelectorMessage) -> Option<Page> {
if let SelectorMessage::Goto = message {
return if self.is_writing {
let writing = WritingState::new(&self.set);
Some(Writing(writing))
} else {
let mut quiz = QuizState::new();
quiz.set = self.set.clone();
Some(Quiz(quiz))
};
}
if let SelectorMessage::ToDictionary = message {
return Some(Dictionary(DictionaryState::new(self.state.clone())));
}
if let SelectorMessage::ToRandomize = message {
return Some(Randomizer(RandomizerState::default()));
}
if let SelectorMessage::ToRepetitions = message {
return Some(Repetitions(RepetitionsState::new(self.state.clone())));
}
if let SelectorMessage::ToSync = message {
return Some(Sync(SyncState::new(self.state.clone())));
}
None
}
fn navigated(&mut self) {}
fn update(&mut self, message: SelectorMessage) -> Task<RootMessage> {
match message {
SelectorMessage::Change => match self.set.chars_type {
KanaType::Katakana => self.set = KanaSet::hiragana(),
KanaType::Hiragana => self.set = KanaSet::katakana(),
},
SelectorMessage::Check(i, b) => self.set.include_map[i] = b,
ChangeMode(b) => self.is_writing = b,
_ => {}
}
Task::none()
}
fn view(&self) -> Element<'_, SelectorMessage> {
container(
iced::widget::column![
row![
button("あ ↔ ア")
.on_press(SelectorMessage::Change)
.style(jl_button),
button("Словарь")
.on_press(SelectorMessage::ToDictionary)
.style(Self::nav_style),
button("Повторение")
.on_press(SelectorMessage::ToRepetitions)
.style(Self::nav_style),
button("Рандомайзер")
.on_press(SelectorMessage::ToRandomize)
.style(Self::nav_style),
button("Синхронизация")
.on_press(SelectorMessage::ToSync)
.style(Self::nav_style)
]
.spacing(DEFAULT_SPACING),
scrollable(self.rows_selector()).horizontal(),
toggler(self.is_writing)
.label("Режим письма")
.on_toggle(ChangeMode),
button("К тесту")
.on_press(SelectorMessage::Goto)
.style(cta_button),
]
.spacing(DEFAULT_SPACING),
)
.padding(10)
.into()
}
}
impl SelectorState {
pub fn new(state: Arc<Mutex<AppState>>) -> Self {
Self {
set: Default::default(),
is_writing: false,
state,
}
}
fn nav_style(theme: &Theme, status: Status) -> Style {
let mut basic = button::text(theme, status);
basic.background = if status == Status::Hovered {
Some(Background::Color(Color::WHITE.scale_alpha(0.1)))
} else {
None
};
basic.border.radius = BUTTON_RADIUS.into();
basic
}
fn rows_selector(&self) -> Element<'_, SelectorMessage> {
let mut row = Row::new();
for i in 0..self.set.dictionary.len() {
let setup_checked = move |b: bool| -> SelectorMessage { SelectorMessage::Check(i, b) };
let mut chars_column: Column<'_, _> = Column::new();
chars_column =
chars_column.push(checkbox(self.set.include_map[i]).on_toggle(setup_checked));
for v in &self.set.dictionary[i] {
chars_column = chars_column.push(
container(text!("{}", v.0.clone().to_lowercase()).size(40))
.align_y(Center)
.align_x(Center)
.width(80)
.height(80)
.style(container::rounded_box),
);
}
row = row.push(
chars_column
.spacing(DEFAULT_SPACING)
.align_x(alignment::Horizontal::Center),
);
}
row.spacing(DEFAULT_SPACING).into()
}
}
+258
View File
@@ -0,0 +1,258 @@
use crate::data_provider::settings::{delete_settings, set_setting};
use crate::data_provider::web_api::*;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
use crate::pages::sync::SyncMessage::*;
use iced::widget::button::danger;
use iced::widget::container::rounded_box;
use iced::widget::{button, column, container, progress_bar, row, space, text, toggler};
use iced::{Center, Element, Fill, Font, Length, Task};
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::Duration;
use log::error;
use crate::app::{fill_state, AppState};
use crate::notification::{NotificationRequest, NotificationData};
#[derive(Clone)]
pub enum SyncMessage {
Back,
CopyKey,
KeyCopied,
IdReceived(String),
InitSync,
GetKey,
Send,
GetLast,
NextAnimation,
NetworkFinished,
Disable,
DisableSync,
SwitchAutoSync,
}
#[derive(Clone)]
pub struct SyncState {
state: Arc<Mutex<AppState>>,
auto_fetch: bool,
frozen: bool,
progress: f32,
}
impl NavigatedPage<SyncMessage> for SyncState {
fn navigate(&mut self, message: &SyncMessage) -> Option<Page> {
if let Back = message
&& !self.frozen
{
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: SyncMessage) -> Task<RootMessage> {
match message {
Back => {}
CopyKey => {
let state = self.state.lock();
let key = state.sync_data.key.clone().unwrap();
return iced::clipboard::write(key)
.map(|_val: String| RootMessage::Sync(KeyCopied));
}
InitSync => {
return Task::perform(first_sync(), |id| {
match id {
Ok(id) => RootMessage::Sync(IdReceived(id)),
Err(err) => RootMessage::PushNotification(NotificationRequest::Error(err))
}
});
}
GetKey => {
return iced::clipboard::read()
.map(|key| RootMessage::Sync(IdReceived(key.unwrap_or_else(String::new))));
}
KeyCopied => {}
IdReceived(new_id) => {
if !validate_id(&new_id) {
return Task::none();
}
let mut state = self.state.lock();
state.sync_data.key = Some(new_id.clone());
set_setting("SYNC_KEY".to_string(), new_id, &state.connection);
}
Send => {
self.prepare_to_db_interaction();
let id = self.state.lock().sync_data.key.clone().unwrap();
let tasks = Task::batch([
Task::perform(
async { tokio::time::sleep(Duration::from_millis(200)).await },
|_| RootMessage::Sync(NextAnimation),
),
Task::perform(send_data(id), |_| RootMessage::Sync(NetworkFinished)),
]);
return tasks;
}
GetLast => {
let id = self.state.lock().sync_data.key.clone().unwrap();
let tasks = Task::batch([
Task::perform(
async { tokio::time::sleep(Duration::from_millis(200)).await },
|_| RootMessage::Sync(NextAnimation),
),
Task::perform(load_data(id, false), |_| RootMessage::Sync(NetworkFinished)),
]);
return tasks;
}
NextAnimation => {
self.progress += 1.0;
if self.progress > 5.0 {
self.progress = 0.0;
}
if self.frozen {
return Task::perform(
async { tokio::time::sleep(Duration::from_millis(200)).await },
|_| RootMessage::Sync(NextAnimation),
);
}
}
NetworkFinished => {
self.frozen = false;
let mut updated_state = AppState::new();
fill_state(&mut updated_state);
let mut state = self.state.lock();
state.sync_data = updated_state.sync_data;
state.connection = updated_state.connection;
state.decks = updated_state.decks;
state.dictionary = updated_state.dictionary;
state.word_groups = updated_state.word_groups;
return Task::done(RootMessage::PushNotification(NotificationRequest::Info(NotificationData::DataLoaded)))
}
Disable => {}
DisableSync => {
let mut state = self.state.lock();
state.sync_data.key = None;
delete_settings("SYNC_KEY".to_string(), &state.connection);
delete_settings("AUTO_WEB_FETCH".to_string(), &state.connection);
}
SwitchAutoSync => {
self.auto_fetch = !self.auto_fetch;
let mut state = self.state.lock();
state.sync_data.auto_web_fetch = self.auto_fetch;
set_setting(
"AUTO_WEB_FETCH".to_string(),
self.auto_fetch.to_string(),
&state.connection,
);
}
}
Task::none()
}
fn view(&self) -> Element<'_, SyncMessage> {
back_overlay(
row![space().width(Fill), self.sync_column(), space().width(Fill)]
.spacing(DEFAULT_SPACING)
.width(Fill)
.into(),
Back,
)
}
}
impl SyncState {
pub fn new(state: Arc<Mutex<AppState>>) -> SyncState {
let auto_fetch;
{
auto_fetch = state.lock().sync_data.auto_web_fetch;
}
Self {
auto_fetch,
state,
frozen: false,
progress: 0.0,
}
}
fn sync_column(&self) -> Element<'_, SyncMessage> {
let network_view: Element<'_, SyncMessage> = if self.frozen {
progress_bar(0.0..=5.0, self.progress).into()
} else {
space().into()
};
{
if let Some(key) = self.state.lock().sync_data.key.clone() {
column![
text!("Ваш ключ синхронизации"),
container(
container(
text!("{}", key)
.size(ACCENT_FONT_SIZE)
.font(Font::MONOSPACE)
)
.padding(3)
)
.style(rounded_box),
button("Скопировать в буфер обмена")
.style(jl_button)
.on_press(CopyKey)
.width(Fill),
row![
button("↑ Отправить").style(jl_button).on_press(Send),
space().width(Fill),
button("↓ Скачать").style(jl_button).on_press(GetLast)
]
.spacing(DEFAULT_SPACING),
toggler(self.auto_fetch)
.label("Автоматическая синхронизация")
.on_toggle(|_| SwitchAutoSync),
container(network_view).width(Fill),
button("Отключить синхронизацию")
.style(danger)
.on_press(DisableSync),
]
} else {
column![
button("Создать сохранение")
.style(jl_button)
.on_press(InitSync),
button("Вставить ключ из буфера")
.style(jl_button)
.on_press(GetKey),
]
}
}
.spacing(DEFAULT_SPACING)
.align_x(Center)
.width(Length::Shrink)
.into()
}
/* fn app_updater(&self) -> Element<'_, SyncMessage> {
column![].width(Fill).into()
}*/
fn prepare_to_db_interaction(&mut self) {
if self.frozen {
return;
}
self.frozen = true;
self.progress = 0.0;
let state = self.state.lock();
state
.connection
.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
.unwrap_or_else(|e| {
error!("Ошибка при checkpoint WAL: {e}");
});
}
}
#[inline]
fn validate_id(id: &str) -> bool {
id.len() == 24 && id.chars().all(|c| c.is_ascii_alphanumeric())
}
+190
View File
@@ -0,0 +1,190 @@
use crate::data_provider::words::{delete_word, update_word};
use crate::lang::WordData;
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
use crate::pages::word::WordMessage::*;
use iced::widget::button::danger;
use iced::widget::{button, column, row, rule, scrollable, text, text_input};
use iced::{Element, Fill, Task};
use parking_lot::Mutex;
use std::sync::Arc;
use crate::app::AppState;
#[derive(Clone)]
pub struct WordState {
state: Arc<Mutex<AppState>>,
index: usize,
word: WordData,
}
impl NavigatedPage<WordMessage> for WordState {
fn navigate(&mut self, message: &WordMessage) -> Option<Page> {
if let Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: WordMessage) -> Task<RootMessage> {
match message {
Back => {}
Save => {
let mut state = self.state.lock();
state.dictionary[self.index] = self.word.clone();
update_word(&mut self.word, &state.connection);
return Task::done(RootMessage::Word(Back));
}
Delete => {
let mut state = self.state.lock();
state.dictionary.remove(self.index);
delete_word(&self.word, &state.connection);
return Task::done(RootMessage::Word(Back));
}
SetTags(n) => self.word.tags = n,
SetKey(n) => {
self.word.key = n;
}
SetValue(n) => {
self.word.value = n;
}
SetAdditional(key, value) => {
self.word.additional.insert(key, value.clone());
}
AddAdditional(key) => {
self.word.additional.insert(key, "".to_string());
}
RemoveAdditional(key) => {
self.word.additional.remove(key.as_str());
}
}
Task::none()
}
fn view(&self) -> Element<'_, WordMessage> {
let mut fast_add = row![];
if !self.word.additional.contains_key("reading") {
fast_add = fast_add.push(
button("Чтение")
.style(button::text)
.on_press(AddAdditional("reading".to_string())),
);
}
if !self.word.additional.contains_key("description") {
fast_add = fast_add.push(
button("Описание")
.style(button::text)
.on_press(AddAdditional("description".to_string())),
);
}
if !self.word.additional.contains_key("context") {
fast_add = fast_add.push(
button("В контексте")
.style(button::text)
.on_press(AddAdditional("context".to_string())),
);
}
let mut col = iced::widget::column![
column![
text!("Ключ"),
text_input("key", &self.word.key).on_input(SetKey),
]
.spacing(QUARTER_SPACING),
column![
text!("Значение"),
text_input("value", &self.word.value).on_input(SetValue),
]
.spacing(QUARTER_SPACING),
column![
text!("Теги"),
text_input("tags", &self.word.tags).on_input(SetTags),
]
.spacing(QUARTER_SPACING),
rule::horizontal(2),
scrollable(fast_add),
];
for more in &self.word.additional {
col = col.push(self.get_view_for_more(more));
}
back_overlay(
column![
col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill),
row![
button("Сохранить").style(jl_button).on_press(Save),
button("Удалить").style(danger).on_press(Delete),
]
.spacing(DEFAULT_SPACING)
]
.spacing(DEFAULT_SPACING)
.into(),
Back,
)
}
}
impl WordState {
pub(crate) fn new(word: WordData, index: usize, state: Arc<Mutex<AppState>>) -> WordState {
WordState { state, index, word }
}
}
impl WordState {
fn get_view_for_more(&self, value: (&String, &String)) -> Element<'_, WordMessage> {
match value.0.as_str() {
"reading" => self.reading_field(value.1),
"description" => self.description_field(value.1),
"context" => self.context_field(value.1),
_ => self.additional_field(value.1, value.0.clone(), value.0.clone()),
}
}
fn reading_field(&self, value: &str) -> Element<'_, WordMessage> {
self.additional_field(value, "Чтение слова".to_string(), "reading".to_string())
}
fn description_field(&self, value: &str) -> Element<'_, WordMessage> {
self.additional_field(value, "Описание".to_string(), "description".to_string())
}
fn context_field(&self, value: &str) -> Element<'_, WordMessage> {
self.additional_field(value, "В контексте".to_string(), "context".to_string())
}
fn additional_field(&self, value: &str, name: String, id: String) -> Element<'_, WordMessage> {
column![
text!("{}", name),
row![
text_input(id.clone().as_str(), value)
.on_input({
let value = id.clone();
move |string| SetAdditional(value.clone(), string)
})
.width(Fill),
button("-")
.on_press(RemoveAdditional(id.clone()))
.style(danger),
]
.spacing(QUARTER_SPACING),
]
.spacing(QUARTER_SPACING)
.into()
}
}
#[derive(Debug, Clone)]
pub enum WordMessage {
Save,
Back,
Delete,
SetTags(String),
SetKey(String),
SetValue(String),
AddAdditional(String),
RemoveAdditional(String),
SetAdditional(String, String),
}
+126
View File
@@ -0,0 +1,126 @@
use crate::navigation::Page::PreviousPage;
use crate::navigation::{NavigatedPage, Page, RootMessage};
use crate::styling::*;
use iced::widget::*;
use iced::{Element, Fill, Task, alignment};
use rand::seq::SliceRandom;
use crate::core::kana_set::KanaSet;
#[derive(Clone, Debug)]
pub struct WritingState {
kana: String,
set: Vec<(String, String)>,
original_set: Vec<(String, String)>,
kana_total: String,
roman_total: String,
next_text: String,
show_all: bool,
}
impl NavigatedPage<WritingMessage> for WritingState {
fn navigate(&mut self, message: &WritingMessage) -> Option<Page> {
if let WritingMessage::Back = message {
Some(PreviousPage)
} else {
None
}
}
fn navigated(&mut self) {}
fn update(&mut self, message: WritingMessage) -> Task<RootMessage> {
match message {
WritingMessage::Back => {}
WritingMessage::Next => self.next(),
WritingMessage::SwitchShowMode(b) => self.show_all = b,
}
Task::none()
}
fn view(&self) -> Element<'_, WritingMessage> {
container(
iced::widget::column![
checkbox(self.show_all)
.label("Показывать все сразу")
.on_toggle(WritingMessage::SwitchShowMode),
text!("{}", self.roman_total).size(34),
text!("{}", self.kana).size(48),
self.answers(),
row![
button(text!("{}", self.next_text))
.style(jl_button)
.on_press(WritingMessage::Next),
button("Закончить")
.style(jl_button)
.on_press(WritingMessage::Back),
]
.spacing(DEFAULT_SPACING)
]
.spacing(DEFAULT_SPACING)
.align_x(alignment::Horizontal::Center),
)
.center_x(Fill)
.center_y(Fill)
.padding(10)
.into()
}
}
impl WritingState {
pub(crate) fn new(set: &KanaSet) -> WritingState {
let mut list = set.list();
list.shuffle(&mut rand::rng());
WritingState {
kana: "".to_string(),
set: list.clone(),
original_set: list,
kana_total: "".to_string(),
roman_total: "".to_string(),
next_text: "Дальше".to_string(),
show_all: false,
}
}
}
impl WritingState {
fn next(&mut self) {
if self.set.is_empty() {
self.kana = "".to_string();
self.roman_total = "".to_string();
self.kana_total = "".to_string();
self.set = self.original_set.clone();
self.set.shuffle(&mut rand::rng());
return;
}
if self.show_all {
if !self.set.is_empty() && !self.kana_total.is_empty() {
self.set.clear();
}
for pair in &self.set {
self.kana = "---".to_string();
self.roman_total += &*format!("{} ", pair.1.clone()).to_string();
self.kana_total += &*format!("{} ", pair.0).to_string();
}
} else {
let current = self.set.pop().unwrap();
self.kana_total += &*format!("{} ", current.0).to_string();
self.roman_total += &*format!("{} ", current.1.clone()).to_string();
self.kana = current.1;
}
}
fn answers(&self) -> Element<'_, WritingMessage> {
if self.set.is_empty() {
text!("{}", self.kana_total).size(36).into()
} else {
space().height(36).into()
}
}
}
#[derive(Debug, Clone)]
pub enum WritingMessage {
Next,
Back,
SwitchShowMode(bool),
}