From b2392d28bd7bf968b43cbdb6ce973160a275a681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20Ram=C3=B3n=20Jim=C3=A9nez?= Date: Sun, 17 Nov 2019 07:38:01 +0100 Subject: [PATCH] Save `todos` async in a JSON file and load on boot --- Cargo.toml | 3 + examples/todos.rs | 334 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 254 insertions(+), 83 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8f9769b2..8766d814 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ iced_web = { version = "0.1.0-alpha", path = "web" } [dev-dependencies] env_logger = "0.7" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +directories = "2.0" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen = "0.2.51" diff --git a/examples/todos.rs b/examples/todos.rs index d97a9e08..2cdf1ea1 100644 --- a/examples/todos.rs +++ b/examples/todos.rs @@ -3,23 +3,34 @@ use iced::{ Application, Background, Button, Checkbox, Color, Column, Command, Container, Element, Font, Length, Row, Scrollable, Text, TextInput, }; +use serde::{Deserialize, Serialize}; pub fn main() { Todos::run() } +#[derive(Debug)] +enum Todos { + Loading, + Loaded(State), +} + #[derive(Debug, Default)] -struct Todos { +struct State { scroll: scrollable::State, input: text_input::State, input_value: String, filter: Filter, tasks: Vec, controls: Controls, + dirty: bool, + saving: bool, } #[derive(Debug, Clone)] -pub enum Message { +enum Message { + Loaded(Result), + Saved(Result<(), SaveError>), InputChanged(String), CreateTask, FilterChanged(Filter), @@ -30,113 +41,175 @@ impl Application for Todos { type Message = Message; fn new() -> (Todos, Command) { - (Todos::default(), Command::none()) + (Todos::Loading, Command::attempt(load(), Message::Loaded)) } fn title(&self) -> String { - String::from("Todos - Iced") + let dirty = match self { + Todos::Loading => false, + Todos::Loaded(state) => state.dirty, + }; + + format!("Todos{} - Iced", if dirty { "*" } else { "" }) } fn update(&mut self, message: Message) -> Command { - match message { - Message::InputChanged(value) => { - self.input_value = value; - } - Message::CreateTask => { - if !self.input_value.is_empty() { - self.tasks.push(Task::new(self.input_value.clone())); - self.input_value.clear(); + match self { + Todos::Loading => { + match message { + Message::Loaded(Ok(state)) => { + *self = Todos::Loaded(State { + input_value: state.input_value, + filter: state.filter, + tasks: state.tasks, + ..State::default() + }); + } + Message::Loaded(Err(_)) => { + *self = Todos::Loaded(State::default()); + } + _ => {} } + + Command::none() } - Message::FilterChanged(filter) => { - self.filter = filter; - } - Message::TaskMessage(i, TaskMessage::Delete) => { - self.tasks.remove(i); - } - Message::TaskMessage(i, task_message) => { - if let Some(task) = self.tasks.get_mut(i) { - task.update(task_message); + Todos::Loaded(state) => { + let mut saved = false; + + match message { + Message::InputChanged(value) => { + state.input_value = value; + } + Message::CreateTask => { + if !state.input_value.is_empty() { + state + .tasks + .push(Task::new(state.input_value.clone())); + state.input_value.clear(); + } + } + Message::FilterChanged(filter) => { + state.filter = filter; + } + Message::TaskMessage(i, TaskMessage::Delete) => { + state.tasks.remove(i); + } + Message::TaskMessage(i, task_message) => { + if let Some(task) = state.tasks.get_mut(i) { + task.update(task_message); + } + } + Message::Saved(_) => { + state.saving = false; + saved = true; + } + _ => {} + } + + if !saved { + state.dirty = true; + } + + if state.dirty && !state.saving { + state.dirty = false; + state.saving = true; + + Command::attempt( + save(SavedState { + input_value: state.input_value.clone(), + filter: state.filter, + tasks: state.tasks.clone(), + }), + Message::Saved, + ) + } else { + Command::none() } } } - - dbg!(self); - - Command::none() } fn view(&mut self) -> Element { - let Todos { - scroll, - input, - input_value, - filter, - tasks, - controls, - } = self; + match self { + Todos::Loading => loading_message(), + Todos::Loaded(State { + scroll, + input, + input_value, + filter, + tasks, + controls, + .. + }) => { + let title = Text::new("todos") + .size(100) + .color([0.5, 0.5, 0.5]) + .horizontal_alignment(HorizontalAlignment::Center); - let title = Text::new("todos") - .size(100) - .color([0.5, 0.5, 0.5]) - .horizontal_alignment(HorizontalAlignment::Center); + let input = TextInput::new( + input, + "What needs to be done?", + input_value, + Message::InputChanged, + ) + .padding(15) + .size(30) + .on_submit(Message::CreateTask); - let input = TextInput::new( - input, - "What needs to be done?", - input_value, - Message::InputChanged, - ) - .padding(15) - .size(30) - .on_submit(Message::CreateTask); + let controls = controls.view(&tasks, *filter); + let filtered_tasks = + tasks.iter().filter(|task| filter.matches(task)); - let controls = controls.view(&tasks, *filter); - let filtered_tasks = tasks.iter().filter(|task| filter.matches(task)); - - let tasks: Element<_> = - if filtered_tasks.count() > 0 { - tasks - .iter_mut() - .enumerate() - .filter(|(_, task)| filter.matches(task)) - .fold(Column::new().spacing(20), |column, (i, task)| { - column.push(task.view().map(move |message| { - Message::TaskMessage(i, message) - })) + let tasks: Element<_> = if filtered_tasks.count() > 0 { + tasks + .iter_mut() + .enumerate() + .filter(|(_, task)| filter.matches(task)) + .fold(Column::new().spacing(20), |column, (i, task)| { + column.push(task.view().map(move |message| { + Message::TaskMessage(i, message) + })) + }) + .into() + } else { + empty_message(match filter { + Filter::All => "You have not created a task yet...", + Filter::Active => "All your tasks are done! :D", + Filter::Completed => { + "You have not completed a task yet..." + } }) + }; + + let content = Column::new() + .max_width(800) + .spacing(20) + .push(title) + .push(input) + .push(controls) + .push(tasks); + + Scrollable::new(scroll) + .padding(40) + .push( + Container::new(content).width(Length::Fill).center_x(), + ) .into() - } else { - empty_message(match filter { - Filter::All => "You have not created a task yet...", - Filter::Active => "All your tasks are done! :D", - Filter::Completed => "You have not completed a task yet...", - }) - }; - - let content = Column::new() - .max_width(800) - .spacing(20) - .push(title) - .push(input) - .push(controls) - .push(tasks); - - Scrollable::new(scroll) - .padding(40) - .push(Container::new(content).width(Length::Fill).center_x()) - .into() + } + } } } -#[derive(Debug)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct Task { description: String, completed: bool, + + #[serde(skip)] state: TaskState, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum TaskState { Idle { edit_button: button::State, @@ -147,6 +220,14 @@ pub enum TaskState { }, } +impl Default for TaskState { + fn default() -> Self { + TaskState::Idle { + edit_button: button::State::new(), + } + } +} + #[derive(Debug, Clone)] pub enum TaskMessage { Completed(bool), @@ -255,7 +336,7 @@ impl Task { } } -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct Controls { all_button: button::State, active_button: button::State, @@ -324,7 +405,7 @@ impl Controls { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Filter { All, Active, @@ -347,6 +428,18 @@ impl Filter { } } +fn loading_message() -> Element<'static, Message> { + Container::new( + Text::new("Loading...") + .horizontal_alignment(HorizontalAlignment::Center) + .size(50), + ) + .width(Length::Fill) + .height(Length::Fill) + .center_y() + .into() +} + fn empty_message(message: &str) -> Element<'static, Message> { Container::new( Text::new(message) @@ -381,3 +474,78 @@ fn edit_icon() -> Text { fn delete_icon() -> Text { icon('\u{F1F8}') } + +// Persistence +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SavedState { + input_value: String, + filter: Filter, + tasks: Vec, +} + +fn save_path() -> std::path::PathBuf { + let mut path = if let Some(project_dirs) = + directories::ProjectDirs::from("rs", "Iced", "Todos") + { + project_dirs.data_dir().into() + } else { + std::env::current_dir() + .expect("The current directory is not accessible") + }; + + path.push("todos.json"); + + path +} + +#[derive(Debug, Clone)] +enum LoadError { + FileError, + FormatError, +} + +async fn load() -> Result { + use std::io::Read; + + let mut contents = String::new(); + + let mut file = + std::fs::File::open(save_path()).map_err(|_| LoadError::FileError)?; + + file.read_to_string(&mut contents) + .map_err(|_| LoadError::FileError)?; + + serde_json::from_str(&contents).map_err(|_| LoadError::FormatError) +} + +#[derive(Debug, Clone)] +enum SaveError { + DirectoryError, + FileError, + WriteError, + FormatError, +} + +async fn save(state: SavedState) -> Result<(), SaveError> { + use std::io::Write; + + let json = serde_json::to_string_pretty(&state) + .map_err(|_| SaveError::FormatError)?; + + let save_path = save_path(); + let save_dir = save_path.parent().ok_or(SaveError::DirectoryError)?; + + std::fs::create_dir_all(save_dir).map_err(|_| SaveError::DirectoryError)?; + + let mut file = + std::fs::File::create(save_path).map_err(|_| SaveError::FileError)?; + + file.write_all(json.as_bytes()) + .map_err(|_| SaveError::WriteError)?; + + // This is a simple way to save at most once every couple seconds + // We will be able to get rid of it once we implement event subscriptions + std::thread::sleep(std::time::Duration::from_secs(2)); + + Ok(()) +}