use eframe::egui;
use egui_extras::{Size, TableBuilder};
use poll_promise::Promise;
use regex::Regex;
use load_dotenv::load_dotenv;
load_dotenv!();
mod api;
mod widget;
enum LoginState {
LoggedOut,
LoggingIn,
Registering,
}
struct InputFields {
username: String,
email: String,
password: String,
hours: i32,
date: String,
}
struct PopupDetails {
visible: bool,
title: String,
message: String,
}
struct UserDetails {
api_key: String,
email: String,
}
struct Promises {
login: Promise<api::AuthResponse>,
register: Promise<api::AuthResponse>,
user_info: Promise<api::UserInfoResponse>,
hour_entries: Promise<Vec<api::HourEntry>>,
insert_hour_entry: Promise<api::HourEntry>,
}
struct PromisesStates {
login_waiting: bool,
register_waiting: bool,
user_info_waiting: bool,
hour_entries_waiting: bool,
insert_hour_entry_waiting: bool,
}
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct App {
#[serde(skip)]
api_address: String,
#[serde(skip)]
user: UserDetails,
#[serde(skip)]
login_state: LoginState,
#[serde(skip)]
popup_details: PopupDetails,
#[serde(skip)]
input: InputFields,
#[serde(skip)]
promise: Promises,
#[serde(skip)]
promise_state: PromisesStates,
#[serde(skip)]
logged_in: bool,
#[serde(skip)]
hour_entries: Vec<api::HourEntry>,
#[serde(skip)]
adding_hour_entry: bool,
}
impl Default for App {
fn default() -> Self {
Self {
api_address: env!("WEB_API_ADDRESS").to_string(),
user: UserDetails {
api_key: String::new(),
email: String::new(),
},
login_state: LoginState::LoggedOut,
popup_details: PopupDetails {
visible: false,
title: String::new(),
message: String::new(),
},
input: InputFields {
username: String::new(),
email: String::new(),
password: String::new(),
hours: 0,
date: String::new(),
},
promise: Promises {
login: Promise::from_ready(api::AuthResponse::default()),
register: Promise::from_ready(api::AuthResponse::default()),
user_info: Promise::from_ready(api::UserInfoResponse::default()),
hour_entries: Promise::from_ready(vec![]),
insert_hour_entry: Promise::from_ready(api::HourEntry::default()),
},
promise_state: PromisesStates {
login_waiting: false,
register_waiting: false,
user_info_waiting: false,
hour_entries_waiting: false,
insert_hour_entry_waiting: false,
},
logged_in: false,
hour_entries: Vec::new(),
adding_hour_entry: false,
}
}
}
impl App {
// Called once before the first frame.
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
// Load previous app state (if any).
if let Some(storage) = cc.storage {
return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
}
Default::default()
}
}
impl eframe::App for App {
/// Called by the frame work to save state before shutdown.
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, eframe::APP_KEY, self);
}
/// Called each time the UI needs repainting, which may be many times per second.
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let Self {
api_address,
user,
login_state,
popup_details,
input,
promise,
promise_state,
logged_in,
hour_entries,
adding_hour_entry,
} = self;
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
// The top panel is often a good place for a menu bar:
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Quit").clicked() {
frame.quit();
}
});
});
});
egui::SidePanel::left("side_panel").show(ctx, |ui| {
if *logged_in {
if promise_state.user_info_waiting {
ui.label("Loading user info...");
if let Some(result) = promise.user_info.ready() {
user.email = result.message.clone();
promise_state.user_info_waiting = false;
}
} else {
ui.heading(format!("Logged in as: {}", user.email));
}
if ui.button("New hour entry").clicked() {
*adding_hour_entry = true;
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
if ui.button("Logout").clicked() {
*logged_in = false;
user.api_key = String::new();
*hour_entries = Vec::new();
}
});
} else {
ui.heading("Not logged in");
}
});
egui::CentralPanel::default().show(ctx, |ui| {
// The central panel the region left after adding TopPanel's and SidePanel's
if *logged_in {
if promise_state.hour_entries_waiting {
ui.heading("Loading...");
// Repaint
ctx.request_repaint();
if let Some(result) = promise.hour_entries.ready() {
*hour_entries = result.to_vec();
promise_state.hour_entries_waiting = false;
}
} else {
egui::ScrollArea::vertical().show(ui, |ui| {
TableBuilder::new(ui)
.striped(true)
.cell_layout(
egui::Layout::left_to_right().with_cross_align(egui::Align::Center),
)
.column(Size::initial(60.0).at_least(40.0))
.column(Size::initial(60.0).at_least(40.0))
.column(Size::initial(60.0).at_least(40.0))
.column(Size::remainder().at_least(60.0))
.resizable(true)
.header(20.0, |mut header| {
header.col(|ui| {
ui.heading("Hours");
});
header.col(|ui| {
ui.heading("Date Worked");
});
header.col(|ui| {
ui.heading("Date Entered");
});
header.col(|ui| {
ui.heading("Actions");
});
})
.body(|mut body| {
for entry in hour_entries.clone() {
body.row(15.0, |mut row| {
row.col(|ui| {
ui.label(entry.hours.to_string());
});
row.col(|ui| {
ui.label(entry.date_worked.to_string());
});
row.col(|ui| {
ui.label(entry.date_entered.to_string());
});
row.col(|ui| {
if ui.button("Delete").clicked() {
let id = entry.id.clone();
let api_key = user.api_key.clone();
let api_addr = api_address.clone();
let _delete_promise =
Promise::spawn_async(async move {
api::delete_hour_entry(
id, api_key, api_addr,
)
.await
});
hour_entries.remove(
hour_entries
.clone()
.iter()
.position(|x| x.id == id)
.unwrap(),
);
}
});
});
}
});
});
}
}
});
if *adding_hour_entry && *logged_in {
egui::Window::new("New time entry").show(ctx, |ui| {
ui.label("Hours worked:");
ui.add(egui::Slider::new(&mut input.hours, 0..=24).suffix("h"));
ui.label("Date worked in YYYY-MM-DD format:");
ui.text_edit_singleline(&mut input.date);
if ui.button("Save").clicked() {
if Regex::new(r"^\d{4}\-(0?[1-9]|1[012])\-(0?[1-9]|[12][0-9]|3[01])$")
.unwrap()
.is_match(&mut input.date)
{
promise_state.insert_hour_entry_waiting = true;
let hours = input.hours.clone();
let date_worked = input.date.clone();
let api_key = user.api_key.clone();
let api_addr = api_address.clone();
promise.insert_hour_entry = Promise::spawn_async(async move {
api::insert_hour_entry(hours, date_worked, api_key, api_addr).await
});
*adding_hour_entry = false;
} else {
popup_details.visible = true;
popup_details.title = "Invalid date".to_owned();
popup_details.message = "Date format is YYYY-MM-DD".to_owned();
}
}
if ui.button("Cancel").clicked() {
*adding_hour_entry = false;
}
});
} else if promise_state.insert_hour_entry_waiting && *logged_in {
// Repaint
ctx.request_repaint();
if let Some(result) = promise.insert_hour_entry.ready() {
self.hour_entries.push(result.clone());
promise_state.insert_hour_entry_waiting = false;
}
}
if !*logged_in {
egui::Window::new("Login / Register")
.title_bar(false)
.resizable(false)
.show(ctx, |ui| match *login_state {
LoginState::LoggedOut => {
ui.label("Welcome! Please, login or register.");
ui.horizontal(|ui| {
if ui.button("Register").clicked() {
*login_state = LoginState::Registering;
}
if ui.button("Login").clicked() {
*login_state = LoginState::LoggingIn;
}
});
}
LoginState::LoggingIn => {
ui.horizontal(|ui| {
ui.label("Username:");
ui.text_edit_singleline(&mut input.username);
});
ui.horizontal(|ui| {
ui.label("Password:");
ui.add(widget::password::password(&mut input.password));
});
ui.add_enabled_ui(!promise_state.login_waiting, |ui| {
ui.horizontal(|ui| {
if ui.button("Back").clicked() {
*login_state = LoginState::LoggedOut;
}
if ui.button("Login").clicked() {
promise_state.login_waiting = true;
let username = input.username.clone();
let password = input.password.clone();
let api_addr = api_address.clone();
promise.login = Promise::spawn_async(async {
api::auth(username, password, api_addr).await
});
input.password = String::new();
}
});
});
if promise_state.login_waiting {
// Repaint
ctx.request_repaint();
if let Some(result) = promise.login.ready() {
if result.success {
user.api_key = result.message.clone();
// TODO: There has to be another way to do this, right? Here I have to make the temporary values twice...
// NOTE: It's a problem with the lifetime of the variables when using them in async functions, while the main program is not async.
let api_key = user.api_key.clone();
let api_addr = api_address.clone();
promise.hour_entries = Promise::spawn_async(async {
api::get_hour_entries(api_key, api_addr).await
});
promise_state.hour_entries_waiting = true;
let api_key = user.api_key.clone();
let api_addr = api_address.clone();
promise.user_info = Promise::spawn_async(async {
api::get_user_info(api_key, api_addr).await
});
promise_state.user_info_waiting = true;
*logged_in = true;
} else {
popup_details.visible = true;
popup_details.title = "Login failed".to_owned();
popup_details.message = result.message.clone();
}
promise_state.login_waiting = false;
}
}
}
LoginState::Registering => {
ui.horizontal(|ui| {
ui.label("Username:");
ui.text_edit_singleline(&mut input.username);
});
ui.horizontal(|ui| {
ui.label("Email:");
ui.text_edit_singleline(&mut input.email);
});
ui.horizontal(|ui| {
ui.label("Password:");
ui.add(widget::password::password(&mut input.password));
});
ui.add_enabled_ui(!promise_state.register_waiting, |ui| {
ui.horizontal(|ui| {
if ui.button("Back").clicked() {
*login_state = LoginState::LoggedOut;
}
if ui.button("Register").clicked() {
promise_state.register_waiting = true;
let username = input.username.clone();
let email = input.email.clone();
let password = input.password.clone();
let api_addr = api_address.clone();
promise.register = Promise::spawn_async(async {
api::register(username, email, password, api_addr).await
});
input.password = String::new();
}
});
});
if promise_state.register_waiting {
// Repaint
ctx.request_repaint();
if let Some(result) = promise.register.ready() {
if result.success {
popup_details.visible = true;
popup_details.title = "Successfully registered!".to_owned();
popup_details.message = result.message.clone();
} else {
popup_details.visible = true;
popup_details.title = "Registration failed".to_owned();
popup_details.message = result.message.clone();
}
promise_state.register_waiting = false;
}
}
}
});
}
if popup_details.visible {
egui::Window::new(popup_details.title.to_string()).show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.label(popup_details.message.to_string());
if ui.button("Ok").clicked() {
popup_details.visible = false;
}
});
});
}
}
}