Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83762720a1 | |||
| d669ca5c05 | |||
| 2fb550229e | |||
| 10a8d1d75e | |||
| 00fec516ac | |||
| 9649ca96b0 | |||
| a35eab35af | |||
| 3379cbd057 | |||
| 7ebafec3c0 | |||
| 822c335864 |
+439
-71
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
use std::sync::mpsc as std_mpsc;
|
use std::sync::mpsc as std_mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -39,7 +40,8 @@ pub struct App {
|
|||||||
pub task_list_scroll: u16,
|
pub task_list_scroll: u16,
|
||||||
pub detail_scroll: u16,
|
pub detail_scroll: u16,
|
||||||
pub notes_scroll: u16,
|
pub notes_scroll: u16,
|
||||||
pub calendar_scroll: u16,
|
pub calendar_scrolls: [u16; 4],
|
||||||
|
pub calendar_active_week: usize,
|
||||||
pub db: Arc<Db>,
|
pub db: Arc<Db>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub api_client: Arc<ApiClient>,
|
pub api_client: Arc<ApiClient>,
|
||||||
@@ -49,6 +51,12 @@ pub struct App {
|
|||||||
last_sync_version: u64,
|
last_sync_version: u64,
|
||||||
editing_task_id: Option<String>,
|
editing_task_id: Option<String>,
|
||||||
pending_date_key: bool,
|
pending_date_key: bool,
|
||||||
|
pending_new_key: bool,
|
||||||
|
pending_bulk_move: bool,
|
||||||
|
pub selected_tasks: BTreeSet<usize>,
|
||||||
|
pub bulk_action_selected: usize,
|
||||||
|
pub popup_list_indices: Vec<(String, String)>,
|
||||||
|
pub popup_list_selected: usize,
|
||||||
auth_tx: std_mpsc::Sender<AuthEvent>,
|
auth_tx: std_mpsc::Sender<AuthEvent>,
|
||||||
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||||
sync_tx: mpsc::Sender<SyncCommand>,
|
sync_tx: mpsc::Sender<SyncCommand>,
|
||||||
@@ -109,7 +117,8 @@ impl App {
|
|||||||
task_list_scroll: 0,
|
task_list_scroll: 0,
|
||||||
detail_scroll: 0,
|
detail_scroll: 0,
|
||||||
notes_scroll: 0,
|
notes_scroll: 0,
|
||||||
calendar_scroll: 0,
|
calendar_scrolls: [0; 4],
|
||||||
|
calendar_active_week: 0,
|
||||||
db,
|
db,
|
||||||
api_client,
|
api_client,
|
||||||
needs_auth: !has_token,
|
needs_auth: !has_token,
|
||||||
@@ -118,6 +127,12 @@ impl App {
|
|||||||
last_sync_version: 0,
|
last_sync_version: 0,
|
||||||
editing_task_id: None,
|
editing_task_id: None,
|
||||||
pending_date_key: false,
|
pending_date_key: false,
|
||||||
|
pending_new_key: false,
|
||||||
|
pending_bulk_move: false,
|
||||||
|
selected_tasks: BTreeSet::new(),
|
||||||
|
bulk_action_selected: 0,
|
||||||
|
popup_list_indices: Vec::new(),
|
||||||
|
popup_list_selected: 0,
|
||||||
auth_tx,
|
auth_tx,
|
||||||
auth_rx,
|
auth_rx,
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -246,6 +261,33 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) {
|
pub fn handle_key(&mut self, key: KeyEvent) {
|
||||||
|
if self.pending_new_key {
|
||||||
|
self.pending_new_key = false;
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('l') | KeyCode::Char('L') => {
|
||||||
|
self.editing_task_id = None;
|
||||||
|
self.popup_input.clear();
|
||||||
|
self.popup_cursor = 0;
|
||||||
|
self.show_popup = Some(Popup::Input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') => {
|
||||||
|
if !self.lists.is_empty() {
|
||||||
|
self.editing_task_id = None;
|
||||||
|
self.popup_input.clear();
|
||||||
|
self.popup_cursor = 0;
|
||||||
|
self.popup_secondary.clear();
|
||||||
|
self.popup_secondary_cursor = 0;
|
||||||
|
self.edit_field = 0;
|
||||||
|
self.notes_scroll = 0;
|
||||||
|
self.show_popup = Some(Popup::EditTask { field: 0 });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref popup) = self.show_popup.clone() {
|
if let Some(ref popup) = self.show_popup.clone() {
|
||||||
self.handle_popup_key(key, popup);
|
self.handle_popup_key(key, popup);
|
||||||
return;
|
return;
|
||||||
@@ -283,12 +325,40 @@ impl App {
|
|||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
self.focus = match self.focus {
|
match self.focus {
|
||||||
Focus::Tabs => Focus::TaskList,
|
Focus::Calendar => {
|
||||||
Focus::TaskList => Focus::Detail,
|
if self.calendar_active_week < 3 {
|
||||||
Focus::Detail => Focus::Calendar,
|
self.calendar_active_week += 1;
|
||||||
Focus::Calendar => Focus::Tabs,
|
} else {
|
||||||
};
|
self.calendar_active_week = 0;
|
||||||
|
self.focus = Focus::Tabs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.focus = match self.focus {
|
||||||
|
Focus::Tabs => Focus::TaskList,
|
||||||
|
Focus::TaskList => Focus::Detail,
|
||||||
|
Focus::Detail => Focus::Calendar,
|
||||||
|
_ => Focus::Tabs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task > 0 {
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.selected_task -= 1;
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task + 1 < self.tasks.len() {
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.selected_task += 1;
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.task_list_scroll += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||||
@@ -303,6 +373,7 @@ impl App {
|
|||||||
KeyCode::Up => match self.focus {
|
KeyCode::Up => match self.focus {
|
||||||
Focus::TaskList => {
|
Focus::TaskList => {
|
||||||
if self.selected_task > 0 {
|
if self.selected_task > 0 {
|
||||||
|
self.clear_selection();
|
||||||
self.selected_task -= 1;
|
self.selected_task -= 1;
|
||||||
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
||||||
}
|
}
|
||||||
@@ -311,13 +382,14 @@ impl App {
|
|||||||
self.detail_scroll = self.detail_scroll.saturating_sub(1);
|
self.detail_scroll = self.detail_scroll.saturating_sub(1);
|
||||||
}
|
}
|
||||||
Focus::Calendar => {
|
Focus::Calendar => {
|
||||||
self.calendar_scroll = self.calendar_scroll.saturating_sub(1);
|
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_sub(1);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::Down => match self.focus {
|
KeyCode::Down => match self.focus {
|
||||||
Focus::TaskList => {
|
Focus::TaskList => {
|
||||||
if self.selected_task + 1 < self.tasks.len() {
|
if self.selected_task + 1 < self.tasks.len() {
|
||||||
|
self.clear_selection();
|
||||||
self.selected_task += 1;
|
self.selected_task += 1;
|
||||||
self.task_list_scroll += 1;
|
self.task_list_scroll += 1;
|
||||||
}
|
}
|
||||||
@@ -326,43 +398,45 @@ impl App {
|
|||||||
self.detail_scroll += 1;
|
self.detail_scroll += 1;
|
||||||
}
|
}
|
||||||
Focus::Calendar => {
|
Focus::Calendar => {
|
||||||
self.calendar_scroll += 1;
|
self.calendar_scrolls[self.calendar_active_week] = self.calendar_scrolls[self.calendar_active_week].saturating_add(1);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
if self.focus == Focus::Tabs && !self.lists.is_empty() {
|
match self.focus {
|
||||||
if self.selected_list + 1 < self.lists.len() {
|
Focus::Tabs => {
|
||||||
self.selected_list += 1;
|
if !self.lists.is_empty() && self.selected_list + 1 < self.lists.len() {
|
||||||
self.load_tasks();
|
self.selected_list += 1;
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Focus::Calendar => {
|
||||||
|
if self.calendar_active_week < 3 {
|
||||||
|
self.calendar_active_week += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
if self.focus == Focus::Tabs && !self.lists.is_empty() {
|
match self.focus {
|
||||||
if self.selected_list > 0 {
|
Focus::Tabs => {
|
||||||
self.selected_list -= 1;
|
if !self.lists.is_empty() && self.selected_list > 0 {
|
||||||
self.load_tasks();
|
self.selected_list -= 1;
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Focus::Calendar => {
|
||||||
|
if self.calendar_active_week > 0 {
|
||||||
|
self.calendar_active_week -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') | KeyCode::Char('N') => {
|
KeyCode::Char('n') | KeyCode::Char('N') => {
|
||||||
if !self.needs_auth {
|
if !self.needs_auth {
|
||||||
if self.focus == Focus::Tabs {
|
self.pending_new_key = true;
|
||||||
self.editing_task_id = None;
|
|
||||||
self.popup_input.clear();
|
|
||||||
self.popup_cursor = 0;
|
|
||||||
self.show_popup = Some(Popup::Input);
|
|
||||||
} else if !self.lists.is_empty() {
|
|
||||||
self.editing_task_id = None;
|
|
||||||
self.popup_input.clear();
|
|
||||||
self.popup_cursor = 0;
|
|
||||||
self.popup_secondary.clear();
|
|
||||||
self.popup_secondary_cursor = 0;
|
|
||||||
self.edit_field = 0;
|
|
||||||
self.notes_scroll = 0;
|
|
||||||
self.show_popup = Some(Popup::EditTask { field: 0 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||||
@@ -392,24 +466,32 @@ impl App {
|
|||||||
if self.focus == Focus::Detail && !self.tasks.is_empty() {
|
if self.focus == Focus::Detail && !self.tasks.is_empty() {
|
||||||
self.show_popup = Some(Popup::DatePicker);
|
self.show_popup = Some(Popup::DatePicker);
|
||||||
} else if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
} else if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||||
let task = &mut self.tasks[self.selected_task];
|
if !self.selected_tasks.is_empty() {
|
||||||
task.status = match task.status {
|
self.show_popup = Some(Popup::BulkAction);
|
||||||
TaskStatus::Completed => TaskStatus::NeedsAction,
|
} else {
|
||||||
TaskStatus::NeedsAction => TaskStatus::Completed,
|
let task = &mut self.tasks[self.selected_task];
|
||||||
};
|
task.status = match task.status {
|
||||||
self.db.update_task(task).ok();
|
TaskStatus::Completed => TaskStatus::NeedsAction,
|
||||||
self.db.push_sync(
|
TaskStatus::NeedsAction => TaskStatus::Completed,
|
||||||
SyncAction::Update,
|
};
|
||||||
&task.id,
|
self.db.update_task(task).ok();
|
||||||
&task.list_id,
|
self.db.push_sync(
|
||||||
&serde_json::to_string(task).unwrap_or_default(),
|
SyncAction::Update,
|
||||||
).ok();
|
&task.id,
|
||||||
self.trigger_sync();
|
&task.list_id,
|
||||||
self.load_tasks();
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.show_popup = None;
|
if self.show_popup.is_some() {
|
||||||
|
self.show_popup = None;
|
||||||
|
} else {
|
||||||
|
self.clear_selection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
self.trigger_full_sync();
|
self.trigger_full_sync();
|
||||||
@@ -447,28 +529,33 @@ impl App {
|
|||||||
},
|
},
|
||||||
Popup::Input => match key.code {
|
Popup::Input => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
self.pending_bulk_move = false;
|
||||||
self.show_popup = None;
|
self.show_popup = None;
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let input = self.popup_input.trim().to_string();
|
let input = self.popup_input.trim().to_string();
|
||||||
if !input.is_empty() {
|
if self.pending_bulk_move && !input.is_empty() {
|
||||||
if self.focus == Focus::Tabs {
|
self.pending_bulk_move = false;
|
||||||
let list = TaskList {
|
self.bulk_move_to_new_list(&input);
|
||||||
id: uuid_v4(),
|
self.show_popup = None;
|
||||||
title: input,
|
} else if !input.is_empty() {
|
||||||
};
|
let list = TaskList {
|
||||||
self.db.insert_list(&list).ok();
|
id: uuid_v4(),
|
||||||
self.db.push_sync(
|
title: input,
|
||||||
SyncAction::Create,
|
};
|
||||||
&list.id,
|
self.db.insert_list(&list).ok();
|
||||||
&list.id,
|
self.db.push_sync(
|
||||||
&serde_json::to_string(&list).unwrap_or_default(),
|
SyncAction::CreateList,
|
||||||
).ok();
|
&list.id,
|
||||||
self.trigger_sync();
|
&list.id,
|
||||||
self.load_lists();
|
&serde_json::to_string(&list).unwrap_or_default(),
|
||||||
}
|
).ok();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_lists();
|
||||||
|
self.show_popup = None;
|
||||||
|
} else {
|
||||||
|
self.show_popup = None;
|
||||||
}
|
}
|
||||||
self.show_popup = None;
|
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
self.popup_input.insert(self.popup_cursor, c);
|
self.popup_input.insert(self.popup_cursor, c);
|
||||||
@@ -726,12 +813,12 @@ impl App {
|
|||||||
if self.selected_list < self.lists.len() {
|
if self.selected_list < self.lists.len() {
|
||||||
let list_id = self.lists[self.selected_list].id.clone();
|
let list_id = self.lists[self.selected_list].id.clone();
|
||||||
self.db.delete_list(&list_id).ok();
|
self.db.delete_list(&list_id).ok();
|
||||||
self.db.push_sync(
|
self.db.push_sync(
|
||||||
SyncAction::Delete,
|
SyncAction::DeleteList,
|
||||||
&list_id,
|
&list_id,
|
||||||
&list_id,
|
&list_id,
|
||||||
"",
|
"",
|
||||||
).ok();
|
).ok();
|
||||||
self.trigger_sync();
|
self.trigger_sync();
|
||||||
self.load_lists();
|
self.load_lists();
|
||||||
if self.selected_list >= self.lists.len() {
|
if self.selected_list >= self.lists.len() {
|
||||||
@@ -764,6 +851,282 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
Popup::BulkAction => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.bulk_action_selected = self.bulk_action_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if self.bulk_action_selected < 6 {
|
||||||
|
self.bulk_action_selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let action = self.bulk_action_selected;
|
||||||
|
self.execute_bulk_action(action);
|
||||||
|
if action <= 4 {
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if let Some(n) = c.to_digit(10) {
|
||||||
|
let idx = n as usize - 1;
|
||||||
|
if idx <= 6 {
|
||||||
|
self.execute_bulk_action(idx);
|
||||||
|
if idx <= 4 {
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Popup::PickList => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.show_popup = Some(Popup::BulkAction);
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
self.popup_list_selected = self.popup_list_selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
if self.popup_list_selected + 1 < self.popup_list_indices.len() {
|
||||||
|
self.popup_list_selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !self.popup_list_indices.is_empty() {
|
||||||
|
let list_id = self.popup_list_indices[self.popup_list_selected].1.clone();
|
||||||
|
self.bulk_move_to_existing_list(&list_id);
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_mark_completed(&mut self) {
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.status = TaskStatus::Completed;
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_set_due_today(&mut self) {
|
||||||
|
let now = chrono::Local::now().naive_local();
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.due = Some(now);
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_move_to_new_list(&mut self, list_name: &str) {
|
||||||
|
let list = TaskList {
|
||||||
|
id: uuid_v4(),
|
||||||
|
title: list_name.to_string(),
|
||||||
|
};
|
||||||
|
self.db.insert_list(&list).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::CreateList,
|
||||||
|
&list.id,
|
||||||
|
&list.id,
|
||||||
|
&serde_json::to_string(&list).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let original = &self.tasks[i];
|
||||||
|
let new_task = Task {
|
||||||
|
id: uuid_v4(),
|
||||||
|
list_id: list.id.clone(),
|
||||||
|
title: original.title.clone(),
|
||||||
|
notes: original.notes.clone(),
|
||||||
|
status: original.status.clone(),
|
||||||
|
due: original.due,
|
||||||
|
position: 0,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
};
|
||||||
|
self.db.insert_task(&new_task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Create,
|
||||||
|
&new_task.id,
|
||||||
|
&list.id,
|
||||||
|
&serde_json::to_string(&new_task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
// Delete original
|
||||||
|
self.db.delete_task(&original.id).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Delete,
|
||||||
|
&original.id,
|
||||||
|
&original.list_id,
|
||||||
|
"",
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_lists();
|
||||||
|
// Switch to the new list
|
||||||
|
if let Some(pos) = self.lists.iter().position(|l| l.id == list.id) {
|
||||||
|
self.selected_list = pos;
|
||||||
|
}
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_mark_uncomplete(&mut self) {
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.status = TaskStatus::NeedsAction;
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_set_due_tomorrow(&mut self) {
|
||||||
|
let tomorrow = chrono::Local::now().naive_local() + chrono::Duration::days(1);
|
||||||
|
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
|
||||||
|
let due = chrono::NaiveDateTime::new(tomorrow.date(), time);
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.due = Some(due);
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_set_due_next_week(&mut self) {
|
||||||
|
let next_week = chrono::Local::now().naive_local() + chrono::Duration::days(7);
|
||||||
|
let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
|
||||||
|
let due = chrono::NaiveDateTime::new(next_week.date(), time);
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.due = Some(due);
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_move_to_existing_list(&mut self, target_list_id: &str) {
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let original = &self.tasks[i];
|
||||||
|
let new_task = Task {
|
||||||
|
id: uuid_v4(),
|
||||||
|
list_id: target_list_id.to_string(),
|
||||||
|
title: original.title.clone(),
|
||||||
|
notes: original.notes.clone(),
|
||||||
|
status: original.status.clone(),
|
||||||
|
due: original.due,
|
||||||
|
position: 0,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
};
|
||||||
|
self.db.insert_task(&new_task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Create,
|
||||||
|
&new_task.id,
|
||||||
|
target_list_id,
|
||||||
|
&serde_json::to_string(&new_task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
self.db.delete_task(&original.id).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Delete,
|
||||||
|
&original.id,
|
||||||
|
&original.list_id,
|
||||||
|
"",
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
if let Some(pos) = self.lists.iter().position(|l| l.id == target_list_id) {
|
||||||
|
self.selected_list = pos;
|
||||||
|
}
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_bulk_action(&mut self, action_idx: usize) {
|
||||||
|
match action_idx {
|
||||||
|
0 => self.bulk_mark_completed(),
|
||||||
|
1 => self.bulk_mark_uncomplete(),
|
||||||
|
2 => self.bulk_set_due_today(),
|
||||||
|
3 => self.bulk_set_due_tomorrow(),
|
||||||
|
4 => self.bulk_set_due_next_week(),
|
||||||
|
5 => {
|
||||||
|
self.popup_input.clear();
|
||||||
|
self.popup_cursor = 0;
|
||||||
|
self.pending_bulk_move = true;
|
||||||
|
self.show_popup = Some(Popup::Input);
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
self.popup_list_indices = self.lists.iter()
|
||||||
|
.filter(|l| !l.id.contains('-'))
|
||||||
|
.map(|l| (l.title.clone(), l.id.clone()))
|
||||||
|
.collect();
|
||||||
|
self.popup_list_selected = 0;
|
||||||
|
self.show_popup = Some(Popup::PickList);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -813,6 +1176,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_tasks(&mut self) {
|
fn load_tasks(&mut self) {
|
||||||
|
self.selected_tasks.clear();
|
||||||
if self.selected_list < self.lists.len() {
|
if self.selected_list < self.lists.len() {
|
||||||
let mut tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
let mut tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
||||||
sort_tasks(&mut tasks);
|
sort_tasks(&mut tasks);
|
||||||
@@ -826,6 +1190,10 @@ impl App {
|
|||||||
self.selected_task = 0;
|
self.selected_task = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_selection(&mut self) {
|
||||||
|
self.selected_tasks.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sort_tasks(tasks: &mut Vec<Task>) {
|
fn sort_tasks(tasks: &mut Vec<Task>) {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub enum SyncAction {
|
|||||||
Update,
|
Update,
|
||||||
Delete,
|
Delete,
|
||||||
Reorder,
|
Reorder,
|
||||||
|
CreateList,
|
||||||
|
DeleteList,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
+133
-94
@@ -78,15 +78,24 @@ impl ApiClient {
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
let value: serde_json::Value = match serde_json::from_str(&content) {
|
let entries: Vec<serde_json::Value> = match serde_json::from_str(&content) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
let scope = match value["scope"].as_str() {
|
for entry in &entries {
|
||||||
Some(s) => s,
|
let scopes = match entry["scopes"].as_array() {
|
||||||
None => return false,
|
Some(s) => s,
|
||||||
};
|
None => continue,
|
||||||
SCOPES.iter().all(|s| scope.contains(s))
|
};
|
||||||
|
let has_all: Vec<&str> = scopes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| s.as_str())
|
||||||
|
.collect();
|
||||||
|
if SCOPES.iter().all(|s| has_all.contains(s)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_and_wait_for_auth(&self) -> Result<(), ApiError> {
|
pub async fn start_and_wait_for_auth(&self) -> Result<(), ApiError> {
|
||||||
@@ -187,47 +196,7 @@ impl ApiClient {
|
|||||||
let tasks = all_items
|
let tasks = all_items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| parse_task_value(item, list_id, i))
|
||||||
let due_str = item["due"].as_str().and_then(|s| {
|
|
||||||
chrono::NaiveDateTime::parse_from_str(
|
|
||||||
&s.replace("T", " ")
|
|
||||||
.replace("Z", "")
|
|
||||||
.chars()
|
|
||||||
.take(16)
|
|
||||||
.collect::<String>(),
|
|
||||||
"%Y-%m-%d %H:%M",
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
let updated = item["updated"].as_str().and_then(|s| {
|
|
||||||
chrono::NaiveDateTime::parse_from_str(
|
|
||||||
&s.replace("T", " ")
|
|
||||||
.replace("Z", "")
|
|
||||||
.chars()
|
|
||||||
.take(19)
|
|
||||||
.collect::<String>(),
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
Task {
|
|
||||||
id: item["id"].as_str().unwrap_or("").to_string(),
|
|
||||||
list_id: list_id.to_string(),
|
|
||||||
title: item["title"].as_str().unwrap_or("").to_string(),
|
|
||||||
notes: item["notes"].as_str().map(|s| s.to_string()),
|
|
||||||
status: if item["status"].as_str() == Some("completed") {
|
|
||||||
TaskStatus::Completed
|
|
||||||
} else {
|
|
||||||
TaskStatus::NeedsAction
|
|
||||||
},
|
|
||||||
due: due_str,
|
|
||||||
position: i as i64,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: updated,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
@@ -281,53 +250,13 @@ impl ApiClient {
|
|||||||
let tasks = all_items
|
let tasks = all_items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, item)| {
|
.map(|(i, item)| parse_task_value(item, list_id, i))
|
||||||
let due_str = item["due"].as_str().and_then(|s| {
|
|
||||||
chrono::NaiveDateTime::parse_from_str(
|
|
||||||
&s.replace("T", " ")
|
|
||||||
.replace("Z", "")
|
|
||||||
.chars()
|
|
||||||
.take(16)
|
|
||||||
.collect::<String>(),
|
|
||||||
"%Y-%m-%d %H:%M",
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
let updated = item["updated"].as_str().and_then(|s| {
|
|
||||||
chrono::NaiveDateTime::parse_from_str(
|
|
||||||
&s.replace("T", " ")
|
|
||||||
.replace("Z", "")
|
|
||||||
.chars()
|
|
||||||
.take(19)
|
|
||||||
.collect::<String>(),
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
.ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
Task {
|
|
||||||
id: item["id"].as_str().unwrap_or("").to_string(),
|
|
||||||
list_id: list_id.to_string(),
|
|
||||||
title: item["title"].as_str().unwrap_or("").to_string(),
|
|
||||||
notes: item["notes"].as_str().map(|s| s.to_string()),
|
|
||||||
status: if item["status"].as_str() == Some("completed") {
|
|
||||||
TaskStatus::Completed
|
|
||||||
} else {
|
|
||||||
TaskStatus::NeedsAction
|
|
||||||
},
|
|
||||||
due: due_str,
|
|
||||||
position: i as i64,
|
|
||||||
created_at: None,
|
|
||||||
updated_at: updated,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(tasks)
|
Ok(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> {
|
pub async fn create_task(&self, list_id: &str, task: &Task) -> Result<Task, ApiError> {
|
||||||
let token = self.get_token().await?;
|
let token = self.get_token().await?;
|
||||||
|
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
@@ -360,13 +289,48 @@ impl ApiClient {
|
|||||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(ApiError::Api(format!(
|
let status = resp.status();
|
||||||
"Create failed: {}",
|
let body_text = resp.text().await.unwrap_or_default();
|
||||||
resp.status()
|
return Err(ApiError::Api(format!("Create failed: {} - {}", status, body_text)));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Api(format!("Create parse error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(parse_task_value(&data, list_id, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_list(&self, title: &str) -> Result<TaskList, ApiError> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
|
||||||
|
let body = serde_json::json!({ "title": title });
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post("https://tasks.googleapis.com/tasks/v1/users/@me/lists")
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body_text = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::Api(format!("Create list failed: {} - {}", status, body_text)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Api(format!("Create list parse error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(TaskList {
|
||||||
|
id: data["id"].as_str().unwrap_or("").to_string(),
|
||||||
|
title: data["title"].as_str().unwrap_or("").to_string(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> {
|
pub async fn update_task(&self, list_id: &str, task: &Task) -> Result<(), ApiError> {
|
||||||
@@ -429,6 +393,10 @@ impl ApiClient {
|
|||||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
|
// 404 means already deleted — treat as success
|
||||||
|
if resp.status() == 404 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
return Err(ApiError::Api(format!(
|
return Err(ApiError::Api(format!(
|
||||||
"Delete failed: {}",
|
"Delete failed: {}",
|
||||||
resp.status()
|
resp.status()
|
||||||
@@ -438,6 +406,35 @@ impl ApiClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_list(&self, list_id: &str) -> Result<(), ApiError> {
|
||||||
|
let token = self.get_token().await?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://tasks.googleapis.com/tasks/v1/users/@me/lists/{}",
|
||||||
|
list_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.delete(&url)
|
||||||
|
.bearer_auth(&token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
// 404 means already deleted — treat as success
|
||||||
|
if status == 404 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(ApiError::Api(format!("Delete list failed: {} - {}", status, body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn move_task(
|
pub async fn move_task(
|
||||||
&self,
|
&self,
|
||||||
list_id: &str,
|
list_id: &str,
|
||||||
@@ -521,6 +518,48 @@ impl ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_task_value(item: &serde_json::Value, list_id: &str, position: usize) -> Task {
|
||||||
|
let due_str = item["due"].as_str().and_then(|s| {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(
|
||||||
|
&s.replace("T", " ")
|
||||||
|
.replace("Z", "")
|
||||||
|
.chars()
|
||||||
|
.take(16)
|
||||||
|
.collect::<String>(),
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
let updated = item["updated"].as_str().and_then(|s| {
|
||||||
|
chrono::NaiveDateTime::parse_from_str(
|
||||||
|
&s.replace("T", " ")
|
||||||
|
.replace("Z", "")
|
||||||
|
.chars()
|
||||||
|
.take(19)
|
||||||
|
.collect::<String>(),
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
Task {
|
||||||
|
id: item["id"].as_str().unwrap_or("").to_string(),
|
||||||
|
list_id: list_id.to_string(),
|
||||||
|
title: item["title"].as_str().unwrap_or("").to_string(),
|
||||||
|
notes: item["notes"].as_str().map(|s| s.to_string()),
|
||||||
|
status: if item["status"].as_str() == Some("completed") {
|
||||||
|
TaskStatus::Completed
|
||||||
|
} else {
|
||||||
|
TaskStatus::NeedsAction
|
||||||
|
},
|
||||||
|
due: due_str,
|
||||||
|
position: position as i64,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_calendar_time(obj: &serde_json::Value) -> Option<chrono::NaiveDateTime> {
|
fn parse_calendar_time(obj: &serde_json::Value) -> Option<chrono::NaiveDateTime> {
|
||||||
if let Some(dt) = obj["dateTime"].as_str() {
|
if let Some(dt) = obj["dateTime"].as_str() {
|
||||||
chrono::DateTime::parse_from_rfc3339(dt).ok().map(|d| d.naive_local())
|
chrono::DateTime::parse_from_rfc3339(dt).ok().map(|d| d.naive_local())
|
||||||
|
|||||||
@@ -281,6 +281,8 @@ impl Db {
|
|||||||
SyncAction::Update => "Update",
|
SyncAction::Update => "Update",
|
||||||
SyncAction::Delete => "Delete",
|
SyncAction::Delete => "Delete",
|
||||||
SyncAction::Reorder => "Reorder",
|
SyncAction::Reorder => "Reorder",
|
||||||
|
SyncAction::CreateList => "CreateList",
|
||||||
|
SyncAction::DeleteList => "DeleteList",
|
||||||
};
|
};
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -306,6 +308,51 @@ impl Db {
|
|||||||
count > 0
|
count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET id = ?1 WHERE id = ?2",
|
||||||
|
params![new_id, old_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_sync_task_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sync_queue SET task_id = ?1 WHERE task_id = ?2",
|
||||||
|
params![new_id, old_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE task_lists SET id = ?1 WHERE id = ?2",
|
||||||
|
params![new_id, old_id],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET list_id = ?1 WHERE list_id = ?2",
|
||||||
|
params![new_id, old_id],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sync_queue SET list_id = ?1 WHERE list_id = ?2",
|
||||||
|
params![new_id, old_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn update_sync_list_id(&self, old_id: &str, new_id: &str) -> SqlResult<()> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sync_queue SET list_id = ?1 WHERE list_id = ?2",
|
||||||
|
params![new_id, old_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
|
pub fn drain_sync(&self) -> Vec<SyncQueueItem> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let items: Vec<SyncQueueItem> = {
|
let items: Vec<SyncQueueItem> = {
|
||||||
@@ -319,6 +366,8 @@ impl Db {
|
|||||||
"\"Update\"" | "Update" => SyncAction::Update,
|
"\"Update\"" | "Update" => SyncAction::Update,
|
||||||
"\"Delete\"" | "Delete" => SyncAction::Delete,
|
"\"Delete\"" | "Delete" => SyncAction::Delete,
|
||||||
"\"Reorder\"" | "Reorder" => SyncAction::Reorder,
|
"\"Reorder\"" | "Reorder" => SyncAction::Reorder,
|
||||||
|
"\"CreateList\"" | "CreateList" => SyncAction::CreateList,
|
||||||
|
"\"DeleteList\"" | "DeleteList" => SyncAction::DeleteList,
|
||||||
_ => SyncAction::Update,
|
_ => SyncAction::Update,
|
||||||
};
|
};
|
||||||
Ok(SyncQueueItem {
|
Ok(SyncQueueItem {
|
||||||
|
|||||||
+60
-3
@@ -150,9 +150,14 @@ fn main() -> io::Result<()> {
|
|||||||
task_list_scroll: app.task_list_scroll,
|
task_list_scroll: app.task_list_scroll,
|
||||||
detail_scroll: app.detail_scroll,
|
detail_scroll: app.detail_scroll,
|
||||||
notes_scroll: app.notes_scroll,
|
notes_scroll: app.notes_scroll,
|
||||||
calendar_scroll: app.calendar_scroll,
|
calendar_scrolls: &app.calendar_scrolls,
|
||||||
|
calendar_active_week: app.calendar_active_week,
|
||||||
auth_error: app.auth_error.as_deref(),
|
auth_error: app.auth_error.as_deref(),
|
||||||
sync_stats: &app.sync_stats,
|
sync_stats: &app.sync_stats,
|
||||||
|
selected_tasks: &app.selected_tasks,
|
||||||
|
bulk_action_selected: app.bulk_action_selected,
|
||||||
|
popup_list_indices: &app.popup_list_indices,
|
||||||
|
popup_list_selected: app.popup_list_selected,
|
||||||
};
|
};
|
||||||
draw(frame, view);
|
draw(frame, view);
|
||||||
})?;
|
})?;
|
||||||
@@ -246,7 +251,7 @@ async fn push_sync(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let items = db.drain_sync();
|
let mut items = db.drain_sync();
|
||||||
let count = items.len();
|
let count = items.len();
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
@@ -257,7 +262,50 @@ async fn push_sync(
|
|||||||
*network_status.lock().await = NetworkStatus::Syncing;
|
*network_status.lock().await = NetworkStatus::Syncing;
|
||||||
|
|
||||||
let mut all_ok = true;
|
let mut all_ok = true;
|
||||||
|
|
||||||
|
// First pass: CreateList items (so list IDs are updated before task operations)
|
||||||
|
for i in 0..items.len() {
|
||||||
|
if items[i].action != SyncAction::CreateList {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let list: TaskList = serde_json::from_str(&items[i].payload).unwrap_or_else(|_| TaskList {
|
||||||
|
id: items[i].task_id.clone(),
|
||||||
|
title: String::new(),
|
||||||
|
});
|
||||||
|
match api.create_list(&list.title).await {
|
||||||
|
Ok(server_list) => {
|
||||||
|
if server_list.id != items[i].task_id {
|
||||||
|
let _ = db.update_list_id(&items[i].task_id, &server_list.id);
|
||||||
|
// Update list_id in remaining items of this batch
|
||||||
|
for j in (i + 1)..items.len() {
|
||||||
|
if items[j].list_id == items[i].task_id {
|
||||||
|
items[j].list_id = server_list.id.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[task_app] Sync failed (retry {}/{}): action=CreateList list={} error={}",
|
||||||
|
items[i].retries, MAX_SYNC_RETRIES, items[i].task_id, err);
|
||||||
|
if items[i].retries < MAX_SYNC_RETRIES {
|
||||||
|
let _ = db.push_sync_with_retry(
|
||||||
|
SyncAction::CreateList,
|
||||||
|
&items[i].task_id,
|
||||||
|
&items[i].list_id,
|
||||||
|
&items[i].payload,
|
||||||
|
items[i].retries + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
all_ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: everything else
|
||||||
for item in &items {
|
for item in &items {
|
||||||
|
if item.action == SyncAction::CreateList {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let result = match item.action {
|
let result = match item.action {
|
||||||
SyncAction::Create => {
|
SyncAction::Create => {
|
||||||
let task = serde_json::from_str::<Task>(&item.payload).unwrap_or_else(|_| Task {
|
let task = serde_json::from_str::<Task>(&item.payload).unwrap_or_else(|_| Task {
|
||||||
@@ -271,7 +319,12 @@ async fn push_sync(
|
|||||||
created_at: None,
|
created_at: None,
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
});
|
});
|
||||||
api.create_task(&item.list_id, &task).await
|
api.create_task(&item.list_id, &task).await.map(|server_task| {
|
||||||
|
if server_task.id != item.task_id {
|
||||||
|
let _ = db.update_task_id(&item.task_id, &server_task.id);
|
||||||
|
let _ = db.update_sync_task_id(&item.task_id, &server_task.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
SyncAction::Update => {
|
SyncAction::Update => {
|
||||||
let task = serde_json::from_str::<Task>(&item.payload).unwrap_or_else(|_| Task {
|
let task = serde_json::from_str::<Task>(&item.payload).unwrap_or_else(|_| Task {
|
||||||
@@ -293,6 +346,10 @@ async fn push_sync(
|
|||||||
SyncAction::Reorder => {
|
SyncAction::Reorder => {
|
||||||
api.move_task(&item.list_id, &item.task_id, None, None).await
|
api.move_task(&item.list_id, &item.task_id, None, None).await
|
||||||
}
|
}
|
||||||
|
SyncAction::DeleteList => {
|
||||||
|
api.delete_list(&item.task_id).await
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = result {
|
if let Err(err) = result {
|
||||||
|
|||||||
+197
-73
@@ -1,3 +1,7 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use chrono::Datelike;
|
||||||
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span, Text};
|
use ratatui::text::{Line, Span, Text};
|
||||||
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap};
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap};
|
||||||
@@ -74,6 +78,7 @@ pub fn render_task_list(
|
|||||||
selected: usize,
|
selected: usize,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
_scroll: u16,
|
_scroll: u16,
|
||||||
|
selected_tasks: &BTreeSet<usize>,
|
||||||
) {
|
) {
|
||||||
let total = tasks.len();
|
let total = tasks.len();
|
||||||
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
|
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
|
||||||
@@ -83,7 +88,9 @@ pub fn render_task_list(
|
|||||||
|
|
||||||
let items: Vec<ListItem> = tasks
|
let items: Vec<ListItem> = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|task| {
|
.enumerate()
|
||||||
|
.map(|(idx, task)| {
|
||||||
|
let is_selected = selected_tasks.contains(&idx);
|
||||||
let checkbox = match task.status {
|
let checkbox = match task.status {
|
||||||
TaskStatus::Completed => "[\u{2713}]",
|
TaskStatus::Completed => "[\u{2713}]",
|
||||||
TaskStatus::NeedsAction => "[ ]",
|
TaskStatus::NeedsAction => "[ ]",
|
||||||
@@ -119,25 +126,29 @@ pub fn render_task_list(
|
|||||||
content_width.saturating_sub(used)
|
content_width.saturating_sub(used)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut spans = vec![
|
let checkbox_style = if is_selected {
|
||||||
Span::styled(
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray)
|
||||||
checkbox_str,
|
} else if task.status == TaskStatus::Completed {
|
||||||
Style::default().fg(if task.status == TaskStatus::Completed {
|
Style::default().fg(Color::Green)
|
||||||
Color::Green
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title_style = if is_selected {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(DETAIL_COLOR).add_modifier(
|
||||||
|
if task.status == TaskStatus::Completed {
|
||||||
|
Modifier::CROSSED_OUT
|
||||||
} else {
|
} else {
|
||||||
Color::DarkGray
|
Modifier::empty()
|
||||||
}),
|
},
|
||||||
),
|
)
|
||||||
Span::styled(
|
};
|
||||||
display_title,
|
|
||||||
Style::default().fg(DETAIL_COLOR).add_modifier(
|
let mut spans = vec![
|
||||||
if task.status == TaskStatus::Completed {
|
Span::styled(checkbox_str, checkbox_style),
|
||||||
Modifier::CROSSED_OUT
|
Span::styled(display_title, title_style),
|
||||||
} else {
|
|
||||||
Modifier::empty()
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if !due_text.is_empty() {
|
if !due_text.is_empty() {
|
||||||
@@ -496,6 +507,91 @@ pub fn render_confirm_popup(frame: &mut Frame, area: Rect) {
|
|||||||
frame.render_widget(paragraph, popup_area);
|
frame.render_widget(paragraph, popup_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize, selected: usize) {
|
||||||
|
let popup_area = centered_rect(55, 12, area);
|
||||||
|
frame.render_widget(Clear, popup_area);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().bg(POPUP_BG))
|
||||||
|
.border_style(Style::default().fg(POPUP_BORDER))
|
||||||
|
.title(format!(" Bulk Actions ({} selected) ", count))
|
||||||
|
.title_alignment(Alignment::Left);
|
||||||
|
|
||||||
|
let options = [
|
||||||
|
"1. Mark as completed",
|
||||||
|
"2. Mark as uncomplete",
|
||||||
|
"3. Set due date to Today",
|
||||||
|
"4. Set due date to Tomorrow",
|
||||||
|
"5. Set due date to Next Week",
|
||||||
|
"6. Move to new list...",
|
||||||
|
"7. Move to existing list...",
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut lines = vec![Line::from("")];
|
||||||
|
for (i, opt) in options.iter().enumerate() {
|
||||||
|
let style = if i == selected {
|
||||||
|
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {}", opt),
|
||||||
|
style,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ↑/↓: navigate Enter:ok 1-7:shortcut Esc:cancel",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(lines))
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, popup_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_pick_list_popup(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
lists: &[(String, String)],
|
||||||
|
selected: usize,
|
||||||
|
) {
|
||||||
|
let popup_area = centered_rect(60, 10, area);
|
||||||
|
frame.render_widget(Clear, popup_area);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().bg(POPUP_BG))
|
||||||
|
.border_style(Style::default().fg(POPUP_BORDER))
|
||||||
|
.title(" Select List ")
|
||||||
|
.title_alignment(Alignment::Left);
|
||||||
|
|
||||||
|
let mut lines = vec![Line::from("")];
|
||||||
|
for (i, (title, _)) in lists.iter().enumerate() {
|
||||||
|
let style = if i == selected {
|
||||||
|
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {}", title),
|
||||||
|
style,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ↑/↓: navigate Enter:ok Esc:cancel",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(lines))
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, popup_area);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_device_auth_popup(
|
pub fn render_device_auth_popup(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
@@ -600,70 +696,98 @@ pub fn render_calendar_panel(
|
|||||||
area: Rect,
|
area: Rect,
|
||||||
events: &[CalendarEvent],
|
events: &[CalendarEvent],
|
||||||
focused: bool,
|
focused: bool,
|
||||||
scroll: u16,
|
scrolls: &[u16; 4],
|
||||||
|
active_week: usize,
|
||||||
) {
|
) {
|
||||||
let block = Block::default()
|
if area.width < 20 || area.height < 3 {
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
|
|
||||||
.title(" Calendar ")
|
|
||||||
.title_alignment(Alignment::Left);
|
|
||||||
|
|
||||||
if events.is_empty() {
|
|
||||||
let paragraph = Paragraph::new(Text::from(Line::from(Span::styled(
|
|
||||||
" No upcoming events ",
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
))))
|
|
||||||
.block(block)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
frame.render_widget(paragraph, area);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let today = chrono::Local::now().naive_local().date();
|
||||||
let mut current_date: Option<chrono::NaiveDate> = None;
|
let weekday = today.weekday().num_days_from_monday();
|
||||||
|
let this_monday = today - chrono::Duration::days(weekday as i64);
|
||||||
|
|
||||||
for event in events {
|
let cols = Layout::default()
|
||||||
if let Some(start) = event.start {
|
.direction(Direction::Horizontal)
|
||||||
let event_date = start.date();
|
.constraints([Constraint::Ratio(1, 4); 4])
|
||||||
if Some(event_date) != current_date {
|
.split(area);
|
||||||
current_date = Some(event_date);
|
|
||||||
let day_header = format!(
|
let has_events = events.iter().any(|e| {
|
||||||
" --- {} {} --- ",
|
e.start.map_or(false, |s| {
|
||||||
event_date.format("%A"),
|
let d = s.date();
|
||||||
event_date.format("%d/%m"),
|
d >= this_monday && d < this_monday + chrono::Duration::days(28)
|
||||||
);
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
for week_idx in 0..4 {
|
||||||
|
let week_start = this_monday + chrono::Duration::weeks(week_idx as i64);
|
||||||
|
let week_title = format!(" W/C {} ", week_start.format("%d/%m"));
|
||||||
|
let col_area = cols[week_idx];
|
||||||
|
|
||||||
|
let border = if focused && week_idx == active_week { FOCUS_COLOR } else { Color::DarkGray };
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(border))
|
||||||
|
.title(week_title)
|
||||||
|
.title_alignment(Alignment::Left);
|
||||||
|
|
||||||
|
if !has_events {
|
||||||
|
let msg = if week_idx == 0 {
|
||||||
|
" No upcoming events "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let paragraph = Paragraph::new(Text::from(Line::from(Span::styled(
|
||||||
|
msg,
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
))))
|
||||||
|
.block(block);
|
||||||
|
frame.render_widget(paragraph, col_area);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for day_offset in 0..7 {
|
||||||
|
let day = week_start + chrono::Duration::days(day_offset);
|
||||||
|
|
||||||
|
let day_style = if day == today {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
|
} else if matches!(day.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
|
||||||
|
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
|
};
|
||||||
|
let day_label = format!(
|
||||||
|
" {} {}",
|
||||||
|
match day.weekday() {
|
||||||
|
chrono::Weekday::Mon => "Mon",
|
||||||
|
chrono::Weekday::Tue => "Tue",
|
||||||
|
chrono::Weekday::Wed => "Wed",
|
||||||
|
chrono::Weekday::Thu => "Thu",
|
||||||
|
chrono::Weekday::Fri => "Fri",
|
||||||
|
chrono::Weekday::Sat => "Sat",
|
||||||
|
chrono::Weekday::Sun => "Sun",
|
||||||
|
},
|
||||||
|
day.format("%d/%m")
|
||||||
|
);
|
||||||
|
lines.push(Line::from(Span::styled(day_label, day_style)));
|
||||||
|
|
||||||
|
for event in events.iter().filter(|e| e.start.map_or(false, |s| s.date() == day)) {
|
||||||
|
let time_str = event.start.map(|s| s.format("%H:%M").to_string()).unwrap_or_default();
|
||||||
|
let line_text = format!(" {} {}", time_str, event.summary);
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
day_header,
|
line_text,
|
||||||
Style::default().fg(Color::Gray),
|
Style::default().fg(DETAIL_COLOR),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let time_str = start.format("%H:%M").to_string();
|
|
||||||
let summary = &event.summary;
|
|
||||||
let line_text = if summary.len() > 30 {
|
|
||||||
format!(" {} {:.30}…", time_str, summary)
|
|
||||||
} else {
|
|
||||||
format!(" {} {}", time_str, summary)
|
|
||||||
};
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
line_text,
|
|
||||||
Style::default().fg(DETAIL_COLOR),
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(lines))
|
||||||
|
.block(block)
|
||||||
|
.scroll((scrolls[week_idx], 0));
|
||||||
|
frame.render_widget(paragraph, col_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
let inner_h = (area.height as usize).saturating_sub(2);
|
|
||||||
let visible_lines: Vec<Line> = lines
|
|
||||||
.iter()
|
|
||||||
.skip(scroll as usize)
|
|
||||||
.take(inner_h)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(Text::from(visible_lines))
|
|
||||||
.block(block)
|
|
||||||
.scroll((0, 0));
|
|
||||||
frame.render_widget(paragraph, area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple word wrap: splits text at word boundaries to fit max_width chars per line
|
/// Simple word wrap: splits text at word boundaries to fit max_width chars per line
|
||||||
|
|||||||
+27
-17
@@ -1,5 +1,7 @@
|
|||||||
pub mod components;
|
pub mod components;
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ pub enum Popup {
|
|||||||
EditTask { field: usize },
|
EditTask { field: usize },
|
||||||
DatePicker,
|
DatePicker,
|
||||||
ConfirmDelete,
|
ConfirmDelete,
|
||||||
|
BulkAction,
|
||||||
|
PickList,
|
||||||
DeviceAuth { url: String, code: String },
|
DeviceAuth { url: String, code: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +52,14 @@ pub struct AppView<'a> {
|
|||||||
pub detail_scroll: u16,
|
pub detail_scroll: u16,
|
||||||
pub notes_scroll: u16,
|
pub notes_scroll: u16,
|
||||||
pub calendar_events: &'a [CalendarEvent],
|
pub calendar_events: &'a [CalendarEvent],
|
||||||
pub calendar_scroll: u16,
|
pub calendar_scrolls: &'a [u16; 4],
|
||||||
|
pub calendar_active_week: usize,
|
||||||
pub auth_error: Option<&'a str>,
|
pub auth_error: Option<&'a str>,
|
||||||
pub sync_stats: &'a SyncStats,
|
pub sync_stats: &'a SyncStats,
|
||||||
|
pub selected_tasks: &'a BTreeSet<usize>,
|
||||||
|
pub bulk_action_selected: usize,
|
||||||
|
pub popup_list_indices: &'a [(String, String)],
|
||||||
|
pub popup_list_selected: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, view: AppView) {
|
pub fn draw(frame: &mut Frame, view: AppView) {
|
||||||
@@ -61,13 +70,15 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
Constraint::Min(0),
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(12),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let tabs_area = main_layout[0];
|
let tabs_area = main_layout[0];
|
||||||
let body_area = main_layout[1];
|
let body_area = main_layout[1];
|
||||||
let status_area = main_layout[2];
|
let calendar_area = main_layout[2];
|
||||||
|
let status_area = main_layout[3];
|
||||||
|
|
||||||
let is_tabs_focused = view.focus == Focus::Tabs;
|
let is_tabs_focused = view.focus == Focus::Tabs;
|
||||||
render_tabs_bar(frame, tabs_area, view.lists, view.selected_list, is_tabs_focused);
|
render_tabs_bar(frame, tabs_area, view.lists, view.selected_list, is_tabs_focused);
|
||||||
@@ -77,28 +88,15 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
.split(body_area);
|
.split(body_area);
|
||||||
|
|
||||||
let left_col = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Min(0), Constraint::Length(8)])
|
|
||||||
.split(body_layout[0]);
|
|
||||||
|
|
||||||
let is_task_list_focused = view.focus == Focus::TaskList;
|
let is_task_list_focused = view.focus == Focus::TaskList;
|
||||||
render_task_list(
|
render_task_list(
|
||||||
frame,
|
frame,
|
||||||
left_col[0],
|
body_layout[0],
|
||||||
view.tasks,
|
view.tasks,
|
||||||
view.selected_task,
|
view.selected_task,
|
||||||
is_task_list_focused,
|
is_task_list_focused,
|
||||||
view.task_list_scroll,
|
view.task_list_scroll,
|
||||||
);
|
view.selected_tasks,
|
||||||
|
|
||||||
let is_calendar_focused = view.focus == Focus::Calendar;
|
|
||||||
render_calendar_panel(
|
|
||||||
frame,
|
|
||||||
left_col[1],
|
|
||||||
view.calendar_events,
|
|
||||||
is_calendar_focused,
|
|
||||||
view.calendar_scroll,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let is_detail_focused = view.focus == Focus::Detail;
|
let is_detail_focused = view.focus == Focus::Detail;
|
||||||
@@ -110,6 +108,16 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
view.detail_scroll,
|
view.detail_scroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let is_calendar_focused = view.focus == Focus::Calendar;
|
||||||
|
render_calendar_panel(
|
||||||
|
frame,
|
||||||
|
calendar_area,
|
||||||
|
view.calendar_events,
|
||||||
|
is_calendar_focused,
|
||||||
|
view.calendar_scrolls,
|
||||||
|
view.calendar_active_week,
|
||||||
|
);
|
||||||
|
|
||||||
render_status_bar(frame, status_area, view.network_status, view.sync_stats);
|
render_status_bar(frame, status_area, view.network_status, view.sync_stats);
|
||||||
|
|
||||||
if let Some(popup) = view.show_popup {
|
if let Some(popup) = view.show_popup {
|
||||||
@@ -122,6 +130,8 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
),
|
),
|
||||||
Popup::DatePicker => render_date_picker(frame, area, view.draft_date),
|
Popup::DatePicker => render_date_picker(frame, area, view.draft_date),
|
||||||
Popup::ConfirmDelete => render_confirm_popup(frame, area),
|
Popup::ConfirmDelete => render_confirm_popup(frame, area),
|
||||||
|
Popup::BulkAction => render_bulk_action_popup(frame, area, view.selected_tasks.len(), view.bulk_action_selected),
|
||||||
|
Popup::PickList => render_pick_list_popup(frame, area, view.popup_list_indices, view.popup_list_selected),
|
||||||
Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
|
Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user