use chrono::Datelike; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::Frame; use crate::domain::models::*; use crate::app::SyncStats; use super::NetworkStatus; const TAB_COLOR: Color = Color::Cyan; const FOCUS_COLOR: Color = Color::Yellow; const SELECTED_COLOR: Color = Color::Green; const DETAIL_COLOR: Color = Color::White; const STATUS_ONLINE: Color = Color::Green; const STATUS_OFFLINE: Color = Color::Red; const STATUS_SYNC: Color = Color::Yellow; const POPUP_BG: Color = Color::Black; const POPUP_BORDER: Color = Color::Cyan; pub fn render_tabs_bar( frame: &mut Frame, area: Rect, lists: &[TaskList], selected: usize, focused: bool, ) { let tab_titles: Vec<&str> = lists.iter().map(|l| l.title.as_str()).collect(); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { TAB_COLOR })) .title(" Lists ") .title_alignment(Alignment::Left); let tabs = if tab_titles.is_empty() { Tabs::new(vec![Line::from(" No lists ")]) .block(block) .highlight_style(Style::default().add_modifier(Modifier::BOLD)) .select(selected) } else { Tabs::new(tab_titles.into_iter().map(|t| Line::from(Span::raw(t))).collect::>()) .block(block) .divider(Span::raw(" | ")) .highlight_style(Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)) .select(selected) }; frame.render_widget(tabs, area); } fn relative_due_str(due: chrono::NaiveDateTime) -> (String, Color) { let now = chrono::Local::now().naive_local(); let diff = due - now; if diff < chrono::Duration::zero() { (" Overdue ".to_string(), Color::Red) } else if diff < chrono::Duration::hours(24) { let hours = diff.num_hours(); (format!(" {}h left ", hours), Color::Yellow) } else { let days = diff.num_days(); ( format!(" {} day{} left ", days, if days == 1 { "" } else { "s" }), Color::DarkGray, ) } } pub fn render_task_list( frame: &mut Frame, area: Rect, tasks: &[Task], selected: usize, focused: bool, _scroll: u16, ) { let total = tasks.len(); let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count(); let todo = total - done; let content_width = (area.width as usize).saturating_sub(5); let items: Vec = tasks .iter() .map(|task| { let checkbox = match task.status { TaskStatus::Completed => "[\u{2713}]", TaskStatus::NeedsAction => "[ ]", }; let (due_text, due_color) = task .due .map(relative_due_str) .unwrap_or((String::new(), Color::DarkGray)); let checkbox_str = format!("{} ", checkbox); let checkbox_width = checkbox_str.chars().count(); let title_width = task.title.chars().count(); let due_width = due_text.chars().count(); let max_title = content_width.saturating_sub(checkbox_width + due_width); let display_title: String = if title_width <= max_title { task.title.to_string() } else if max_title >= 2 { let take = max_title - 1; let mut s: String = task.title.chars().take(take).collect(); s.push('…'); s } else { task.title.chars().take(max_title).collect() }; let pad = if due_text.is_empty() { 0 } else { let used = checkbox_width + display_title.chars().count() + due_width; content_width.saturating_sub(used) }; let mut spans = vec![ Span::styled( checkbox_str, Style::default().fg(if task.status == TaskStatus::Completed { Color::Green } else { Color::DarkGray }), ), Span::styled( display_title, Style::default().fg(DETAIL_COLOR).add_modifier( if task.status == TaskStatus::Completed { Modifier::CROSSED_OUT } else { Modifier::empty() }, ), ), ]; if !due_text.is_empty() { spans.push(Span::raw(" ".repeat(pad))); spans.push(Span::styled(due_text, Style::default().fg(due_color))); } ListItem::new(Line::from(spans)) }) .collect(); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray })) .title(format!(" Tasks ({} todo / {} done) ", todo, done)) .title_alignment(Alignment::Left); let list = List::new(items) .block(block) .highlight_style( Style::default() .bg(if focused { Color::DarkGray } else { Color::Black }) .fg(SELECTED_COLOR) .add_modifier(Modifier::BOLD), ) .highlight_symbol(">> "); frame.render_stateful_widget(list, area, &mut ratatui::widgets::ListState::default().with_selected(Some(selected))); } pub fn render_detail( frame: &mut Frame, area: Rect, task: Option<&Task>, focused: bool, scroll: u16, ) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray })) .title(" Details ") .title_alignment(Alignment::Left); if let Some(task) = task { let mut lines = Vec::new(); let status_text = match task.status { TaskStatus::Completed => "Completed", TaskStatus::NeedsAction => "Needs Action", }; lines.push(Line::from(Span::styled( format!("Title: {}", task.title), Style::default().fg(DETAIL_COLOR).add_modifier(Modifier::BOLD), ))); lines.push(Line::from(Span::styled( format!("Status: {}", status_text), Style::default().fg(Color::Cyan), ))); if let Some(due) = task.due { lines.push(Line::from(Span::styled( format!("Due: {}", due.format("%d/%m/%Y %H:%M")), Style::default().fg(Color::Yellow), ))); } if let Some(created) = task.created_at { lines.push(Line::from(Span::styled( format!("Created: {}", created.format("%d/%m/%Y %H:%M")), Style::default().fg(Color::DarkGray), ))); } if let Some(updated) = task.updated_at { lines.push(Line::from(Span::styled( format!("Updated: {}", updated.format("%d/%m/%Y %H:%M")), Style::default().fg(Color::DarkGray), ))); } if let Some(notes) = &task.notes { if !notes.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "Notes:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), ))); for note_line in notes.lines() { lines.push(Line::from(Span::raw(note_line.to_string()))); } } } let paragraph = Paragraph::new(Text::from(lines)) .block(block) .wrap(Wrap { trim: false }) .scroll((scroll, 0)); frame.render_widget(paragraph, area); } else { let paragraph = Paragraph::new(Text::from(Line::from(Span::styled( "Select a task to view details", Style::default().fg(Color::DarkGray), )))) .block(block) .alignment(Alignment::Center); frame.render_widget(paragraph, area); } } pub fn render_status_bar( frame: &mut Frame, area: Rect, status: &NetworkStatus, sync_stats: &SyncStats, ) { let (status_text, color) = match status { NetworkStatus::Online => (" ONLINE ", STATUS_ONLINE), NetworkStatus::Offline => (" OFFLINE ", STATUS_OFFLINE), NetworkStatus::Syncing => (" SYNCING... ", STATUS_SYNC), }; let right_text = match sync_stats.last_sync_time { Some(time) => { let time_str = time.format("%H:%M:%S").to_string(); let mut parts: Vec = Vec::new(); if sync_stats.lists_changed > 0 { parts.push(format!("{} lists", sync_stats.lists_changed)); } if sync_stats.tasks_changed > 0 { parts.push(format!("{} tasks", sync_stats.tasks_changed)); } if parts.is_empty() { format!(" {} last sync ", time_str) } else { format!(" {} last sync: {} ", time_str, parts.join(", ")) } } None => String::new(), }; let full_text = format!("{}{}", status_text, right_text); let block = Block::default() .style(Style::default().bg(color).fg(Color::Black)); let paragraph = Paragraph::new(Line::from(Span::styled( full_text, Style::default().fg(Color::Black).add_modifier(Modifier::BOLD), ))) .block(block) .alignment(Alignment::Left); frame.render_widget(paragraph, area); } pub fn render_input_popup( frame: &mut Frame, area: Rect, input: &str, cursor: usize, ) { let popup_area = centered_rect(75, 3, 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(" Input ") .title_alignment(Alignment::Left); let paragraph = Paragraph::new(Text::from(Line::from(Span::raw(input)))) .block(block); frame.render_widget(paragraph, popup_area); frame.set_cursor_position(ratatui::layout::Position::new( popup_area.x + 1 + cursor as u16, popup_area.y + 1, )); } pub fn render_edit_task_popup( frame: &mut Frame, area: Rect, title: &str, title_cursor: usize, notes: &str, notes_cursor: usize, notes_scroll: u16, active_field: usize, ) { let popup_area = centered_rect(75, 14, area); frame.render_widget(Clear, popup_area); let outer_block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(POPUP_BORDER)) .title(" Edit Task "); let inner_area = outer_block.inner(popup_area); frame.render_widget(Clear, popup_area); frame.render_widget(outer_block, popup_area); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ]) .split(inner_area); // ── Title block ── let title_style = if active_field == 0 { Style::default().fg(FOCUS_COLOR) } else { Style::default().fg(Color::DarkGray) }; let title_block = Block::default() .borders(Borders::ALL) .border_style(title_style) .title(" Title ") .title_alignment(Alignment::Left); let title_para = Paragraph::new(Text::from(Line::from(Span::raw(title)))) .block(title_block); frame.render_widget(title_para, rows[0]); // ── Notes block ── let notes_style = if active_field == 1 { Style::default().fg(FOCUS_COLOR) } else { Style::default().fg(Color::DarkGray) }; let notes_block = Block::default() .borders(Borders::ALL) .border_style(notes_style) .title(" Notes ") .title_alignment(Alignment::Left); let notes_lines: Vec = notes.lines().map(|l| Line::from(Span::raw(l.to_string()))).collect(); let notes_para = Paragraph::new(Text::from(notes_lines)) .block(notes_block) .wrap(Wrap { trim: false }) .scroll((notes_scroll, 0)); frame.render_widget(notes_para, rows[2]); // ── Date buttons row ── let today_style = if active_field == 2 { Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; let tomorrow_style = if active_field == 3 { Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; let next_week_style = if active_field == 4 { Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::DarkGray) }; let buttons = Paragraph::new(Line::from(vec![ Span::styled(" [ Today ] ", today_style), Span::raw(" "), Span::styled(" [ Tomorrow ] ", tomorrow_style), Span::raw(" "), Span::styled(" [ Next Week ] ", next_week_style), ])) .alignment(Alignment::Center); frame.render_widget(buttons, rows[4]); // ── Hint row ── let hint = Paragraph::new(Line::from(Span::styled( " Tab:switch field Enter:save Esc:cancel ", Style::default().fg(Color::Gray), ))) .alignment(Alignment::Center); frame.render_widget(hint, rows[5]); // ── Cursor ── let (cursor_x, cursor_y) = match active_field { 0 => (rows[0].x + 1 + title_cursor as u16, rows[0].y + 1), 1 => (rows[2].x + 1 + notes_cursor as u16, rows[2].y + 1), _ => return, }; frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y)); } pub fn render_date_picker( frame: &mut Frame, area: Rect, date: chrono::NaiveDateTime, ) { let popup_area = centered_rect(60, 7, 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(" Date Picker ") .title_alignment(Alignment::Left); let date_str = date.format("%Y-%m-%d %H:%M").to_string(); let text = Text::from(vec![ Line::from(Span::raw("Edit date/time:")), Line::from(""), Line::from(Span::styled( format!(" {} ", date_str), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled( " Tab:next field Up/Down:change Enter:ok Esc:cancel", Style::default().fg(Color::DarkGray), )), ]); let paragraph = Paragraph::new(text) .block(block) .alignment(Alignment::Center); frame.render_widget(paragraph, popup_area); } pub fn render_confirm_popup(frame: &mut Frame, area: Rect) { let popup_area = centered_rect(50, 5, 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(Color::Red)) .title(" Confirm ") .title_alignment(Alignment::Left); let text = Text::from(vec![ Line::from(""), Line::from(Span::styled( " Delete this item? ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::styled( " Enter:confirm Esc:cancel ", Style::default().fg(Color::DarkGray), )), ]); let paragraph = Paragraph::new(text) .block(block) .alignment(Alignment::Center); frame.render_widget(paragraph, popup_area); } pub fn render_device_auth_popup( frame: &mut Frame, area: Rect, url: &str, _code: &str, error: Option<&str>, ) { let popup_area = centered_rect(80, 13, area); frame.render_widget(Clear, popup_area); let border_color = if error.is_some() { Color::Red } else if url == "starting..." || !url.is_empty() { Color::Yellow } else { POPUP_BORDER }; let block = Block::default() .borders(Borders::ALL) .style(Style::default().bg(POPUP_BG)) .border_style(Style::default().fg(border_color)) .title(" Google Tasks - Authorization ") .title_alignment(Alignment::Left); let mut lines = Vec::new(); if let Some(err) = error { lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Authorization Error ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); let wrapped = textwrap(&err, 50); for line in wrapped { lines.push(Line::from(Span::styled( line, Style::default().fg(Color::White), ))); } lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Press Enter to retry | Esc to cancel ", Style::default().fg(Color::DarkGray), ))); } else if url == "starting..." || !url.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Authorization in progress... ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( " A browser tab should open automatically. ", Style::default().fg(Color::White), ))); lines.push(Line::from(Span::styled( " If not, check that GOOGLE_CLIENT_ID and ", Style::default().fg(Color::DarkGray), ))); lines.push(Line::from(Span::styled( " GOOGLE_CLIENT_SECRET are set correctly. ", Style::default().fg(Color::DarkGray), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( " (Esc to cancel) ", Style::default().fg(Color::DarkGray), ))); } else { lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Google Tasks Authorization ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( " This app needs access to your Google Tasks. ", Style::default().fg(Color::White), ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Press Enter to start ", Style::default().fg(Color::Cyan), ))); lines.push(Line::from(Span::styled( " Press Esc to skip ", 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_calendar_panel( frame: &mut Frame, area: Rect, events: &[CalendarEvent], focused: bool, _scroll: u16, ) { if area.width < 20 || area.height < 3 { return; } let today = chrono::Local::now().naive_local().date(); let weekday = today.weekday().num_days_from_monday(); let this_monday = today - chrono::Duration::days(weekday as i64); let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Ratio(1, 4); 4]) .split(area); let has_events = events.iter().any(|e| { e.start.map_or(false, |s| { let d = s.date(); 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 { 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 inner = block.inner(col_area); let inner_h = inner.height as usize; let mut lines: Vec = 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 { 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))); if lines.len() >= inner_h { break; } 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( line_text, Style::default().fg(DETAIL_COLOR), ))); if lines.len() >= inner_h { break; } } } let paragraph = Paragraph::new(Text::from(lines)).block(block); frame.render_widget(paragraph, col_area); } } /// Simple word wrap: splits text at word boundaries to fit max_width chars per line fn textwrap(text: &str, max_width: usize) -> Vec { let mut result = Vec::new(); let mut current = String::new(); for word in text.split_whitespace() { if current.len() + word.len() + 1 > max_width && !current.is_empty() { result.push(current.clone()); current.clear(); } if !current.is_empty() { current.push(' '); } current.push_str(word); } if !current.is_empty() { result.push(current); } result } fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { let popup_layout = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) .constraints([ ratatui::layout::Constraint::Length((area.height.saturating_sub(percent_y)) / 2), ratatui::layout::Constraint::Length(percent_y), ratatui::layout::Constraint::Min(0), ]) .split(area); ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Horizontal) .constraints([ ratatui::layout::Constraint::Length((area.width.saturating_sub(percent_x)) / 2), ratatui::layout::Constraint::Length(percent_x), ratatui::layout::Constraint::Min(0), ]) .split(popup_layout[1])[1] }