1use crate::{
2 theme::KonnektorenTheme,
3 ui::{
4 responsive::{ResponsiveFontSize, ResponsiveInfo, ResponsiveSpacing},
5 widgets::{ResponsiveText, ThemedButton},
6 },
7};
8use bevy::prelude::*;
9use bevy_egui::{
10 egui::{self, Color32, Widget},
11 EguiContextPass, EguiContexts,
12};
13use chrono::Utc;
14
15pub struct AboutPlugin;
17
18impl Plugin for AboutPlugin {
19 fn build(&self, app: &mut App) {
20 app.add_event::<AboutDismissed>()
21 .add_systems(Update, (check_about_config, handle_about_completion))
22 .add_systems(EguiContextPass, render_about_ui);
23 }
24}
25
26#[derive(Component, Clone)]
28pub struct AboutConfig {
29 pub app_title: String,
31 pub subtitle: String,
33 pub version: Option<String>,
35 pub description: String,
37 pub why_choose_us: Option<String>,
39 pub features: Vec<(String, String)>,
41 pub technologies: Vec<(String, String)>,
43 pub status_message: Option<(String, Color32)>, pub websites: Vec<WebsiteLink>,
47 pub copyright_holder: String,
49 pub manual_dismissal: bool,
51 pub extension_widget: Option<fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo)>,
53 pub custom_sections: Vec<CustomSection>,
55 pub dismiss_button_text: String,
57}
58
59#[derive(Clone)]
61pub struct WebsiteLink {
62 pub title: String,
63 pub description: String,
64 pub url: String,
65 pub icon: Option<String>, }
67
68#[derive(Clone)]
70pub struct CustomSection {
71 pub title: String,
72 pub renderer: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
73}
74
75impl Default for AboutConfig {
76 fn default() -> Self {
77 Self {
78 app_title: "Konnektoren".to_string(),
79 subtitle: "Educational Platform".to_string(),
80 version: Some("Beta".to_string()),
81 description: "Welcome to Konnektoren, an innovative educational platform designed to make learning engaging and effective.".to_string(),
82 why_choose_us: Some("We offer interactive experiences designed for different proficiency levels, making learning both fun and effective.".to_string()),
83 features: vec![
84 ("🎮 Interactive Experience".to_string(), "Engaging activities that make learning enjoyable".to_string()),
85 ("📊 Progress Tracking".to_string(), "Monitor your improvement and see your strengths".to_string()),
86 ("🏆 Achievements".to_string(), "Earn rewards as you master new concepts".to_string()),
87 ("📱 Cross-Platform".to_string(), "Access on desktop or in your web browser".to_string()),
88 ],
89 technologies: vec![
90 ("Rust".to_string(), "Safe, fast, and modern programming language".to_string()),
91 ("Bevy".to_string(), "Data-driven game engine".to_string()),
92 ("egui".to_string(), "Immediate mode GUI library".to_string()),
93 ("WebAssembly".to_string(), "For web browser support".to_string()),
94 ],
95 status_message: Some(("Currently in Beta - Thank you for being part of our journey!".to_string(), egui::Color32::from_rgb(255, 193, 7))),
96 websites: vec![
97 WebsiteLink {
98 title: "Konnektoren Web App".to_string(),
99 description: "Visit our main web application".to_string(),
100 url: "https://konnektoren.help".to_string(),
101 icon: Some("🌐".to_string()),
102 },
103 ],
104 copyright_holder: "Konnektoren".to_string(),
105 manual_dismissal: true,
106 extension_widget: None,
107 custom_sections: vec![],
108 dismiss_button_text: "← Back".to_string(),
109 }
110 }
111}
112
113impl AboutConfig {
114 pub fn new(app_title: impl Into<String>) -> Self {
115 Self {
116 app_title: app_title.into(),
117 ..Default::default()
118 }
119 }
120
121 pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
122 self.subtitle = subtitle.into();
123 self
124 }
125
126 pub fn with_version(mut self, version: impl Into<String>) -> Self {
127 self.version = Some(version.into());
128 self
129 }
130
131 pub fn with_description(mut self, description: impl Into<String>) -> Self {
132 self.description = description.into();
133 self
134 }
135
136 pub fn with_why_choose_us(mut self, text: impl Into<String>) -> Self {
137 self.why_choose_us = Some(text.into());
138 self
139 }
140
141 pub fn with_features(mut self, features: Vec<(String, String)>) -> Self {
142 self.features = features;
143 self
144 }
145
146 pub fn add_feature(mut self, title: impl Into<String>, description: impl Into<String>) -> Self {
147 self.features.push((title.into(), description.into()));
148 self
149 }
150
151 pub fn with_technologies(mut self, technologies: Vec<(String, String)>) -> Self {
152 self.technologies = technologies;
153 self
154 }
155
156 pub fn add_technology(
157 mut self,
158 name: impl Into<String>,
159 description: impl Into<String>,
160 ) -> Self {
161 self.technologies.push((name.into(), description.into()));
162 self
163 }
164
165 pub fn with_status_message(mut self, message: impl Into<String>, color: egui::Color32) -> Self {
166 self.status_message = Some((message.into(), color));
167 self
168 }
169
170 pub fn with_websites(mut self, websites: Vec<WebsiteLink>) -> Self {
171 self.websites = websites;
172 self
173 }
174
175 pub fn add_website(
176 mut self,
177 title: impl Into<String>,
178 description: impl Into<String>,
179 url: impl Into<String>,
180 ) -> Self {
181 self.websites.push(WebsiteLink {
182 title: title.into(),
183 description: description.into(),
184 url: url.into(),
185 icon: Some("🌐".to_string()),
186 });
187 self
188 }
189
190 pub fn with_copyright_holder(mut self, holder: impl Into<String>) -> Self {
191 self.copyright_holder = holder.into();
192 self
193 }
194
195 pub fn with_extension_widget(
196 mut self,
197 widget: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
198 ) -> Self {
199 self.extension_widget = Some(widget);
200 self
201 }
202
203 pub fn add_custom_section(
204 mut self,
205 title: impl Into<String>,
206 renderer: fn(&mut egui::Ui, &KonnektorenTheme, &ResponsiveInfo),
207 ) -> Self {
208 self.custom_sections.push(CustomSection {
209 title: title.into(),
210 renderer,
211 });
212 self
213 }
214
215 pub fn with_dismiss_button_text(mut self, text: impl Into<String>) -> Self {
216 self.dismiss_button_text = text.into();
217 self
218 }
219
220 pub fn for_game(title: impl Into<String>) -> Self {
222 Self {
223 app_title: title.into(),
224 subtitle: "Educational Game".to_string(),
225 description: "An engaging educational game designed to make learning fun and effective through interactive gameplay.".to_string(),
226 features: vec![
227 ("🎮 Interactive Gameplay".to_string(), "Fun challenges that test your knowledge".to_string()),
228 ("🗺️ Challenge Map".to_string(), "Visual progression system to track your learning journey".to_string()),
229 ("📊 Progress Tracking".to_string(), "Monitor your improvement and see your strengths".to_string()),
230 ("🏆 Achievements".to_string(), "Earn rewards as you master new concepts".to_string()),
231 ("📱 Cross-Platform".to_string(), "Play on desktop or in your web browser".to_string()),
232 ],
233 ..Default::default()
234 }
235 }
236}
237
238#[derive(Component)]
240pub struct ActiveAbout {
241 config: AboutConfig,
242 navigation_state: NavigationState,
243}
244
245#[derive(Clone)]
247pub struct NavigationState {
248 pub current_index: usize,
249 pub max_index: usize,
250 pub enabled: bool,
251}
252
253impl Default for NavigationState {
254 fn default() -> Self {
255 Self {
256 current_index: 0,
257 max_index: 0,
258 enabled: true,
259 }
260 }
261}
262
263#[derive(Event)]
265pub struct AboutDismissed {
266 pub entity: Entity,
267}
268
269#[allow(clippy::type_complexity)]
271fn check_about_config(
272 mut commands: Commands,
273 query: Query<(Entity, &AboutConfig), (Without<ActiveAbout>, Changed<AboutConfig>)>,
274 existing_about: Query<Entity, With<ActiveAbout>>,
275) {
276 for (entity, config) in query.iter() {
277 info!("Setting up about screen for entity {:?}", entity);
278
279 for existing_entity in existing_about.iter() {
281 info!("Cleaning up existing about screen: {:?}", existing_entity);
282 commands.entity(existing_entity).remove::<ActiveAbout>();
283 }
284
285 let mut nav_state = NavigationState {
287 max_index: config.websites.len(),
288 ..Default::default()
289 };
290 if config.manual_dismissal {
291 nav_state.max_index += 1;
292 }
293
294 commands.entity(entity).insert(ActiveAbout {
295 config: config.clone(),
296 navigation_state: nav_state,
297 });
298 }
299}
300
301fn render_about_ui(
303 mut contexts: EguiContexts,
304 theme: Res<KonnektorenTheme>,
305 responsive: Res<ResponsiveInfo>,
306 mut query: Query<(Entity, &mut ActiveAbout)>,
307 mut dismiss_events: EventWriter<AboutDismissed>,
308 input: Res<ButtonInput<KeyCode>>,
309) {
310 if query.is_empty() {
311 return;
312 }
313
314 let ctx = contexts.ctx_mut();
315
316 if let Some((entity, mut about)) = query.iter_mut().next() {
318 let should_dismiss = about.config.manual_dismissal && input.just_pressed(KeyCode::Escape);
320 if should_dismiss {
321 dismiss_events.write(AboutDismissed { entity });
322 return;
323 }
324
325 let ActiveAbout {
327 config,
328 navigation_state,
329 } = &mut *about;
330
331 egui::CentralPanel::default()
332 .frame(egui::Frame::NONE.fill(theme.base_100))
333 .show(ctx, |ui| {
334 render_about_content(
335 ui,
336 config,
337 navigation_state,
338 &theme,
339 &responsive,
340 entity,
341 &mut dismiss_events,
342 );
343 });
344 }
345}
346
347fn render_about_content(
349 ui: &mut egui::Ui,
350 config: &AboutConfig,
351 nav_state: &mut NavigationState,
352 theme: &KonnektorenTheme,
353 responsive: &ResponsiveInfo,
354 entity: Entity,
355 dismiss_events: &mut EventWriter<AboutDismissed>,
356) {
357 ui.vertical_centered(|ui| {
358 let max_width = if responsive.is_mobile() {
359 ui.available_width() * 0.95
360 } else {
361 800.0_f32.min(ui.available_width() * 0.9)
362 };
363
364 ui.set_max_width(max_width);
365
366 let top_spacing = responsive.spacing(ResponsiveSpacing::Large);
367 ui.add_space(top_spacing);
368
369 render_header(ui, config, theme, responsive);
371
372 let scroll_height = ui.available_height() - 80.0;
374 egui::ScrollArea::vertical()
375 .max_height(scroll_height)
376 .auto_shrink([false; 2])
377 .show(ui, |ui| {
378 render_content_sections(ui, config, nav_state, theme, responsive);
379 });
380
381 if config.manual_dismissal {
383 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
384 render_dismiss_button(
385 ui,
386 config,
387 theme,
388 responsive,
389 nav_state,
390 entity,
391 dismiss_events,
392 );
393 }
394
395 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
396 });
397}
398
399fn render_header(
400 ui: &mut egui::Ui,
401 config: &AboutConfig,
402 theme: &KonnektorenTheme,
403 responsive: &ResponsiveInfo,
404) {
405 ui.vertical_centered(|ui| {
406 ResponsiveText::new(
408 &format!("About {}", config.app_title),
409 ResponsiveFontSize::Title,
410 theme.primary,
411 )
412 .responsive(responsive)
413 .strong()
414 .ui(ui);
415
416 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
417
418 if let Some(version) = &config.version {
420 let badge_frame = egui::Frame {
421 inner_margin: egui::Margin::symmetric(12, 6),
422 corner_radius: egui::CornerRadius::same(16),
423 fill: theme.secondary,
424 ..Default::default()
425 };
426
427 badge_frame.show(ui, |ui| {
428 ResponsiveText::new(version, ResponsiveFontSize::Small, theme.secondary_content)
429 .responsive(responsive)
430 .strong()
431 .ui(ui);
432 });
433
434 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
435 }
436
437 ResponsiveText::new(
439 &config.subtitle,
440 ResponsiveFontSize::Large,
441 theme.base_content,
442 )
443 .responsive(responsive)
444 .ui(ui);
445
446 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
447 });
448}
449
450fn render_content_sections(
451 ui: &mut egui::Ui,
452 config: &AboutConfig,
453 nav_state: &NavigationState,
454 theme: &KonnektorenTheme,
455 responsive: &ResponsiveInfo,
456) {
457 let section_spacing = responsive.spacing(ResponsiveSpacing::Large);
458
459 render_section(ui, theme, responsive, "About This Platform", |ui| {
461 ResponsiveText::new(
462 &config.description,
463 ResponsiveFontSize::Medium,
464 theme.base_content,
465 )
466 .responsive(responsive)
467 .ui(ui);
468 });
469
470 ui.add_space(section_spacing);
471
472 if let Some(why_choose_us) = &config.why_choose_us {
474 render_section(ui, theme, responsive, "Why Choose Us?", |ui| {
475 ResponsiveText::new(
476 why_choose_us,
477 ResponsiveFontSize::Medium,
478 theme.base_content,
479 )
480 .responsive(responsive)
481 .ui(ui);
482 });
483 ui.add_space(section_spacing);
484 }
485
486 if !config.features.is_empty() {
488 render_section(ui, theme, responsive, "Features", |ui| {
489 for (title, description) in &config.features {
490 render_feature_item(ui, theme, responsive, title, description);
491 }
492 });
493 ui.add_space(section_spacing);
494 }
495
496 if let Some(extension_widget) = config.extension_widget {
498 extension_widget(ui, theme, responsive);
499 ui.add_space(section_spacing);
500 }
501
502 for custom_section in &config.custom_sections {
504 render_section(ui, theme, responsive, &custom_section.title, |ui| {
505 (custom_section.renderer)(ui, theme, responsive);
506 });
507 ui.add_space(section_spacing);
508 }
509
510 if !config.technologies.is_empty() {
512 render_section(ui, theme, responsive, "Built With", |ui| {
513 for (tech_name, description) in &config.technologies {
514 render_tech_item(ui, theme, responsive, tech_name, description);
515 }
516 });
517 ui.add_space(section_spacing);
518 }
519
520 if let Some((message, color)) = &config.status_message {
522 render_section(ui, theme, responsive, "Status", |ui| {
523 ResponsiveText::new(message, ResponsiveFontSize::Medium, *color)
524 .responsive(responsive)
525 .ui(ui);
526 });
527 ui.add_space(section_spacing * 1.5);
528 }
529
530 if !config.websites.is_empty() {
532 ui.vertical_centered(|ui| {
533 ResponsiveText::new(
534 "Visit Our Websites",
535 ResponsiveFontSize::Large,
536 theme.secondary,
537 )
538 .responsive(responsive)
539 .strong()
540 .ui(ui);
541
542 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
543
544 for (index, website) in config.websites.iter().enumerate() {
545 render_website_link(ui, theme, responsive, nav_state, website, index + 1);
546 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
547 }
548 });
549 ui.add_space(section_spacing * 2.0);
550 }
551
552 ui.vertical_centered(|ui| {
554 let current_year = Utc::now().format("%Y").to_string();
555 ResponsiveText::new(
556 &format!(
557 "© {} {}. All rights reserved.",
558 current_year, config.copyright_holder
559 ),
560 ResponsiveFontSize::Small,
561 theme.accent,
562 )
563 .responsive(responsive)
564 .ui(ui);
565 });
566
567 ui.add_space(responsive.spacing(ResponsiveSpacing::XLarge));
568}
569
570fn render_section<F>(
571 ui: &mut egui::Ui,
572 theme: &KonnektorenTheme,
573 responsive: &ResponsiveInfo,
574 title: &str,
575 content: F,
576) where
577 F: FnOnce(&mut egui::Ui),
578{
579 let margin = if responsive.is_mobile() { 12 } else { 16 };
580 let frame = egui::Frame {
581 inner_margin: egui::Margin::same(margin),
582 corner_radius: egui::CornerRadius::same(8),
583 fill: theme.base_200,
584 stroke: egui::Stroke::new(1.0, theme.accent.linear_multiply(0.3)),
585 ..Default::default()
586 };
587
588 frame.show(ui, |ui| {
589 ResponsiveText::new(title, ResponsiveFontSize::Large, theme.primary)
590 .responsive(responsive)
591 .strong()
592 .ui(ui);
593
594 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
595
596 content(ui);
597 });
598}
599
600fn render_feature_item(
601 ui: &mut egui::Ui,
602 theme: &KonnektorenTheme,
603 responsive: &ResponsiveInfo,
604 title: &str,
605 description: &str,
606) {
607 if responsive.is_mobile() {
608 ui.vertical(|ui| {
609 ResponsiveText::new(title, ResponsiveFontSize::Medium, theme.secondary)
610 .responsive(responsive)
611 .strong()
612 .ui(ui);
613 ResponsiveText::new(description, ResponsiveFontSize::Small, theme.base_content)
614 .responsive(responsive)
615 .ui(ui);
616 });
617 } else {
618 ui.horizontal(|ui| {
619 ResponsiveText::new(title, ResponsiveFontSize::Medium, theme.secondary)
620 .responsive(responsive)
621 .strong()
622 .ui(ui);
623 ui.label(" - ");
624 ResponsiveText::new(description, ResponsiveFontSize::Medium, theme.base_content)
625 .responsive(responsive)
626 .ui(ui);
627 });
628 }
629
630 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
631}
632
633fn render_tech_item(
634 ui: &mut egui::Ui,
635 theme: &KonnektorenTheme,
636 responsive: &ResponsiveInfo,
637 tech_name: &str,
638 description: &str,
639) {
640 ui.horizontal(|ui| {
641 let tech_badge = egui::Frame {
642 inner_margin: egui::Margin::symmetric(8, 4),
643 corner_radius: egui::CornerRadius::same(4),
644 fill: theme.info,
645 ..Default::default()
646 };
647
648 tech_badge.show(ui, |ui| {
649 ResponsiveText::new(tech_name, ResponsiveFontSize::Small, theme.primary_content)
650 .responsive(responsive)
651 .strong()
652 .ui(ui);
653 });
654
655 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
656
657 ResponsiveText::new(description, ResponsiveFontSize::Medium, theme.base_content)
658 .responsive(responsive)
659 .ui(ui);
660 });
661
662 ui.add_space(responsive.spacing(ResponsiveSpacing::XSmall));
663}
664
665fn render_website_link(
666 ui: &mut egui::Ui,
667 theme: &KonnektorenTheme,
668 responsive: &ResponsiveInfo,
669 nav_state: &NavigationState,
670 website: &WebsiteLink,
671 nav_index: usize,
672) {
673 let is_focused = nav_state.enabled && nav_state.current_index == nav_index;
674
675 let link_frame = egui::Frame {
676 inner_margin: egui::Margin::same(12),
677 corner_radius: egui::CornerRadius::same(8),
678 fill: if is_focused {
679 theme.base_300.linear_multiply(1.2)
680 } else {
681 theme.base_300
682 },
683 stroke: egui::Stroke::new(
684 if is_focused { 2.0 } else { 1.0 },
685 if is_focused {
686 theme.primary
687 } else {
688 theme.accent
689 },
690 ),
691 ..Default::default()
692 };
693
694 link_frame.show(ui, |ui| {
695 ui.vertical_centered(|ui| {
696 ResponsiveText::new(&website.title, ResponsiveFontSize::Medium, theme.primary)
697 .responsive(responsive)
698 .strong()
699 .ui(ui);
700
701 ResponsiveText::new(
702 &website.description,
703 ResponsiveFontSize::Small,
704 theme.base_content,
705 )
706 .responsive(responsive)
707 .ui(ui);
708
709 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
710
711 let button_text = if let Some(icon) = &website.icon {
712 format!("{} {}", icon, website.url)
713 } else {
714 website.url.clone()
715 };
716
717 let url_button = ThemedButton::new(&button_text, theme).responsive(responsive);
718
719 if ui.add(url_button).clicked() {
720 open_url(&website.url);
721 }
722 });
723 });
724}
725
726fn render_dismiss_button(
727 ui: &mut egui::Ui,
728 config: &AboutConfig,
729 theme: &KonnektorenTheme,
730 responsive: &ResponsiveInfo,
731 nav_state: &NavigationState,
732 entity: Entity,
733 dismiss_events: &mut EventWriter<AboutDismissed>,
734) {
735 ui.vertical_centered(|ui| {
736 let _is_focused = nav_state.enabled && nav_state.current_index == 0;
737
738 let back_button = ThemedButton::new(&config.dismiss_button_text, theme)
739 .responsive(responsive)
740 .width(if responsive.is_mobile() { 200.0 } else { 250.0 });
741
742 if ui.add(back_button).clicked() {
743 dismiss_events.write(AboutDismissed { entity });
744 }
745 });
746}
747
748fn handle_about_completion(
750 mut commands: Commands,
751 mut dismiss_events: EventReader<AboutDismissed>,
752) {
753 for event in dismiss_events.read() {
754 info!("Dismissing about screen for entity {:?}", event.entity);
755 commands.entity(event.entity).remove::<ActiveAbout>();
756 }
757}
758
759fn open_url(url: &str) {
761 #[cfg(target_arch = "wasm32")]
762 {
763 use wasm_bindgen::prelude::*;
764
765 #[wasm_bindgen]
766 extern "C" {
767 #[wasm_bindgen(js_namespace = ["window"], js_name = open)]
768 fn window_open(url: &str);
769 }
770
771 window_open(url);
772 }
773
774 #[cfg(not(target_arch = "wasm32"))]
775 {
776 if let Err(e) = std::process::Command::new("xdg-open")
777 .arg(url)
778 .spawn()
779 .or_else(|_| std::process::Command::new("open").arg(url).spawn())
780 .or_else(|_| std::process::Command::new("start").arg(url).spawn())
781 {
782 error!("Failed to open URL: {}", e);
783 }
784 }
785}
786
787pub trait AboutScreenExt {
789 fn spawn_about(&mut self, config: AboutConfig) -> Entity;
791
792 fn spawn_simple_about(&mut self, title: impl Into<String>) -> Entity;
794
795 fn spawn_game_about(&mut self, title: impl Into<String>) -> Entity;
797}
798
799impl AboutScreenExt for Commands<'_, '_> {
800 fn spawn_about(&mut self, config: AboutConfig) -> Entity {
801 self.spawn((Name::new("About Screen"), config)).id()
802 }
803
804 fn spawn_simple_about(&mut self, title: impl Into<String>) -> Entity {
805 self.spawn_about(AboutConfig::new(title))
806 }
807
808 fn spawn_game_about(&mut self, title: impl Into<String>) -> Entity {
809 self.spawn_about(AboutConfig::for_game(title))
810 }
811}