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 let today_logged = crate::storage::today_focus_minutes(&app.data);
249 f.render_widget(
250 Paragraph::new(vec![
251 Line::from(vec![
252 Span::styled(
253 main_time,
254 Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
255 ),
256 Span::styled(tenths, Style::default().fg(theme.dim)),
257 ]),
258 Line::from(Span::styled(
259 format!(
260 "{} logged today",
261 super::widgets::format_minutes(today_logged)
262 ),
263 Style::default().fg(theme.dim),
264 )),
265 ])
266 .alignment(Alignment::Center),
267 layout[1],
268 );
269
270 draw_timer_footer(f, app, &layout[2..], mc);
271}
272
273pub(crate) fn draw_timer_footer(f: &mut Frame, app: &App, areas: &[Rect], mc: Color) {
274 let theme = &app.theme;
275 let t = &app.timer;
276
277 let cycle = t.config.long_break_every.max(1);
278 let done_in_cycle = t.completed_focus_sessions % cycle;
279 let in_focus = t.mode == TimerMode::Focus
280 && matches!(
281 t.state,
282 crate::model::TimerState::Running | crate::model::TimerState::Paused
283 );
284 let dots = session_dots(done_in_cycle, cycle, in_focus);
285
286 f.render_widget(
287 Paragraph::new(Line::from(vec![
288 Span::styled(dots, Style::default().fg(mc)),
289 Span::styled(
290 format!(" {} ", t.cycle_label()),
291 Style::default().fg(theme.dim),
292 ),
293 Span::styled(
294 format!("{}% left", ((1.0 - t.progress()) * 100.0) as u32),
295 Style::default().fg(theme.dim),
296 ),
297 ]))
298 .alignment(Alignment::Center),
299 areas[0],
300 );
301
302 if let Some(mut spans) = active_task_spans(app, theme) {
303 if let Some(id) = app.active_task {
304 if let Some(task) = app.data.tasks.iter().find(|t| t.id == id) {
305 let left = crate::storage::sessions_remaining_hint(task, app.data.focus_minutes);
306 if left > 0 {
307 spans.push(Span::styled(
308 format!(" ~{} left", left),
309 Style::default().fg(theme.dim),
310 ));
311 }
312 f.render_widget(
313 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
314 areas[1],
315 );
316 }
317 }
318 } else if areas.len() > 1 {
319 let msg = if app.queue_empty() {
320 "All tasks done — free focus (general sessions)"
321 } else {
322 "No active task — Tasks tab, Space to set one"
323 };
324 f.render_widget(
325 Paragraph::new(Span::styled(msg, Style::default().fg(theme.dim)))
326 .alignment(Alignment::Center),
327 areas[1],
328 );
329 }
330
331 if areas.len() > 2 {
332 if is_break_mode(t.mode) {
333 draw_break_tip(f, areas[2], t, mc, theme.text, theme.dim, app.icons.heart);
334 } else {
335 let state_label = match t.state {
336 crate::model::TimerState::Idle => "ready",
337 crate::model::TimerState::Running => "focusing",
338 crate::model::TimerState::Paused => "paused",
339 crate::model::TimerState::Finished => "complete",
340 };
341 f.render_widget(
342 Paragraph::new(Span::styled(state_label, Style::default().fg(mc)))
343 .alignment(Alignment::Center),
344 areas[2],
345 );
346 }
347 }
348}
349
350pub(crate) fn mode_color(theme: &crate::app::Theme, mode: TimerMode) -> Color {
351 match mode {
352 TimerMode::Focus => theme.accent,
353 TimerMode::ShortBreak => theme.success,
354 TimerMode::LongBreak => theme.warning,
355 TimerMode::Custom => theme.info,
356 }
357}