From d584df359640176ec4bc06f59d1e8d42ab17a413 Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 12 Mar 2022 13:27:29 +0100 Subject: Update and delete recurring events --- src/gui/app.rs | 17 ++-- src/gui/calendar.rs | 28 ++++-- src/gui/form/mod.rs | 238 ++++++++++++++++++++++++++++++++++++++------- src/gui/form/repetition.rs | 47 +++++---- src/gui/mod.rs | 9 +- src/gui/style.css | 2 +- src/gui/update.rs | 107 +++++++++++++++++--- 7 files changed, 356 insertions(+), 92 deletions(-) (limited to 'src/gui') diff --git a/src/gui/app.rs b/src/gui/app.rs index ebaceb3..9f37301 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1,5 +1,6 @@ use gtk4 as gtk; +use anyhow::Result; use async_channel::Sender; use chrono::{Datelike, Duration, NaiveDate, Weekday}; use gtk::glib::signal::Inhibit; @@ -16,7 +17,7 @@ pub struct App { pub window: Rc, pub grid: gtk::Grid, pub events: Vec, - pub repeated_events: Vec, + pub recurring_events: Vec, pub today: NaiveDate, pub start_date: NaiveDate, pub end_date: NaiveDate, @@ -24,7 +25,7 @@ pub struct App { } impl App { - pub fn new(conn: Rc, app: >k::Application, tx: Sender) -> Self { + pub fn new(conn: Rc, app: >k::Application, tx: Sender) -> Result { let window = Rc::new( gtk::ApplicationWindow::builder() .application(app) @@ -40,8 +41,8 @@ impl App { NaiveDate::from_isoywd(today.year(), today.iso_week().week(), Weekday::Mon); let end_date = start_date + Duration::days(7 * 4 - 1); - let events = db::list_non_repeated_between(&conn, start_date, end_date).unwrap_or_default(); - let repeated_events = db::list_repeated(&conn).unwrap_or_default(); + let events = db::list_non_recurring_between(&conn, start_date, end_date)?; + let recurring_events = db::list_recurring(&conn)?; let grid = calendar::create( tx.clone(), @@ -49,7 +50,7 @@ impl App { start_date, end_date, &events, - &repeated_events, + &recurring_events, ); window.set_child(Some(&grid)); @@ -61,16 +62,16 @@ impl App { Inhibit(false) }); - Self { + Ok(Self { conn, window, grid, events, - repeated_events, + recurring_events, today, start_date, end_date, tx, - } + }) } } diff --git a/src/gui/calendar.rs b/src/gui/calendar.rs index 3f5b6a7..cad2465 100644 --- a/src/gui/calendar.rs +++ b/src/gui/calendar.rs @@ -19,7 +19,7 @@ pub fn create( start_date: NaiveDate, end_date: NaiveDate, events: &[Event], - repeated_events: &[Event], + recurring_events: &[Event], ) -> gtk::Grid { let grid = gtk::Grid::builder().build(); @@ -27,7 +27,7 @@ pub fn create( grid.attach(&day_title(col), col, 0, 1, 1); } - let repetitions = event::repetitions_between(repeated_events, start_date, end_date); + let repetitions = event::repetitions_between(recurring_events, start_date, end_date); attach_days(tx.clone(), &grid, start_date, today, events, &repetitions); let event_controller_key = gtk::EventControllerKey::new(); @@ -126,12 +126,12 @@ pub fn day_entry( .iter() .filter(|e| e.date == date) .collect::>(); - let repeated_events = repetitions.get(&date).cloned().unwrap_or_default(); - events.extend(repeated_events.iter()); + let recurring_events = repetitions.get(&date).cloned().unwrap_or_default(); + events.extend(recurring_events.iter()); events.sort_by_key(|e| e.start); if !events.is_empty() { - vbox.append(&day_events(tx, events)); + vbox.append(&day_events(date, tx, events)); } gtk::ScrolledWindow::builder() @@ -147,18 +147,24 @@ fn day_label(today: NaiveDate, date: NaiveDate) -> gtk::Label { .label(&format!( "{} {}", date.day(), - if date == today || date.day() == 1 { MONTHES[date.month0() as usize] } else { "" } + if date == today || date.day() == 1 { + MONTHES[date.month0() as usize] + } else { + "" + } )) .halign(gtk::Align::Start) .build(); label.add_css_class("g-Calendar__DayNumber"); - if date.day() == 1 { label.add_css_class("g-Calendar__DayNumber--FirstOfMonth") } + if date.day() == 1 { + label.add_css_class("g-Calendar__DayNumber--FirstOfMonth") + } label } -fn day_events(tx: Sender, events: Vec<&Event>) -> gtk::Box { +fn day_events(date: NaiveDate, tx: Sender, events: Vec<&Event>) -> gtk::Box { let vbox = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); @@ -174,7 +180,11 @@ fn day_events(tx: Sender, events: Vec<&Event>) -> gtk::Box { glib::clone!(@strong event, @strong tx => move |gesture, n, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); if n == 2 { - update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + if event.repetition.is_some() { + update::send(tx.clone(), Msg::ShowRepetitionDialog { date, event: event.clone() }); + } else { + update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + } } }), ); diff --git a/src/gui/form/mod.rs b/src/gui/form/mod.rs index 57ccac7..68e6539 100644 --- a/src/gui/form/mod.rs +++ b/src/gui/form/mod.rs @@ -2,8 +2,13 @@ mod repetition; use gtk4 as gtk; +use anyhow::Result; +use chrono::{NaiveDate, NaiveTime}; use gtk::glib; use gtk::prelude::*; +use rusqlite::Connection; +use thiserror::Error; +use uuid::Uuid; use crate::{ db, @@ -11,12 +16,70 @@ use crate::{ model::{event, event::Event}, }; -pub async fn show(app: &App, event: Event, is_new: bool) { +pub async fn repetition_dialog(app: &App, date: NaiveDate, event: Event) { let dialog = gtk::Dialog::builder() .transient_for(&*app.window) .modal(true) - .title(if is_new { "Ajouter" } else { "Modifier" }) - .css_classes(vec!["g-Form".to_string()]) + .title("Modifier") + .css_classes(vec!["g-Dialog".to_string()]) + .build(); + + let content_area = dialog.content_area(); + + let lines = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + content_area.append(&lines); + + let button = gtk::Button::builder() + .label("Cette occurence") + .margin_bottom(10) + .build(); + lines.append(&button); + let tx = app.tx.clone(); + button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { + update::send(tx.clone(), Msg::ShowUpdateRepetitionForm { date, event: event.clone() }); + dialog.close() + })); + + let button = gtk::Button::builder() + .label("Toutes les occurences") + .build(); + lines.append(&button); + let tx = app.tx.clone(); + button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { + update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + dialog.close() + })); + + dialog.run_future().await; +} + +#[derive(Clone)] +pub enum Target { + New { date: NaiveDate }, + Update { event: Event }, + UpdateRepetition { event: Event, date: NaiveDate }, +} + +pub async fn show(app: &App, target: Target) { + let event = match target { + Target::New { .. } => None, + Target::Update { ref event } => Some(event.clone()), + Target::UpdateRepetition { ref event, .. } => Some(event.clone()), + }; + + let title = if event.is_some() { + "Modifier" + } else { + "Ajouter" + }; + + let dialog = gtk::Dialog::builder() + .transient_for(&*app.window) + .modal(true) + .title(title) + .css_classes(vec!["g-Dialog".to_string()]) .build(); let content_area = dialog.content_area(); @@ -29,7 +92,6 @@ pub async fn show(app: &App, event: Event, is_new: bool) { let columns = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .build(); - columns.add_css_class("g-Form__Columns"); lines.append(&columns); // First column @@ -37,79 +99,145 @@ pub async fn show(app: &App, event: Event, is_new: bool) { let column1 = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); - column1.add_css_class("g-Form__Inputs"); columns.append(&column1); - let name = entry(&event.name); + let name = event.as_ref().map(|e| entry(&e.name)).unwrap_or_default(); column1.append(&label("Événement")); column1.append(&name); - let date = entry(&event.date.format(event::DATE_FORMAT).to_string()); + let date = match target { + Target::New { date } => date, + Target::Update { ref event } => event.date, + Target::UpdateRepetition { date, .. } => date, + }; + let date = entry(&date.format(event::DATE_FORMAT).to_string()); column1.append(&label("Jour")); column1.append(&date); - let start = entry( - &event - .start - .map(event::pprint_time) - .unwrap_or_else(|| "".to_string()), - ); + let start = event + .as_ref() + .map(|e| time_entry(e.start)) + .unwrap_or_else(|| entry("")); column1.append(&label("Début")); column1.append(&start); - let end = entry( - &event - .end - .map(event::pprint_time) - .unwrap_or_else(|| "".to_string()), - ); + let end = event + .as_ref() + .map(|e| time_entry(e.end)) + .unwrap_or_else(|| entry("")); column1.append(&label("Fin")); column1.append(&end); // Second column - let repetition_model = repetition::view(&event); + let repetition = match target { + Target::Update { ref event } => event.repetition.as_ref(), + _ => None, + }; + let repetition_model = repetition::view(repetition); columns.append(&repetition_model.view); // Buttons + let button_title = match target { + Target::New { .. } => "Créer", + Target::Update { .. } => "Modifier", + Target::UpdateRepetition { .. } => "Modifier l’occurence", + }; + let button = gtk::Button::builder() - .label(if is_new { "Créer" } else { "Modifier" }) + .label(button_title) .margin_bottom(10) .build(); lines.append(&button); let conn = app.conn.clone(); let tx = app.tx.clone(); - button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { + button.connect_clicked(glib::clone!(@weak dialog, @strong target, @strong event => move |_| { match repetition::validate(&repetition_model) { Ok(repetition) => { - match event::validate(event.id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text(), repetition) { + let id = match &target { + Target::Update {event} => event.id, + _ => Uuid::new_v4(), + }; + match event::validate(id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text(), repetition) { Some(new) => { - match if is_new { db::insert(&conn, &new) } else { db::update(&conn, &new) } { - Ok(_) => { - let msg = if is_new { Msg::AddEvent { new } } else { Msg::UpdateEvent { old: event.clone(), new } }; - update::send(tx.clone(), msg); - dialog.close() - }, - Err(err) => eprintln!("Error when upserting event: {err}") + match &target { + Target::New {..} => { + match db::insert(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::AddEvent { new }); + dialog.close() + }, + Err(err) => eprintln!("Error when inserting event: {}", err) + } + } + Target::Update {event} => { + match db::update(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::UpdateEvent { old: event.clone(), new }); + dialog.close() + }, + Err(err) => eprintln!("Error when updating event: {}", err) + } + } + Target::UpdateRepetition { event, date } => { + // TODO: improve intermediate error state + match delete_repetition_occurence(&conn, event, *date) { + Ok(occurence) => { + match db::insert(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::UpdateEventOccurence { + event: event.clone(), + occurence, + date: *date, + new + }) + } + Err(err) => eprintln!("Error when updating repetition: {}", err) + }; + dialog.close() + }, + Err(err) => eprintln!("Error when updating repetition: {}", err) + } + } } } - None => eprintln!("Event is not valid: {event:?}") + None => eprintln!("Event is not valid.") } }, - Err(message) => eprintln!("{message}") + Err(message) => eprintln!("{}", message) } })); - if !is_new { - let button = gtk::Button::builder().label("Supprimer").build(); + if let Some(event) = event { + let label = match target { + Target::Update { .. } => "Supprimer", + _ => "Supprimer l’occurence", + }; + let button = gtk::Button::builder().label(label).build(); lines.append(&button); let conn = app.conn.clone(); let tx = app.tx.clone(); button.connect_clicked(glib::clone!(@weak dialog => move |_| { - if db::delete(&conn, &event.id).is_ok() { - update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); - dialog.close() + match target { + Target::UpdateRepetition { date, .. } => { + match delete_repetition_occurence(&conn, &event, date) { + Ok(occurence) => { + update::send(tx.clone(), Msg::DeleteOccurence { event: event.clone(), date, occurence }); + dialog.close() + } + Err(err) => { + eprintln!("{:?}", err); + } + } + } + _ => { + let operation = db::delete(&conn, &event.id); + if operation.is_ok() { + update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); + dialog.close() + } + } } })); } @@ -117,6 +245,42 @@ pub async fn show(app: &App, event: Event, is_new: bool) { dialog.run_future().await; } +#[derive(Error, Debug)] +enum DeleteError { + #[error("Repetition not found")] + RepetitionNotFound, + #[error("Occurence not found")] + OccurenceNotFound, +} + +fn delete_repetition_occurence( + conn: &Connection, + event: &Event, + occurence_date: NaiveDate, +) -> Result { + if let Some(ref repetition) = event.repetition { + if let Some(occurence) = repetition.occurence_index(event.date, occurence_date) { + let mut event = event.clone(); + let mut repetition = repetition.clone(); + repetition.removed_occurences.insert(occurence); + event.repetition = Some(repetition); + db::update(conn, &event).map(|_| occurence) + } else { + Err(anyhow::Error::new(DeleteError::OccurenceNotFound)) + } + } else { + Err(anyhow::Error::new(DeleteError::RepetitionNotFound)) + } +} + +fn time_entry(time: Option) -> gtk::Entry { + entry( + &time + .map(event::pprint_time) + .unwrap_or_else(|| "".to_string()), + ) +} + fn entry(text: &str) -> gtk::Entry { gtk::Entry::builder().text(text).margin_bottom(10).build() } diff --git a/src/gui/form/repetition.rs b/src/gui/form/repetition.rs index accb091..1d36765 100644 --- a/src/gui/form/repetition.rs +++ b/src/gui/form/repetition.rs @@ -2,13 +2,11 @@ use gtk4 as gtk; use chrono::{Weekday, Weekday::*}; use gtk::prelude::*; +use std::collections::HashSet; -use crate::{ - model::event::Event, - model::{ - repetition, - repetition::{DayOfMonth, Repetition}, - }, +use crate::model::{ + repetition, + repetition::{DayOfMonth, Frequency, Repetition}, }; static WEEKDAYS_STR: [&str; 7] = [ @@ -29,7 +27,7 @@ pub struct Model { yearly_radio: gtk::CheckButton, } -pub fn view(event: &Event) -> Model { +pub fn view(repetition: Option<&Repetition>) -> Model { let view = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); @@ -39,12 +37,14 @@ pub fn view(event: &Event) -> Model { let no_radio = gtk::CheckButton::builder() .label("Non") - .active(event.repetition.is_none()) + .active(repetition.is_none()) .build(); view.append(&no_radio); - let default = match event.repetition { - Some(Repetition::Daily { period }) => period.to_string(), + let frequency = repetition.as_ref().map(|r| r.frequency.clone()); + + let default = match frequency { + Some(Frequency::Daily { period }) => period.to_string(), _ => "".to_string(), }; let day_interval_entry = gtk::Entry::builder().text(&default).build(); @@ -56,8 +56,8 @@ pub fn view(event: &Event) -> Model { ); view.append(&day_interval_box); - let default = match event.repetition { - Some(Repetition::Monthly { + let default = match frequency { + Some(Frequency::Monthly { day: DayOfMonth::Day { day }, }) => day.to_string(), _ => "".to_string(), @@ -67,8 +67,8 @@ pub fn view(event: &Event) -> Model { radio_input(&no_radio, !default.is_empty(), &monthly_entry, "Mensuel"); view.append(&monthly_box); - let (active, default) = match event.repetition { - Some(Repetition::Monthly { + let (active, default) = match frequency { + Some(Frequency::Monthly { day: DayOfMonth::Weekday { weekday }, }) => (true, weekday), _ => (false, Mon), @@ -83,7 +83,7 @@ pub fn view(event: &Event) -> Model { let yearly_radio = gtk::CheckButton::builder() .group(&no_radio) .label("Annuel") - .active(event.repetition == Some(Repetition::Yearly)) + .active(frequency == Some(Frequency::Yearly)) .build(); view.append(&yearly_radio); @@ -127,24 +127,29 @@ fn label(text: &str) -> gtk::Label { } pub fn validate(model: &Model) -> Result, String> { - if model.no_radio.is_active() { + let frequency = if model.no_radio.is_active() { Ok(None) } else if model.day_interval_radio.is_active() { let period = repetition::validate_period(&model.day_interval_entry.buffer().text())?; - Ok(Some(Repetition::Daily { period })) + Ok(Some(Frequency::Daily { period })) } else if model.monthly_radio.is_active() { let day = repetition::validate_day(&model.monthly_entry.buffer().text())?; - Ok(Some(Repetition::Monthly { + Ok(Some(Frequency::Monthly { day: DayOfMonth::Day { day }, })) } else if model.first_day_radio.is_active() { let weekday = WEEKDAYS[model.first_day_dropdown.selected() as usize]; - Ok(Some(Repetition::Monthly { + Ok(Some(Frequency::Monthly { day: DayOfMonth::Weekday { weekday }, })) } else if model.yearly_radio.is_active() { - Ok(Some(Repetition::Yearly)) + Ok(Some(Frequency::Yearly)) } else { Err("Aucune option n’a été sélectionnée".to_string()) - } + }?; + + Ok(frequency.map(|frequency| Repetition { + frequency, + removed_occurences: HashSet::new(), + })) } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c33500b..e7f457f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -24,8 +24,13 @@ pub fn run(conn: Connection) { fn build_ui(conn: Rc, app: >k::Application) { let (tx, rx) = async_channel::unbounded(); - let app = App::new(conn, app, tx); - utils::spawn(update::event_handler(rx, app)) + match App::new(conn, app, tx) { + Ok(app) => utils::spawn(update::event_handler(rx, app)), + Err(err) => { + eprintln!("{}", err); + std::process::exit(1) + } + } } fn load_style() { diff --git a/src/gui/style.css b/src/gui/style.css index 70385d1..59ae30d 100644 --- a/src/gui/style.css +++ b/src/gui/style.css @@ -38,7 +38,7 @@ background-color: pink; } -.g-Form { +.g-Dialog { background-color: white; color: black; padding: 10px; diff --git a/src/gui/update.rs b/src/gui/update.rs index c8dfa6d..419a6e4 100644 --- a/src/gui/update.rs +++ b/src/gui/update.rs @@ -2,6 +2,7 @@ use async_channel::{Receiver, Sender}; use chrono::{Duration, NaiveDate}; use gtk4::prelude::GridExt; use std::collections::HashSet; +use std::iter::FromIterator; use crate::{ gui::{calendar, form, utils, App}, @@ -15,11 +16,41 @@ pub fn send(tx: Sender, msg: Msg) { } pub enum Msg { - ShowAddForm { date: NaiveDate }, - ShowUpdateForm { event: Event }, - AddEvent { new: Event }, - UpdateEvent { old: Event, new: Event }, - DeleteEvent { event: Event }, + ShowAddForm { + date: NaiveDate, + }, + ShowRepetitionDialog { + date: NaiveDate, + event: Event, + }, + ShowUpdateForm { + event: Event, + }, + ShowUpdateRepetitionForm { + date: NaiveDate, + event: Event, + }, + AddEvent { + new: Event, + }, + UpdateEvent { + old: Event, + new: Event, + }, + UpdateEventOccurence { + event: Event, + occurence: usize, + date: NaiveDate, + new: Event, + }, + DeleteEvent { + event: Event, + }, + DeleteOccurence { + event: Event, + date: NaiveDate, + occurence: usize, + }, SelectPreviousWeek, SelectNextWeek, } @@ -27,8 +58,14 @@ pub enum Msg { pub async fn event_handler(rx: Receiver, mut app: App) { while let Ok(msg) = rx.recv().await { match msg { - Msg::ShowAddForm { date } => form::show(&app, event::init(date), true).await, - Msg::ShowUpdateForm { event } => form::show(&app, event, false).await, + Msg::ShowAddForm { date } => form::show(&app, form::Target::New { date }).await, + Msg::ShowRepetitionDialog { date, event } => { + form::repetition_dialog(&app, date, event).await + } + Msg::ShowUpdateForm { event } => form::show(&app, form::Target::Update { event }).await, + Msg::ShowUpdateRepetitionForm { date, event } => { + form::show(&app, form::Target::UpdateRepetition { event, date }).await + } Msg::AddEvent { new } => { let refresh_dates = add(&mut app, &new); refresh(&app, &refresh_dates) @@ -38,10 +75,29 @@ pub async fn event_handler(rx: Receiver, mut app: App) { refresh_dates.extend(add(&mut app, &new)); refresh(&app, &refresh_dates); } + Msg::UpdateEventOccurence { + event, + occurence, + date, + new, + } => { + remove_occurence(&mut app, &event, occurence); + let mut refresh_dates = add(&mut app, &new); + refresh_dates.insert(date); + refresh(&app, &refresh_dates) + } Msg::DeleteEvent { event } => { let refresh_dates = remove(&mut app, &event); refresh(&app, &refresh_dates) } + Msg::DeleteOccurence { + event, + date, + occurence, + } => { + remove_occurence(&mut app, &event, occurence); + refresh(&app, &HashSet::from([date])) + } Msg::SelectPreviousWeek => { app.grid.remove_row(4); app.grid.insert_row(1); @@ -66,12 +122,10 @@ pub async fn event_handler(rx: Receiver, mut app: App) { /// Remove event and return dates that should be refreshed. fn remove(app: &mut App, event: &Event) -> HashSet { if event.repetition.is_some() { - match app.repeated_events.iter().position(|e| e.id == event.id) { + match app.recurring_events.iter().position(|e| e.id == event.id) { Some(index) => { - app.repeated_events.remove(index); - let mut dates = repetition_dates(app, event); - dates.insert(event.date); - dates + app.recurring_events.remove(index); + repetition_dates(app, event) } None => { eprintln!("Event not found when trying to delete {:?}", event); @@ -92,10 +146,35 @@ fn remove(app: &mut App, event: &Event) -> HashSet { } } +/// Remove event repetition +fn remove_occurence(app: &mut App, event: &Event, occurence: usize) { + match app.recurring_events.iter().position(|e| e.id == event.id) { + Some(index) => { + let event = app.recurring_events.get_mut(index).unwrap(); + match event.repetition.clone() { + Some(mut repetition) => { + repetition.removed_occurences.insert(occurence); + event.repetition = Some(repetition.clone()) + } + None => eprintln!( + "Repetition not found when trying to delete repetition of {:?}", + event + ), + } + } + None => { + eprintln!( + "Event not found when trying to delete repetition of {:?}", + event + ) + } + } +} + /// Add event and return dates that should be refreshed. fn add(app: &mut App, event: &Event) -> HashSet { if event.repetition.is_some() { - app.repeated_events.push(event.clone()); + app.recurring_events.push(event.clone()); let mut dates = repetition_dates(app, event); dates.insert(event.date); dates @@ -114,7 +193,7 @@ fn repetition_dates(app: &App, event: &Event) -> HashSet { /// Refresh app for the given dates. fn refresh(app: &App, dates: &HashSet) { let repetitions = - event::repetitions_between(&app.repeated_events, app.start_date, app.end_date); + event::repetitions_between(&app.recurring_events, app.start_date, app.end_date); for date in dates { calendar::refresh_date(app, *date, &repetitions) -- cgit v1.2.3