use eframe::egui::{self, Color32, RichText, Sense, Ui};
use super::theme::{ButtonStyle, Theme};
use super::types::Trend;
pub fn metric_card(
ui: &mut Ui,
theme: &Theme,
label: &str,
value: &str,
unit: &str,
trend: Option<Trend>,
accent: Color32,
) {
egui::Frame::new()
.fill(theme.bg_card)
.inner_margin(egui::Margin::same(theme.spacing.card_padding as i8))
.corner_radius(egui::CornerRadius::same(theme.rounding.md as u8))
.stroke(egui::Stroke::new(1.0, theme.border_subtle))
.show(ui, |ui| {
ui.set_min_width(100.0);
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.color(theme.text_muted)
.size(theme.typography.caption),
);
ui.add_space(theme.spacing.xs);
ui.horizontal(|ui| {
ui.label(
RichText::new(value)
.color(accent)
.size(theme.typography.display)
.strong(),
);
ui.label(
RichText::new(unit)
.color(theme.text_muted)
.size(theme.typography.body),
);
if let Some(t) = trend {
let trend_color = match t {
Trend::Rising => theme.caution,
Trend::Falling => theme.info,
Trend::Stable => theme.text_muted,
};
ui.label(
RichText::new(t.indicator())
.color(trend_color)
.size(theme.typography.subheading),
);
}
});
});
});
}
#[derive(Debug, Clone, Copy, Default)]
pub enum EmptyStateKind {
#[default]
NoData,
NoMatch,
}
pub fn empty_state(ui: &mut Ui, theme: &Theme, title: &str, description: &str) {
empty_state_with_kind(ui, theme, title, description, EmptyStateKind::NoData);
}
pub fn empty_state_with_kind(
ui: &mut Ui,
theme: &Theme,
title: &str,
description: &str,
kind: EmptyStateKind,
) {
let (icon, icon_color, title_color) = match kind {
EmptyStateKind::NoData => ("○", theme.text_muted, theme.text_secondary),
EmptyStateKind::NoMatch => ("◇", theme.info, theme.text_secondary),
};
ui.vertical_centered(|ui| {
ui.add_space(theme.spacing.xl * 2.0);
ui.label(
RichText::new(icon)
.color(icon_color)
.size(theme.typography.display),
);
ui.add_space(theme.spacing.md);
ui.label(
RichText::new(title)
.color(title_color)
.size(theme.typography.subheading)
.strong(),
);
ui.add_space(theme.spacing.xs);
ui.label(
RichText::new(description)
.color(theme.text_secondary)
.size(theme.typography.body),
);
});
}
pub fn section_header(ui: &mut Ui, theme: &Theme, title: &str) {
ui.horizontal(|ui| {
let bar_height = theme.typography.subheading + 4.0;
let (rect, _) = ui.allocate_exact_size(egui::vec2(3.0, bar_height), egui::Sense::hover());
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(1), theme.accent);
ui.add_space(theme.spacing.sm);
ui.label(
RichText::new(title)
.color(theme.text_primary)
.size(theme.typography.subheading)
.strong(),
);
});
ui.add_space(theme.spacing.sm);
}
pub fn status_badge(ui: &mut Ui, theme: &Theme, text: &str, color: Color32) {
let bg = theme.tint_medium(color);
egui::Frame::new()
.fill(bg)
.inner_margin(egui::Margin::symmetric(
theme.spacing.sm as i8,
theme.spacing.xs as i8,
))
.corner_radius(egui::CornerRadius::same(theme.rounding.full as u8))
.show(ui, |ui| {
ui.label(
RichText::new(text)
.color(color)
.size(theme.typography.caption),
);
});
}
pub fn themed_button(
ui: &mut Ui,
theme: &Theme,
text: &str,
style: ButtonStyle,
text_size: f32,
) -> egui::Response {
let stroke = if style.border == Color32::TRANSPARENT {
egui::Stroke::NONE
} else {
egui::Stroke::new(1.0, style.border)
};
ui.add(
egui::Button::new(RichText::new(text).color(style.text).size(text_size))
.fill(style.fill)
.stroke(stroke)
.corner_radius(egui::CornerRadius::same(theme.rounding.sm as u8))
.min_size(egui::vec2(0.0, theme.spacing.lg + 2.0)),
)
}
pub fn toggle_chip(
ui: &mut Ui,
theme: &Theme,
text: &str,
selected: bool,
active_color: Color32,
) -> egui::Response {
let fill = if selected {
theme.tint_medium(active_color)
} else {
theme.bg_card
};
let text_color = if selected {
active_color
} else {
theme.text_secondary
};
let stroke = if selected {
active_color
} else {
theme.border_subtle
};
ui.add(
egui::Button::new(
RichText::new(text)
.color(text_color)
.size(theme.typography.caption + 1.0),
)
.fill(fill)
.stroke(egui::Stroke::new(1.0, stroke))
.corner_radius(egui::CornerRadius::same(theme.rounding.full as u8))
.min_size(egui::vec2(0.0, theme.spacing.lg + 2.0)),
)
}
pub fn nav_tab(ui: &mut Ui, theme: &Theme, text: &str, selected: bool) -> egui::Response {
let fill = if selected {
theme.tint_medium(theme.accent)
} else {
Color32::TRANSPARENT
};
let text_color = if selected {
theme.accent
} else {
theme.text_secondary
};
let stroke = if selected {
theme.accent
} else {
theme.border_subtle
};
ui.add(
egui::Button::new(
RichText::new(text)
.color(text_color)
.size(theme.typography.body),
)
.fill(fill)
.stroke(egui::Stroke::new(1.0, stroke))
.corner_radius(egui::CornerRadius::same(theme.rounding.md as u8))
.min_size(egui::vec2(84.0, theme.spacing.xl)),
)
}
pub fn status_dot(ui: &mut Ui, color: Color32, tooltip: &str) -> egui::Response {
let size = 8.0;
let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), Sense::hover());
if ui.is_rect_visible(rect) {
let painter = ui.painter();
painter.circle_filled(rect.center(), size / 2.0, color);
}
response.on_hover_text(tooltip)
}
pub fn co2_gauge(ui: &mut Ui, theme: &Theme, co2: u16) {
let max_ppm = 2500.0_f32;
let pct = (co2 as f32 / max_ppm).min(1.0);
let available_width = ui.available_width();
let bar_height = 14.0;
let indicator_height = 20.0; let label_height = 18.0;
let (rect, _) = ui.allocate_exact_size(
egui::vec2(
available_width,
indicator_height + bar_height + label_height,
),
Sense::hover(),
);
let painter = ui.painter();
let bar_rect = egui::Rect::from_min_size(
rect.min + egui::vec2(0.0, indicator_height),
egui::vec2(available_width, bar_height),
);
let zones = [
(800.0 / max_ppm, theme.success),
(200.0 / max_ppm, theme.warning),
(500.0 / max_ppm, theme.caution),
(1.0 - 1500.0 / max_ppm, theme.danger),
];
let mut x_offset = 0.0;
for (width_pct, color) in zones {
let width = width_pct * available_width;
painter.rect_filled(
egui::Rect::from_min_size(
bar_rect.min + egui::vec2(x_offset, 0.0),
egui::vec2(width, bar_height),
),
egui::CornerRadius::ZERO,
color.gamma_multiply(0.2),
);
x_offset += width;
}
painter.rect_stroke(
bar_rect,
egui::CornerRadius::same(theme.rounding.sm as u8),
egui::Stroke::new(1.0, theme.border),
egui::StrokeKind::Outside,
);
let fill_width = pct * available_width;
let fill_color = theme.co2_color(co2);
painter.rect_filled(
egui::Rect::from_min_size(bar_rect.min, egui::vec2(fill_width, bar_height)),
egui::CornerRadius::same(theme.rounding.sm as u8),
fill_color.gamma_multiply(0.85),
);
let indicator_x = bar_rect.min.x + pct * available_width;
let triangle_size = 6.0;
let triangle_y = bar_rect.min.y - 2.0;
painter.add(egui::Shape::convex_polygon(
vec![
egui::pos2(indicator_x, triangle_y),
egui::pos2(
indicator_x - triangle_size / 2.0,
triangle_y - triangle_size,
),
egui::pos2(
indicator_x + triangle_size / 2.0,
triangle_y - triangle_size,
),
],
fill_color,
egui::Stroke::NONE,
));
painter.text(
egui::pos2(indicator_x, triangle_y - triangle_size - 2.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", co2),
egui::FontId::proportional(theme.typography.caption),
fill_color,
);
let label_y = bar_rect.max.y + 3.0;
let ticks = [(800.0, "800"), (1000.0, "1k"), (1500.0, "1.5k")];
for (ppm, label) in ticks {
let x = bar_rect.min.x + (ppm / max_ppm) * available_width;
painter.line_segment(
[egui::pos2(x, bar_rect.min.y), egui::pos2(x, bar_rect.max.y)],
egui::Stroke::new(1.0, theme.text_muted.gamma_multiply(0.4)),
);
painter.text(
egui::pos2(x, label_y),
egui::Align2::CENTER_TOP,
label,
egui::FontId::proportional(theme.typography.caption),
theme.text_muted,
);
}
}
pub fn loading_indicator(ui: &mut Ui, theme: &Theme, message: Option<&str>) {
ui.horizontal(|ui| {
ui.spinner();
if let Some(msg) = message {
ui.add_space(theme.spacing.sm);
ui.label(RichText::new(msg).color(theme.text_muted));
}
});
}
pub fn cached_data_banner(
ui: &mut Ui,
theme: &Theme,
captured_at: Option<time::OffsetDateTime>,
is_stale: bool,
) {
let (bg_color, border_color, icon, message) = if is_stale {
(
theme.tint_light(theme.warning),
theme.warning,
"⚠",
"Cached data - reading may be outdated",
)
} else {
(
theme.tint_light(theme.info),
theme.info,
"ℹ",
"Showing cached data - device offline",
)
};
egui::Frame::new()
.fill(bg_color)
.inner_margin(egui::Margin::symmetric(
theme.spacing.md as i8,
theme.spacing.sm as i8,
))
.corner_radius(egui::CornerRadius::same(theme.rounding.md as u8))
.stroke(egui::Stroke::new(2.0, border_color))
.show(ui, |ui| {
ui.horizontal(|ui| {
let icon_color = if is_stale { theme.warning } else { theme.info };
ui.label(
RichText::new(icon)
.color(icon_color)
.size(theme.typography.subheading),
);
ui.add_space(theme.spacing.sm);
ui.vertical(|ui| {
ui.label(
RichText::new(message)
.color(theme.text_primary)
.size(theme.typography.body)
.strong(),
);
if let Some(ts) = captured_at {
let age = format_reading_age(ts);
ui.label(
RichText::new(format!("Last reading: {}", age))
.color(theme.text_secondary)
.size(theme.typography.caption),
);
}
});
});
});
}
fn format_reading_age(captured_at: time::OffsetDateTime) -> String {
let now = time::OffsetDateTime::now_utc();
let duration = now - captured_at;
let total_seconds = duration.whole_seconds();
if total_seconds < 0 {
return "just now".to_string();
}
let minutes = duration.whole_minutes();
let hours = duration.whole_hours();
let days = duration.whole_days();
if days > 0 {
if days == 1 {
"1 day ago".to_string()
} else {
format!("{} days ago", days)
}
} else if hours > 0 {
if hours == 1 {
"1 hour ago".to_string()
} else {
format!("{} hours ago", hours)
}
} else if minutes > 0 {
if minutes == 1 {
"1 minute ago".to_string()
} else {
format!("{} minutes ago", minutes)
}
} else {
"just now".to_string()
}
}
pub fn is_reading_stale(captured_at: Option<time::OffsetDateTime>, interval_secs: u16) -> bool {
let Some(ts) = captured_at else {
return false; };
let now = time::OffsetDateTime::now_utc();
let age_secs = (now - ts).whole_seconds();
if age_secs < 0 {
return false;
}
let threshold = if interval_secs > 0 {
(interval_secs as i64) * 2
} else {
30 * 60 };
age_secs > threshold
}