Compare commits

...

8 commits

Author SHA1 Message Date
jane400
5fc628a54d fix: paket: visual cleanups 2024-09-19 21:58:25 +02:00
jane400
cf986694eb feat: paket: logout 2024-09-19 21:43:35 +02:00
jane400
4d0f8f57ce feat: libpaket: openid: logout and revoke 2024-09-19 21:42:11 +02:00
jane400
31698154e0 feat: paket: account page 2024-09-19 20:17:51 +02:00
jane400
b9fb7fcea4 fix: paket: adjust viewswitcher in title 2024-09-19 18:00:16 +02:00
jane400
87befa2f13 feat: paket: refactor views and cleanups 2024-09-18 17:07:51 +02:00
jane400
d122cfc065 fix: libpaket: add more headers to tracking search 2024-09-18 17:05:27 +02:00
jane400
ff66184471 chore: libpaket: rename CustomerDataService 2024-09-17 20:21:50 +02:00
14 changed files with 877 additions and 490 deletions

View file

@ -1,3 +1,3 @@
app_id = "de.j4ne.Paket" app_id = "de.j4ne.Paket"
icons = ["plus", "minus", "package-x-generic", "mail"] icons = ["plus", "minus", "package-x-generic", "mail", "loupe-large", "person", "copy"]

View file

@ -1,16 +1,22 @@
use super::utils::CodeVerfier;
use super::dhl_claims::DHLClaimsOptional; use super::dhl_claims::DHLClaimsOptional;
use super::utils::CodeVerfier;
use serde::Serialize; use serde::Serialize;
pub fn client_id() -> &'static str { pub fn client_id() -> &'static str {
"42ec7de4-e357-4c5d-aa63-f6aae5ca4d8f" "42ec7de4-e357-4c5d-aa63-f6aae5ca4d8f"
} }
pub fn redirect_uri() -> &'static str { pub fn redirect_uri_login() -> &'static str {
"dhllogin://de.dhl.paket/login" "dhllogin://de.dhl.paket/login"
} }
pub fn redirect_uri_logout() -> &'static str {
"dhllogout://de.dhl.paket/logout"
}
pub mod token { pub mod token {
use crate::login::DHLIdToken;
use super::*; use super::*;
pub fn refresh_token_form(refresh_token: &str) -> Vec<(&str, &str)> { pub fn refresh_token_form(refresh_token: &str) -> Vec<(&str, &str)> {
@ -28,12 +34,21 @@ pub mod token {
vec![ vec![
("code".to_string(), authorization_code), ("code".to_string(), authorization_code),
("grant_type".to_string(), "authorization_code".to_string()), ("grant_type".to_string(), "authorization_code".to_string()),
("redirect_uri".to_string(), redirect_uri().to_string()), ("redirect_uri".to_string(), redirect_uri_login().to_string()),
("code_verifier".to_string(), code_verfier.code_verfier()), ("code_verifier".to_string(), code_verfier.code_verfier()),
("client_id".to_string(), client_id().to_string()), ("client_id".to_string(), client_id().to_string()),
] ]
} }
pub fn revoke_form(
token: String
) -> Vec<(String, String)> {
vec![
("token".into(), token),
("client_id".into(), client_id().into()),
]
}
pub fn user_agent() -> &'static str { pub fn user_agent() -> &'static str {
"Dalvik/2.1.0 (Linux; U; Android 11; OnePlus 6T Build/RQ3A.211001.001)" "Dalvik/2.1.0 (Linux; U; Android 11; OnePlus 6T Build/RQ3A.211001.001)"
} }
@ -42,6 +57,9 @@ pub mod token {
"https://login.dhl.de/af5f9bb6-27ad-4af4-9445-008e7a5cddb8/login/token" "https://login.dhl.de/af5f9bb6-27ad-4af4-9445-008e7a5cddb8/login/token"
} }
pub fn endpoint_revoke() -> &'static str {
"https://login.dhl.de/af5f9bb6-27ad-4af4-9445-008e7a5cddb8/login/token/revoke"
}
pub fn headers() -> reqwest::header::HeaderMap { pub fn headers() -> reqwest::header::HeaderMap {
let aaa = vec![ let aaa = vec![
@ -62,9 +80,33 @@ pub mod token {
} }
} }
pub mod logout {
use crate::{constants::web_user_agent, login::{
constants::{client_id, redirect_uri_logout},
DHLIdToken,
}};
pub fn form(id_token: &DHLIdToken) -> Vec<(String, String)> {
vec![
("id_token_hint".into(), id_token.to_string()),
("state".into(), "esnGubTtYjK5ImleO84prQ".into()),
("client_id".into(), client_id().into()),
("redirect_uri".into(), redirect_uri_logout().into()),
]
}
pub fn endpoint() -> &'static str {
"https://login.dhl.de/af5f9bb6-27ad-4af4-9445-008e7a5cddb8/auth-ui/logout"
}
pub fn user_agent() -> String {
web_user_agent()
}
}
pub mod webbrowser_authorize { pub mod webbrowser_authorize {
use crate::constants::web_user_agent;
use super::*; use super::*;
use crate::constants::web_user_agent;
pub fn user_agent() -> String { pub fn user_agent() -> String {
web_user_agent() web_user_agent()
@ -112,7 +154,7 @@ pub mod webbrowser_authorize {
pub fn authorize_query(nonce: &String, code_verfier: &CodeVerfier) -> Vec<(String, String)> { pub fn authorize_query(nonce: &String, code_verfier: &CodeVerfier) -> Vec<(String, String)> {
vec![ vec![
("redirect_uri".to_string(), redirect_uri().to_string()), ("redirect_uri".to_string(), redirect_uri_login().to_string()),
("client_id".to_string(), client_id().to_string()), ("client_id".to_string(), client_id().to_string()),
("response_type".to_string(), "code".to_string()), ("response_type".to_string(), "code".to_string()),
("prompt".to_string(), "login".to_string()), ("prompt".to_string(), "login".to_string()),

View file

@ -4,12 +4,14 @@ mod openid_response;
pub mod openid_token; pub mod openid_token;
mod utils; mod utils;
use constants::redirect_uri_logout;
pub use self::dhl_claims::{DHLCs, DHLIdToken}; pub use self::dhl_claims::{DHLCs, DHLIdToken};
pub use self::openid_response::{RefreshToken, TokenResponse}; pub use self::openid_response::{RefreshToken, TokenResponse};
pub use self::utils::{create_nonce, CodeVerfier}; pub use self::utils::{create_nonce, CodeVerfier};
use super::common::APIResult; use super::common::APIResult;
use crate::LibraryResult; use crate::{LibraryError, LibraryResult};
pub struct OpenIdClient { pub struct OpenIdClient {
client: reqwest::Client, client: reqwest::Client,
@ -67,4 +69,43 @@ impl OpenIdClient {
Ok(parse_json_response_from_apiresult!(res, TokenResponse)) Ok(parse_json_response_from_apiresult!(res, TokenResponse))
} }
// TODO: Unauthorized not correct error response
pub async fn logout(&self, dhli_token: &DHLIdToken) -> LibraryResult<()> {
let req = self
.client
.get(constants::logout::endpoint())
.form(constants::logout::form(dhli_token).as_slice())
.header("Host", "login.dhl.de")
.header("User-Agent", constants::logout::user_agent())
.build()
.unwrap();
let res = self.client.execute(req).await;
let res = parse_response_internal!(res);
if let Some(value) = res.headers().get("Location") {
if value.to_str().unwrap().starts_with(redirect_uri_logout()) {
Ok(())
} else {
Err(crate::LibraryError::Unauthorized)
}
} else {
Err(crate::LibraryError::Unauthorized)
}
}
// TODO: Unauthorized not correct error response
pub async fn revoke(&self, refresh_token: &RefreshToken) -> LibraryResult<()> {
let res = request_post!(self.client,
(constants::token::endpoint()),
.form(constants::token::revoke_form(refresh_token.to_string()).as_slice())
);
if res.status() == 200 {
Ok(())
} else {
Err(LibraryError::Unauthorized)
}
}
} }

View file

@ -127,7 +127,7 @@ pub enum CustomerDataService {
#[serde(rename = "POSTFILIALE_DIREKT")] #[serde(rename = "POSTFILIALE_DIREKT")]
PostfilialeDirekt, PostfilialeDirekt,
#[serde(rename = "DIGIBEN")] #[serde(rename = "DIGIBEN")]
Digiben, DigitaleBenachrichtigung,
#[serde(rename = "GERAET_AKTIVIERT")] #[serde(rename = "GERAET_AKTIVIERT")]
GeraetAktiviert, GeraetAktiviert,
#[serde(rename = "BRIEFANKUENDIGUNG")] #[serde(rename = "BRIEFANKUENDIGUNG")]

View file

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{login::DHLIdToken, LibraryError, LibraryResult}; use crate::{constants::web_user_agent, login::DHLIdToken, LibraryResult};
#[derive(Default)] #[derive(Default)]
pub struct TrackingParams { pub struct TrackingParams {
@ -19,30 +19,38 @@ impl crate::WebClient {
&self, &self,
params: TrackingParams, params: TrackingParams,
ids: Vec<String>, ids: Vec<String>,
dhli: Option<&DHLIdToken>, dhli: &DHLIdToken,
) -> LibraryResult<Vec<Shipment>> { ) -> LibraryResult<Vec<Shipment>> {
let api_key = crate::www::api_key_header(); let api_key = crate::www::api_key_header();
let res = if let Some(dhli) = dhli { let cookie_value = crate::utils::CookieHeaderValueBuilder::new()
let cookie_value = crate::utils::CookieHeaderValueBuilder::new() .add_dhli(&dhli)
.add_dhli(&dhli) .add_dhlcs(&dhli)
.add_dhlcs(&dhli) .build_string();
.build_string();
request!( let res = request!(
self.web_client, self.web_client,
endpoint_data_search, endpoint_data_search,
query(&query_parameters_data_search(&params, ids)), query(&query_parameters_data_search(&params, ids)),
header(api_key.0, api_key.1), header(api_key.0, api_key.1),
header("cookie", cookie_value), header("cookie", cookie_value),
header("x-requested-with", "de.dhl.paket"), header("accept-language", "de-DE;q=1.0"),
header("sec-ch-ua-platform", "\"Android\""), header("x-requested-with", "de.dhl.paket"),
header("sec-ch-ua", r#""Chromium";v="122", "Not(A:Brand";v="24", "Android WebView";v="122""#), header("sec-ch-ua-platform", "\"Android\""),
header("sec-ch-ua-mobile", "?1"), header(
header("content-type", "application/json"), "sec-ch-ua",
header("accept", "application/json") r#""Chromium";v="122", "Not(A:Brand";v="24", "Android WebView";v="122""#
) ),
} else { header("sec-ch-ua-mobile", "?1"),
return Err(LibraryError::InvalidArgument("only supported with a logged-in session".to_string())); header("accept", "application/json"),
}; header("content-type", "application/json"),
header(
"referer",
"https://www.dhl.de/int-static/pdapp/spa/prod/ver5-SPA-VERFOLGEN.html"
),
header("verfolgen-wg", "0"),
header("user-agent", web_user_agent()),
headers(crate::www::web_headers())
);
let resp = parse_json_response!(res, Response); let resp = parse_json_response!(res, Response);
resp.into() resp.into()
@ -418,7 +426,7 @@ fn query_parameters_data_search(
let mut out = vec![ let mut out = vec![
("noRedirect".to_string(), "true".to_string()), ("noRedirect".to_string(), "true".to_string()),
("cid".to_string(), "app".to_string()), ("cid".to_string(), "app".to_string()),
]; ];
if let Some(lang) = params.language.as_ref() { if let Some(lang) = params.language.as_ref() {
out.push(("language".to_string(), lang.clone())); out.push(("language".to_string(), lang.clone()));

View file

@ -12,7 +12,7 @@ relm4-components = { version = "0.9", git = "https://github.com/Relm4/Relm4.git"
relm4-macros = { version = "0.9", git = "https://github.com/Relm4/Relm4.git" } relm4-macros = { version = "0.9", git = "https://github.com/Relm4/Relm4.git" }
relm4-icons = { version = "0.9", git = "https://github.com/Relm4/icons.git" } relm4-icons = { version = "0.9", git = "https://github.com/Relm4/icons.git" }
tracker = "0.2" tracker = "0.2"
adw = {package = "libadwaita", version = "0.7", features = [ "v1_5" ]} adw = {package = "libadwaita", version = "0.7", features = [ "v1_6" ]}
webkit = { package = "webkit6", version = "0.4" } webkit = { package = "webkit6", version = "0.4" }
reqwest = "0.12" reqwest = "0.12"
libpaket = { path = "../libpaket" } libpaket = { path = "../libpaket" }

248
paket/src/account.rs Normal file
View file

@ -0,0 +1,248 @@
use adw::prelude::*;
use libpaket::{stammdaten::CustomerDataFull, LibraryError, LibraryResult};
use relm4::{Component, ComponentParts};
use crate::{send_log_out, LoginSharedState};
#[tracker::track]
pub struct AccountView {
logged_in: bool,
#[do_not_track]
login: LoginSharedState,
#[no_eq]
customer_data_full: Option<CustomerDataFull>,
}
// We have to handle both events, as we want to reset everything, if we log out.
#[derive(Debug)]
pub enum AccountCmd {
LoggedIn,
LoggedOut,
GotCustomerDataFull(LibraryResult<CustomerDataFull>),
}
#[derive(Debug)]
pub enum CopyTargets {
PostNumber,
}
#[derive(Debug)]
pub enum AccountInput {
Copy(CopyTargets),
LogOut,
}
#[derive(Debug)]
pub enum AccountServices {
Advices,
SendungVerfolgung,
}
#[derive(Debug)]
pub enum AccountOutput {
LoggedOut,
LoggedIn,
HaveService(AccountServices),
}
macro_rules! get_str_from_customer_data {
($model: ident, $id: ident) => {{
|| -> Option<String> {
let data = $model.customer_data_full.as_ref()?;
Some(data.common.$id.clone())
}()
}};
}
#[relm4::component(pub)]
impl Component for AccountView {
type Input = AccountInput;
type Output = AccountOutput;
type Init = LoginSharedState;
type CommandOutput = AccountCmd;
view! {
#[root]
adw::Bin {
#[wrap(Some)]
set_child = &gtk::ScrolledWindow {
set_propagate_natural_width: true,
#[wrap(Some)]
set_child = &adw::Clamp {
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_margin_start: 12,
set_margin_end: 12,
set_margin_top: 12,
set_margin_bottom: 12,
// General infos
append = &adw::PreferencesGroup {
#[track(model.changed_customer_data_full() && model.get_customer_data_full().is_some())]
set_title?: get_str_from_customer_data!(model, display_name).as_ref(),
#[wrap(Some)]
set_header_suffix = &gtk::Box {
add_css_class: relm4::css::LINKED,
append = &gtk::Button {
add_css_class: relm4::css::DESTRUCTIVE_ACTION,
set_label: "Log out",
connect_clicked => AccountInput::LogOut,
},
},
// Postnumber
add = &adw::ActionRow {
add_css_class: relm4::css::NUMERIC,
set_subtitle: "Postnummer",
#[track(model.changed_customer_data_full() && model.get_customer_data_full().is_some())]
set_title?: get_str_from_customer_data!(model, post_number).as_ref(),
add_suffix = &gtk::Button {
set_vexpand: false,
set_valign: gtk::Align::Center,
#[wrap(Some)]
set_child = &adw::ButtonContent {
set_icon_name: relm4_icons::icon_names::COPY,
set_label: "Copy",
},
connect_clicked => AccountInput::Copy(CopyTargets::PostNumber),
},
}
}
},
},
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: relm4::ComponentSender<Self>,
) -> relm4::ComponentParts<Self> {
let model = AccountView {
logged_in: false,
login: init,
tracker: 0,
customer_data_full: None,
};
{
let login = model.login.clone();
sender.command(move |out, shutdown| {
shutdown
.register(async move {
let login = { login.clone().as_ref().lock().await.clone() };
let (sender, receiver) = relm4::channel::<AccountCmd>();
login.subscribe(&sender, |model| match model {
Some(_) => AccountCmd::LoggedIn,
None => AccountCmd::LoggedOut,
});
loop {
out.send(receiver.recv().await.unwrap()).unwrap();
}
})
.drop_on_shutdown()
});
}
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(
&mut self,
message: Self::Input,
sender: relm4::ComponentSender<Self>,
root: &Self::Root,
) {
self.reset();
match message {
AccountInput::Copy(target) => {
let value = match target {
CopyTargets::PostNumber => {
get_str_from_customer_data!(self, post_number)
}
};
if let Some(value) = value {
let display = root.display();
let clipboard = display.clipboard();
clipboard.set_text(value.as_str());
}
}
AccountInput::LogOut => {
send_log_out();
}
};
}
fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: relm4::ComponentSender<Self>,
root: &Self::Root,
) {
self.reset();
match message {
AccountCmd::LoggedIn => {
self.set_logged_in(true);
}
AccountCmd::LoggedOut => {
self.set_logged_in(false);
}
AccountCmd::GotCustomerDataFull(data) => match data {
Ok(data) => {
for service in &data.common.services {
match service {
libpaket::stammdaten::CustomerDataService::Packstation => (),
libpaket::stammdaten::CustomerDataService::Paketankuendigung => sender
.output(AccountOutput::HaveService(
AccountServices::SendungVerfolgung,
))
.unwrap(),
libpaket::stammdaten::CustomerDataService::Briefankuendigung => sender
.output(AccountOutput::HaveService(AccountServices::Advices))
.unwrap(),
_ => (),
}
}
self.set_customer_data_full(Some(data));
}
Err(err) => todo!(),
},
}
if self.changed_logged_in() {
if self.logged_in {
sender.output(AccountOutput::LoggedIn).unwrap();
let token = self.login.clone();
sender.oneshot_command(async move {
let token = crate::login::get_id_token(&token).await.unwrap();
let client = libpaket::StammdatenClient::new();
let mut res: LibraryResult<CustomerDataFull> = Err(LibraryError::NetworkFetch);
while res.is_err() && *res.as_ref().err().unwrap() == LibraryError::NetworkFetch
{
res = client.customer_data_full(&token).await;
}
AccountCmd::GotCustomerDataFull(res)
});
} else {
sender.output(AccountOutput::LoggedOut).unwrap();
}
}
}
}

View file

@ -1,5 +1,5 @@
use libpaket::LibraryError; use libpaket::LibraryError;
use relm4::{adw, gtk, gtk::gdk, gtk::gio, gtk::glib}; use relm4::{adw, gtk, gtk::gdk, gtk::gio};
use adw::prelude::*; use adw::prelude::*;
use relm4::prelude::*; use relm4::prelude::*;

View file

@ -117,6 +117,7 @@ pub struct AdvicesView {
#[derive(Debug)] #[derive(Debug)]
pub enum AdvicesViewInput { pub enum AdvicesViewInput {
Fetch, Fetch,
Reset,
} }
#[derive(Debug)] #[derive(Debug)]
@ -197,7 +198,13 @@ impl AsyncComponent for AdvicesView {
sender: AsyncComponentSender<Self>, sender: AsyncComponentSender<Self>,
root: &Self::Root, root: &Self::Root,
) { ) {
self.reset();
match message { match message {
AdvicesViewInput::Reset => {
self.set_state(AdvicesViewState::Loading);
self.factory.guard().clear();
},
AdvicesViewInput::Fetch => { AdvicesViewInput::Fetch => {
self.set_state(AdvicesViewState::Loading); self.set_state(AdvicesViewState::Loading);

View file

@ -7,9 +7,9 @@ use relm4::{main_adw_application, prelude::*, AsyncComponentSender, RELM_THREADS
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
enum AppState { enum AppState {
Loading, Loading,
RequiresLogIn, RequiresLogin,
FatalError,
Ready, Ready,
Error,
} }
#[derive(Debug)] #[derive(Debug)]
@ -20,13 +20,11 @@ struct AppError {
#[derive(Debug)] #[derive(Debug)]
enum AppInput { enum AppInput {
ErrorOccoured(AppError), AddBreakpoint(adw::Breakpoint),
FatalErrorOccoured(AppError),
SwitchToLogin, SwitchToLogin,
SwitchToLoading, SwitchToLoading,
SwitchToReady, SwitchToReady,
NetworkFail, FatalErr(AppError),
Notification(String, u32),
} }
#[tracker::track] #[tracker::track]
@ -50,13 +48,6 @@ impl AsyncComponent for App {
view! { view! {
#[root] #[root]
main_window = adw::ApplicationWindow::new(&main_adw_application()) { main_window = adw::ApplicationWindow::new(&main_adw_application()) {
add_breakpoint = adw::Breakpoint::new(
adw::BreakpointCondition::new_length(adw::BreakpointConditionLengthType::MaxWidth, 550.0, adw::LengthUnit::Sp)
) {
add_setter: (&ready_headerbar, "show-title", Some(&glib::Value::from(false))),
add_setter: (&ready_switcherbar, "reveal", Some(&glib::Value::from(true)))
},
set_default_height: 600, set_default_height: 600,
set_default_width: 800, set_default_width: 800,
set_width_request: 300, set_width_request: 300,
@ -64,92 +55,35 @@ impl AsyncComponent for App {
#[wrap(Some)] #[wrap(Some)]
set_content = &adw::ViewStack { set_content = &adw::ViewStack {
#[name = "page_prepare"] #[name = "page_loading"]
add = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = prepare_toast_overlay = &adw::ToastOverlay {
#[wrap(Some)]
set_child = &adw::ViewStack {
#[name = "page_loading"]
add = &adw::Bin {
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
gtk::Spinner {
start: (),
}
}
},
/* Will be filled in init with a webkit */
#[local_ref]
add = page_login -> adw::Bin {},
#[name = "page_fatal"]
add = &adw::Bin {
#[wrap(Some)]
set_child = fatal_status_page = &adw::StatusPage {
},
},
#[track(model.changed(App::state()) && model.state != AppState::Ready)]
set_visible_child: {
let page: &adw::Bin = match model.state {
AppState::Loading => page_loading.as_ref(),
AppState::RequiresLogIn => page_login.as_ref(),
AppState::FatalError => page_fatal.as_ref(),
AppState::Ready => panic!(),
};
page
}
},
},
},
#[name = "page_ready"]
add = &adw::Bin { add = &adw::Bin {
#[wrap(Some)] #[wrap(Some)]
set_child = &adw::NavigationView { set_child = &adw::Spinner {}
add = &adw::NavigationPage {
#[wrap(Some)]
set_child = &adw::ToolbarView {
add_top_bar = ready_headerbar = &adw::HeaderBar {
#[wrap(Some)]
set_title_widget = ready_switchertop = &adw::ViewSwitcher{
set_stack: Some(ready_view_stack),
}
},
#[wrap(Some)]
set_content = ready_toast_overlay = &adw::ToastOverlay {
set_child: Some(ready_view_stack),
},
add_bottom_bar = ready_switcherbar = &adw::ViewSwitcherBar {
set_stack: Some(ready_view_stack),
}
}
},
}
}, },
#[track(model.changed(App::state()) && model.state == AppState::Ready)] #[local_ref]
add = page_login -> adw::Bin {},
#[local_ref]
add = page_ready -> adw::Bin {},
#[name = "page_error"]
add = &adw::Bin {
#[name = "page_error_status"]
#[wrap(Some)]
set_child = &adw::StatusPage {}
},
#[track(model.changed(App::state()))]
set_visible_child: { set_visible_child: {
let page: &adw::Bin = page_ready.as_ref(); let page: &adw::Bin = match model.state {
AppState::Loading => page_loading.as_ref(),
AppState::RequiresLogin => page_login.as_ref(),
AppState::Ready => page_ready.as_ref(),
AppState::Error => page_error.as_ref(),
};
page page
}, }
#[track(model.changed(App::state()) && model.state != AppState::Ready)]
set_visible_child: {
let page: &adw::ToolbarView = page_prepare.as_ref();
page
},
} }
}, },
@ -167,7 +101,7 @@ impl AsyncComponent for App {
.forward(sender.input_sender(), convert_ready_response); .forward(sender.input_sender(), convert_ready_response);
let login = Login::builder() let login = Login::builder()
.launch(login_shared_state.clone()) .launch_with_broker(login_shared_state.clone(), &paket::LOGIN_BROKER)
.forward(sender.input_sender(), convert_login_response); .forward(sender.input_sender(), convert_login_response);
let model = App { let model = App {
@ -178,8 +112,8 @@ impl AsyncComponent for App {
tracker: 0, tracker: 0,
}; };
let ready_view_stack = model.ready.widget();
let page_login = model.login.widget(); let page_login = model.login.widget();
let page_ready = model.ready.widget();
let widgets = view_output!(); let widgets = view_output!();
@ -195,57 +129,23 @@ impl AsyncComponent for App {
) -> Self::Output { ) -> Self::Output {
self.reset(); self.reset();
match message { match message {
AppInput::ErrorOccoured(error) => { AppInput::AddBreakpoint(breakpoint) => {
let dialog: adw::AlertDialog = adw::AlertDialog::builder() root.add_breakpoint(breakpoint);
.title(error.short) },
.body(error.long)
.build();
dialog.present(Some(root));
}
AppInput::SwitchToLoading => { AppInput::SwitchToLoading => {
self.set_state(AppState::Loading); self.set_state(AppState::Loading);
} }
AppInput::SwitchToLogin => { AppInput::SwitchToLogin => {
self.set_state(AppState::RequiresLogIn); self.set_state(AppState::RequiresLogin);
}
AppInput::NetworkFail => {
self.set__network_fail(true);
if self.changed__network_fail() {
sender.input(AppInput::Notification(
"The internet connection is unstable.".to_string(),
10,
));
}
}
AppInput::Notification(notification, timeout) => {
let toast_overlay = match self.state {
AppState::Loading => &widgets.prepare_toast_overlay,
AppState::RequiresLogIn => &widgets.prepare_toast_overlay,
AppState::Ready => &widgets.ready_toast_overlay,
AppState::FatalError => &widgets.prepare_toast_overlay,
};
toast_overlay.add_toast(
adw::Toast::builder()
.title(notification.as_str())
.timeout(timeout)
.build(),
);
}
AppInput::FatalErrorOccoured(error) => {
widgets.fatal_status_page.set_title(&error.short);
widgets.fatal_status_page.set_description(Some(
format!(
"{}\nThis error is fatal, the app can't continue.",
&error.long
)
.as_str(),
));
self.set_state(AppState::FatalError);
} }
AppInput::SwitchToReady => { AppInput::SwitchToReady => {
self.set_state(AppState::Ready); self.set_state(AppState::Ready);
} }
AppInput::FatalErr(err) => {
widgets.page_error_status.set_title(&err.short);
widgets.page_error_status.set_description(Some(&err.long));
self.set_state(AppState::Error);
}
} }
self.update_view(widgets, sender); self.update_view(widgets, sender);
} }
@ -255,31 +155,15 @@ fn convert_login_response(response: LoginOutput) -> AppInput {
match response { match response {
LoginOutput::RequiresLogin => AppInput::SwitchToLogin, LoginOutput::RequiresLogin => AppInput::SwitchToLogin,
LoginOutput::RequiresLoading => AppInput::SwitchToLoading, LoginOutput::RequiresLoading => AppInput::SwitchToLoading,
LoginOutput::Error(err) => AppInput::ErrorOccoured(AppError { LoginOutput::Error(library_error) => AppInput::FatalErr(AppError { short: "Unhandled API error".to_string(), long: library_error.to_string() }),
short: "An authorization error occured :(".to_string(), LoginOutput::KeyringError(error) => AppInput::FatalErr(AppError { short: "Keyring usage failed".to_string(), long: error.to_string() }),
long: err.to_string(),
}),
LoginOutput::NetworkFail => AppInput::NetworkFail,
LoginOutput::KeyringError(err) => AppInput::FatalErrorOccoured(AppError {
short: "Unable to operate on the keyring :(".to_string(),
long: err.to_string(),
}),
} }
} }
fn convert_ready_response(response: ReadyOutput) -> AppInput { fn convert_ready_response(response: ReadyOutput) -> AppInput {
match response { match response {
ReadyOutput::FatalError(err) => AppInput::FatalErrorOccoured(AppError {
short: "Unexpted error occured.".to_string(),
long: err.to_string(),
}),
ReadyOutput::NoServicesEnabled => AppInput::FatalErrorOccoured(AppError {
short: "You can't use this app".to_string(),
long: "There is no feature on your account which is supported by this app. You need the offical app and register for one or more of:\n\"Briefasnkündigung\"".to_string(),
}),
ReadyOutput::Error(err) => AppInput::ErrorOccoured(AppError { short: "meow".to_string(), long: err.to_string() }),
ReadyOutput::Notification(value) => AppInput::Notification(value, 60),
ReadyOutput::Ready => AppInput::SwitchToReady, ReadyOutput::Ready => AppInput::SwitchToReady,
ReadyOutput::AddBreakpoint(breakpoint) => AppInput::AddBreakpoint(breakpoint),
} }
} }

View file

@ -1,3 +1,4 @@
pub mod account;
pub mod advice; pub mod advice;
pub mod advices; pub mod advices;
pub mod constants; pub mod constants;
@ -6,3 +7,8 @@ pub mod ready;
pub mod tracking; pub mod tracking;
pub use login::LoginSharedState; pub use login::LoginSharedState;
pub static LOGIN_BROKER: relm4::MessageBroker<login::LoginInput> = relm4::MessageBroker::new();
pub fn send_log_out() {
LOGIN_BROKER.send(login::LoginInput::LogOut);
}

View file

@ -21,6 +21,7 @@ static KEYRING: OnceLock<oo7::Keyring> = OnceLock::new();
#[derive(Debug)] #[derive(Debug)]
pub enum LoginInput { pub enum LoginInput {
LogOut,
NeedsLogin, NeedsLogin,
NeedsRefresh, NeedsRefresh,
ReceivedAuthCode(String), ReceivedAuthCode(String),
@ -66,7 +67,6 @@ pub struct Login {
pub enum LoginOutput { pub enum LoginOutput {
RequiresLogin, RequiresLogin,
RequiresLoading, RequiresLoading,
NetworkFail,
Error(libpaket::LibraryError), Error(libpaket::LibraryError),
KeyringError(oo7::Error), KeyringError(oo7::Error),
} }
@ -195,6 +195,41 @@ impl AsyncComponent for Login {
self.reset(); self.reset();
match message { match message {
LoginInput::LogOut => {
sender.input(LoginInput::NeedsLogin);
{
let token = get_id_token(&self.shared_id_token).await;
if let Some(token) = token {
sender.command(|_, shutdown| {
shutdown
.register(async move {
let client = OpenIdClient::new();
let _ = client.logout(&token).await;
})
.drop_on_shutdown()
});
}
}
{
let token = self.shared_id_token.lock().await;
*token.write() = None;
}
if let Some(refresh_token) = self.refresh_token.clone() {
sender.command(|out, shutdown| {
shutdown
.register(async move {
let client = OpenIdClient::new();
let _ = client.revoke(&refresh_token).await;
})
.drop_on_shutdown()
});
}
self.refresh_token = None;
let keyring = KEYRING.get().unwrap();
let _ = keyring
.delete(&HashMap::from([("app", crate::constants::APP_ID)]))
.await;
}
LoginInput::NeedsRefresh => { LoginInput::NeedsRefresh => {
let refresh_token = self.refresh_token.as_ref().unwrap().clone(); let refresh_token = self.refresh_token.as_ref().unwrap().clone();
sender.oneshot_command(async { sender.oneshot_command(async {
@ -322,9 +357,7 @@ impl Login {
libpaket::LibraryError::InvalidArgument(_) => { libpaket::LibraryError::InvalidArgument(_) => {
panic!("{}", res); panic!("{}", res);
} }
libpaket::LibraryError::NetworkFetch => { libpaket::LibraryError::NetworkFetch => {}
sender.output(LoginOutput::NetworkFail).unwrap();
}
libpaket::LibraryError::DecodeError(_) => { libpaket::LibraryError::DecodeError(_) => {
sender.output(LoginOutput::Error(res)).unwrap(); sender.output(LoginOutput::Error(res)).unwrap();
} }
@ -346,7 +379,7 @@ macro_rules! received {
async fn $func_name$args -> LoginCommand { async fn $func_name$args -> LoginCommand {
let client = OpenIdClient::new(); let client = OpenIdClient::new();
let mut err = LibraryError::NetworkFetch; let mut err = LibraryError::NetworkFetch;
for _ in 0..6 { while err == LibraryError::NetworkFetch {
let result: Result<TokenResponse, LibraryError> = client let result: Result<TokenResponse, LibraryError> = client
.$calling$calling_args .$calling$calling_args
.await; .await;

View file

@ -1,57 +1,46 @@
use adw::prelude::*; use adw::prelude::*;
use libpaket::{ use relm4::prelude::*;
self,
tracking::{Shipment, TrackingParams},
LibraryError, LibraryResult,
};
use relm4::{adw, factory::FactoryHashMap, prelude::*};
use crate::advices::{AdvicesView, AdvicesViewInput}; use crate::{
account::{AccountOutput, AccountServices, AccountView},
advices::{AdvicesView, AdvicesViewInput},
tracking::{TrackingInput, TrackingOutput, TrackingView},
};
#[tracker::track] #[tracker::track]
pub struct Ready { pub struct Ready {
#[do_not_track] logged_in: bool,
login: crate::LoginSharedState,
activate: bool,
have_service_advices: bool, have_service_advices: bool,
have_service_tracking: bool, have_service_tracking: bool,
#[do_not_track]
account_component: Controller<AccountView>,
#[do_not_track] #[do_not_track]
advices_component: AsyncController<AdvicesView>, advices_component: AsyncController<AdvicesView>,
#[do_not_track] #[do_not_track]
tracking_factory: FactoryHashMap<String, crate::tracking::ShipmentView>, tracking_component: Controller<TrackingView>,
#[do_not_track]
toast_overlay: adw::ToastOverlay,
} }
#[derive(Debug)] #[derive(Debug)]
pub enum ReadyOutput { pub enum ReadyOutput {
Ready, Ready,
Error(LibraryError), AddBreakpoint(adw::Breakpoint),
FatalError(LibraryError),
Notification(String),
NoServicesEnabled,
} }
#[derive(Debug)] #[derive(Debug)]
pub enum ReadyCmds { pub enum ReadyCmds {
LoggedIn, LoggedIn,
LoggedOut, LoggedOut,
GotCustomerDataFull(LibraryResult<libpaket::stammdaten::CustomerDataFull>),
GotTracking(LibraryResult<Vec<Shipment>>),
}
#[derive(Debug)]
pub enum Services {
Advices,
SendungVerfolgung,
} }
#[derive(Debug)] #[derive(Debug)]
pub enum ReadyInput { pub enum ReadyInput {
Activate, LoggedIn,
Deactivate, LoggedOut,
HaveService(Services), HaveService(AccountServices),
ServiceBorked(Services), ServiceBorked(AccountServices),
SearchTracking(String),
} }
#[relm4::component(pub)] #[relm4::component(pub)]
@ -63,61 +52,73 @@ impl Component for Ready {
view! { view! {
#[root] #[root]
adw::ViewStack { adw::Bin {
add = &model.advices_component.widget().clone() -> gtk::ScrolledWindow { #[wrap(Some)]
#[track(model.changed_have_service_advices())] set_child = &adw::NavigationView {
set_visible: model.have_service_advices, add = &adw::NavigationPage {
set_title: "",
} -> page_advices: adw::ViewStackPage {
set_title: Some("Mail notification"),
set_name: Some("page_advices"),
set_icon_name: Some(relm4_icons::icon_names::MAIL),
#[track(model.changed_have_service_advices())]
set_visible: model.have_service_advices,
},
add = &adw::Bin {
#[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking,
#[wrap(Some)]
set_child = &gtk::ScrolledWindow {
#[wrap(Some)] #[wrap(Some)]
set_child = &gtk::Box { set_child = &adw::ToolbarView {
set_orientation: gtk::Orientation::Vertical, add_top_bar = ready_headerbar = &adw::HeaderBar {
#[wrap(Some)]
set_title_widget = ready_switchertop = &adw::ViewSwitcher{
set_policy: adw::ViewSwitcherPolicy::Wide,
set_stack: Some(&ready_view_stack),
}
},
gtk::Box { #[wrap(Some)]
set_orientation: gtk::Orientation::Horizontal, set_content = &model.toast_overlay.clone() -> adw::ToastOverlay {
set_margin_all: 8, #[wrap(Some)]
add_css_class: relm4::css::TOOLBAR, #[name = "ready_view_stack"]
set_child = &adw::ViewStack {
add = &model.advices_component.widget().clone() -> gtk::ScrolledWindow {
#[track(model.changed_have_service_advices())]
set_visible: model.have_service_advices,
#[name = "tracking_entry"] } -> page_advices: adw::ViewStackPage {
gtk::Entry { set_title: Some("Mail notification"),
set_input_hints: gtk::InputHints::PRIVATE, set_name: Some("page_advices"),
set_hexpand: true, set_icon_name: Some(relm4_icons::icon_names::MAIL),
#[track(model.changed_have_service_advices())]
set_visible: model.have_service_advices,
},
add = &model.tracking_component.widget().clone() -> adw::ToastOverlay {
#[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking,
} -> page_tracking: adw::ViewStackPage {
set_title: Some("Shipment tracking"),
set_name: Some("page_tracking"),
set_icon_name: Some(relm4_icons::icon_names::PACKAGE_X_GENERIC),
#[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking,
},
add = &model.account_component.widget().clone() -> adw::Bin {
} -> page_account: adw::ViewStackPage {
set_title: Some("Account"),
set_name: Some("page_account"),
set_icon_name: Some(relm4_icons::icon_names::PERSON)
}
}, },
#[name = "tracking_entry_button"]
gtk::Button {}
}, },
#[local_ref] add_bottom_bar = ready_switcherbar = &adw::ViewSwitcherBar {
tracking_box -> gtk::Box { set_stack: Some(&ready_view_stack),
set_spacing: 8 }
},
} }
} },
} -> page_tracking: adw::ViewStackPage { }
set_title: Some("Shipment tracking"), },
set_name: Some("page_tracking"),
set_icon_name: Some(relm4_icons::icon_names::PACKAGE_X_GENERIC),
#[track(model.changed_have_service_tracking())]
set_visible: model.have_service_tracking,
},
}
} }
fn init( fn init(
@ -125,55 +126,52 @@ impl Component for Ready {
root: Self::Root, root: Self::Root,
sender: ComponentSender<Self>, sender: ComponentSender<Self>,
) -> ComponentParts<Self> { ) -> ComponentParts<Self> {
let tracking_factory = FactoryHashMap::builder().launch_default().detach();
let advices_component = AdvicesView::builder().launch(init.clone()).detach(); let advices_component = AdvicesView::builder().launch(init.clone()).detach();
let tracking_component = TrackingView::builder()
.launch(init.clone())
.forward(&sender.input_sender(), convert_tracking_output);
let account_component = AccountView::builder()
.launch(init.clone())
.forward(&sender.input_sender(), convert_account_output);
let toast_overlay = adw::ToastOverlay::new();
let model = Ready { let model = Ready {
have_service_advices: false, have_service_advices: false,
have_service_tracking: true, have_service_tracking: true,
login: init.clone(), logged_in: false,
activate: false,
tracking_factory, account_component,
advices_component, advices_component,
tracking_component,
toast_overlay,
tracker: 0, tracker: 0,
}; };
let tracking_box = model.tracking_factory.widget(); let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length(
adw::BreakpointConditionLengthType::MaxWidth,
550.0,
adw::LengthUnit::Sp,
));
let widgets = view_output!(); let widgets = view_output!();
{
let login = model.login.clone(); breakpoint.add_setter(
sender.command(move |out, shutdown| { widgets.ready_headerbar.widget_ref(),
shutdown "show-title",
.register(async move { Some(&gtk::glib::Value::from(false)),
let login = { login.clone().as_ref().lock().await.clone() }; );
let (sender, receiver) = relm4::channel::<ReadyCmds>(); breakpoint.add_setter(
login.subscribe(&sender, |model| match model { widgets.ready_switcherbar.widget_ref(),
Some(_) => ReadyCmds::LoggedIn, "reveal",
None => ReadyCmds::LoggedOut, Some(&gtk::glib::Value::from(true)),
}); );
loop { sender
out.send(receiver.recv().await.unwrap()).unwrap(); .output(ReadyOutput::AddBreakpoint(breakpoint))
} .unwrap();
})
.drop_on_shutdown()
});
}
{
let sender = sender.clone();
widgets.tracking_entry.connect_activate(move |entry| {
sender.input(ReadyInput::SearchTracking(entry.text().into()));
entry.set_text("");
});
}
{
let sender = sender.clone();
let entry = widgets.tracking_entry.clone();
widgets.tracking_entry_button.connect_clicked(move |_| {
sender.input(ReadyInput::SearchTracking(entry.text().into()));
entry.set_text("");
});
}
ComponentParts { model, widgets } ComponentParts { model, widgets }
} }
@ -182,167 +180,65 @@ impl Component for Ready {
self.reset(); self.reset();
match message { match message {
ReadyInput::Activate => { ReadyInput::LoggedIn => {
self.set_activate(true); self.set_logged_in(true);
if self.changed_activate() {
let token = self.login.clone();
sender.oneshot_command(async move {
let token = crate::login::get_id_token(&token).await.unwrap();
let client = libpaket::StammdatenClient::new();
ReadyCmds::GotCustomerDataFull(client.customer_data_full(&token).await)
});
}
} }
ReadyInput::SearchTracking(value) => { ReadyInput::LoggedOut => {
sender.oneshot_command(async move { self.set_logged_in(false);
let client = libpaket::WebClient::new();
let mut vec = Vec::new();
vec.push(value);
ReadyCmds::GotTracking(
client
.tracking_search(
TrackingParams {
language: Some("de".to_string()),
},
vec,
None,
)
.await,
)
});
}
ReadyInput::Deactivate => {
self.set_activate(false);
} }
ReadyInput::HaveService(service) => match service { ReadyInput::HaveService(service) => match service {
Services::Advices => { AccountServices::Advices => {
self.set_have_service_advices(true); self.set_have_service_advices(true);
self.advices_component.emit(AdvicesViewInput::Fetch); self.advices_component.emit(AdvicesViewInput::Fetch);
} }
Services::SendungVerfolgung => { AccountServices::SendungVerfolgung => {
self.set_have_service_tracking(true); self.set_have_service_tracking(true);
let token = self.login.clone(); self.tracking_component.emit(TrackingInput::Search(None))
sender.oneshot_command(async move {
// fetching advices
let dhli_token = crate::login::get_id_token(&token).await.unwrap();
let client = libpaket::WebClient::new();
ReadyCmds::GotTracking(
client
.tracking_search(
TrackingParams {
language: Some("de".to_string()),
},
Vec::new(),
Some(&dhli_token),
)
.await,
)
});
} }
}, },
ReadyInput::ServiceBorked(service) => match service { ReadyInput::ServiceBorked(service) => match service {
Services::Advices => self.set_have_service_advices(false), AccountServices::Advices => {
Services::SendungVerfolgung => self.set_have_service_tracking(false), self.toast_overlay.add_toast(
adw::Toast::builder()
.title("Service borked: Mail notifications")
.timeout(30)
.build(),
);
self.set_have_service_advices(false);
}
AccountServices::SendungVerfolgung => {
self.toast_overlay.add_toast(
adw::Toast::builder()
.title("Service borked: Shipment tracking")
.timeout(30)
.build(),
);
self.set_have_service_tracking(false);
}
}, },
} };
}
fn update_cmd( if self.changed_logged_in() {
&mut self, if self.logged_in {
message: Self::CommandOutput, sender.output(ReadyOutput::Ready).unwrap();
sender: ComponentSender<Self>, } else {
_: &Self::Root, self.advices_component.emit(AdvicesViewInput::Reset);
) { self.tracking_component.emit(TrackingInput::Reset);
match message { }
ReadyCmds::LoggedIn => sender.input(ReadyInput::Activate),
ReadyCmds::LoggedOut => sender.input(ReadyInput::Deactivate),
ReadyCmds::GotCustomerDataFull(res) => match res {
Ok(res) => {
for service in &res.common.services {
match service {
libpaket::stammdaten::CustomerDataService::Packstation => (),
libpaket::stammdaten::CustomerDataService::Paketankuendigung => {
sender.input(ReadyInput::HaveService(Services::SendungVerfolgung))
}
libpaket::stammdaten::CustomerDataService::Briefankuendigung => {
sender.input(ReadyInput::HaveService(Services::Advices))
}
_ => (),
}
}
sender.output(ReadyOutput::Ready).unwrap()
}
Err(err) => sender.output(ReadyOutput::FatalError(err)).unwrap(),
},
ReadyCmds::GotTracking(res) => match res {
Ok(shipment_vec) => {
for item in shipment_vec {
if let Some(err) = item.error {
// TODO: gettext
if err.id_invalid {
sender
.output(ReadyOutput::Notification(format!(
"The id is invalid ({})",
item.id
)))
.unwrap();
} else if err.letter_not_found {
sender
.output(ReadyOutput::Notification(format!(
"The letter wasn't found ({})",
item.id
)))
.unwrap();
} else if err.id_not_searchable {
sender
.output(ReadyOutput::Notification(format!(
"The id is not searchable ({})",
item.id
)))
.unwrap();
} else if err.data_to_old {
sender
.output(ReadyOutput::Notification(format!(
"No data available with id ({}) (data expired)",
item.id
)))
.unwrap();
} else if err.not_from_dhl {
sender
.output(ReadyOutput::Notification(format!(
"The id is not from DHL ({})",
item.id
)))
.unwrap();
} else if err.no_data_available {
sender
.output(ReadyOutput::Notification(format!(
"No data available with id ({})",
item.id
)))
.unwrap();
}
} else {
self.tracking_factory.insert(item.id.clone(), item);
}
}
}
Err(err) => {
if err == LibraryError::APIChange {
println!("Upstream API for parcel tracking broke");
sender.input(ReadyInput::ServiceBorked(Services::SendungVerfolgung));
sender
.output(ReadyOutput::Notification(
"Shipment Tracking API has changed. Deactivating that service."
.to_string(),
))
.unwrap();
} else {
sender.output(ReadyOutput::Error(err)).unwrap();
}
}
},
} }
} }
} }
fn convert_tracking_output(value: TrackingOutput) -> ReadyInput {
match value {
TrackingOutput::Borked => ReadyInput::ServiceBorked(AccountServices::SendungVerfolgung),
}
}
fn convert_account_output(value: AccountOutput) -> ReadyInput {
match value {
AccountOutput::LoggedOut => ReadyInput::LoggedOut,
AccountOutput::LoggedIn => ReadyInput::LoggedIn,
AccountOutput::HaveService(service) => ReadyInput::HaveService(service),
}
}

View file

@ -1,10 +1,236 @@
use adw::prelude::*; use adw::prelude::*;
use libpaket::tracking::Shipment; use libpaket::tracking::{Shipment, TrackingParams};
use relm4::factory::FactoryComponent; use libpaket::{LibraryError, LibraryResult};
use relm4::factory::{FactoryComponent, FactoryHashMap};
use relm4::prelude::*; use relm4::prelude::*;
use relm4::{adw, gtk};
pub struct ShipmentView { use crate::login::get_id_token;
use crate::LoginSharedState;
pub struct TrackingView {
factory: FactoryHashMap<String, crate::tracking::ShipmentView>,
login: LoginSharedState,
}
#[derive(Debug)]
pub enum TrackingInput {
Search(Option<String>),
Notification(String),
Reset,
}
#[derive(Debug)]
pub enum TrackingCmds {
GotTracking(LibraryResult<Vec<Shipment>>),
}
#[derive(Debug)]
pub enum TrackingOutput {
Borked,
}
#[relm4::component(pub)]
impl Component for TrackingView {
type Init = LoginSharedState;
type Input = TrackingInput;
type Output = TrackingOutput;
type CommandOutput = TrackingCmds;
view! {
#[root]
adw::ToastOverlay {
#[wrap(Some)]
set_child = &gtk::ScrolledWindow {
set_propagate_natural_width: true,
#[wrap(Some)]
set_child = &adw::Clamp {
set_maximum_size: 1800,
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_margin_start: 6,
set_margin_end: 6,
set_margin_bottom: 12,
adw::Clamp {
#[wrap(Some)]
set_child = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_all: 2,
set_halign: gtk::Align::Center,
add_css_class: relm4::css::TOOLBAR,
#[name = "tracking_entry"]
gtk::Entry {
set_input_hints: gtk::InputHints::PRIVATE,
set_hexpand: true,
},
#[name = "tracking_entry_button"]
gtk::Button {
set_icon_name: relm4_icons::icon_names::LOUPE_LARGE
}
},
},
#[local_ref]
tracking_box -> gtk::Box {
set_spacing: 8
},
}
},
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let factory = FactoryHashMap::builder().launch_default().detach();
let model = TrackingView {
factory,
login: init,
};
let tracking_box = model.factory.widget();
let widgets = view_output!();
{
let sender = sender.clone();
widgets.tracking_entry.connect_activate(move |entry| {
sender.input(TrackingInput::Search(Some(entry.text().into())));
entry.set_text("");
});
}
{
let sender = sender.clone();
let entry = widgets.tracking_entry.clone();
widgets.tracking_entry_button.connect_clicked(move |_| {
sender.input(TrackingInput::Search(Some(entry.text().into())));
entry.set_text("");
});
}
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, root: &Self::Root) {
match message {
TrackingInput::Reset => {
self.factory.clear();
}
TrackingInput::Search(value) => {
// Some input validation
if let Some(value) = &value {
// https://www.pakete-verfolgen.de/dhl-sendungsnummer/
if value.len() < 8 {
sender.input(TrackingInput::Notification(
"The id is too short to be valid.".to_string(),
));
return;
}
}
let token = self.login.clone();
sender.oneshot_command(async move {
let token = get_id_token(&token).await.unwrap();
let client = libpaket::WebClient::new();
let mut vec = Vec::new();
if let Some(value) = value {
vec.push(value);
}
TrackingCmds::GotTracking(
client
.tracking_search(
TrackingParams {
language: Some("de".to_string()),
},
vec,
&token,
)
.await,
)
});
}
TrackingInput::Notification(value) => {
root.add_toast(adw::Toast::builder().timeout(15).title(value).build());
}
}
}
fn update_cmd(
&mut self,
message: Self::CommandOutput,
sender: ComponentSender<Self>,
root: &Self::Root,
) {
match message {
TrackingCmds::GotTracking(res) => match res {
Ok(shipment_vec) => {
for item in shipment_vec {
if let Some(err) = item.error {
// TODO: gettext
if err.id_invalid {
sender.input(TrackingInput::Notification(format!(
"The id is invalid ({})",
item.id
)));
} else if err.letter_not_found {
sender.input(TrackingInput::Notification(format!(
"The letter wasn't found ({})",
item.id
)));
} else if err.id_not_searchable {
sender.input(TrackingInput::Notification(format!(
"The id is not searchable ({})",
item.id
)));
} else if err.data_to_old {
sender.input(TrackingInput::Notification(format!(
"No data available with id ({}) (data expired)",
item.id
)));
} else if err.not_from_dhl {
sender.input(TrackingInput::Notification(format!(
"The id is not from DHL ({})",
item.id
)));
} else if err.no_data_available {
sender.input(TrackingInput::Notification(format!(
"No data available with id ({})",
item.id
)));
}
} else {
self.factory.insert(item.id.clone(), item);
}
}
}
Err(err) => {
if err == LibraryError::APIChange {
println!("Upstream API for parcel tracking broke");
sender.output(TrackingOutput::Borked).unwrap();
} else {
sender.input(TrackingInput::Notification(format!(
"Unknown Error: {}",
err.to_string()
)))
}
}
},
};
}
}
struct ShipmentView {
model: Shipment, model: Shipment,
// model abstraction // model abstraction
@ -14,7 +240,7 @@ pub struct ShipmentView {
expanded: bool, expanded: bool,
// workarounds // workarounds
list_box_history: gtk::ListBox, list_box_history: gtk::Box,
} }
#[derive(Debug)] #[derive(Debug)]
@ -22,7 +248,7 @@ pub enum ViewInput {
ToggleExpand, ToggleExpand,
} }
#[relm4::factory(pub)] #[relm4::factory]
impl FactoryComponent for ShipmentView { impl FactoryComponent for ShipmentView {
type CommandOutput = (); type CommandOutput = ();
type Init = Shipment; type Init = Shipment;
@ -38,15 +264,7 @@ impl FactoryComponent for ShipmentView {
set_hexpand: true, set_hexpand: true,
set_margin_all: 8, set_margin_all: 8,
set_orientation: gtk::Orientation::Vertical, set_orientation: gtk::Orientation::Vertical,
inline_css: "border-radius: 12px 12px 0px 0px",
gtk::ProgressBar {
add_css_class: relm4::css::OSD,
set_margin_start: 8,
set_margin_end: 8,
set_margin_bottom: 1,
set_fraction: f64::from(self.model.history.maximal_fortschritt) / f64::from(self.model.history.fortschritt),
},
// title box // title box
gtk::Box { gtk::Box {
@ -70,6 +288,7 @@ impl FactoryComponent for ShipmentView {
gtk::Label { gtk::Label {
add_css_class: relm4::css::HEADING, add_css_class: relm4::css::HEADING,
set_halign: gtk::Align::Start, set_halign: gtk::Align::Start,
set_wrap: true,
set_label: { set_label: {
// TODO: gettext // TODO: gettext
@ -106,27 +325,29 @@ impl FactoryComponent for ShipmentView {
} }
}, // title box end }, // title box end
gtk::ProgressBar {
add_css_class: relm4::css::OSD,
set_fraction: f64::from(self.model.history.maximal_fortschritt) / f64::from(self.model.history.fortschritt),
},
gtk::Revealer { gtk::Revealer {
#[watch] #[watch]
set_reveal_child: self.expanded, set_reveal_child: self.expanded,
#[wrap(Some)] #[wrap(Some)]
set_child = &gtk::Box { set_child = &gtk::Box {
set_margin_all: 8,
// history viewstack // history viewstack
adw::StatusPage { gtk::Label {
set_visible: !self.have_events, set_visible: !self.have_events,
set_title: "No events", set_label: "No events",
}, },
append = &self.list_box_history.clone() { append = &self.list_box_history.clone() {
set_visible: self.have_events, set_visible: self.have_events,
add_css_class: relm4::css::BOXED_LIST,
set_selection_mode: gtk::SelectionMode::None,
}, },
} }
@ -142,7 +363,10 @@ impl FactoryComponent for ShipmentView {
) -> Self { ) -> Self {
let have_events = init.history.events.as_ref().is_some_and(|a| a.len() > 0); let have_events = init.history.events.as_ref().is_some_and(|a| a.len() > 0);
let list_box_history = gtk::ListBox::new(); let list_box_history = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let _self = ShipmentView { let _self = ShipmentView {
have_events, have_events,
@ -169,8 +393,6 @@ impl FactoryComponent for ShipmentView {
let boxie = gtk::Box::builder() let boxie = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal) .orientation(gtk::Orientation::Horizontal)
.margin_start(8)
.margin_end(8)
.spacing(8) .spacing(8)
.build(); .build();