1use super::*;
2
3pub(crate) fn draw_dashboard(f: &mut Frame, app: &App, area: Rect) {
4 let theme = &app.theme;
5 let icons = app.icons;
6 let chunks = Layout::default()
7 .direction(Direction::Vertical)
8 .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
9 .split(area);
10
11 draw_compact_timer_block(f, app, chunks[0]);
12
13 let today = crate::storage::today_focus_minutes(&app.data);
14 let goal = app.data.daily_goal_minutes.max(1);
15 let progress_ratio = (today as f64 / goal as f64).min(1.0);
16
17 let task_chunks = Layout::default()
18 .direction(Direction::Horizontal)
19 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
20 .split(chunks[1]);
21
22 let task_block = dense_panel(
23 theme,
24 Line::from(Span::styled(
25 format!(" {} Tasks ", icons.tasks),
26 Style::default().fg(theme.accent),
27 )),
28 );
29 let pending_tasks = app.dashboard_tasks();
30 if pending_tasks.is_empty() {
31 let empty_msg = if app.queue_empty() && !app.data.tasks.is_empty() {
32 "All tasks done — free focus or [a] add more"
33 } else {
34 "No tasks yet — press [a] to add one"
35 };
36 let empty_list = List::new(vec![ListItem::new(Span::styled(
37 empty_msg,
38 Style::default().fg(if app.queue_empty() && !app.data.tasks.is_empty() {
39 theme.success
40 } else {
41 theme.dim
42 }),
43 ))])
44 .block(task_block);
45 f.render_widget(empty_list, task_chunks[0]);
46 } else {
47 let visible = chunks[1].height.saturating_sub(4) as usize;
48 let pending: Vec<ListItem> = pending_tasks
49 .into_iter()
50 .take(visible.max(4))
51 .enumerate()
52 .map(|(idx, t)| {
53 let selected = idx == app.dashboard_task_selected;
54 let marker = match t.priority {
55 crate::model::Priority::High => icons.alert,
56 crate::model::Priority::Medium => icons.dot,
57 crate::model::Priority::Low => " ",
58 };
59 let active = if app.active_task == Some(t.id) {
60 format!("{} ", icons.task_active)
61 } else if selected {
62 format!("{} ", icons.chevron)
63 } else {
64 " ".into()
65 };
66 let status_color = task_status_color(theme, t.status);
67 let row_style = if selected {
68 Style::default()
69 .bg(theme.select_bg)
70 .fg(theme.select_fg)
71 .add_modifier(Modifier::BOLD)
72 } else {
73 Style::default().fg(theme.text)
74 };
75 ListItem::new(Line::from(vec![
76 Span::styled(active, Style::default().fg(theme.accent)),
77 Span::styled(
78 format!("{} ", task_status_icon(icons, t.status)),
79 Style::default().fg(status_color),
80 ),
81 Span::styled(format!("[{}] ", marker), Style::default().fg(theme.warning)),
82 Span::styled(t.title.clone(), row_style),
83 Span::styled(
84 format!(" {}m", t.estimated_minutes),
85 Style::default().fg(theme.dim),
86 ),
87 ]))
88 .style(row_style)
89 })
90 .collect();
91 let list = List::new(pending).block(task_block);
92 f.render_widget(list, task_chunks[0]);
93 }
94
95 let goal_met = app.daily_goal_met();
96 let remaining = goal.saturating_sub(today);
97 let goal_reached = progress_ratio >= 1.0;
98 let gauge_color = if goal_reached {
99 theme.accent
100 } else {
101 theme.success
102 };
103
104 let goal_inner = Layout::default()
105 .direction(Direction::Vertical)
106 .constraints([Constraint::Length(2), Constraint::Min(2)])
107 .split(task_chunks[1]);
108
109 f.render_widget(
110 Gauge::default()
111 .gauge_style(Style::default().fg(gauge_color).bg(theme.task_track))
112 .ratio(progress_ratio)
113 .label(format!(
114 "{} {}/{} ({}%)",
115 icons.target,
116 format_minutes(today),
117 format_minutes(goal),
118 (progress_ratio * 100.0) as u32
119 ))
120 .block(dense_panel(
121 theme,
122 Line::from(Span::styled(
123 format!(" {} Daily goal ", icons.target),
124 Style::default().fg(theme.accent),
125 )),
126 )),
127 goal_inner[0],
128 );
129
130 let goal_lines = vec![
131 Line::from(Span::styled(
132 if goal_met {
133 format!("{} Goal complete!", icons.check)
134 } else {
135 format!(
136 "{} {} remaining today",
137 icons.dot,
138 format_minutes(remaining)
139 )
140 },
141 Style::default().fg(if goal_met { theme.success } else { theme.text }),
142 )),
143 Line::from(vec![
144 Span::styled(format!("{} ", icons.fire), Style::default().fg(theme.info)),
145 Span::styled(
146 format!(
147 "{}d · {}d goal · {} open",
148 app.data.streak_days,
149 app.data.goal_streak_days,
150 crate::storage::pending_tasks(&app.data).count()
151 ),
152 Style::default().fg(theme.dim),
153 ),
154 ]),
155 Line::from(vec![
156 Span::styled(format!("{} ", icons.chart), Style::default().fg(theme.dim)),
157 Span::styled(
158 format!(
159 "{} all-time focus",
160 format_minutes(app.data.total_focus_minutes)
161 ),
162 Style::default().fg(theme.dim),
163 ),
164 ]),
165 ];
166 f.render_widget(
167 Paragraph::new(goal_lines).block(dense_panel(
168 theme,
169 Line::from(Span::styled(
170 format!(" {} Today ", icons.stats),
171 Style::default().fg(theme.accent),
172 )),
173 )),
174 goal_inner[1],
175 );
176}
177
178pub(crate) fn draw_compact_timer_block(f: &mut Frame, app: &App, area: Rect) {
179 let theme = &app.theme;
180 let t = &app.timer;
181 let mc = mode_color(theme, t.mode);
182
183 let is_finished = t.state == crate::model::TimerState::Finished;
184 let border_color = if is_finished {
185 theme.success
186 } else {
187 theme.panel_border
188 };
189 let title_suffix = if is_finished {
190 format!(" {} DONE ", app.icons.check)
191 } else {
192 String::new()
193 };
194 let outer = timer_panel(
195 theme,
196 Line::from(Span::styled(
197 format!(" {} {}", t.mode.label(), title_suffix),
198 Style::default()
199 .fg(if is_finished { theme.success } else { mc })
200 .add_modifier(Modifier::BOLD),
201 )),
202 border_color,
203 );
204 f.render_widget(outer, area);
205
206 let inner = Rect {
207 x: area.x + 1,
208 y: area.y + 1,
209 width: area.width.saturating_sub(2),
210 height: area.height.saturating_sub(2),
211 };
212
213 let on_break = is_break_mode(t.mode);
214 let layout = Layout::default()
215 .direction(Direction::Vertical)
216 .constraints(if on_break {
217 [
218 Constraint::Min(5),
219 Constraint::Length(2),
220 Constraint::Length(1),
221 Constraint::Length(1),
222 Constraint::Length(2),
223 ]
224 } else {
225 [
226 Constraint::Min(5),
227 Constraint::Length(2),
228 Constraint::Length(1),
229 Constraint::Length(1),
230 Constraint::Length(1),
231 ]
232 })
233 .split(inner);
234
235 let cycle = t.config.long_break_every.max(1);
236 let style = theme.scene_style(mc);
237 let options = DashboardSceneOptions {
238 task_progress: app.active_task_progress(),
239 pending_tasks: app.pending_task_count(),
240 active_task_index: app.active_task_pending_index(),
241 sessions_done: t.completed_focus_sessions % cycle,
242 sessions_total: cycle,
243 layout: crate::canvas_timer::SceneLayout::Dashboard,
244 };
245 draw_dashboard_canvas(f, layout[0], t, &style, &options);
246
247 let (main_time, tenths, _) = format_time_stack(t);
248 f.render_widget(
249 Paragraph::new(Line::from(vec![
250 Span::styled(
251 main_time,
252 Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
253 ),
254 Span::styled(tenths, Style::default().fg(theme.dim)),
255 ]))
256 .alignment(Alignment::Center),
257 layout[1],
258 );
259
260 draw_timer_footer(f, app, &layout[2..], mc);
261}
262
263pub(crate) fn draw_timer_footer(f: &mut Frame, app: &App, areas: &[Rect], mc: Color) {
264 let theme = &app.theme;
265 let t = &app.timer;
266
267 let cycle = t.config.long_break_every.max(1);
268 let done_in_cycle = t.completed_focus_sessions % cycle;
269 let in_focus = t.mode == TimerMode::Focus
270 && matches!(
271 t.state,
272 crate::model::TimerState::Running | crate::model::TimerState::Paused
273 );
274 let dots = session_dots(done_in_cycle, cycle, in_focus);
275
276 f.render_widget(
277 Paragraph::new(Line::from(vec![
278 Span::styled(dots, Style::default().fg(mc)),
279 Span::styled(
280 format!(" {} ", t.cycle_label()),
281 Style::default().fg(theme.dim),
282 ),
283 Span::styled(
284 format!("{}% left", ((1.0 - t.progress()) * 100.0) as u32),
285 Style::default().fg(theme.dim),
286 ),
287 ]))
288 .alignment(Alignment::Center),
289 areas[0],
290 );
291
292 if let Some(mut spans) = active_task_spans(app, theme) {
293 if let Some(id) = app.active_task {
294 if let Some(task) = app.data.tasks.iter().find(|t| t.id == id) {
295 let left = crate::storage::sessions_remaining_hint(task, app.data.focus_minutes);
296 if left > 0 {
297 spans.push(Span::styled(
298 format!(" ~{} left", left),
299 Style::default().fg(theme.dim),
300 ));
301 }
302 f.render_widget(
303 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
304 areas[1],
305 );
306 }
307 }
308 } else if areas.len() > 1 {
309 let msg = if app.queue_empty() {
310 "All tasks done — free focus (general sessions)"
311 } else {
312 "No active task — Tasks tab, Space to set one"
313 };
314 f.render_widget(
315 Paragraph::new(Span::styled(msg, Style::default().fg(theme.dim)))
316 .alignment(Alignment::Center),
317 areas[1],
318 );
319 }
320
321 if areas.len() > 2 {
322 if is_break_mode(t.mode) {
323 draw_break_tip(f, areas[2], t, mc, theme.text, theme.dim, app.icons.heart);
324 } else {
325 let state_label = match t.state {
326 crate::model::TimerState::Idle => "ready",
327 crate::model::TimerState::Running => "focusing",
328 crate::model::TimerState::Paused => "paused",
329 crate::model::TimerState::Finished => "complete",
330 };
331 f.render_widget(
332 Paragraph::new(Span::styled(state_label, Style::default().fg(mc)))
333 .alignment(Alignment::Center),
334 areas[2],
335 );
336 }
337 }
338}
339
340pub(crate) fn mode_color(theme: &crate::app::Theme, mode: TimerMode) -> Color {
341 match mode {
342 TimerMode::Focus => theme.accent,
343 TimerMode::ShortBreak => theme.success,
344 TimerMode::LongBreak => theme.warning,
345 TimerMode::Custom => theme.info,
346 }
347}