window.rs (725673B)
1 use gpui::{ 2 Animation, AnimationExt, AnyElement, App, AppContext, Bounds, ClickEvent, Context, ElementId, 3 Entity, Image, ImageFormat, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, 4 SharedString, Styled, StyledImage, Subscription, Timer, Window, WindowBounds, WindowOptions, 5 div, img, prelude::FluentBuilder, px, relative, rgb, size, transparent_black, 6 }; 7 use gpui_component::{ 8 Icon, IconName, IndexPath, Root, Sizable, Size, 9 input::InputEvent, 10 input::InputState, 11 menu::PopupMenuItem, 12 select::{SearchableVec, Select, SelectDelegate, SelectEvent, SelectState}, 13 }; 14 use radroots_app_core::{ 15 AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, 16 }; 17 use radroots_app_i18n::{AppTextKey, app_text}; 18 use radroots_app_remote_signer::{ 19 RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, 20 RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSource, 21 radroots_app_remote_signer_connect_pending, 22 radroots_app_remote_signer_poll_pending_session_with_progress, 23 radroots_app_remote_signer_preview, radroots_app_remote_signer_requested_permissions, 24 }; 25 use radroots_app_sqlite::{AppSqliteError, derive_farm_rules_readiness}; 26 use radroots_app_state::{ 27 BuyerOrdersScreenProjection, FarmSetupFlowStage, FarmWorkspaceStatus, HomeRoute, 28 PackDayBatchPrintRequest, PackDayExportProjection, PackDayHostHandoffRequest, 29 PackDayPrintRequest, derive_product_publish_blockers, 30 }; 31 use radroots_app_sync::{ 32 AppSyncRunStatus, SyncAggregateRef, SyncCheckpointState, SyncConflict, SyncConflictKind, 33 SyncConflictResolutionStatus, SyncConflictSeverity, 34 }; 35 use radroots_app_ui::{ 36 APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec, AppPillTabSpec, 37 AppSegmentButtonIconSpec as IconSegmentButtonSpec, AppUnderlineTabSpec, LabelValueRow, 38 SettingsPreferencesGeneralRowState, app_button_account_selector_row as account_selector_row, 39 app_button_card, app_button_choice as choice_button, 40 app_button_compact as action_button_compact, app_button_ellipsis_menu as action_ellipsis_menu, 41 app_button_list_row as list_row_button, app_button_primary as action_button_primary, 42 app_button_primary_compact as action_button_primary_compact, 43 app_button_primary_compact_disabled as action_button_primary_compact_disabled, 44 app_button_primary_disabled as action_button_primary_disabled, 45 app_button_primary_full_width as action_button_primary_full_width, 46 app_button_secondary as action_button, app_button_secondary_disabled as action_button_disabled, 47 app_button_secondary_full_width as action_button_full_width, app_button_sidebar_account_menu, 48 app_button_square_dropdown_secondary as action_dropdown_button, app_button_text as text_button, 49 app_checkbox_field, app_cluster, app_detail_row, app_divider as section_divider, 50 app_focused_detail_view, app_focused_task_view, app_form_field, app_form_input_text, 51 app_form_section, app_heading_section, app_heading_view, app_input_text as app_text_input, 52 app_pill_tabs, app_scroll_panel, app_segment_button_icon as icon_segment_button, 53 app_shared_label_text, app_shared_text, app_split_shell, app_stack_h, app_stack_v, 54 app_status_indicator as status_indicator, app_surface_card, 55 app_surface_card_section as home_card, app_surface_panel, app_surface_sidebar, 56 app_surface_window as app_window_shell, app_text_badge as settings_badge_text, 57 app_text_body_subtle as home_body_text, app_text_label, 58 app_text_label as home_farm_setup_field_label, app_text_value, app_underline_tabs, 59 label_value_list, runtime_metadata_rows, settings_preferences_general_rows, utility_title_row, 60 }; 61 pub use radroots_app_view::SettingsSection as SettingsPanelViewKey; 62 use radroots_app_view::{ 63 AccountCustody, AccountSummary, ActiveSurface, AppStartupGate, BlackoutPeriodId, 64 BlackoutPeriodRecord, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, 65 BuyerListingRow, BuyerOrderDetailProjection, BuyerOrderReviewDraft, 66 BuyerOrderReviewSummaryProjection, BuyerOrderStatus, BuyerOrdersListRow, 67 BuyerProductDetailProjection, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, 68 FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, 69 FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind, FarmerSection, 70 FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary, LoggedOutStartupPhase, 71 OrderDetailItemRow, OrderDetailProjection, OrderId, OrderListRow, OrderPrimaryAction, 72 OrderStatus, OrdersFilter, OrdersListRow, PackDayBatchPrintFailureKind, 73 PackDayBatchPrintStatus, PackDayExportBundle, PackDayExportStatus, PackDayHostHandoffKind, 74 PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, 75 PackDayPrintStatus, PackDayProductTotalRow, PackDayRosterRow, PersonalEntryState, 76 PersonalSection, PickupLocationId, PickupLocationRecord, ProductAttentionState, 77 ProductEditorDraft, ProductId, ProductListRow, ProductPricePresentation, ProductPublishBlocker, 78 ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ReminderDeadlineProjection, 79 ReminderDeliveryState, ReminderId, ReminderLogEntryProjection, ReminderLogProjection, 80 ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, 81 SettingsAccountProjection, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, 82 TradeAgreementStatus, TradeEconomicsProjection, TradeInventoryStatus, TradeRevisionStatus, 83 TradeValidationReceiptProjection, TradeValidationReceiptResult, TradeValidationReceiptType, 84 TradeWorkflowProjection, TradeWorkflowSource, 85 }; 86 use radroots_nostr::prelude::RadrootsNostrClient; 87 use std::{ 88 collections::BTreeSet, 89 path::{Component, Path, PathBuf}, 90 sync::Arc, 91 time::Duration, 92 }; 93 use tracing::error; 94 95 use crate::pack_day_host_handoff::{ 96 PackDayHostHandoffCommandPlan, PackDayHostHandoffError, execute_pack_day_host_handoff_plan, 97 }; 98 use crate::pack_day_print::{ 99 PackDayBatchPrintCommandPlan, PackDayBatchPrintError, PackDayPrintCommandPlan, 100 PackDayPrintError, execute_pack_day_batch_print_plan, execute_pack_day_print_plan, 101 }; 102 use crate::runtime::{ 103 DesktopAppRuntime, DesktopAppRuntimeProductEditorSaveError, 104 DesktopAppRuntimeProductStockUpdateError, DesktopAppRuntimeSummary, 105 DesktopAppSdkDiagnosticsState, DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, 106 DesktopAppSdkReadyDiagnosticsSummary, DesktopAppSdkStatusSummary, 107 DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, 108 }; 109 110 const HOME_WINDOW_MIN_WIDTH_PX: f32 = 1080.0; 111 const HOME_WINDOW_MIN_HEIGHT_PX: f32 = 720.0; 112 113 pub fn home_titlebar_options() -> gpui::TitlebarOptions { 114 gpui::TitlebarOptions { 115 title: None, 116 appears_transparent: true, 117 ..Default::default() 118 } 119 } 120 121 pub fn settings_titlebar_options() -> gpui::TitlebarOptions { 122 gpui::TitlebarOptions { 123 title: None, 124 appears_transparent: true, 125 ..Default::default() 126 } 127 } 128 129 #[cfg(test)] 130 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 131 pub enum PrimaryWindowTarget { 132 Home, 133 } 134 135 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 136 pub enum HomeStage { 137 Setup, 138 AccountWorkspace, 139 BuyerWorkspace, 140 FarmerWorkspace, 141 } 142 143 #[cfg(test)] 144 pub fn primary_window_target(_: &DesktopAppRuntimeSummary) -> PrimaryWindowTarget { 145 PrimaryWindowTarget::Home 146 } 147 148 pub fn home_stage(summary: &DesktopAppRuntimeSummary) -> HomeStage { 149 if summary.startup_issue.is_some() || summary.startup_gate == AppStartupGate::Blocked { 150 HomeStage::Setup 151 } else if matches!( 152 summary.shell_projection.selected_section, 153 ShellSection::Account 154 ) { 155 HomeStage::AccountWorkspace 156 } else if summary.startup_gate == AppStartupGate::Farmer { 157 HomeStage::FarmerWorkspace 158 } else if matches!( 159 summary.shell_projection.selected_section, 160 ShellSection::Personal(_) 161 ) || summary.startup_gate == AppStartupGate::Personal 162 { 163 HomeStage::BuyerWorkspace 164 } else { 165 HomeStage::Setup 166 } 167 } 168 169 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 170 enum HomeFocusedView { 171 FarmSetup, 172 ProductEditor, 173 FarmerOrderDetail(OrderId), 174 BuyerProductDetail(PersonalSection), 175 BuyerOrderReview, 176 BuyerOrderDetail(OrderId), 177 } 178 179 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 180 enum AccountTab { 181 #[default] 182 Profile, 183 FarmDetails, 184 Preferences, 185 Settings, 186 } 187 188 impl AccountTab { 189 const ORDERED: [Self; 4] = [ 190 Self::Profile, 191 Self::FarmDetails, 192 Self::Preferences, 193 Self::Settings, 194 ]; 195 196 const fn text_key(self) -> AppTextKey { 197 match self { 198 Self::Profile => AppTextKey::AccountTabProfile, 199 Self::FarmDetails => AppTextKey::AccountTabFarmDetails, 200 Self::Preferences => AppTextKey::AccountTabPreferences, 201 Self::Settings => AppTextKey::AccountTabSettings, 202 } 203 } 204 205 const fn panel_text_key(self) -> AppTextKey { 206 match self { 207 Self::Profile | Self::FarmDetails => self.text_key(), 208 Self::Preferences => AppTextKey::AccountNotImplemented, 209 Self::Settings => AppTextKey::AccountSettingsTitle, 210 } 211 } 212 213 fn selected_index(self) -> usize { 214 Self::ORDERED 215 .iter() 216 .position(|tab| *tab == self) 217 .unwrap_or(0) 218 } 219 220 fn from_index(index: usize) -> Self { 221 Self::ORDERED.get(index).copied().unwrap_or_default() 222 } 223 } 224 225 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 226 enum AccountFarmDetailsTab { 227 #[default] 228 Profile, 229 Location, 230 Operations, 231 Fulfilment, 232 } 233 234 impl AccountFarmDetailsTab { 235 const ORDERED: [Self; 4] = [ 236 Self::Profile, 237 Self::Location, 238 Self::Operations, 239 Self::Fulfilment, 240 ]; 241 242 const fn text_key(self) -> AppTextKey { 243 match self { 244 Self::Profile => AppTextKey::AccountFarmDetailsTabProfile, 245 Self::Location => AppTextKey::AccountFarmDetailsTabLocation, 246 Self::Operations => AppTextKey::AccountFarmDetailsTabOperations, 247 Self::Fulfilment => AppTextKey::AccountFarmDetailsTabFulfilment, 248 } 249 } 250 251 fn selected_index(self) -> usize { 252 Self::ORDERED 253 .iter() 254 .position(|tab| *tab == self) 255 .unwrap_or(0) 256 } 257 258 fn from_index(index: usize) -> Self { 259 Self::ORDERED.get(index).copied().unwrap_or_default() 260 } 261 } 262 263 type AccountProfileSelectState = SelectState<SearchableVec<SharedString>>; 264 type AccountFarmProfileSelectState = SelectState<SearchableVec<SharedString>>; 265 266 #[derive(Clone)] 267 struct AccountProfileFormState { 268 full_name_input: Entity<InputState>, 269 email_input: Entity<InputState>, 270 phone_input: Entity<InputState>, 271 role_select: Entity<AccountProfileSelectState>, 272 time_zone_select: Entity<AccountProfileSelectState>, 273 language_select: Entity<AccountProfileSelectState>, 274 } 275 276 impl AccountProfileFormState { 277 fn new(window: &mut Window, cx: &mut Context<HomeView>) -> Self { 278 Self { 279 full_name_input: account_profile_input_state( 280 AppTextKey::AccountProfileFullNameValue, 281 window, 282 cx, 283 ), 284 email_input: account_profile_input_state( 285 AppTextKey::AccountProfileEmailValue, 286 window, 287 cx, 288 ), 289 phone_input: account_profile_input_state( 290 AppTextKey::AccountProfilePhoneValue, 291 window, 292 cx, 293 ), 294 role_select: account_profile_select_state( 295 &[ 296 AppTextKey::AccountProfileRoleValue, 297 AppTextKey::AccountProfileRoleFarmManagerValue, 298 AppTextKey::AccountProfileRoleTeamMemberValue, 299 ], 300 window, 301 cx, 302 ), 303 time_zone_select: account_profile_select_state( 304 &[ 305 AppTextKey::AccountProfileTimeZoneValue, 306 AppTextKey::AccountProfileTimeZoneMountainValue, 307 AppTextKey::AccountProfileTimeZoneEasternValue, 308 ], 309 window, 310 cx, 311 ), 312 language_select: account_profile_select_state( 313 &[ 314 AppTextKey::AccountProfileLanguageValue, 315 AppTextKey::AccountProfileLanguageFrenchValue, 316 AppTextKey::AccountProfileLanguageSpanishValue, 317 ], 318 window, 319 cx, 320 ), 321 } 322 } 323 } 324 325 fn account_profile_input_state( 326 value_key: AppTextKey, 327 window: &mut Window, 328 cx: &mut Context<HomeView>, 329 ) -> Entity<InputState> { 330 let input = cx.new(|cx| InputState::new(window, cx).default_value(app_text(value_key))); 331 account_subscribe_input_change(&input, window, cx); 332 input 333 } 334 335 fn account_profile_autogrow_input_state( 336 value_key: AppTextKey, 337 window: &mut Window, 338 cx: &mut Context<HomeView>, 339 ) -> Entity<InputState> { 340 let input = cx.new(|cx| { 341 InputState::new(window, cx) 342 .auto_grow(3, 6) 343 .default_value(app_text(value_key)) 344 }); 345 account_subscribe_input_change(&input, window, cx); 346 input 347 } 348 349 fn account_profile_select_state( 350 value_keys: &[AppTextKey], 351 window: &mut Window, 352 cx: &mut Context<HomeView>, 353 ) -> Entity<AccountProfileSelectState> { 354 let values = value_keys 355 .iter() 356 .copied() 357 .map(app_shared_text) 358 .collect::<Vec<_>>(); 359 let select = cx.new(|cx| { 360 SelectState::new( 361 SearchableVec::new(values), 362 Some(IndexPath::default().row(0)), 363 window, 364 cx, 365 ) 366 }); 367 account_subscribe_profile_select_change(&select, window, cx); 368 select 369 } 370 371 #[derive(Clone)] 372 struct AccountFarmProfileFormState { 373 farm_name_input: Entity<InputState>, 374 public_farm_name_input: Entity<InputState>, 375 short_description_input: Entity<InputState>, 376 contact_email_input: Entity<InputState>, 377 public_phone_input: Entity<InputState>, 378 website_input: Entity<InputState>, 379 established_year_input: Entity<InputState>, 380 about_farm_input: Entity<InputState>, 381 farm_type_select: Entity<AccountFarmProfileSelectState>, 382 street_address_input: Entity<InputState>, 383 city_input: Entity<InputState>, 384 postal_code_input: Entity<InputState>, 385 province_select: Entity<AccountFarmProfileSelectState>, 386 country_select: Entity<AccountFarmProfileSelectState>, 387 service_area_select: Entity<AccountFarmProfileSelectState>, 388 growing_practices_select: Entity<AccountFarmProfileSelectState>, 389 season_start_input: Entity<InputState>, 390 season_end_input: Entity<InputState>, 391 about_products_input: Entity<InputState>, 392 customer_note_input: Entity<InputState>, 393 primary_pickup_location_select: Entity<AccountFarmProfileSelectState>, 394 pickup_instructions_input: Entity<InputState>, 395 order_cutoff_select: Entity<AccountFarmProfileSelectState>, 396 delivery_radius_input: Entity<InputState>, 397 } 398 399 impl AccountFarmProfileFormState { 400 fn new(window: &mut Window, cx: &mut Context<HomeView>) -> Self { 401 Self { 402 farm_name_input: account_profile_input_state( 403 AppTextKey::AccountFarmDetailsFarmNameValue, 404 window, 405 cx, 406 ), 407 public_farm_name_input: account_profile_input_state( 408 AppTextKey::AccountFarmDetailsPublicFarmNameValue, 409 window, 410 cx, 411 ), 412 short_description_input: account_profile_input_state( 413 AppTextKey::AccountFarmDetailsShortDescriptionValue, 414 window, 415 cx, 416 ), 417 contact_email_input: account_profile_input_state( 418 AppTextKey::AccountFarmDetailsContactEmailValue, 419 window, 420 cx, 421 ), 422 public_phone_input: account_profile_input_state( 423 AppTextKey::AccountFarmDetailsPublicPhoneValue, 424 window, 425 cx, 426 ), 427 website_input: account_profile_input_state( 428 AppTextKey::AccountFarmDetailsWebsiteValue, 429 window, 430 cx, 431 ), 432 established_year_input: account_profile_input_state( 433 AppTextKey::AccountFarmDetailsEstablishedYearValue, 434 window, 435 cx, 436 ), 437 about_farm_input: account_profile_autogrow_input_state( 438 AppTextKey::AccountFarmDetailsAboutFarmValue, 439 window, 440 cx, 441 ), 442 farm_type_select: account_farm_profile_select_state( 443 &[ 444 AppTextKey::AccountFarmDetailsFarmTypeVegetableFarm, 445 AppTextKey::AccountFarmDetailsFarmTypeFruitOrchard, 446 AppTextKey::AccountFarmDetailsFarmTypeBerryFarm, 447 AppTextKey::AccountFarmDetailsFarmTypeHerbFarm, 448 AppTextKey::AccountFarmDetailsFarmTypeFlowerFarm, 449 AppTextKey::AccountFarmDetailsFarmTypeMushroomFarm, 450 AppTextKey::AccountFarmDetailsFarmTypeGrainFieldCropFarm, 451 AppTextKey::AccountFarmDetailsFarmTypeDairyFarm, 452 AppTextKey::AccountFarmDetailsFarmTypeEggPoultryFarm, 453 AppTextKey::AccountFarmDetailsFarmTypeLivestockFarm, 454 AppTextKey::AccountFarmDetailsFarmTypeHoneyApiary, 455 AppTextKey::AccountFarmDetailsFarmTypeNurseryPlantFarm, 456 AppTextKey::AccountFarmDetailsFarmTypeMixedFarm, 457 AppTextKey::AccountFarmDetailsFarmTypeOther, 458 ], 459 window, 460 cx, 461 ), 462 street_address_input: account_profile_input_state( 463 AppTextKey::AccountFarmDetailsStreetAddressValue, 464 window, 465 cx, 466 ), 467 city_input: account_profile_input_state( 468 AppTextKey::AccountFarmDetailsCityValue, 469 window, 470 cx, 471 ), 472 postal_code_input: account_profile_input_state( 473 AppTextKey::AccountFarmDetailsPostalCodeValue, 474 window, 475 cx, 476 ), 477 province_select: account_farm_profile_select_state( 478 &[ 479 AppTextKey::AccountFarmDetailsProvinceBritishColumbia, 480 AppTextKey::AccountFarmDetailsProvinceAlberta, 481 ], 482 window, 483 cx, 484 ), 485 country_select: account_farm_profile_select_state( 486 &[ 487 AppTextKey::AccountFarmDetailsCountryCanada, 488 AppTextKey::AccountFarmDetailsCountryUnitedStates, 489 ], 490 window, 491 cx, 492 ), 493 service_area_select: account_farm_profile_select_state( 494 &[AppTextKey::AccountFarmDetailsServiceAreaValue], 495 window, 496 cx, 497 ), 498 growing_practices_select: account_farm_profile_select_state( 499 &[ 500 AppTextKey::AccountFarmDetailsGrowingPracticeRegenerative, 501 AppTextKey::AccountFarmDetailsGrowingPracticeOrganic, 502 ], 503 window, 504 cx, 505 ), 506 season_start_input: account_profile_input_state( 507 AppTextKey::AccountFarmDetailsSeasonStartValue, 508 window, 509 cx, 510 ), 511 season_end_input: account_profile_input_state( 512 AppTextKey::AccountFarmDetailsSeasonEndValue, 513 window, 514 cx, 515 ), 516 about_products_input: account_profile_autogrow_input_state( 517 AppTextKey::AccountFarmDetailsAboutProductsValue, 518 window, 519 cx, 520 ), 521 customer_note_input: account_profile_autogrow_input_state( 522 AppTextKey::AccountFarmDetailsCustomerNoteValue, 523 window, 524 cx, 525 ), 526 primary_pickup_location_select: account_farm_profile_select_state( 527 &[AppTextKey::AccountFarmDetailsPrimaryPickupLocationTitleValue], 528 window, 529 cx, 530 ), 531 pickup_instructions_input: account_profile_autogrow_input_state( 532 AppTextKey::AccountFarmDetailsPickupInstructionsValue, 533 window, 534 cx, 535 ), 536 order_cutoff_select: account_farm_profile_select_state( 537 &[AppTextKey::AccountFarmDetailsOrderCutoffNoonValue], 538 window, 539 cx, 540 ), 541 delivery_radius_input: account_profile_input_state( 542 AppTextKey::AccountFarmDetailsDeliveryRadiusValue, 543 window, 544 cx, 545 ), 546 } 547 } 548 549 fn is_dirty(&self, cx: &App) -> bool { 550 account_input_is_dirty( 551 &self.farm_name_input, 552 AppTextKey::AccountFarmDetailsFarmNameValue, 553 cx, 554 ) || account_input_is_dirty( 555 &self.public_farm_name_input, 556 AppTextKey::AccountFarmDetailsPublicFarmNameValue, 557 cx, 558 ) || account_input_is_dirty( 559 &self.short_description_input, 560 AppTextKey::AccountFarmDetailsShortDescriptionValue, 561 cx, 562 ) || account_input_is_dirty( 563 &self.contact_email_input, 564 AppTextKey::AccountFarmDetailsContactEmailValue, 565 cx, 566 ) || account_input_is_dirty( 567 &self.public_phone_input, 568 AppTextKey::AccountFarmDetailsPublicPhoneValue, 569 cx, 570 ) || account_input_is_dirty( 571 &self.website_input, 572 AppTextKey::AccountFarmDetailsWebsiteValue, 573 cx, 574 ) || account_input_is_dirty( 575 &self.established_year_input, 576 AppTextKey::AccountFarmDetailsEstablishedYearValue, 577 cx, 578 ) || account_input_is_dirty( 579 &self.about_farm_input, 580 AppTextKey::AccountFarmDetailsAboutFarmValue, 581 cx, 582 ) || account_select_is_dirty(&self.farm_type_select, cx) 583 || account_input_is_dirty( 584 &self.street_address_input, 585 AppTextKey::AccountFarmDetailsStreetAddressValue, 586 cx, 587 ) 588 || account_input_is_dirty( 589 &self.city_input, 590 AppTextKey::AccountFarmDetailsCityValue, 591 cx, 592 ) 593 || account_input_is_dirty( 594 &self.postal_code_input, 595 AppTextKey::AccountFarmDetailsPostalCodeValue, 596 cx, 597 ) 598 || account_select_is_dirty(&self.province_select, cx) 599 || account_select_is_dirty(&self.country_select, cx) 600 || account_select_is_dirty(&self.service_area_select, cx) 601 || account_select_is_dirty(&self.growing_practices_select, cx) 602 || account_input_is_dirty( 603 &self.season_start_input, 604 AppTextKey::AccountFarmDetailsSeasonStartValue, 605 cx, 606 ) 607 || account_input_is_dirty( 608 &self.season_end_input, 609 AppTextKey::AccountFarmDetailsSeasonEndValue, 610 cx, 611 ) 612 || account_input_is_dirty( 613 &self.about_products_input, 614 AppTextKey::AccountFarmDetailsAboutProductsValue, 615 cx, 616 ) 617 || account_input_is_dirty( 618 &self.customer_note_input, 619 AppTextKey::AccountFarmDetailsCustomerNoteValue, 620 cx, 621 ) 622 || account_select_is_dirty(&self.primary_pickup_location_select, cx) 623 || account_input_is_dirty( 624 &self.pickup_instructions_input, 625 AppTextKey::AccountFarmDetailsPickupInstructionsValue, 626 cx, 627 ) 628 || account_select_is_dirty(&self.order_cutoff_select, cx) 629 || account_input_is_dirty( 630 &self.delivery_radius_input, 631 AppTextKey::AccountFarmDetailsDeliveryRadiusValue, 632 cx, 633 ) 634 } 635 } 636 637 #[derive(Clone)] 638 struct AccountSettingsFormState { 639 add_relay_input: Entity<InputState>, 640 blossom_server_input: Entity<InputState>, 641 } 642 643 impl AccountSettingsFormState { 644 fn new(window: &mut Window, cx: &mut Context<HomeView>) -> Self { 645 let add_relay_input = cx.new(|cx| { 646 InputState::new(window, cx) 647 .placeholder(app_text(AppTextKey::AccountSettingsAddRelayPlaceholder)) 648 }); 649 let blossom_server_input = cx.new(|cx| { 650 InputState::new(window, cx).default_value(ACCOUNT_SETTINGS_DEFAULT_BLOSSOM_SERVER) 651 }); 652 account_subscribe_input_change(&add_relay_input, window, cx); 653 account_subscribe_input_change(&blossom_server_input, window, cx); 654 655 Self { 656 add_relay_input, 657 blossom_server_input, 658 } 659 } 660 661 fn is_dirty(&self, cx: &App) -> bool { 662 !self.add_relay_input.read(cx).value().trim().is_empty() 663 || self.blossom_server_input.read(cx).value().as_ref() 664 != ACCOUNT_SETTINGS_DEFAULT_BLOSSOM_SERVER 665 } 666 } 667 668 fn account_farm_profile_select_state( 669 value_keys: &[AppTextKey], 670 window: &mut Window, 671 cx: &mut Context<HomeView>, 672 ) -> Entity<AccountFarmProfileSelectState> { 673 let values = value_keys 674 .iter() 675 .copied() 676 .map(app_shared_text) 677 .collect::<Vec<_>>(); 678 let select = cx.new(|cx| { 679 SelectState::new( 680 SearchableVec::new(values), 681 Some(IndexPath::default().row(0)), 682 window, 683 cx, 684 ) 685 }); 686 account_subscribe_farm_select_change(&select, window, cx); 687 select 688 } 689 690 fn account_input_is_dirty( 691 input: &Entity<InputState>, 692 initial_value_key: AppTextKey, 693 cx: &App, 694 ) -> bool { 695 input.read(cx).value().as_ref() != app_text(initial_value_key) 696 } 697 698 fn account_select_is_dirty(select: &Entity<AccountProfileSelectState>, cx: &App) -> bool { 699 select 700 .read(cx) 701 .selected_index(cx) 702 .is_some_and(|index| index.row != 0) 703 } 704 705 fn account_subscribe_input_change( 706 input: &Entity<InputState>, 707 window: &mut Window, 708 cx: &mut Context<HomeView>, 709 ) { 710 cx.subscribe_in( 711 input, 712 window, 713 |_: &mut HomeView, _: &Entity<InputState>, event: &InputEvent, _, cx| { 714 if matches!(event, InputEvent::Change) { 715 cx.notify(); 716 } 717 }, 718 ) 719 .detach(); 720 } 721 722 fn account_subscribe_profile_select_change( 723 select: &Entity<AccountProfileSelectState>, 724 window: &mut Window, 725 cx: &mut Context<HomeView>, 726 ) { 727 cx.subscribe_in( 728 select, 729 window, 730 |_: &mut HomeView, 731 _: &Entity<AccountProfileSelectState>, 732 event: &SelectEvent<SearchableVec<SharedString>>, 733 _, 734 cx| { 735 if matches!(event, SelectEvent::Confirm(_)) { 736 cx.notify(); 737 } 738 }, 739 ) 740 .detach(); 741 } 742 743 fn account_subscribe_farm_select_change( 744 select: &Entity<AccountFarmProfileSelectState>, 745 window: &mut Window, 746 cx: &mut Context<HomeView>, 747 ) { 748 cx.subscribe_in( 749 select, 750 window, 751 |_: &mut HomeView, 752 _: &Entity<AccountFarmProfileSelectState>, 753 event: &SelectEvent<SearchableVec<SharedString>>, 754 _, 755 cx| { 756 if matches!(event, SelectEvent::Confirm(_)) { 757 cx.notify(); 758 } 759 }, 760 ) 761 .detach(); 762 } 763 764 fn buyer_order_detail_focus_after_open( 765 runtime_changed: bool, 766 runtime: &DesktopAppRuntimeSummary, 767 order_id: OrderId, 768 ) -> Option<HomeFocusedView> { 769 if runtime_changed 770 || runtime 771 .personal_projection 772 .orders 773 .detail 774 .as_ref() 775 .is_some_and(|detail| detail.order_id == order_id) 776 { 777 Some(HomeFocusedView::BuyerOrderDetail(order_id)) 778 } else { 779 None 780 } 781 } 782 783 fn farmer_order_detail_focus_after_open( 784 runtime_changed: bool, 785 runtime: &DesktopAppRuntimeSummary, 786 order_id: OrderId, 787 ) -> Option<HomeFocusedView> { 788 if runtime_changed 789 || runtime 790 .orders_projection 791 .detail 792 .as_ref() 793 .is_some_and(|detail| detail.order_id == order_id) 794 { 795 Some(HomeFocusedView::FarmerOrderDetail(order_id)) 796 } else { 797 None 798 } 799 } 800 801 pub fn home_window_options(cx: &mut App) -> WindowOptions { 802 let (launch_width_px, launch_height_px) = home_window_launch_size_px(); 803 let (minimum_width_px, minimum_height_px) = home_window_minimum_size_px(); 804 let bounds = Bounds::centered(None, size(px(launch_width_px), px(launch_height_px)), cx); 805 806 WindowOptions { 807 window_bounds: Some(WindowBounds::Windowed(bounds)), 808 window_min_size: Some(size(px(minimum_width_px), px(minimum_height_px))), 809 titlebar: Some(home_titlebar_options()), 810 ..Default::default() 811 } 812 } 813 814 fn home_window_launch_size_px() -> (f32, f32) { 815 ( 816 APP_UI_THEME.shells.home_min_width_px, 817 APP_UI_THEME.shells.home_min_height_px, 818 ) 819 } 820 821 fn home_window_minimum_size_px() -> (f32, f32) { 822 (HOME_WINDOW_MIN_WIDTH_PX, HOME_WINDOW_MIN_HEIGHT_PX) 823 } 824 825 pub fn settings_window_options(cx: &mut App) -> WindowOptions { 826 let bounds = Bounds::centered( 827 None, 828 size( 829 px(APP_UI_THEME.shells.settings_width_px), 830 px(APP_UI_THEME.shells.settings_height_px), 831 ), 832 cx, 833 ); 834 835 WindowOptions { 836 window_bounds: Some(WindowBounds::Windowed(bounds)), 837 window_min_size: Some(size( 838 px(APP_UI_THEME.shells.settings_width_px), 839 px(APP_UI_THEME.shells.settings_height_px), 840 )), 841 titlebar: Some(settings_titlebar_options()), 842 ..Default::default() 843 } 844 } 845 846 pub fn open_home_window( 847 window: &mut Window, 848 cx: &mut App, 849 runtime: DesktopAppRuntime, 850 ) -> gpui::Entity<Root> { 851 let _ = runtime.record_home_opened(); 852 let view = cx.new(|_| HomeView::new(runtime)); 853 cx.new(|cx| Root::new(view, window, cx)) 854 } 855 856 pub fn open_settings_window( 857 window: &mut Window, 858 cx: &mut App, 859 runtime: DesktopAppRuntime, 860 initial_view: SettingsPanelViewKey, 861 ) -> gpui::Entity<Root> { 862 let _ = runtime.sync_settings_section(initial_view); 863 let _ = runtime.record_settings_opened(initial_view); 864 let view = cx.new(|_| SettingsWindowView::new(runtime, initial_view)); 865 cx.new(|cx| Root::new(view, window, cx)) 866 } 867 868 pub struct HomeView { 869 runtime: DesktopAppRuntime, 870 startup_view: StartupHomeView, 871 startup_signer_entry: Option<StartupSignerEntryState>, 872 startup_signer_connect_state: StartupSignerConnectState, 873 startup_signer_task_token: u64, 874 startup_signer_recovery_attempted: bool, 875 farm_setup_form: Option<FarmSetupFormState>, 876 personal_search: Option<PersonalSearchState>, 877 buyer_order_review_form: Option<BuyerOrderReviewFormState>, 878 products_search: Option<ProductsSearchState>, 879 products_stock_editor: Option<ProductsStockEditorState>, 880 product_editor_form: Option<ProductEditorFormState>, 881 focused_view: Option<HomeFocusedView>, 882 selected_account_tab: AccountTab, 883 selected_account_farm_details_tab: AccountFarmDetailsTab, 884 account_profile_form: Option<AccountProfileFormState>, 885 account_farm_profile_form: Option<AccountFarmProfileFormState>, 886 account_settings_form: Option<AccountSettingsFormState>, 887 account_farm_profile_textarea_wrap_ready: bool, 888 account_farm_profile_textarea_wrap_requested: bool, 889 relay_client: Option<RadrootsNostrClient>, 890 buyer_workspace_notice: Option<String>, 891 } 892 893 #[derive(Clone, Debug)] 894 enum StartupSignerConnectState { 895 Idle, 896 Connecting, 897 PendingApproval { 898 pending_session: RadrootsAppRemoteSignerPendingSession, 899 auth_challenge_url: Option<String>, 900 }, 901 Approved { 902 pending_session: RadrootsAppRemoteSignerPendingSession, 903 approved_session: RadrootsAppRemoteSignerApprovedSession, 904 auth_challenge_url: Option<String>, 905 }, 906 } 907 908 #[derive(Clone, Debug, Eq, PartialEq)] 909 struct StartupSignerPreviewSummary { 910 source_label: String, 911 signer_npub: String, 912 relays_label: String, 913 permissions_label: String, 914 } 915 916 #[derive(Clone, Debug)] 917 struct StartupSignerPollCycleResult { 918 auth_challenge_url: Option<String>, 919 outcome: Result<RadrootsAppRemoteSignerPendingPollOutcome, String>, 920 } 921 922 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 923 struct HomeAutoFocusState { 924 has_startup_signer_input: bool, 925 startup_signer_input_is_editable: bool, 926 has_farm_setup_form: bool, 927 has_personal_search_input: bool, 928 has_buyer_order_review_form: bool, 929 has_products_search_input: bool, 930 has_products_stock_editor: bool, 931 has_product_editor_form: bool, 932 } 933 934 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 935 enum HomeAutoFocusTarget { 936 StartupContinue, 937 StartupGenerateKey, 938 StartupSignerInput, 939 StartupSignerBack, 940 BuyerSearchInput, 941 BuyerListingOpenFirst, 942 BuyerDetailBack, 943 BuyerCartOpenOrderReview, 944 BuyerOrderReviewNameInput, 945 BuyerOrderOpenFirst, 946 BuyerOrderConfirmReplace, 947 BuyerOrderRepeatDemand, 948 FarmerReminderPrimary, 949 FarmerReminderDismiss, 950 FarmerSetupStart, 951 FarmerSetupContinue, 952 FarmerSetupFarmNameInput, 953 FarmerTodayReminderChipFirst, 954 FarmerTodayOpenPackDay, 955 FarmerTodayOpenOrders, 956 FarmerTodayOpenProductsLowStock, 957 FarmerTodayOpenProductsDrafts, 958 ProductsSearchInput, 959 ProductsRowOpenFirst, 960 ProductsStockInput, 961 ProductEditorTitleInput, 962 OrdersRowOpenFirst, 963 } 964 965 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 966 enum BuyerWorkspaceNotice { 967 MarketplaceRefreshFailed, 968 DetailOpenFailed, 969 OrderPlaceFailed, 970 OrderCoordinationFailed, 971 } 972 973 impl BuyerWorkspaceNotice { 974 fn text_key(self) -> AppTextKey { 975 match self { 976 Self::MarketplaceRefreshFailed => AppTextKey::PersonalMarketplaceRefreshFailedNotice, 977 Self::DetailOpenFailed => AppTextKey::PersonalDetailOpenFailedNotice, 978 Self::OrderPlaceFailed => AppTextKey::PersonalOrderPlaceFailedNotice, 979 Self::OrderCoordinationFailed => AppTextKey::PersonalOrderCoordinationFailedNotice, 980 } 981 } 982 983 fn text(self) -> String { 984 app_text(self.text_key()) 985 } 986 } 987 988 impl HomeView { 989 pub fn new(runtime: DesktopAppRuntime) -> Self { 990 Self { 991 runtime, 992 startup_view: StartupHomeView::new(), 993 startup_signer_entry: None, 994 startup_signer_connect_state: StartupSignerConnectState::Idle, 995 startup_signer_task_token: 0, 996 startup_signer_recovery_attempted: false, 997 farm_setup_form: None, 998 personal_search: None, 999 buyer_order_review_form: None, 1000 products_search: None, 1001 products_stock_editor: None, 1002 product_editor_form: None, 1003 focused_view: None, 1004 selected_account_tab: AccountTab::default(), 1005 selected_account_farm_details_tab: AccountFarmDetailsTab::default(), 1006 account_profile_form: None, 1007 account_farm_profile_form: None, 1008 account_settings_form: None, 1009 account_farm_profile_textarea_wrap_ready: false, 1010 account_farm_profile_textarea_wrap_requested: false, 1011 relay_client: None, 1012 buyer_workspace_notice: None, 1013 } 1014 } 1015 1016 fn auto_focus_state(&self) -> HomeAutoFocusState { 1017 HomeAutoFocusState { 1018 has_startup_signer_input: self.startup_signer_entry.is_some(), 1019 startup_signer_input_is_editable: startup_signer_source_input_is_editable( 1020 &self.startup_signer_connect_state, 1021 ), 1022 has_farm_setup_form: self.farm_setup_form.is_some(), 1023 has_personal_search_input: self.personal_search.is_some(), 1024 has_buyer_order_review_form: self.buyer_order_review_form.is_some(), 1025 has_products_search_input: self.products_search.is_some(), 1026 has_products_stock_editor: self.products_stock_editor.is_some(), 1027 has_product_editor_form: self.product_editor_form.is_some(), 1028 } 1029 } 1030 1031 fn clear_focused_view(&mut self) -> bool { 1032 self.focused_view.take().is_some() 1033 } 1034 1035 fn clear_focused_view_matching(&mut self, view: HomeFocusedView) -> bool { 1036 if self.focused_view == Some(view) { 1037 self.focused_view = None; 1038 true 1039 } else { 1040 false 1041 } 1042 } 1043 1044 fn apply_auto_focus( 1045 &mut self, 1046 runtime: &DesktopAppRuntimeSummary, 1047 window: &mut Window, 1048 cx: &mut Context<Self>, 1049 ) { 1050 let desired_target = home_auto_focus_target(runtime, self.auto_focus_state()); 1051 let focus_state = window.use_state(cx, |_, _| Option::<HomeAutoFocusTarget>::None); 1052 let should_focus = { 1053 let last_target = focus_state.read(cx); 1054 last_target.as_ref().copied() != desired_target 1055 }; 1056 1057 if !should_focus { 1058 return; 1059 } 1060 1061 if let Some(target) = desired_target { 1062 match target { 1063 HomeAutoFocusTarget::StartupContinue => { 1064 focus_button(window, "home-continue", cx); 1065 } 1066 HomeAutoFocusTarget::StartupGenerateKey => { 1067 focus_button(window, "home-generate-key", cx); 1068 } 1069 HomeAutoFocusTarget::StartupSignerInput => { 1070 if let Some(entry) = self.startup_signer_entry.as_ref() { 1071 entry.input.update(cx, |input, cx| input.focus(window, cx)); 1072 } 1073 } 1074 HomeAutoFocusTarget::StartupSignerBack => { 1075 focus_button(window, "home-signer-back", cx); 1076 } 1077 HomeAutoFocusTarget::BuyerSearchInput => { 1078 if let Some(search) = self.personal_search.as_ref() { 1079 search.input.update(cx, |input, cx| input.focus(window, cx)); 1080 } 1081 } 1082 HomeAutoFocusTarget::BuyerListingOpenFirst => { 1083 focus_button(window, ("buyer-listing-open", 0_usize), cx); 1084 } 1085 HomeAutoFocusTarget::BuyerDetailBack => { 1086 focus_button(window, "buyer-detail-back", cx); 1087 } 1088 HomeAutoFocusTarget::BuyerCartOpenOrderReview => { 1089 focus_button(window, "buyer-cart-open-order-review", cx); 1090 } 1091 HomeAutoFocusTarget::BuyerOrderReviewNameInput => { 1092 if let Some(form) = self.buyer_order_review_form.as_ref() { 1093 form.name_input 1094 .update(cx, |input, cx| input.focus(window, cx)); 1095 } 1096 } 1097 HomeAutoFocusTarget::BuyerOrderOpenFirst => { 1098 focus_button(window, ("buyer-order-open", 0_usize), cx); 1099 } 1100 HomeAutoFocusTarget::BuyerOrderConfirmReplace => { 1101 focus_button(window, "buyer-order-confirm-replace", cx); 1102 } 1103 HomeAutoFocusTarget::BuyerOrderRepeatDemand => { 1104 focus_button(window, "buyer-order-repeat-demand", cx); 1105 } 1106 HomeAutoFocusTarget::FarmerReminderPrimary => { 1107 focus_button(window, "reminder-banner-action", cx); 1108 } 1109 HomeAutoFocusTarget::FarmerReminderDismiss => { 1110 focus_button(window, "reminder-banner-dismiss", cx); 1111 } 1112 HomeAutoFocusTarget::FarmerSetupStart => { 1113 focus_button(window, "home-farm-setup-start", cx); 1114 } 1115 HomeAutoFocusTarget::FarmerSetupContinue => { 1116 focus_button(window, "home-farm-setup-continue", cx); 1117 } 1118 HomeAutoFocusTarget::FarmerSetupFarmNameInput => { 1119 if let Some(form) = self.farm_setup_form.as_ref() { 1120 form.farm_name_input 1121 .update(cx, |input, cx| input.focus(window, cx)); 1122 } 1123 } 1124 HomeAutoFocusTarget::FarmerTodayReminderChipFirst => { 1125 focus_button(window, ("today-reminder-chip", 0_usize), cx); 1126 } 1127 HomeAutoFocusTarget::FarmerTodayOpenPackDay => { 1128 focus_button(window, "home-today-open-pack-day", cx); 1129 } 1130 HomeAutoFocusTarget::FarmerTodayOpenOrders => { 1131 focus_button(window, "home-today-open-orders", cx); 1132 } 1133 HomeAutoFocusTarget::FarmerTodayOpenProductsLowStock => { 1134 focus_button(window, "home-today-open-products-low-stock", cx); 1135 } 1136 HomeAutoFocusTarget::FarmerTodayOpenProductsDrafts => { 1137 focus_button(window, "home-today-open-products-drafts", cx); 1138 } 1139 HomeAutoFocusTarget::ProductsSearchInput => { 1140 if let Some(search) = self.products_search.as_ref() { 1141 search.input.update(cx, |input, cx| input.focus(window, cx)); 1142 } 1143 } 1144 HomeAutoFocusTarget::ProductsRowOpenFirst => { 1145 focus_button(window, ("products-row-open", 0_usize), cx); 1146 } 1147 HomeAutoFocusTarget::ProductsStockInput => { 1148 if let Some(editor) = self.products_stock_editor.as_ref() { 1149 editor.input.update(cx, |input, cx| input.focus(window, cx)); 1150 } 1151 } 1152 HomeAutoFocusTarget::ProductEditorTitleInput => { 1153 if let Some(form) = self.product_editor_form.as_ref() { 1154 form.title_input 1155 .update(cx, |input, cx| input.focus(window, cx)); 1156 } 1157 } 1158 HomeAutoFocusTarget::OrdersRowOpenFirst => { 1159 focus_button(window, ("orders-row-open", 0_usize), cx); 1160 } 1161 } 1162 } 1163 1164 focus_state.update(cx, |last_target, _| *last_target = desired_target); 1165 } 1166 1167 fn generate_local_account(&mut self, cx: &mut Context<Self>) -> bool { 1168 if self.runtime.generate_local_account(None).unwrap_or(false) { 1169 cx.refresh_windows(); 1170 cx.notify(); 1171 return true; 1172 } 1173 1174 false 1175 } 1176 1177 fn reset_startup_signer_flow(&mut self) { 1178 self.startup_signer_task_token = self.startup_signer_task_token.wrapping_add(1); 1179 self.startup_signer_connect_state = StartupSignerConnectState::Idle; 1180 } 1181 1182 fn next_startup_signer_task_token(&mut self) -> u64 { 1183 self.startup_signer_task_token = self.startup_signer_task_token.wrapping_add(1); 1184 self.startup_signer_task_token 1185 } 1186 1187 fn startup_signer_task_is_current(&self, task_token: u64) -> bool { 1188 self.startup_signer_task_token == task_token 1189 } 1190 1191 fn show_startup_identity_choice(&mut self, cx: &mut Context<Self>) { 1192 self.startup_view.clear_notice(); 1193 self.reset_startup_signer_flow(); 1194 self.startup_signer_recovery_attempted = false; 1195 if self.runtime.show_startup_identity_choice() { 1196 cx.notify(); 1197 } 1198 } 1199 1200 fn cancel_startup_signer_flow(&mut self, cx: &mut Context<Self>) -> bool { 1201 self.reset_startup_signer_flow(); 1202 if !self.clear_startup_pending_remote_signer_session(cx) { 1203 return false; 1204 } 1205 1206 self.startup_signer_recovery_attempted = false; 1207 true 1208 } 1209 1210 fn back_out_of_startup_signer_entry(&mut self, cx: &mut Context<Self>) { 1211 if !self.cancel_startup_signer_flow(cx) { 1212 return; 1213 } 1214 1215 self.startup_view.clear_notice(); 1216 if self.runtime.show_startup_identity_choice() { 1217 cx.notify(); 1218 } 1219 } 1220 1221 fn show_startup_signer_entry(&mut self, cx: &mut Context<Self>) { 1222 self.startup_view.clear_notice(); 1223 self.reset_startup_signer_flow(); 1224 self.startup_signer_recovery_attempted = false; 1225 if self.runtime.show_startup_signer_entry() { 1226 cx.notify(); 1227 } 1228 } 1229 1230 fn start_generate_key(&mut self, window: &mut Window, cx: &mut Context<Self>) { 1231 if !self.cancel_startup_signer_flow(cx) { 1232 return; 1233 } 1234 if !self.runtime.begin_generate_key_startup() { 1235 return; 1236 } 1237 1238 self.startup_view.clear_notice(); 1239 let relay_urls = self.runtime.nostr_relay_urls(); 1240 cx.notify(); 1241 cx.spawn_in(window, async move |this, cx| { 1242 let startup_task = cx 1243 .background_executor() 1244 .spawn(run_startup_app_init(relay_urls)); 1245 Timer::after(Duration::from_secs(1)).await; 1246 let startup_result = startup_task.await; 1247 let _ = this.update(cx, |this, cx| { 1248 this.finish_generate_key(startup_result, cx); 1249 }); 1250 }) 1251 .detach(); 1252 } 1253 1254 fn finish_generate_key( 1255 &mut self, 1256 startup_result: Result<StartupAppInitResult, String>, 1257 cx: &mut Context<Self>, 1258 ) { 1259 match startup_result { 1260 Ok(result) => { 1261 self.relay_client = Some(result.relay_client); 1262 self.startup_view.clear_notice(); 1263 if !self.generate_local_account(cx) { 1264 self.show_startup_identity_choice(cx); 1265 } 1266 } 1267 Err(error) => { 1268 self.runtime.show_startup_identity_choice(); 1269 self.startup_view.set_notice(error); 1270 cx.notify(); 1271 } 1272 } 1273 } 1274 1275 fn sync_startup_signer_entry( 1276 &mut self, 1277 runtime_summary: &DesktopAppRuntimeSummary, 1278 window: &mut Window, 1279 cx: &mut Context<Self>, 1280 ) { 1281 if runtime_summary.startup_gate != AppStartupGate::SetupRequired 1282 || runtime_summary.logged_out_startup.phase != LoggedOutStartupPhase::SignerEntry 1283 { 1284 if self.startup_signer_entry.is_some() 1285 || !matches!( 1286 self.startup_signer_connect_state, 1287 StartupSignerConnectState::Idle 1288 ) 1289 { 1290 self.reset_startup_signer_flow(); 1291 } 1292 self.startup_signer_recovery_attempted = false; 1293 self.startup_signer_entry = None; 1294 return; 1295 } 1296 1297 let source_input = runtime_summary 1298 .logged_out_startup 1299 .signer_entry 1300 .source_input 1301 .as_str(); 1302 1303 match self.startup_signer_entry.as_mut() { 1304 Some(entry) => entry.sync(source_input, window, cx), 1305 None => { 1306 self.startup_signer_entry = 1307 Some(StartupSignerEntryState::new(source_input, window, cx)); 1308 } 1309 } 1310 1311 if !self.startup_signer_recovery_attempted { 1312 self.startup_signer_recovery_attempted = true; 1313 self.restore_startup_pending_remote_signer_session(window, cx); 1314 } 1315 } 1316 1317 fn submit_startup_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) { 1318 let Some(entry) = self.startup_signer_entry.as_ref() else { 1319 return; 1320 }; 1321 1322 let source_input = entry.input.read(cx).value().to_string(); 1323 match startup_signer_preview_summary(source_input.as_str()) { 1324 Ok(_) => {} 1325 Err(error) => { 1326 self.startup_view.set_notice(error); 1327 cx.notify(); 1328 return; 1329 } 1330 } 1331 1332 self.startup_view.clear_notice(); 1333 let task_token = self.next_startup_signer_task_token(); 1334 self.startup_signer_connect_state = StartupSignerConnectState::Connecting; 1335 cx.notify(); 1336 1337 cx.spawn_in(window, async move |this, cx| { 1338 let connect_result = cx 1339 .background_executor() 1340 .spawn(run_startup_signer_connect(source_input)) 1341 .await; 1342 let Some(pending_session) = this 1343 .update(cx, |this, cx| { 1344 this.finish_startup_signer_connect(task_token, connect_result, cx) 1345 }) 1346 .ok() 1347 .flatten() 1348 else { 1349 return; 1350 }; 1351 let _ = this.update_in(cx, |this, window, cx| { 1352 this.spawn_startup_signer_pending_poll(window, task_token, pending_session, cx); 1353 }); 1354 }) 1355 .detach(); 1356 } 1357 1358 fn finish_startup_signer_connect( 1359 &mut self, 1360 task_token: u64, 1361 connect_result: Result<RadrootsAppRemoteSignerPendingSession, String>, 1362 cx: &mut Context<Self>, 1363 ) -> Option<RadrootsAppRemoteSignerPendingSession> { 1364 if !self.startup_signer_task_is_current(task_token) { 1365 return None; 1366 } 1367 1368 match connect_result { 1369 Ok(pending_session) => { 1370 if let Err(error) = self 1371 .runtime 1372 .store_startup_pending_remote_signer_session(&pending_session) 1373 { 1374 self.startup_signer_connect_state = StartupSignerConnectState::Idle; 1375 self.startup_view.set_notice(error.to_string()); 1376 cx.notify(); 1377 return None; 1378 } 1379 self.startup_view.clear_notice(); 1380 self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { 1381 pending_session: pending_session.clone(), 1382 auth_challenge_url: None, 1383 }; 1384 cx.notify(); 1385 Some(pending_session) 1386 } 1387 Err(error) => { 1388 self.startup_signer_connect_state = StartupSignerConnectState::Idle; 1389 self.startup_view.set_notice(error); 1390 cx.notify(); 1391 None 1392 } 1393 } 1394 } 1395 1396 fn apply_startup_signer_poll_result( 1397 &mut self, 1398 task_token: u64, 1399 pending_session: RadrootsAppRemoteSignerPendingSession, 1400 poll_result: StartupSignerPollCycleResult, 1401 cx: &mut Context<Self>, 1402 ) -> bool { 1403 if !self.startup_signer_task_is_current(task_token) { 1404 return false; 1405 } 1406 1407 let auth_challenge_url = poll_result.auth_challenge_url; 1408 match poll_result.outcome { 1409 Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) => { 1410 self.startup_view.clear_notice(); 1411 self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { 1412 pending_session, 1413 auth_challenge_url, 1414 }; 1415 cx.notify(); 1416 true 1417 } 1418 Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message }) => { 1419 if startup_signer_transport_failure_requires_notice(message.as_str()) { 1420 self.startup_view.set_notice(message); 1421 } else { 1422 self.startup_view.clear_notice(); 1423 } 1424 self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { 1425 pending_session, 1426 auth_challenge_url, 1427 }; 1428 cx.notify(); 1429 true 1430 } 1431 Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(approved_session)) => match self 1432 .runtime 1433 .activate_startup_approved_remote_signer_session( 1434 &pending_session, 1435 &approved_session, 1436 ) { 1437 Ok(_) => { 1438 self.startup_view.clear_notice(); 1439 self.startup_signer_connect_state = StartupSignerConnectState::Approved { 1440 pending_session, 1441 approved_session, 1442 auth_challenge_url, 1443 }; 1444 cx.notify(); 1445 false 1446 } 1447 Err(error) => { 1448 self.startup_view.set_notice(error.to_string()); 1449 self.startup_signer_connect_state = 1450 StartupSignerConnectState::PendingApproval { 1451 pending_session, 1452 auth_challenge_url, 1453 }; 1454 cx.notify(); 1455 false 1456 } 1457 }, 1458 Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) 1459 | Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) 1460 | Err(message) => { 1461 let _ = self.runtime.clear_startup_pending_remote_signer_session(); 1462 self.startup_signer_connect_state = StartupSignerConnectState::Idle; 1463 self.startup_view.set_notice(message); 1464 cx.notify(); 1465 false 1466 } 1467 } 1468 } 1469 1470 fn spawn_startup_signer_pending_poll( 1471 &mut self, 1472 window: &mut Window, 1473 task_token: u64, 1474 pending_session: RadrootsAppRemoteSignerPendingSession, 1475 cx: &mut Context<Self>, 1476 ) { 1477 cx.spawn_in(window, async move |this, cx| { 1478 loop { 1479 let poll_result = cx 1480 .background_executor() 1481 .spawn(run_startup_signer_pending_poll( 1482 pending_session.record.clone(), 1483 pending_session.client_secret_key_hex.clone(), 1484 )) 1485 .await; 1486 let should_continue = this 1487 .update(cx, |this, cx| { 1488 this.apply_startup_signer_poll_result( 1489 task_token, 1490 pending_session.clone(), 1491 poll_result, 1492 cx, 1493 ) 1494 }) 1495 .ok() 1496 .unwrap_or(false); 1497 if !should_continue { 1498 return; 1499 } 1500 1501 Timer::after(Duration::from_secs(1)).await; 1502 } 1503 }) 1504 .detach(); 1505 } 1506 1507 fn restore_startup_pending_remote_signer_session( 1508 &mut self, 1509 window: &mut Window, 1510 cx: &mut Context<Self>, 1511 ) { 1512 let pending_session = match self.runtime.load_startup_pending_remote_signer_session() { 1513 Ok(Some(pending_session)) => pending_session, 1514 Ok(None) => return, 1515 Err(error) => { 1516 self.startup_view.set_notice(error.to_string()); 1517 cx.notify(); 1518 return; 1519 } 1520 }; 1521 1522 let task_token = self.next_startup_signer_task_token(); 1523 self.startup_view.clear_notice(); 1524 self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { 1525 pending_session: pending_session.clone(), 1526 auth_challenge_url: None, 1527 }; 1528 cx.notify(); 1529 self.spawn_startup_signer_pending_poll(window, task_token, pending_session, cx); 1530 } 1531 1532 fn clear_startup_pending_remote_signer_session(&mut self, cx: &mut Context<Self>) -> bool { 1533 match self.runtime.clear_startup_pending_remote_signer_session() { 1534 Ok(_) => true, 1535 Err(error) => { 1536 self.startup_view.set_notice(error.to_string()); 1537 cx.notify(); 1538 false 1539 } 1540 } 1541 } 1542 1543 fn open_farm_setup(&mut self, window: &mut Window, cx: &mut Context<Self>) { 1544 let runtime_summary = self.runtime.summary(); 1545 let Some(account_id) = runtime_summary 1546 .settings_account_projection 1547 .selected_account 1548 .as_ref() 1549 .map(|account| account.account.account_id.clone()) 1550 else { 1551 return; 1552 }; 1553 1554 if runtime_summary.farm_setup_projection.has_saved_farm() { 1555 self.farm_setup_form = Some(FarmSetupFormState::new( 1556 account_id, 1557 runtime_summary.farm_setup_projection.draft, 1558 window, 1559 cx, 1560 )); 1561 self.focused_view = Some(HomeFocusedView::FarmSetup); 1562 cx.notify(); 1563 return; 1564 } 1565 1566 let stage_changed = self 1567 .runtime 1568 .select_farm_setup_flow_stage(FarmSetupFlowStage::Editing); 1569 1570 self.farm_setup_form = Some(FarmSetupFormState::new( 1571 account_id, 1572 runtime_summary.farm_setup_projection.draft, 1573 window, 1574 cx, 1575 )); 1576 self.focused_view = Some(HomeFocusedView::FarmSetup); 1577 if stage_changed || self.farm_setup_form.is_some() { 1578 cx.notify(); 1579 } 1580 } 1581 1582 fn sync_farm_setup_form( 1583 &mut self, 1584 runtime_summary: &DesktopAppRuntimeSummary, 1585 window: &mut Window, 1586 cx: &mut Context<Self>, 1587 ) { 1588 let Some(account_id) = runtime_summary 1589 .settings_account_projection 1590 .selected_account 1591 .as_ref() 1592 .map(|account| account.account.account_id.clone()) 1593 else { 1594 self.farm_setup_form = None; 1595 self.clear_focused_view_matching(HomeFocusedView::FarmSetup); 1596 return; 1597 }; 1598 1599 if runtime_summary.home_route != HomeRoute::FarmSetupForm && self.farm_setup_form.is_none() 1600 { 1601 self.farm_setup_form = None; 1602 return; 1603 } 1604 1605 let draft = runtime_summary.farm_setup_projection.draft.clone(); 1606 let should_reset = self 1607 .farm_setup_form 1608 .as_ref() 1609 .map(|form| form.account_id != account_id) 1610 .unwrap_or(true); 1611 1612 if should_reset { 1613 self.farm_setup_form = Some(FarmSetupFormState::new(account_id, draft, window, cx)); 1614 } 1615 1616 if runtime_summary.home_route == HomeRoute::FarmSetupForm { 1617 self.focused_view = Some(HomeFocusedView::FarmSetup); 1618 } 1619 } 1620 1621 fn sync_products_search( 1622 &mut self, 1623 runtime_summary: &DesktopAppRuntimeSummary, 1624 window: &mut Window, 1625 cx: &mut Context<Self>, 1626 ) { 1627 let Some(account_id) = runtime_summary 1628 .settings_account_projection 1629 .selected_account 1630 .as_ref() 1631 .map(|account| account.account.account_id.clone()) 1632 else { 1633 self.products_search = None; 1634 return; 1635 }; 1636 1637 if !runtime_summary.farm_setup_projection.has_saved_farm() { 1638 self.products_search = None; 1639 return; 1640 } 1641 1642 let search_query = runtime_summary 1643 .products_projection 1644 .query 1645 .search_query 1646 .as_str(); 1647 let should_reset = self 1648 .products_search 1649 .as_ref() 1650 .map(|state| state.account_id != account_id) 1651 .unwrap_or(true); 1652 1653 if should_reset { 1654 self.products_search = Some(ProductsSearchState::new( 1655 account_id, 1656 search_query, 1657 window, 1658 cx, 1659 )); 1660 return; 1661 } 1662 1663 if let Some(products_search) = self.products_search.as_mut() { 1664 products_search.sync(search_query, window, cx); 1665 } 1666 } 1667 1668 fn sync_personal_search( 1669 &mut self, 1670 runtime_summary: &DesktopAppRuntimeSummary, 1671 window: &mut Window, 1672 cx: &mut Context<Self>, 1673 ) { 1674 if home_stage(runtime_summary) != HomeStage::BuyerWorkspace 1675 || selected_personal_section(runtime_summary) != PersonalSection::Search 1676 { 1677 self.personal_search = None; 1678 return; 1679 } 1680 1681 let workspace_id = runtime_summary 1682 .settings_account_projection 1683 .selected_account 1684 .as_ref() 1685 .map(|account| account.account.account_id.clone()) 1686 .unwrap_or_else(|| "guest".to_owned()); 1687 let search_query = runtime_summary 1688 .personal_projection 1689 .search 1690 .query 1691 .search_query 1692 .as_str(); 1693 let should_reset = self 1694 .personal_search 1695 .as_ref() 1696 .map(|state| state.workspace_id != workspace_id) 1697 .unwrap_or(true); 1698 1699 if should_reset { 1700 self.personal_search = Some(PersonalSearchState::new( 1701 workspace_id, 1702 search_query, 1703 window, 1704 cx, 1705 )); 1706 return; 1707 } 1708 1709 if let Some(personal_search) = self.personal_search.as_mut() { 1710 personal_search.sync(search_query, window, cx); 1711 } 1712 } 1713 1714 fn sync_buyer_order_review_form( 1715 &mut self, 1716 runtime_summary: &DesktopAppRuntimeSummary, 1717 window: &mut Window, 1718 cx: &mut Context<Self>, 1719 ) { 1720 if home_stage(runtime_summary) != HomeStage::BuyerWorkspace 1721 || selected_personal_section(runtime_summary) != PersonalSection::Cart 1722 || runtime_summary 1723 .personal_projection 1724 .cart 1725 .cart 1726 .lines 1727 .is_empty() 1728 { 1729 self.buyer_order_review_form = None; 1730 return; 1731 } 1732 1733 let workspace_id = personal_workspace_id(runtime_summary); 1734 let draft = &runtime_summary.personal_projection.cart.order_review.draft; 1735 let should_reset = self 1736 .buyer_order_review_form 1737 .as_ref() 1738 .map(|form| form.workspace_id != workspace_id) 1739 .unwrap_or(false); 1740 1741 if should_reset { 1742 self.buyer_order_review_form = Some(BuyerOrderReviewFormState::new( 1743 workspace_id, 1744 draft, 1745 window, 1746 cx, 1747 )); 1748 return; 1749 } 1750 1751 if let Some(form) = self.buyer_order_review_form.as_mut() { 1752 form.sync(draft, window, cx); 1753 } 1754 } 1755 1756 fn sync_products_stock_editor(&mut self, runtime_summary: &DesktopAppRuntimeSummary) { 1757 let Some(editor) = self.products_stock_editor.as_ref() else { 1758 return; 1759 }; 1760 let Some(account_id) = runtime_summary 1761 .settings_account_projection 1762 .selected_account 1763 .as_ref() 1764 .map(|account| account.account.account_id.as_str()) 1765 else { 1766 self.products_stock_editor = None; 1767 return; 1768 }; 1769 1770 let should_clear = editor.account_id != account_id 1771 || selected_farmer_section(runtime_summary) != FarmerSection::Products 1772 || !runtime_summary.farm_setup_projection.has_saved_farm() 1773 || !runtime_summary 1774 .products_projection 1775 .list 1776 .rows 1777 .iter() 1778 .any(|row| row.product_id == editor.product_id); 1779 1780 if should_clear { 1781 self.products_stock_editor = None; 1782 } 1783 } 1784 1785 fn sync_product_editor_form( 1786 &mut self, 1787 runtime_summary: &DesktopAppRuntimeSummary, 1788 window: &mut Window, 1789 cx: &mut Context<Self>, 1790 ) { 1791 let Some(account_id) = runtime_summary 1792 .settings_account_projection 1793 .selected_account 1794 .as_ref() 1795 .map(|account| account.account.account_id.clone()) 1796 else { 1797 self.product_editor_form = None; 1798 self.clear_focused_view_matching(HomeFocusedView::ProductEditor); 1799 return; 1800 }; 1801 1802 if selected_farmer_section(runtime_summary) != FarmerSection::Products 1803 || !runtime_summary.farm_setup_projection.has_saved_farm() 1804 { 1805 self.product_editor_form = None; 1806 self.clear_focused_view_matching(HomeFocusedView::ProductEditor); 1807 return; 1808 } 1809 1810 let radroots_app_state::ProductEditorState::Open(session) = 1811 &runtime_summary.products_projection.editor 1812 else { 1813 self.product_editor_form = None; 1814 self.clear_focused_view_matching(HomeFocusedView::ProductEditor); 1815 return; 1816 }; 1817 let Some(product_id) = session.selected_product_id else { 1818 self.product_editor_form = None; 1819 self.clear_focused_view_matching(HomeFocusedView::ProductEditor); 1820 return; 1821 }; 1822 let should_reset = self 1823 .product_editor_form 1824 .as_ref() 1825 .map(|form| form.account_id != account_id || form.product_id != product_id) 1826 .unwrap_or(true); 1827 1828 if should_reset { 1829 self.product_editor_form = Some(ProductEditorFormState::new( 1830 account_id, 1831 product_id, 1832 session.draft.clone(), 1833 window, 1834 cx, 1835 )); 1836 } 1837 } 1838 1839 fn select_farmer_section(&mut self, section: FarmerSection, cx: &mut Context<Self>) { 1840 if self.runtime.select_farmer_section(section) { 1841 self.products_stock_editor = None; 1842 self.clear_focused_view(); 1843 if section != FarmerSection::Products { 1844 self.product_editor_form = None; 1845 } 1846 cx.notify(); 1847 } 1848 } 1849 1850 fn select_personal_section(&mut self, section: PersonalSection, cx: &mut Context<Self>) { 1851 if self.select_personal_section_update(section) { 1852 cx.notify(); 1853 } 1854 } 1855 1856 fn select_personal_section_update(&mut self, section: PersonalSection) -> bool { 1857 match self.runtime.select_personal_section(section) { 1858 Ok(true) => { 1859 self.products_stock_editor = None; 1860 self.product_editor_form = None; 1861 self.clear_focused_view(); 1862 self.clear_buyer_workspace_notice(); 1863 true 1864 } 1865 Ok(false) => self.clear_buyer_workspace_notice(), 1866 Err(runtime_error) => { 1867 error!( 1868 target: "shell", 1869 event = "buyer.section_select_failed", 1870 section = ?section, 1871 error = %runtime_error, 1872 "failed to select buyer section" 1873 ); 1874 self.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed) 1875 } 1876 } 1877 } 1878 1879 fn switch_to_marketplace(&mut self, cx: &mut Context<Self>) { 1880 match self 1881 .runtime 1882 .select_active_surface(radroots_app_view::ActiveSurface::Personal) 1883 { 1884 Ok(true) => { 1885 self.products_stock_editor = None; 1886 self.product_editor_form = None; 1887 self.clear_focused_view(); 1888 cx.notify(); 1889 } 1890 Ok(false) => {} 1891 Err(runtime_error) => { 1892 error!( 1893 target: "shell", 1894 event = "shell.switch_marketplace_failed", 1895 error = %runtime_error, 1896 "failed to switch into marketplace mode" 1897 ); 1898 } 1899 } 1900 } 1901 1902 fn switch_to_farmer_workspace(&mut self, cx: &mut Context<Self>) { 1903 match self 1904 .runtime 1905 .select_active_surface(radroots_app_view::ActiveSurface::Farmer) 1906 { 1907 Ok(true) => { 1908 self.products_stock_editor = None; 1909 self.product_editor_form = None; 1910 self.clear_focused_view(); 1911 cx.notify(); 1912 } 1913 Ok(false) => {} 1914 Err(runtime_error) => { 1915 error!( 1916 target: "shell", 1917 event = "shell.switch_farm_failed", 1918 error = %runtime_error, 1919 "failed to switch into farm mode" 1920 ); 1921 } 1922 } 1923 } 1924 1925 fn open_account_entry(&mut self, cx: &mut Context<Self>) { 1926 if self.runtime.select_account() { 1927 self.products_stock_editor = None; 1928 self.product_editor_form = None; 1929 self.clear_focused_view(); 1930 cx.notify(); 1931 } 1932 } 1933 1934 fn open_account_tab(&mut self, tab: AccountTab, cx: &mut Context<Self>) { 1935 let route_changed = self.runtime.select_account(); 1936 if route_changed { 1937 self.products_stock_editor = None; 1938 self.product_editor_form = None; 1939 self.clear_focused_view(); 1940 } 1941 1942 let tab_changed = self.selected_account_tab != tab; 1943 self.selected_account_tab = tab; 1944 1945 if route_changed || tab_changed { 1946 cx.notify(); 1947 } 1948 } 1949 1950 fn select_account_tab(&mut self, tab: AccountTab, cx: &mut Context<Self>) { 1951 if self.selected_account_tab != tab { 1952 self.selected_account_tab = tab; 1953 cx.notify(); 1954 } 1955 } 1956 1957 fn select_account_farm_details_tab( 1958 &mut self, 1959 tab: AccountFarmDetailsTab, 1960 cx: &mut Context<Self>, 1961 ) { 1962 if self.selected_account_farm_details_tab != tab { 1963 self.selected_account_farm_details_tab = tab; 1964 cx.notify(); 1965 } 1966 } 1967 1968 fn handle_startup_signer_input_event( 1969 &mut self, 1970 state: &Entity<InputState>, 1971 event: &InputEvent, 1972 _: &mut Window, 1973 cx: &mut Context<Self>, 1974 ) { 1975 if !matches!(event, InputEvent::Change) { 1976 return; 1977 } 1978 1979 let Some(entry) = self.startup_signer_entry.as_ref() else { 1980 return; 1981 }; 1982 if entry.input != *state { 1983 return; 1984 } 1985 if !startup_signer_source_input_is_editable(&self.startup_signer_connect_state) { 1986 return; 1987 } 1988 1989 let value = state.read(cx).value().to_string(); 1990 if self.runtime.set_startup_signer_source_input(value.as_str()) { 1991 self.startup_view.clear_notice(); 1992 self.reset_startup_signer_flow(); 1993 cx.notify(); 1994 } 1995 } 1996 1997 fn handle_products_search_input_event( 1998 &mut self, 1999 state: &Entity<InputState>, 2000 event: &InputEvent, 2001 _: &mut Window, 2002 cx: &mut Context<Self>, 2003 ) { 2004 if !matches!(event, InputEvent::Change) { 2005 return; 2006 } 2007 2008 let value = state.read(cx).value().to_string(); 2009 match self.runtime.set_products_search_query(value.as_str()) { 2010 Ok(true) => cx.notify(), 2011 Ok(false) => {} 2012 Err(runtime_error) => { 2013 error!( 2014 target: "products", 2015 event = "products.search_query_update_failed", 2016 error = %runtime_error, 2017 "failed to update products search query" 2018 ); 2019 } 2020 } 2021 } 2022 2023 fn handle_personal_search_input_event( 2024 &mut self, 2025 state: &Entity<InputState>, 2026 event: &InputEvent, 2027 _: &mut Window, 2028 cx: &mut Context<Self>, 2029 ) { 2030 if !matches!(event, InputEvent::Change) { 2031 return; 2032 } 2033 2034 let value = state.read(cx).value().to_string(); 2035 if self.set_personal_search_query_update(value.as_str()) { 2036 cx.notify(); 2037 } 2038 } 2039 2040 fn set_personal_search_query_update(&mut self, value: &str) -> bool { 2041 match self.runtime.set_personal_search_query(value) { 2042 Ok(changed) => self.clear_buyer_workspace_notice() || changed, 2043 Err(runtime_error) => { 2044 error!( 2045 target: "buyer", 2046 event = "buyer.search_query_update_failed", 2047 error = %runtime_error, 2048 "failed to update buyer search query" 2049 ); 2050 self.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed) 2051 } 2052 } 2053 } 2054 2055 fn handle_buyer_order_review_input_event( 2056 &mut self, 2057 state: &Entity<InputState>, 2058 event: &InputEvent, 2059 _: &mut Window, 2060 cx: &mut Context<Self>, 2061 ) { 2062 if !matches!(event, InputEvent::Change) { 2063 return; 2064 } 2065 2066 let Some(form) = self.buyer_order_review_form.as_ref() else { 2067 return; 2068 }; 2069 let matches_input = form.name_input == *state 2070 || form.email_input == *state 2071 || form.phone_input == *state 2072 || form.order_note_input == *state; 2073 if !matches_input { 2074 return; 2075 } 2076 2077 match self 2078 .runtime 2079 .save_personal_order_review_draft(form.current_draft(cx)) 2080 { 2081 Ok(true) => cx.notify(), 2082 Ok(false) => {} 2083 Err(runtime_error) => { 2084 error!( 2085 target: "buyer", 2086 event = "buyer.order_review_save_failed", 2087 error = %runtime_error, 2088 "failed to save buyer order review draft" 2089 ); 2090 } 2091 } 2092 } 2093 2094 fn toggle_personal_search_fulfillment_method( 2095 &mut self, 2096 method: FarmOrderMethod, 2097 enabled: bool, 2098 cx: &mut Context<Self>, 2099 ) { 2100 if self.set_personal_search_fulfillment_method_update(method, enabled) { 2101 cx.notify(); 2102 } 2103 } 2104 2105 fn set_personal_search_fulfillment_method_update( 2106 &mut self, 2107 method: FarmOrderMethod, 2108 enabled: bool, 2109 ) -> bool { 2110 match self 2111 .runtime 2112 .set_personal_search_fulfillment_method(method, enabled) 2113 { 2114 Ok(changed) => self.clear_buyer_workspace_notice() || changed, 2115 Err(runtime_error) => { 2116 error!( 2117 target: "buyer", 2118 event = "buyer.fulfillment_filter_update_failed", 2119 error = %runtime_error, 2120 method = method.storage_key(), 2121 "failed to update buyer fulfillment filter" 2122 ); 2123 self.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed) 2124 } 2125 } 2126 } 2127 2128 fn open_personal_product_detail( 2129 &mut self, 2130 section: PersonalSection, 2131 product_id: ProductId, 2132 cx: &mut Context<Self>, 2133 ) { 2134 if self.open_personal_product_detail_update(section, product_id) { 2135 cx.notify(); 2136 } 2137 } 2138 2139 fn open_personal_product_detail_update( 2140 &mut self, 2141 section: PersonalSection, 2142 product_id: ProductId, 2143 ) -> bool { 2144 match self 2145 .runtime 2146 .open_personal_product_detail(section, product_id) 2147 { 2148 Ok(true) => { 2149 self.clear_buyer_workspace_notice(); 2150 self.focused_view = Some(HomeFocusedView::BuyerProductDetail(section)); 2151 true 2152 } 2153 Ok(false) => self.clear_buyer_workspace_notice(), 2154 Err(runtime_error) => { 2155 error!( 2156 target: "buyer", 2157 event = "buyer.detail_open_failed", 2158 error = %runtime_error, 2159 "failed to open buyer product detail" 2160 ); 2161 self.set_buyer_workspace_notice(BuyerWorkspaceNotice::DetailOpenFailed) 2162 } 2163 } 2164 } 2165 2166 fn set_buyer_workspace_notice(&mut self, notice: BuyerWorkspaceNotice) -> bool { 2167 let notice = notice.text(); 2168 let changed = self.buyer_workspace_notice.as_deref() != Some(notice.as_str()); 2169 self.buyer_workspace_notice = Some(notice); 2170 changed 2171 } 2172 2173 fn clear_buyer_workspace_notice(&mut self) -> bool { 2174 self.buyer_workspace_notice.take().is_some() 2175 } 2176 2177 fn close_personal_product_detail(&mut self, section: PersonalSection, cx: &mut Context<Self>) { 2178 let runtime_changed = self.runtime.close_personal_product_detail(section); 2179 let focus_changed = 2180 self.clear_focused_view_matching(HomeFocusedView::BuyerProductDetail(section)); 2181 if runtime_changed || focus_changed { 2182 cx.notify(); 2183 } 2184 } 2185 2186 fn increase_personal_product_quantity( 2187 &mut self, 2188 section: PersonalSection, 2189 cx: &mut Context<Self>, 2190 ) { 2191 if self.runtime.increase_personal_product_quantity(section) { 2192 cx.notify(); 2193 } 2194 } 2195 2196 fn decrease_personal_product_quantity( 2197 &mut self, 2198 section: PersonalSection, 2199 cx: &mut Context<Self>, 2200 ) { 2201 if self.runtime.decrease_personal_product_quantity(section) { 2202 cx.notify(); 2203 } 2204 } 2205 2206 fn add_personal_product_to_cart( 2207 &mut self, 2208 section: PersonalSection, 2209 replace_existing: bool, 2210 cx: &mut Context<Self>, 2211 ) { 2212 match self 2213 .runtime 2214 .add_personal_product_to_cart(section, replace_existing) 2215 { 2216 Ok(true) => cx.notify(), 2217 Ok(false) => {} 2218 Err(runtime_error) => { 2219 error!( 2220 target: "buyer", 2221 event = "buyer.add_to_cart_failed", 2222 error = %runtime_error, 2223 "failed to add buyer product to cart" 2224 ); 2225 } 2226 } 2227 } 2228 2229 fn clear_personal_cart_replace_confirmation(&mut self, cx: &mut Context<Self>) { 2230 if self.runtime.clear_personal_cart_replace_confirmation() { 2231 cx.notify(); 2232 } 2233 } 2234 2235 fn open_personal_order_review(&mut self, window: &mut Window, cx: &mut Context<Self>) { 2236 if self.buyer_order_review_form.is_some() { 2237 return; 2238 } 2239 2240 let runtime_summary = self.runtime.summary(); 2241 if home_stage(&runtime_summary) != HomeStage::BuyerWorkspace 2242 || selected_personal_section(&runtime_summary) != PersonalSection::Cart 2243 || runtime_summary 2244 .personal_projection 2245 .cart 2246 .cart 2247 .lines 2248 .is_empty() 2249 { 2250 return; 2251 } 2252 2253 self.buyer_order_review_form = Some(BuyerOrderReviewFormState::new( 2254 personal_workspace_id(&runtime_summary), 2255 &runtime_summary.personal_projection.cart.order_review.draft, 2256 window, 2257 cx, 2258 )); 2259 self.focused_view = Some(HomeFocusedView::BuyerOrderReview); 2260 cx.notify(); 2261 } 2262 2263 fn close_personal_order_review(&mut self, cx: &mut Context<Self>) { 2264 let cleared = self.buyer_order_review_form.take().is_some(); 2265 let focus_changed = self.clear_focused_view_matching(HomeFocusedView::BuyerOrderReview); 2266 if cleared || focus_changed { 2267 cx.notify(); 2268 } 2269 } 2270 2271 fn remove_personal_cart_line(&mut self, product_id: ProductId, cx: &mut Context<Self>) { 2272 match self.runtime.remove_personal_cart_line(product_id) { 2273 Ok(true) => cx.notify(), 2274 Ok(false) => {} 2275 Err(runtime_error) => { 2276 error!( 2277 target: "buyer", 2278 event = "buyer.cart_remove_failed", 2279 error = %runtime_error, 2280 product_id = %product_id, 2281 "failed to remove buyer cart line" 2282 ); 2283 } 2284 } 2285 } 2286 2287 fn place_personal_order(&mut self, cx: &mut Context<Self>) { 2288 if self.place_personal_order_update() { 2289 cx.notify(); 2290 } 2291 } 2292 2293 fn place_personal_order_update(&mut self) -> bool { 2294 match self.runtime.place_personal_order() { 2295 Ok(true) => { 2296 self.buyer_order_review_form = None; 2297 let _ = self.clear_buyer_workspace_notice(); 2298 true 2299 } 2300 Ok(false) => false, 2301 Err(runtime_error) => { 2302 let notice = buyer_order_place_failure_notice(&runtime_error); 2303 if notice == BuyerWorkspaceNotice::OrderCoordinationFailed { 2304 self.buyer_order_review_form = None; 2305 } 2306 error!( 2307 target: "buyer", 2308 event = "buyer.order_review_place_failed", 2309 error = %runtime_error, 2310 "failed to place buyer order" 2311 ); 2312 let notice_changed = self.set_buyer_workspace_notice(notice); 2313 buyer_order_coordination_notice_forces_redraw(notice) || notice_changed 2314 } 2315 } 2316 } 2317 2318 fn retry_pending_personal_order_coordination(&mut self, cx: &mut Context<Self>) { 2319 if self.retry_pending_personal_order_coordination_update() { 2320 cx.notify(); 2321 } 2322 } 2323 2324 fn retry_pending_personal_order_coordination_update(&mut self) -> bool { 2325 match self.runtime.retry_pending_personal_order_coordination() { 2326 Ok(true) => { 2327 let _ = self.clear_buyer_workspace_notice(); 2328 true 2329 } 2330 Ok(false) => false, 2331 Err(runtime_error) => { 2332 error!( 2333 target: "buyer", 2334 event = "buyer.order_coordination_retry_failed", 2335 error = %runtime_error, 2336 "failed to retry buyer order coordination" 2337 ); 2338 let notice = BuyerWorkspaceNotice::OrderCoordinationFailed; 2339 let notice_changed = self.set_buyer_workspace_notice(notice); 2340 buyer_order_coordination_notice_forces_redraw(notice) || notice_changed 2341 } 2342 } 2343 } 2344 2345 fn open_personal_order_detail(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2346 match self.runtime.open_personal_order_detail(order_id) { 2347 Ok(runtime_changed) => { 2348 let Some(focused_view) = buyer_order_detail_focus_after_open( 2349 runtime_changed, 2350 &self.runtime.summary(), 2351 order_id, 2352 ) else { 2353 return; 2354 }; 2355 self.focused_view = Some(focused_view); 2356 cx.notify(); 2357 } 2358 Err(runtime_error) => { 2359 error!( 2360 target: "buyer", 2361 event = "buyer.order_open_failed", 2362 error = %runtime_error, 2363 order_id = %order_id, 2364 "failed to open buyer order detail" 2365 ); 2366 } 2367 } 2368 } 2369 2370 fn close_personal_order_detail(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2371 if self.clear_focused_view_matching(HomeFocusedView::BuyerOrderDetail(order_id)) { 2372 cx.notify(); 2373 } 2374 } 2375 2376 fn repeat_personal_order( 2377 &mut self, 2378 order_id: OrderId, 2379 replace_existing: bool, 2380 cx: &mut Context<Self>, 2381 ) { 2382 match self 2383 .runtime 2384 .repeat_personal_order(order_id, replace_existing) 2385 { 2386 Ok(true) => cx.notify(), 2387 Ok(false) => {} 2388 Err(runtime_error) => { 2389 error!( 2390 target: "buyer", 2391 event = "buyer.repeat_demand_failed", 2392 error = %runtime_error, 2393 order_id = %order_id, 2394 "failed to reorder buyer order" 2395 ); 2396 } 2397 } 2398 } 2399 2400 fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) { 2401 match self.runtime.select_products_filter(filter) { 2402 Ok(true) => { 2403 self.products_stock_editor = None; 2404 cx.notify(); 2405 } 2406 Ok(false) => {} 2407 Err(runtime_error) => { 2408 error!( 2409 target: "products", 2410 event = "products.filter_update_failed", 2411 error = %runtime_error, 2412 filter = filter.storage_key(), 2413 "failed to update products filter" 2414 ); 2415 } 2416 } 2417 } 2418 2419 fn select_products_sort(&mut self, sort: ProductsSort, cx: &mut Context<Self>) { 2420 match self.runtime.select_products_sort(sort) { 2421 Ok(true) => { 2422 self.products_stock_editor = None; 2423 cx.notify(); 2424 } 2425 Ok(false) => {} 2426 Err(runtime_error) => { 2427 error!( 2428 target: "products", 2429 event = "products.sort_update_failed", 2430 error = %runtime_error, 2431 sort = sort.storage_key(), 2432 "failed to update products sort" 2433 ); 2434 } 2435 } 2436 } 2437 2438 fn open_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) { 2439 match self.runtime.open_products_filter(filter) { 2440 Ok(true) => { 2441 self.products_stock_editor = None; 2442 cx.notify(); 2443 } 2444 Ok(false) => {} 2445 Err(runtime_error) => { 2446 error!( 2447 target: "products", 2448 event = "products.route_failed", 2449 error = %runtime_error, 2450 filter = filter.storage_key(), 2451 "failed to route into products view" 2452 ); 2453 } 2454 } 2455 } 2456 2457 fn open_orders(&mut self, cx: &mut Context<Self>) { 2458 match self.runtime.open_orders() { 2459 Ok(true) => { 2460 self.products_stock_editor = None; 2461 self.product_editor_form = None; 2462 self.clear_focused_view(); 2463 cx.notify(); 2464 } 2465 Ok(false) => {} 2466 Err(runtime_error) => { 2467 error!( 2468 target: "orders", 2469 event = "orders.route_failed", 2470 error = %runtime_error, 2471 "failed to route into orders view" 2472 ); 2473 } 2474 } 2475 } 2476 2477 fn open_orders_fulfillment_window( 2478 &mut self, 2479 fulfillment_window_id: FulfillmentWindowId, 2480 cx: &mut Context<Self>, 2481 ) { 2482 match self 2483 .runtime 2484 .open_orders_fulfillment_window(fulfillment_window_id) 2485 { 2486 Ok(true) => { 2487 self.products_stock_editor = None; 2488 self.product_editor_form = None; 2489 self.clear_focused_view(); 2490 cx.notify(); 2491 } 2492 Ok(false) => {} 2493 Err(runtime_error) => { 2494 error!( 2495 target: "orders", 2496 event = "orders.route_failed", 2497 error = %runtime_error, 2498 fulfillment_window_id = %fulfillment_window_id, 2499 "failed to route into orders view" 2500 ); 2501 } 2502 } 2503 } 2504 2505 fn open_pack_day( 2506 &mut self, 2507 fulfillment_window_id: Option<FulfillmentWindowId>, 2508 cx: &mut Context<Self>, 2509 ) { 2510 match self.runtime.open_pack_day(fulfillment_window_id) { 2511 Ok(true) => { 2512 self.products_stock_editor = None; 2513 self.product_editor_form = None; 2514 self.clear_focused_view(); 2515 cx.notify(); 2516 } 2517 Ok(false) => {} 2518 Err(runtime_error) => { 2519 error!( 2520 target: "pack_day", 2521 event = "pack_day.route_failed", 2522 error = %runtime_error, 2523 "failed to route into pack day view" 2524 ); 2525 } 2526 } 2527 } 2528 2529 fn export_pack_day(&mut self, cx: &mut Context<Self>) { 2530 match self.runtime.export_pack_day() { 2531 Ok(true) => cx.notify(), 2532 Ok(false) => {} 2533 Err(runtime_error) => { 2534 error!( 2535 target: "pack_day", 2536 event = "pack_day.export_failed", 2537 error = %runtime_error, 2538 "failed to export pack day" 2539 ); 2540 cx.notify(); 2541 } 2542 } 2543 } 2544 2545 fn start_pack_day_host_handoff( 2546 &mut self, 2547 kind: PackDayHostHandoffKind, 2548 window: &mut Window, 2549 cx: &mut Context<Self>, 2550 ) { 2551 match self.runtime.prepare_pack_day_host_handoff(kind) { 2552 Ok(Some((request, plan))) => { 2553 cx.notify(); 2554 cx.spawn_in(window, async move |this, cx| { 2555 let result = cx 2556 .background_executor() 2557 .spawn(run_pack_day_host_handoff(plan)) 2558 .await; 2559 let _ = this.update(cx, |this, cx| { 2560 this.finish_pack_day_host_handoff(request, result, cx); 2561 }); 2562 }) 2563 .detach(); 2564 } 2565 Ok(None) => {} 2566 Err(runtime_error) => { 2567 error!( 2568 target: "pack_day", 2569 event = "pack_day.host_handoff_prepare_failed", 2570 kind = %kind.storage_key(), 2571 error = %runtime_error, 2572 "failed to prepare pack day host handoff" 2573 ); 2574 cx.notify(); 2575 } 2576 } 2577 } 2578 2579 fn start_pack_day_print( 2580 &mut self, 2581 kind: PackDayPrintKind, 2582 window: &mut Window, 2583 cx: &mut Context<Self>, 2584 ) { 2585 match self.runtime.prepare_pack_day_print(kind) { 2586 Ok(Some((request, plan))) => { 2587 cx.notify(); 2588 cx.spawn_in(window, async move |this, cx| { 2589 let result = cx 2590 .background_executor() 2591 .spawn(run_pack_day_print(plan)) 2592 .await; 2593 let _ = this.update(cx, |this, cx| { 2594 this.finish_pack_day_print(request, result, cx); 2595 }); 2596 }) 2597 .detach(); 2598 } 2599 Ok(None) => {} 2600 Err(runtime_error) => { 2601 error!( 2602 target: "pack_day", 2603 event = "pack_day.print_prepare_failed", 2604 kind = %kind.storage_key(), 2605 error = %runtime_error, 2606 "failed to prepare pack day print" 2607 ); 2608 cx.notify(); 2609 } 2610 } 2611 } 2612 2613 fn start_pack_day_batch_print(&mut self, window: &mut Window, cx: &mut Context<Self>) { 2614 match self.runtime.prepare_pack_day_batch_print() { 2615 Ok(Some((request, plan))) => { 2616 cx.notify(); 2617 cx.spawn_in(window, async move |this, cx| { 2618 let result = cx 2619 .background_executor() 2620 .spawn(run_pack_day_batch_print(plan)) 2621 .await; 2622 let _ = this.update(cx, |this, cx| { 2623 this.finish_pack_day_batch_print(request, result, cx); 2624 }); 2625 }) 2626 .detach(); 2627 } 2628 Ok(None) => {} 2629 Err(runtime_error) => { 2630 error!( 2631 target: "pack_day", 2632 event = "pack_day.batch_print_prepare_failed", 2633 error = %runtime_error, 2634 "failed to prepare pack day batch print" 2635 ); 2636 cx.notify(); 2637 } 2638 } 2639 } 2640 2641 fn finish_pack_day_host_handoff( 2642 &mut self, 2643 request: PackDayHostHandoffRequest, 2644 result: Result<(), PackDayHostHandoffError>, 2645 cx: &mut Context<Self>, 2646 ) { 2647 let kind = request.kind.storage_key(); 2648 match self.runtime.finish_pack_day_host_handoff(request, result) { 2649 Ok(true) => cx.notify(), 2650 Ok(false) => {} 2651 Err(runtime_error) => { 2652 error!( 2653 target: "pack_day", 2654 event = "pack_day.host_handoff_failed", 2655 kind = %kind, 2656 error = %runtime_error, 2657 "failed to complete pack day host handoff" 2658 ); 2659 cx.notify(); 2660 } 2661 } 2662 } 2663 2664 fn finish_pack_day_print( 2665 &mut self, 2666 request: PackDayPrintRequest, 2667 result: Result<(), PackDayPrintError>, 2668 cx: &mut Context<Self>, 2669 ) { 2670 let kind = request.kind.storage_key(); 2671 match self.runtime.finish_pack_day_print(request, result) { 2672 Ok(true) => cx.notify(), 2673 Ok(false) => {} 2674 Err(runtime_error) => { 2675 error!( 2676 target: "pack_day", 2677 event = "pack_day.print_failed", 2678 kind = %kind, 2679 error = %runtime_error, 2680 "failed to complete pack day print" 2681 ); 2682 cx.notify(); 2683 } 2684 } 2685 } 2686 2687 fn finish_pack_day_batch_print( 2688 &mut self, 2689 request: PackDayBatchPrintRequest, 2690 result: Result<(), PackDayBatchPrintError>, 2691 cx: &mut Context<Self>, 2692 ) { 2693 match self.runtime.finish_pack_day_batch_print(request, result) { 2694 Ok(true) => cx.notify(), 2695 Ok(false) => {} 2696 Err(runtime_error) => { 2697 error!( 2698 target: "pack_day", 2699 event = "pack_day.batch_print_failed", 2700 error = %runtime_error, 2701 "failed to complete pack day batch print" 2702 ); 2703 cx.notify(); 2704 } 2705 } 2706 } 2707 2708 fn open_today_next_window( 2709 &mut self, 2710 fulfillment_window_id: Option<FulfillmentWindowId>, 2711 cx: &mut Context<Self>, 2712 ) { 2713 let Some(fulfillment_window_id) = fulfillment_window_id else { 2714 return; 2715 }; 2716 2717 match self.runtime.open_pack_day(Some(fulfillment_window_id)) { 2718 Ok(true) => { 2719 self.products_stock_editor = None; 2720 self.product_editor_form = None; 2721 self.clear_focused_view(); 2722 cx.notify(); 2723 } 2724 Ok(false) => self.open_orders_fulfillment_window(fulfillment_window_id, cx), 2725 Err(runtime_error) => { 2726 error!( 2727 target: "pack_day", 2728 event = "pack_day.route_failed", 2729 error = %runtime_error, 2730 "failed to route into pack day view" 2731 ); 2732 } 2733 } 2734 } 2735 2736 fn select_orders_filter(&mut self, filter: OrdersFilter, cx: &mut Context<Self>) { 2737 match self.runtime.select_orders_filter(filter) { 2738 Ok(true) => cx.notify(), 2739 Ok(false) => {} 2740 Err(runtime_error) => { 2741 error!( 2742 target: "orders", 2743 event = "orders.filter_update_failed", 2744 error = %runtime_error, 2745 filter = filter.storage_key(), 2746 "failed to update orders filter" 2747 ); 2748 } 2749 } 2750 } 2751 2752 fn open_order_detail(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2753 match self.runtime.open_order_detail(order_id) { 2754 Ok(runtime_changed) => { 2755 let Some(focused_view) = farmer_order_detail_focus_after_open( 2756 runtime_changed, 2757 &self.runtime.summary(), 2758 order_id, 2759 ) else { 2760 return; 2761 }; 2762 self.products_stock_editor = None; 2763 self.product_editor_form = None; 2764 self.focused_view = Some(focused_view); 2765 cx.notify(); 2766 } 2767 Err(runtime_error) => { 2768 error!( 2769 target: "orders", 2770 event = "orders.detail_open_failed", 2771 error = %runtime_error, 2772 order_id = %order_id, 2773 "failed to open order detail" 2774 ); 2775 } 2776 } 2777 } 2778 2779 fn close_order_detail(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2780 if self.clear_focused_view_matching(HomeFocusedView::FarmerOrderDetail(order_id)) { 2781 cx.notify(); 2782 } 2783 } 2784 2785 fn dismiss_presented_reminder(&mut self, reminder_id: ReminderId, cx: &mut Context<Self>) { 2786 match self.runtime.acknowledge_reminder(reminder_id) { 2787 Ok(true) => cx.notify(), 2788 Ok(false) => {} 2789 Err(runtime_error) => { 2790 error!( 2791 target: "reminders", 2792 event = "reminders.ack_failed", 2793 error = %runtime_error, 2794 reminder_id = %reminder_id, 2795 "failed to acknowledge reminder" 2796 ); 2797 } 2798 } 2799 } 2800 2801 fn open_presented_order_reminder( 2802 &mut self, 2803 reminder_id: ReminderId, 2804 order_id: OrderId, 2805 cx: &mut Context<Self>, 2806 ) { 2807 match self.runtime.open_order_detail(order_id) { 2808 Ok(true) | Ok(false) => { 2809 self.products_stock_editor = None; 2810 self.product_editor_form = None; 2811 self.focused_view = Some(HomeFocusedView::FarmerOrderDetail(order_id)); 2812 self.dismiss_presented_reminder(reminder_id, cx); 2813 } 2814 Err(runtime_error) => { 2815 error!( 2816 target: "orders", 2817 event = "orders.detail_open_failed", 2818 error = %runtime_error, 2819 order_id = %order_id, 2820 "failed to open order detail" 2821 ); 2822 } 2823 } 2824 } 2825 2826 fn open_presented_pack_day_reminder( 2827 &mut self, 2828 reminder_id: ReminderId, 2829 fulfillment_window_id: FulfillmentWindowId, 2830 cx: &mut Context<Self>, 2831 ) { 2832 match self.runtime.open_pack_day(Some(fulfillment_window_id)) { 2833 Ok(true) | Ok(false) => { 2834 self.products_stock_editor = None; 2835 self.product_editor_form = None; 2836 self.dismiss_presented_reminder(reminder_id, cx); 2837 } 2838 Err(runtime_error) => { 2839 error!( 2840 target: "pack_day", 2841 event = "pack_day.route_failed", 2842 error = %runtime_error, 2843 "failed to route into pack day view" 2844 ); 2845 } 2846 } 2847 } 2848 2849 fn open_presented_orders_reminder(&mut self, reminder_id: ReminderId, cx: &mut Context<Self>) { 2850 match self.runtime.open_orders() { 2851 Ok(true) | Ok(false) => { 2852 self.products_stock_editor = None; 2853 self.product_editor_form = None; 2854 self.dismiss_presented_reminder(reminder_id, cx); 2855 } 2856 Err(runtime_error) => { 2857 error!( 2858 target: "orders", 2859 event = "orders.route_failed", 2860 error = %runtime_error, 2861 "failed to route into orders view" 2862 ); 2863 } 2864 } 2865 } 2866 2867 fn cancel_buyer_order(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2868 match self.runtime.publish_buyer_order_cancel(order_id) { 2869 Ok(true) => cx.notify(), 2870 Ok(false) => {} 2871 Err(runtime_error) => { 2872 error!( 2873 target: "personal_orders", 2874 event = "buyer.order_cancel_failed", 2875 error = %runtime_error, 2876 order_id = %order_id, 2877 "failed to cancel buyer order" 2878 ); 2879 } 2880 } 2881 } 2882 2883 fn accept_buyer_order_revision(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2884 match self.runtime.publish_buyer_order_revision_accept(order_id) { 2885 Ok(true) => cx.notify(), 2886 Ok(false) => {} 2887 Err(runtime_error) => { 2888 error!( 2889 target: "personal_orders", 2890 event = "buyer.order_revision_accept_failed", 2891 error = %runtime_error, 2892 order_id = %order_id, 2893 "failed to accept buyer order change" 2894 ); 2895 } 2896 } 2897 } 2898 2899 fn decline_buyer_order_revision(&mut self, order_id: OrderId, cx: &mut Context<Self>) { 2900 match self.runtime.publish_buyer_order_revision_decline(order_id) { 2901 Ok(true) => cx.notify(), 2902 Ok(false) => {} 2903 Err(runtime_error) => { 2904 error!( 2905 target: "personal_orders", 2906 event = "buyer.order_revision_decline_failed", 2907 error = %runtime_error, 2908 order_id = %order_id, 2909 "failed to keep buyer order" 2910 ); 2911 } 2912 } 2913 } 2914 2915 fn open_products_stock_editor( 2916 &mut self, 2917 product_id: ProductId, 2918 stock_quantity: Option<u32>, 2919 window: &mut Window, 2920 cx: &mut Context<Self>, 2921 ) { 2922 let _ = self.runtime.close_product_editor(); 2923 let Some(account_id) = self 2924 .runtime 2925 .summary() 2926 .settings_account_projection 2927 .selected_account 2928 .as_ref() 2929 .map(|account| account.account.account_id.clone()) 2930 else { 2931 return; 2932 }; 2933 2934 if self 2935 .products_stock_editor 2936 .as_ref() 2937 .map(|editor| editor.product_id == product_id) 2938 .unwrap_or(false) 2939 { 2940 self.products_stock_editor = None; 2941 cx.notify(); 2942 return; 2943 } 2944 2945 self.products_stock_editor = Some(ProductsStockEditorState::new( 2946 account_id, 2947 product_id, 2948 stock_quantity, 2949 window, 2950 cx, 2951 )); 2952 self.product_editor_form = None; 2953 cx.notify(); 2954 } 2955 2956 fn close_products_stock_editor(&mut self, cx: &mut Context<Self>) { 2957 if self.products_stock_editor.take().is_some() { 2958 cx.notify(); 2959 } 2960 } 2961 2962 fn handle_products_stock_input_event( 2963 &mut self, 2964 state: &Entity<InputState>, 2965 event: &InputEvent, 2966 _: &mut Window, 2967 cx: &mut Context<Self>, 2968 ) { 2969 if !matches!(event, InputEvent::Change) { 2970 return; 2971 } 2972 2973 let Some(editor) = self.products_stock_editor.as_mut() else { 2974 return; 2975 }; 2976 2977 if editor.input != *state || editor.save_issue.is_none() { 2978 return; 2979 } 2980 2981 editor.save_issue = None; 2982 cx.notify(); 2983 } 2984 2985 fn save_products_stock_editor(&mut self, cx: &mut Context<Self>) { 2986 let Some((product_id, stock_quantity)) = 2987 self.products_stock_editor.as_ref().and_then(|editor| { 2988 editor 2989 .parsed_stock_quantity(cx) 2990 .map(|stock_quantity| (editor.product_id, stock_quantity)) 2991 }) 2992 else { 2993 return; 2994 }; 2995 2996 match self 2997 .runtime 2998 .update_product_stock(product_id, stock_quantity) 2999 { 3000 Ok(true) => { 3001 self.products_stock_editor = None; 3002 cx.notify(); 3003 } 3004 Ok(false) => {} 3005 Err(runtime_error) => { 3006 error!( 3007 target: "products", 3008 event = "products.stock_update_failed", 3009 error = %runtime_error, 3010 product_id = %product_id, 3011 stock_quantity, 3012 "failed to update product stock" 3013 ); 3014 3015 if let Some(editor) = self.products_stock_editor.as_mut() { 3016 let save_issue = 3017 ProductsStockEditorSaveIssue::from_runtime_error(&runtime_error); 3018 if save_issue == ProductsStockEditorSaveIssue::PublishQueueFailed { 3019 editor.initial_stock_quantity = Some(stock_quantity); 3020 } 3021 editor.save_issue = Some(save_issue); 3022 } 3023 cx.notify(); 3024 } 3025 } 3026 } 3027 3028 fn open_new_product_editor(&mut self, cx: &mut Context<Self>) { 3029 match self.runtime.open_new_product_editor() { 3030 Ok(true) => { 3031 self.products_stock_editor = None; 3032 self.focused_view = Some(HomeFocusedView::ProductEditor); 3033 cx.notify(); 3034 } 3035 Ok(false) => {} 3036 Err(runtime_error) => { 3037 error!( 3038 target: "products", 3039 event = "products.new_editor_open_failed", 3040 error = %runtime_error, 3041 "failed to open new product editor" 3042 ); 3043 } 3044 } 3045 } 3046 3047 fn open_existing_product_editor(&mut self, product_id: ProductId, cx: &mut Context<Self>) { 3048 match self.runtime.open_existing_product_editor(product_id) { 3049 Ok(true) => { 3050 self.products_stock_editor = None; 3051 self.focused_view = Some(HomeFocusedView::ProductEditor); 3052 cx.notify(); 3053 } 3054 Ok(false) => {} 3055 Err(runtime_error) => { 3056 error!( 3057 target: "products", 3058 event = "products.editor_open_failed", 3059 error = %runtime_error, 3060 product_id = %product_id, 3061 "failed to open existing product editor" 3062 ); 3063 } 3064 } 3065 } 3066 3067 fn close_product_editor(&mut self, cx: &mut Context<Self>) { 3068 let changed = self.runtime.close_product_editor(); 3069 let cleared = self.product_editor_form.take().is_some(); 3070 let focus_changed = self.clear_focused_view_matching(HomeFocusedView::ProductEditor); 3071 3072 if changed || cleared || focus_changed { 3073 cx.notify(); 3074 } 3075 } 3076 3077 fn handle_product_editor_input_event( 3078 &mut self, 3079 state: &Entity<InputState>, 3080 event: &InputEvent, 3081 _: &mut Window, 3082 cx: &mut Context<Self>, 3083 ) { 3084 if !matches!(event, InputEvent::Change) { 3085 return; 3086 } 3087 3088 let Some(form) = self.product_editor_form.as_mut() else { 3089 return; 3090 }; 3091 let matches_input = form.title_input == *state 3092 || form.subtitle_input == *state 3093 || form.category_input == *state 3094 || form.unit_input == *state 3095 || form.price_input == *state 3096 || form.stock_input == *state; 3097 3098 if !matches_input { 3099 return; 3100 } 3101 3102 if form.save_issue.is_some() { 3103 form.save_issue = None; 3104 } 3105 3106 cx.notify(); 3107 } 3108 3109 fn select_product_editor_availability_window( 3110 &mut self, 3111 availability_window_id: FulfillmentWindowId, 3112 cx: &mut Context<Self>, 3113 ) { 3114 let Some(form) = self.product_editor_form.as_mut() else { 3115 return; 3116 }; 3117 3118 if form.selected_availability_window_id == Some(availability_window_id) { 3119 return; 3120 } 3121 3122 form.selected_availability_window_id = Some(availability_window_id); 3123 form.save_issue = None; 3124 cx.notify(); 3125 } 3126 3127 fn select_product_editor_status(&mut self, status: ProductStatus, cx: &mut Context<Self>) { 3128 let Some(form) = self.product_editor_form.as_mut() else { 3129 return; 3130 }; 3131 3132 if form.status == status { 3133 return; 3134 } 3135 3136 form.status = status; 3137 form.save_issue = None; 3138 cx.notify(); 3139 } 3140 3141 fn save_product_editor(&mut self, cx: &mut Context<Self>) { 3142 let Some(form) = self.product_editor_form.as_mut() else { 3143 return; 3144 }; 3145 let Some(draft) = form.current_draft(cx) else { 3146 return; 3147 }; 3148 3149 match self.runtime.save_product_editor_draft(draft.clone()) { 3150 Ok(true) => { 3151 form.initial_draft = draft; 3152 form.save_issue = None; 3153 cx.notify(); 3154 } 3155 Ok(false) => {} 3156 Err(runtime_error) => { 3157 error!( 3158 target: "products", 3159 event = "products.editor_save_failed", 3160 error = %runtime_error, 3161 product_id = %form.product_id, 3162 "failed to save product editor draft" 3163 ); 3164 if runtime_error.is_listing_publish_sdk_enqueue_failed() { 3165 form.initial_draft = draft; 3166 } 3167 form.save_issue = Some(ProductEditorSaveIssue::from_runtime_error(&runtime_error)); 3168 cx.notify(); 3169 } 3170 } 3171 } 3172 3173 fn handle_farm_name_input_event( 3174 &mut self, 3175 state: &Entity<InputState>, 3176 event: &InputEvent, 3177 _: &mut Window, 3178 cx: &mut Context<Self>, 3179 ) { 3180 if matches!(event, InputEvent::Change) { 3181 let value = state.read(cx).value().to_string(); 3182 self.update_farm_setup_draft(cx, |draft| { 3183 draft.farm_name = value; 3184 }); 3185 } 3186 } 3187 3188 fn handle_location_input_event( 3189 &mut self, 3190 state: &Entity<InputState>, 3191 event: &InputEvent, 3192 _: &mut Window, 3193 cx: &mut Context<Self>, 3194 ) { 3195 if matches!(event, InputEvent::Change) { 3196 let value = state.read(cx).value().to_string(); 3197 self.update_farm_setup_draft(cx, |draft| { 3198 draft.location_or_service_area = value; 3199 }); 3200 } 3201 } 3202 3203 fn toggle_farm_order_method( 3204 &mut self, 3205 method: FarmOrderMethod, 3206 enabled: bool, 3207 cx: &mut Context<Self>, 3208 ) { 3209 self.update_farm_setup_draft(cx, |draft| { 3210 if enabled { 3211 draft.order_methods.insert(method); 3212 } else { 3213 draft.order_methods.remove(&method); 3214 } 3215 }); 3216 } 3217 3218 fn update_farm_setup_draft( 3219 &mut self, 3220 cx: &mut Context<Self>, 3221 update: impl FnOnce(&mut FarmSetupDraft), 3222 ) { 3223 let Some(form) = self.farm_setup_form.as_mut() else { 3224 return; 3225 }; 3226 3227 update(&mut form.draft); 3228 3229 match self.runtime.save_farm_setup_draft(form.draft.clone()) { 3230 Ok(projection) => { 3231 form.draft = projection.draft; 3232 form.save_state = FarmSetupSaveState::SavedLocally; 3233 } 3234 Err(_) => { 3235 form.save_state = FarmSetupSaveState::SaveFailed; 3236 } 3237 } 3238 3239 cx.notify(); 3240 } 3241 3242 fn finish_farm_setup(&mut self, cx: &mut Context<Self>) { 3243 let Some(form) = self.farm_setup_form.as_mut() else { 3244 return; 3245 }; 3246 3247 match self.runtime.finish_farm_setup() { 3248 Ok(_) => { 3249 form.save_state = FarmSetupSaveState::SavedLocally; 3250 self.farm_setup_form = None; 3251 } 3252 Err(_) => { 3253 form.save_state = FarmSetupSaveState::SaveFailed; 3254 } 3255 } 3256 3257 cx.notify(); 3258 } 3259 3260 fn render_today_content( 3261 &mut self, 3262 runtime: &DesktopAppRuntimeSummary, 3263 cx: &mut Context<Self>, 3264 ) -> AnyElement { 3265 let projection = &runtime.today_projection; 3266 let home_status = home_status_presentation(runtime); 3267 let setup_onboarding = farm_setup_onboarding_card_spec(runtime.home_route); 3268 let farm_state = farmer_home_farm_state(runtime); 3269 let next_fulfillment_window_id = projection 3270 .next_fulfillment_window 3271 .as_ref() 3272 .map(|window| window.fulfillment_window_id); 3273 let mut sections = Vec::<AnyElement>::new(); 3274 3275 if let Some(summary) = projection.summary.as_ref() { 3276 sections.push(home_summary_card(summary).into_any_element()); 3277 } 3278 3279 if let Some(issue) = runtime.startup_issue.as_ref() { 3280 sections.push( 3281 home_card( 3282 app_shared_text(AppTextKey::MetadataStartupIssue), 3283 home_body_text(issue.clone()), 3284 ) 3285 .into_any_element(), 3286 ); 3287 } 3288 3289 if let Some(spec) = setup_onboarding { 3290 sections.push( 3291 home_farm_setup_onboarding_card( 3292 spec, 3293 cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)), 3294 cx, 3295 ) 3296 .into_any_element(), 3297 ); 3298 } else if projection.needs_setup() { 3299 sections.push( 3300 home_setup_card( 3301 projection, 3302 matches!(farm_state, FarmerHomeFarmState::IncompleteFarm).then_some( 3303 action_button_primary( 3304 "home-farm-setup-continue", 3305 app_shared_text(AppTextKey::HomeFarmSetupContinueAction), 3306 cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)), 3307 cx, 3308 ) 3309 .into_any_element(), 3310 ), 3311 ) 3312 .into_any_element(), 3313 ); 3314 } 3315 3316 if let Some(saved_farm_summary_card) = home_saved_farm_summary_card(runtime) { 3317 sections.push(saved_farm_summary_card); 3318 } 3319 3320 if !projection.reminders.is_empty() { 3321 sections.push(self.render_today_reminder_strip(&projection.reminders.items, cx)); 3322 } 3323 3324 if let Some(next_window) = projection.next_fulfillment_window.as_ref() { 3325 sections.push( 3326 home_next_fulfillment_window_card( 3327 next_window, 3328 Some( 3329 action_button_compact( 3330 "home-today-open-pack-day", 3331 app_shared_text(AppTextKey::HomeTodayOpenInPackDayAction), 3332 cx.listener(move |this, _, _, cx| { 3333 this.open_today_next_window(next_fulfillment_window_id, cx) 3334 }), 3335 cx, 3336 ) 3337 .into_any_element(), 3338 ), 3339 ) 3340 .into_any_element(), 3341 ); 3342 } 3343 3344 if !projection.orders_needing_action.is_empty() { 3345 sections.push( 3346 home_list_card( 3347 AppTextKey::HomeTodayOrdersNeedingAction, 3348 projection 3349 .orders_needing_action 3350 .iter() 3351 .enumerate() 3352 .map(|(index, order)| { 3353 home_order_row( 3354 index, 3355 order, 3356 cx.listener({ 3357 let order_id = order.order_id; 3358 move |this, _, _, cx| this.open_order_detail(order_id, cx) 3359 }), 3360 cx, 3361 ) 3362 }) 3363 .collect::<Vec<_>>(), 3364 Some( 3365 action_button_compact( 3366 "home-today-open-orders", 3367 app_shared_text(AppTextKey::HomeTodayOpenInOrdersAction), 3368 cx.listener(|this, _, _, cx| this.open_orders(cx)), 3369 cx, 3370 ) 3371 .into_any_element(), 3372 ), 3373 ) 3374 .into_any_element(), 3375 ); 3376 } 3377 3378 if !projection.low_stock_products.is_empty() { 3379 sections.push( 3380 home_list_card( 3381 AppTextKey::HomeTodayLowStock, 3382 projection 3383 .low_stock_products 3384 .iter() 3385 .map(home_low_stock_row) 3386 .collect::<Vec<_>>(), 3387 Some( 3388 action_button_compact( 3389 "home-today-open-products-low-stock", 3390 app_shared_text(AppTextKey::HomeTodayOpenInProductsAction), 3391 cx.listener(|this, _, _, cx| { 3392 this.open_products_filter(ProductsFilter::NeedAttention, cx) 3393 }), 3394 cx, 3395 ) 3396 .into_any_element(), 3397 ), 3398 ) 3399 .into_any_element(), 3400 ); 3401 } 3402 3403 if !projection.draft_products.is_empty() { 3404 sections.push( 3405 home_list_card( 3406 AppTextKey::HomeTodayDraftProducts, 3407 projection 3408 .draft_products 3409 .iter() 3410 .map(home_draft_row) 3411 .collect::<Vec<_>>(), 3412 Some( 3413 action_button_compact( 3414 "home-today-open-products-drafts", 3415 app_shared_text(AppTextKey::HomeTodayOpenInProductsAction), 3416 cx.listener(|this, _, _, cx| { 3417 this.open_products_filter(ProductsFilter::Drafts, cx) 3418 }), 3419 cx, 3420 ) 3421 .into_any_element(), 3422 ), 3423 ) 3424 .into_any_element(), 3425 ); 3426 } 3427 3428 if runtime.startup_issue.is_none() && runtime.startup_gate == AppStartupGate::SetupRequired 3429 { 3430 sections.push( 3431 home_empty_state_card( 3432 AppTextKey::HomeTodayEmptySetupTitle, 3433 AppTextKey::HomeTodayEmptySetupBody, 3434 ) 3435 .into_any_element(), 3436 ); 3437 } else if runtime.startup_issue.is_none() 3438 && farm_state == FarmerHomeFarmState::NoFarm 3439 && setup_onboarding.is_none() 3440 { 3441 sections.push( 3442 home_empty_state_card( 3443 AppTextKey::HomeTodayEmptyNoFarmTitle, 3444 AppTextKey::HomeTodayEmptyNoFarmBody, 3445 ) 3446 .into_any_element(), 3447 ); 3448 } else if runtime.startup_issue.is_none() 3449 && farm_state == FarmerHomeFarmState::ConfiguredFarm 3450 && !projection.needs_setup() 3451 && projection.next_fulfillment_window.is_none() 3452 && !projection.has_attention_items() 3453 { 3454 sections.push( 3455 home_empty_state_card( 3456 AppTextKey::HomeTodayEmptyQuietTitle, 3457 AppTextKey::HomeTodayEmptyQuietBody, 3458 ) 3459 .into_any_element(), 3460 ); 3461 } 3462 3463 div() 3464 .w_full() 3465 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 3466 .mx_auto() 3467 .flex() 3468 .flex_col() 3469 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 3470 .child( 3471 div() 3472 .w_full() 3473 .flex() 3474 .flex_col() 3475 .gap(px(4.0)) 3476 .child( 3477 div() 3478 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) 3479 .font_weight(gpui::FontWeight::BOLD) 3480 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 3481 .child(app_shared_text(AppTextKey::HomeTodayTitle)), 3482 ) 3483 .child( 3484 div() 3485 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 3486 .font_weight(gpui::FontWeight::MEDIUM) 3487 .line_height(relative(1.2)) 3488 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 3489 .when_some(home_saved_farm(runtime), |this, farm| { 3490 this.child(farm.display_name.clone()) 3491 }) 3492 .when(home_saved_farm(runtime).is_none(), |this| { 3493 this.child(app_shared_text(home_status.label_key)) 3494 }), 3495 ) 3496 .child(home_status_row(&home_status)), 3497 ) 3498 .children(sections) 3499 .into_any_element() 3500 } 3501 3502 fn render_buyer_workspace( 3503 &mut self, 3504 runtime: &DesktopAppRuntimeSummary, 3505 cx: &mut Context<Self>, 3506 ) -> AnyElement { 3507 let selected_personal_section = selected_personal_section(runtime); 3508 let main_content = self 3509 .render_buyer_focused_view(runtime, cx) 3510 .unwrap_or_else(|| match selected_personal_section { 3511 PersonalSection::Browse => self 3512 .render_buyer_browse_content(runtime, cx) 3513 .into_any_element(), 3514 PersonalSection::Search => self 3515 .render_buyer_search_content(runtime, cx) 3516 .into_any_element(), 3517 PersonalSection::Cart => self 3518 .render_buyer_cart_content(runtime, cx) 3519 .into_any_element(), 3520 PersonalSection::Orders => self 3521 .render_buyer_orders_content(runtime, cx) 3522 .into_any_element(), 3523 }); 3524 3525 app_split_shell( 3526 buyer_sidebar( 3527 runtime, 3528 cx.listener(|this, _, _, cx| { 3529 this.select_personal_section(PersonalSection::Browse, cx) 3530 }), 3531 cx.listener(|this, _, _, cx| { 3532 this.select_personal_section(PersonalSection::Search, cx) 3533 }), 3534 cx.listener(|this, _, _, cx| { 3535 this.select_personal_section(PersonalSection::Cart, cx) 3536 }), 3537 cx.listener(|this, _, _, cx| { 3538 this.select_personal_section(PersonalSection::Orders, cx) 3539 }), 3540 cx, 3541 ) 3542 .into_any_element(), 3543 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3544 .size_full() 3545 .child(shared_shell_header( 3546 runtime, 3547 cx.listener(|this, _, _, cx| this.switch_to_marketplace(cx)), 3548 cx.listener(|this, _, _, cx| this.switch_to_farmer_workspace(cx)), 3549 cx.listener(|this, _, _, cx| this.open_account_entry(cx)), 3550 cx, 3551 )) 3552 .when_some(self.buyer_workspace_notice.as_deref(), |this, notice| { 3553 this.child(buyer_workspace_notice_card(notice.to_owned())) 3554 }) 3555 .child( 3556 app_scroll_panel( 3557 buyer_content_scroll_id(selected_personal_section), 3558 0.0, 3559 None, 3560 main_content, 3561 ) 3562 .into_any_element(), 3563 ) 3564 .into_any_element(), 3565 ) 3566 .into_any_element() 3567 } 3568 3569 fn render_buyer_focused_view( 3570 &mut self, 3571 runtime: &DesktopAppRuntimeSummary, 3572 cx: &mut Context<Self>, 3573 ) -> Option<AnyElement> { 3574 match self.focused_view? { 3575 HomeFocusedView::BuyerProductDetail(section) => { 3576 let detail = match section { 3577 PersonalSection::Browse => runtime.personal_projection.browse.detail.as_ref(), 3578 PersonalSection::Search => runtime.personal_projection.search.detail.as_ref(), 3579 PersonalSection::Cart | PersonalSection::Orders => None, 3580 }?; 3581 Some( 3582 buyer_product_detail_card( 3583 detail, 3584 runtime 3585 .personal_projection 3586 .cart 3587 .cart 3588 .replace_confirmation 3589 .as_ref(), 3590 cx.listener(move |this, _, _, cx| { 3591 this.close_personal_product_detail(section, cx) 3592 }), 3593 cx.listener(move |this, _, _, cx| { 3594 this.decrease_personal_product_quantity(section, cx) 3595 }), 3596 cx.listener(move |this, _, _, cx| { 3597 this.increase_personal_product_quantity(section, cx) 3598 }), 3599 cx.listener(move |this, _, _, cx| { 3600 this.add_personal_product_to_cart(section, false, cx) 3601 }), 3602 cx.listener(move |this, _, _, cx| { 3603 this.add_personal_product_to_cart(section, true, cx) 3604 }), 3605 cx.listener(|this, _, _, cx| { 3606 this.clear_personal_cart_replace_confirmation(cx) 3607 }), 3608 cx, 3609 ) 3610 .into_any_element(), 3611 ) 3612 } 3613 HomeFocusedView::BuyerOrderReview => { 3614 let form = self.buyer_order_review_form.as_ref()?; 3615 Some( 3616 buyer_order_review_card( 3617 form, 3618 &runtime.personal_projection.cart.order_review, 3619 cx.listener(|this, _, _, cx| this.close_personal_order_review(cx)), 3620 cx.listener(|this, _, _, cx| this.place_personal_order(cx)), 3621 cx, 3622 ) 3623 .into_any_element(), 3624 ) 3625 } 3626 HomeFocusedView::BuyerOrderDetail(order_id) => { 3627 let detail = runtime 3628 .personal_projection 3629 .orders 3630 .detail 3631 .as_ref() 3632 .filter(|detail| detail.order_id == order_id)?; 3633 Some( 3634 buyer_order_detail_card( 3635 detail, 3636 runtime 3637 .personal_projection 3638 .cart 3639 .cart 3640 .replace_confirmation 3641 .as_ref(), 3642 cx.listener(move |this, _, _, cx| { 3643 this.close_personal_order_detail(order_id, cx) 3644 }), 3645 cx, 3646 ) 3647 .into_any_element(), 3648 ) 3649 } 3650 HomeFocusedView::FarmSetup 3651 | HomeFocusedView::ProductEditor 3652 | HomeFocusedView::FarmerOrderDetail(_) => None, 3653 } 3654 } 3655 3656 fn render_buyer_browse_content( 3657 &mut self, 3658 runtime: &DesktopAppRuntimeSummary, 3659 cx: &mut Context<Self>, 3660 ) -> AnyElement { 3661 let listings = &runtime.personal_projection.browse.listings.rows; 3662 let selected_product_id = runtime 3663 .personal_projection 3664 .browse 3665 .detail 3666 .as_ref() 3667 .map(|detail| detail.listing.product_id); 3668 3669 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3670 .w_full() 3671 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 3672 .mx_auto() 3673 .child(buyer_workspace_title_block( 3674 AppTextKey::HomeNavBrowse, 3675 AppTextKey::PersonalBrowsePlaceholderBody, 3676 )) 3677 .child(if listings.is_empty() { 3678 home_empty_state_card( 3679 AppTextKey::PersonalBrowseEmptyTitle, 3680 AppTextKey::PersonalBrowseEmptyBody, 3681 ) 3682 .into_any_element() 3683 } else { 3684 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3685 .w_full() 3686 .child(buyer_listings_feed( 3687 PersonalSection::Browse, 3688 listings, 3689 selected_product_id, 3690 cx, 3691 )) 3692 .into_any_element() 3693 }) 3694 .into_any_element() 3695 } 3696 3697 fn render_buyer_search_content( 3698 &mut self, 3699 runtime: &DesktopAppRuntimeSummary, 3700 cx: &mut Context<Self>, 3701 ) -> AnyElement { 3702 let query = &runtime.personal_projection.search.query; 3703 let listings = &runtime.personal_projection.search.listings.rows; 3704 let selected_product_id = runtime 3705 .personal_projection 3706 .search 3707 .detail 3708 .as_ref() 3709 .map(|detail| detail.listing.product_id); 3710 3711 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3712 .w_full() 3713 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 3714 .mx_auto() 3715 .child(buyer_workspace_title_block( 3716 AppTextKey::HomeNavSearch, 3717 AppTextKey::PersonalSearchPlaceholderBody, 3718 )) 3719 .child( 3720 home_card( 3721 app_shared_text(AppTextKey::PersonalSearchFiltersTitle), 3722 div() 3723 .w_full() 3724 .flex() 3725 .flex_col() 3726 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 3727 .when_some(self.personal_search.as_ref(), |this, personal_search| { 3728 this.child( 3729 app_text_input(&personal_search.input, false) 3730 .cleanable(true) 3731 .w_full(), 3732 ) 3733 }) 3734 .child( 3735 app_cluster(8.0) 3736 .child(choice_button( 3737 "personal-search-pickup", 3738 app_shared_text(AppTextKey::HomeFarmSetupOrderMethodPickup), 3739 query.fulfillment_methods.contains(&FarmOrderMethod::Pickup), 3740 cx.listener(|this, _, _, cx| { 3741 let enabled = !this 3742 .runtime 3743 .summary() 3744 .personal_projection 3745 .search 3746 .query 3747 .fulfillment_methods 3748 .contains(&FarmOrderMethod::Pickup); 3749 this.toggle_personal_search_fulfillment_method( 3750 FarmOrderMethod::Pickup, 3751 enabled, 3752 cx, 3753 ) 3754 }), 3755 cx, 3756 )) 3757 .child(choice_button( 3758 "personal-search-delivery", 3759 app_shared_text(AppTextKey::HomeFarmSetupOrderMethodDelivery), 3760 query 3761 .fulfillment_methods 3762 .contains(&FarmOrderMethod::Delivery), 3763 cx.listener(|this, _, _, cx| { 3764 let enabled = !this 3765 .runtime 3766 .summary() 3767 .personal_projection 3768 .search 3769 .query 3770 .fulfillment_methods 3771 .contains(&FarmOrderMethod::Delivery); 3772 this.toggle_personal_search_fulfillment_method( 3773 FarmOrderMethod::Delivery, 3774 enabled, 3775 cx, 3776 ) 3777 }), 3778 cx, 3779 )) 3780 .child(choice_button( 3781 "personal-search-shipping", 3782 app_shared_text(AppTextKey::HomeFarmSetupOrderMethodShipping), 3783 query 3784 .fulfillment_methods 3785 .contains(&FarmOrderMethod::Shipping), 3786 cx.listener(|this, _, _, cx| { 3787 let enabled = !this 3788 .runtime 3789 .summary() 3790 .personal_projection 3791 .search 3792 .query 3793 .fulfillment_methods 3794 .contains(&FarmOrderMethod::Shipping); 3795 this.toggle_personal_search_fulfillment_method( 3796 FarmOrderMethod::Shipping, 3797 enabled, 3798 cx, 3799 ) 3800 }), 3801 cx, 3802 )), 3803 ), 3804 ) 3805 .into_any_element(), 3806 ) 3807 .child(if listings.is_empty() { 3808 home_empty_state_card( 3809 AppTextKey::PersonalSearchEmptyTitle, 3810 AppTextKey::PersonalSearchEmptyBody, 3811 ) 3812 .into_any_element() 3813 } else { 3814 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3815 .w_full() 3816 .child(buyer_listings_feed( 3817 PersonalSection::Search, 3818 listings, 3819 selected_product_id, 3820 cx, 3821 )) 3822 .into_any_element() 3823 }) 3824 .into_any_element() 3825 } 3826 3827 fn render_buyer_cart_content( 3828 &mut self, 3829 runtime: &DesktopAppRuntimeSummary, 3830 cx: &mut Context<Self>, 3831 ) -> AnyElement { 3832 let cart = &runtime.personal_projection.cart.cart; 3833 let order_review = &runtime.personal_projection.cart.order_review; 3834 3835 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3836 .w_full() 3837 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 3838 .mx_auto() 3839 .child(buyer_workspace_title_block( 3840 AppTextKey::HomeNavCart, 3841 AppTextKey::PersonalCartSurfaceBody, 3842 )) 3843 .child(if cart.lines.is_empty() { 3844 app_surface_card(home_body_text(app_shared_text( 3845 AppTextKey::PersonalCartPlaceholderBody, 3846 ))) 3847 .into_any_element() 3848 } else { 3849 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3850 .w_full() 3851 .child(buyer_cart_card( 3852 cart, 3853 &order_review.summary, 3854 self.buyer_order_review_form.is_some(), 3855 cx, 3856 )) 3857 .into_any_element() 3858 }) 3859 .into_any_element() 3860 } 3861 3862 fn render_buyer_orders_content( 3863 &mut self, 3864 runtime: &DesktopAppRuntimeSummary, 3865 cx: &mut Context<Self>, 3866 ) -> AnyElement { 3867 let orders = &runtime.personal_projection.orders; 3868 let selected_order_id = orders.detail.as_ref().map(|detail| detail.order_id); 3869 3870 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3871 .w_full() 3872 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 3873 .mx_auto() 3874 .child(buyer_workspace_title_block( 3875 AppTextKey::HomeNavOrders, 3876 AppTextKey::PersonalOrdersSurfaceBody, 3877 )) 3878 .when(buyer_orders_retry_action_visible(orders), |this| { 3879 this.child(buyer_orders_retry_card(cx)) 3880 }) 3881 .child(if orders.list.rows.is_empty() { 3882 home_empty_state_card( 3883 AppTextKey::PersonalOrdersEmptyTitle, 3884 AppTextKey::PersonalOrdersEmptyBody, 3885 ) 3886 .into_any_element() 3887 } else { 3888 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3889 .w_full() 3890 .child(buyer_orders_list_card( 3891 &orders.list.rows, 3892 selected_order_id, 3893 cx, 3894 )) 3895 .into_any_element() 3896 }) 3897 .into_any_element() 3898 } 3899 3900 fn render_farmer_workspace( 3901 &mut self, 3902 runtime: &DesktopAppRuntimeSummary, 3903 cx: &mut Context<Self>, 3904 ) -> AnyElement { 3905 let selected_farmer_section = selected_farmer_section(runtime); 3906 let main_content = self 3907 .render_farmer_focused_view(runtime, cx) 3908 .unwrap_or_else(|| match selected_farmer_section { 3909 FarmerSection::Products if farmer_products_available(runtime) => { 3910 self.render_products_content(runtime, cx) 3911 } 3912 FarmerSection::Orders if farmer_products_available(runtime) => { 3913 self.render_orders_content(runtime, cx) 3914 } 3915 FarmerSection::PackDay if farmer_pack_day_available(runtime) => { 3916 self.render_pack_day_content(runtime, cx) 3917 } 3918 FarmerSection::Today 3919 | FarmerSection::Products 3920 | FarmerSection::Orders 3921 | FarmerSection::PackDay 3922 | FarmerSection::Farm => self.render_today_content(runtime, cx), 3923 }); 3924 3925 app_split_shell( 3926 home_sidebar( 3927 runtime, 3928 cx.listener(|this, _, _, cx| this.select_farmer_section(FarmerSection::Today, cx)), 3929 cx.listener(|this, _, _, cx| { 3930 this.select_farmer_section(FarmerSection::Products, cx) 3931 }), 3932 cx.listener(|this, _, _, cx| this.open_orders(cx)), 3933 cx.listener(|this, _, _, cx| this.open_pack_day(None, cx)), 3934 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::Profile, cx)), 3935 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::FarmDetails, cx)), 3936 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::Preferences, cx)), 3937 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::Settings, cx)), 3938 cx, 3939 ) 3940 .into_any_element(), 3941 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 3942 .size_full() 3943 .child(shared_shell_header( 3944 runtime, 3945 cx.listener(|this, _, _, cx| this.switch_to_marketplace(cx)), 3946 cx.listener(|this, _, _, cx| this.switch_to_farmer_workspace(cx)), 3947 cx.listener(|this, _, _, cx| this.open_account_entry(cx)), 3948 cx, 3949 )) 3950 .when_some(presented_farmer_reminder(runtime), |this, reminder| { 3951 this.child( 3952 div() 3953 .w_full() 3954 .px(px(APP_UI_THEME.shells.home_window_padding_px)) 3955 .child( 3956 div() 3957 .w_full() 3958 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 3959 .mx_auto() 3960 .child(self.render_presented_reminder_banner(reminder, cx)), 3961 ), 3962 ) 3963 }) 3964 .child( 3965 app_scroll_panel( 3966 home_content_scroll_id(selected_farmer_section), 3967 0.0, 3968 None, 3969 main_content, 3970 ) 3971 .into_any_element(), 3972 ) 3973 .into_any_element(), 3974 ) 3975 .into_any_element() 3976 } 3977 3978 fn render_farmer_focused_view( 3979 &mut self, 3980 runtime: &DesktopAppRuntimeSummary, 3981 cx: &mut Context<Self>, 3982 ) -> Option<AnyElement> { 3983 match self.focused_view? { 3984 HomeFocusedView::FarmSetup => { 3985 let form = self.farm_setup_form.as_ref()?; 3986 Some( 3987 home_farm_setup_form_card( 3988 form, 3989 cx.listener(|this, checked: &bool, _, cx| { 3990 this.toggle_farm_order_method(FarmOrderMethod::Pickup, *checked, cx) 3991 }), 3992 cx.listener(|this, checked: &bool, _, cx| { 3993 this.toggle_farm_order_method(FarmOrderMethod::Delivery, *checked, cx) 3994 }), 3995 cx.listener(|this, checked: &bool, _, cx| { 3996 this.toggle_farm_order_method(FarmOrderMethod::Shipping, *checked, cx) 3997 }), 3998 cx.listener(|this, _, _, cx| this.finish_farm_setup(cx)), 3999 cx, 4000 ) 4001 .into_any_element(), 4002 ) 4003 } 4004 HomeFocusedView::ProductEditor => { 4005 let form = self.product_editor_form.as_ref()?; 4006 Some(products_editor_surface(form, runtime, cx).into_any_element()) 4007 } 4008 HomeFocusedView::FarmerOrderDetail(order_id) => { 4009 let detail = runtime 4010 .orders_projection 4011 .detail 4012 .as_ref() 4013 .filter(|detail| detail.order_id == order_id)?; 4014 Some(self.render_order_detail_card( 4015 detail, 4016 cx.listener(move |this, _, _, cx| this.close_order_detail(order_id, cx)), 4017 cx, 4018 )) 4019 } 4020 HomeFocusedView::BuyerProductDetail(_) 4021 | HomeFocusedView::BuyerOrderReview 4022 | HomeFocusedView::BuyerOrderDetail(_) => None, 4023 } 4024 } 4025 4026 fn render_products_content( 4027 &mut self, 4028 runtime: &DesktopAppRuntimeSummary, 4029 cx: &mut Context<Self>, 4030 ) -> AnyElement { 4031 let projection = &runtime.products_projection; 4032 let summary = &projection.list.summary; 4033 4034 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 4035 .w_full() 4036 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 4037 .mx_auto() 4038 .child(products_title_row( 4039 runtime, 4040 action_button_primary( 4041 "products-add-product", 4042 app_shared_text(AppTextKey::ProductsAddAction), 4043 cx.listener(|this, _, _, cx| this.open_new_product_editor(cx)), 4044 cx, 4045 ) 4046 .into_any_element(), 4047 )) 4048 .child( 4049 div() 4050 .w_full() 4051 .flex() 4052 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 4053 .child(home_summary_metric( 4054 AppTextKey::ProductsSummaryTotal, 4055 summary.total_products, 4056 )) 4057 .child(home_summary_metric( 4058 AppTextKey::ProductsSummaryLive, 4059 summary.live_products, 4060 )) 4061 .child(home_summary_metric( 4062 AppTextKey::ProductsSummaryNeedAttention, 4063 summary.need_attention_products, 4064 )) 4065 .child(home_summary_metric( 4066 AppTextKey::ProductsSummaryDrafts, 4067 summary.draft_products, 4068 )), 4069 ) 4070 .child(products_controls_card( 4071 runtime, 4072 self.products_search.as_ref(), 4073 cx.listener(|this, _, _, cx| this.select_products_filter(ProductsFilter::All, cx)), 4074 cx.listener(|this, _, _, cx| this.select_products_filter(ProductsFilter::Live, cx)), 4075 cx.listener(|this, _, _, cx| { 4076 this.select_products_filter(ProductsFilter::Drafts, cx) 4077 }), 4078 cx.listener(|this, _, _, cx| { 4079 this.select_products_filter(ProductsFilter::NeedAttention, cx) 4080 }), 4081 cx.listener(|this, _, _, cx| { 4082 this.select_products_filter(ProductsFilter::Paused, cx) 4083 }), 4084 cx.listener(|this, _, _, cx| { 4085 this.select_products_filter(ProductsFilter::Archived, cx) 4086 }), 4087 cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Updated, cx)), 4088 cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Name, cx)), 4089 cx.listener(|this, _, _, cx| { 4090 this.select_products_sort(ProductsSort::Availability, cx) 4091 }), 4092 cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Stock, cx)), 4093 cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Price, cx)), 4094 cx, 4095 )) 4096 .child(if projection.list.is_empty() { 4097 products_empty_state_card(projection.query.filter).into_any_element() 4098 } else { 4099 self.render_products_table_card(&projection.list.rows, cx) 4100 }) 4101 .into_any_element() 4102 } 4103 4104 fn render_orders_content( 4105 &mut self, 4106 runtime: &DesktopAppRuntimeSummary, 4107 cx: &mut Context<Self>, 4108 ) -> AnyElement { 4109 let projection = &runtime.orders_projection; 4110 let summary = &projection.list.summary; 4111 4112 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 4113 .w_full() 4114 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 4115 .mx_auto() 4116 .child(app_text_value(app_shared_text(AppTextKey::OrdersTitle))) 4117 .child( 4118 div() 4119 .w_full() 4120 .flex() 4121 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 4122 .child(home_summary_metric( 4123 AppTextKey::OrdersSummaryTotal, 4124 summary.total_orders, 4125 )) 4126 .child(home_summary_metric( 4127 AppTextKey::OrdersStatusNeedsAction, 4128 summary.needs_action_orders, 4129 )) 4130 .child(home_summary_metric( 4131 AppTextKey::OrdersStatusScheduled, 4132 summary.scheduled_orders, 4133 )) 4134 .child(home_summary_metric( 4135 AppTextKey::OrdersStatusInHandoff, 4136 summary.packed_orders, 4137 )), 4138 ) 4139 .child(home_card( 4140 app_shared_text(AppTextKey::OrdersFiltersTitle), 4141 app_cluster(APP_UI_THEME.foundation.spacing.tight_px) 4142 .child(choice_button( 4143 "orders-filter-all", 4144 app_shared_text(AppTextKey::OrdersFilterAll), 4145 projection.query.filter == OrdersFilter::All, 4146 cx.listener(|this, _, _, cx| { 4147 this.select_orders_filter(OrdersFilter::All, cx) 4148 }), 4149 cx, 4150 )) 4151 .child(choice_button( 4152 "orders-filter-needs-action", 4153 app_shared_text(AppTextKey::OrdersStatusNeedsAction), 4154 projection.query.filter == OrdersFilter::NeedsAction, 4155 cx.listener(|this, _, _, cx| { 4156 this.select_orders_filter(OrdersFilter::NeedsAction, cx) 4157 }), 4158 cx, 4159 )) 4160 .child(choice_button( 4161 "orders-filter-scheduled", 4162 app_shared_text(AppTextKey::OrdersStatusScheduled), 4163 projection.query.filter == OrdersFilter::Scheduled, 4164 cx.listener(|this, _, _, cx| { 4165 this.select_orders_filter(OrdersFilter::Scheduled, cx) 4166 }), 4167 cx, 4168 )) 4169 .child(choice_button( 4170 "orders-filter-packed", 4171 app_shared_text(AppTextKey::OrdersStatusInHandoff), 4172 projection.query.filter == OrdersFilter::Packed, 4173 cx.listener(|this, _, _, cx| { 4174 this.select_orders_filter(OrdersFilter::Packed, cx) 4175 }), 4176 cx, 4177 )) 4178 .child(choice_button( 4179 "orders-filter-completed", 4180 app_shared_text(AppTextKey::OrdersStatusCompleted), 4181 projection.query.filter == OrdersFilter::Completed, 4182 cx.listener(|this, _, _, cx| { 4183 this.select_orders_filter(OrdersFilter::Completed, cx) 4184 }), 4185 cx, 4186 )), 4187 )) 4188 .when(!projection.reminders.is_empty(), |this| { 4189 this.child(self.render_reminder_feed_card( 4190 "orders-reminders", 4191 AppTextKey::OrdersRemindersTitle, 4192 &projection.reminders.items, 4193 cx, 4194 )) 4195 }) 4196 .child(self.render_orders_reminder_log_card(&runtime.reminder_log)) 4197 .child(if projection.list.is_empty() { 4198 orders_empty_state_card(projection.query.filter).into_any_element() 4199 } else { 4200 self.render_orders_table_card( 4201 &projection.list.rows, 4202 projection.detail.as_ref().map(|detail| detail.order_id), 4203 cx, 4204 ) 4205 }) 4206 .into_any_element() 4207 } 4208 4209 fn render_presented_reminder_banner( 4210 &mut self, 4211 reminder: &ReminderDeadlineProjection, 4212 cx: &mut Context<Self>, 4213 ) -> AnyElement { 4214 let primary_action = self.render_presented_reminder_primary_action(reminder, cx); 4215 4216 home_card( 4217 app_shared_text(AppTextKey::ReminderPresentationTitle), 4218 app_stack_v(APP_UI_THEME.foundation.spacing.medium_px) 4219 .w_full() 4220 .child( 4221 div() 4222 .w_full() 4223 .min_w_0() 4224 .flex() 4225 .items_start() 4226 .justify_between() 4227 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4228 .child( 4229 div() 4230 .flex_1() 4231 .min_w_0() 4232 .flex() 4233 .items_center() 4234 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4235 .child(status_indicator(reminder_urgency_color(reminder.urgency))) 4236 .child( 4237 div() 4238 .flex_1() 4239 .min_w_0() 4240 .text_size(px(APP_UI_THEME 4241 .foundation 4242 .typography 4243 .body_text_px)) 4244 .font_weight(gpui::FontWeight::SEMIBOLD) 4245 .line_height(relative(1.2)) 4246 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 4247 .child(reminder.title.clone()), 4248 ), 4249 ) 4250 .child( 4251 app_cluster(APP_UI_THEME.foundation.spacing.tight_px) 4252 .child(reminder_urgency_badge(reminder.urgency)) 4253 .child(reminder_delivery_state_badge(reminder.delivery_state)), 4254 ), 4255 ) 4256 .when(!reminder.detail.trim().is_empty(), |this| { 4257 this.child(home_body_text(reminder.detail.clone())) 4258 }) 4259 .child( 4260 div() 4261 .w_full() 4262 .min_w_0() 4263 .flex() 4264 .items_center() 4265 .justify_between() 4266 .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) 4267 .child( 4268 div() 4269 .min_w_0() 4270 .text_size(px(APP_UI_THEME 4271 .foundation 4272 .typography 4273 .utility_title_text_px)) 4274 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 4275 .child(reminder_deadline_text(reminder)), 4276 ) 4277 .child( 4278 div() 4279 .flex() 4280 .items_center() 4281 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4282 .when_some(primary_action, |this, action| this.child(action)) 4283 .child(text_button( 4284 "reminder-banner-dismiss", 4285 app_shared_text(AppTextKey::ReminderPresentationDismissAction), 4286 cx.listener({ 4287 let reminder_id = reminder.reminder_id; 4288 move |this, _, _, cx| { 4289 this.dismiss_presented_reminder(reminder_id, cx) 4290 } 4291 }), 4292 cx, 4293 )), 4294 ), 4295 ), 4296 ) 4297 .into_any_element() 4298 } 4299 4300 fn render_presented_reminder_primary_action( 4301 &mut self, 4302 reminder: &ReminderDeadlineProjection, 4303 cx: &mut Context<Self>, 4304 ) -> Option<AnyElement> { 4305 let label = reminder.action_label.clone()?; 4306 4307 match reminder_action_target(reminder) { 4308 Some(ReminderActionTarget::OrderDetail(order_id)) => Some( 4309 action_button_primary( 4310 "reminder-banner-action", 4311 SharedString::from(label), 4312 cx.listener({ 4313 let reminder_id = reminder.reminder_id; 4314 move |this, _, _, cx| { 4315 this.open_presented_order_reminder(reminder_id, order_id, cx) 4316 } 4317 }), 4318 cx, 4319 ) 4320 .into_any_element(), 4321 ), 4322 Some(ReminderActionTarget::PackDay(fulfillment_window_id)) => Some( 4323 action_button_primary( 4324 "reminder-banner-action", 4325 SharedString::from(label), 4326 cx.listener({ 4327 let reminder_id = reminder.reminder_id; 4328 move |this, _, _, cx| { 4329 this.open_presented_pack_day_reminder( 4330 reminder_id, 4331 fulfillment_window_id, 4332 cx, 4333 ) 4334 } 4335 }), 4336 cx, 4337 ) 4338 .into_any_element(), 4339 ), 4340 None if reminder.surface == ReminderSurface::Orders => Some( 4341 action_button_primary( 4342 "reminder-banner-action", 4343 SharedString::from(label), 4344 cx.listener({ 4345 let reminder_id = reminder.reminder_id; 4346 move |this, _, _, cx| this.open_presented_orders_reminder(reminder_id, cx) 4347 }), 4348 cx, 4349 ) 4350 .into_any_element(), 4351 ), 4352 None => None, 4353 } 4354 } 4355 4356 fn render_pack_day_content( 4357 &mut self, 4358 runtime: &DesktopAppRuntimeSummary, 4359 cx: &mut Context<Self>, 4360 ) -> AnyElement { 4361 let projection = &runtime.pack_day_projection.projection; 4362 let Some(fulfillment_window) = projection.fulfillment_window.as_ref() else { 4363 return home_empty_state_card( 4364 AppTextKey::PackDayEmptyTitle, 4365 AppTextKey::PackDayEmptyBody, 4366 ) 4367 .into_any_element(); 4368 }; 4369 4370 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 4371 .w_full() 4372 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 4373 .mx_auto() 4374 .child(pack_day_title_row(runtime)) 4375 .child(pack_day_export_card( 4376 runtime, 4377 cx.listener(|this, _, _, cx| this.export_pack_day(cx)), 4378 cx.listener(|this, _, window, cx| { 4379 this.start_pack_day_host_handoff( 4380 PackDayHostHandoffKind::RevealBundle, 4381 window, 4382 cx, 4383 ) 4384 }), 4385 cx.listener(|this, _, window, cx| { 4386 this.start_pack_day_host_handoff( 4387 PackDayHostHandoffKind::OpenPackSheet, 4388 window, 4389 cx, 4390 ) 4391 }), 4392 cx.listener(|this, _, window, cx| { 4393 this.start_pack_day_host_handoff( 4394 PackDayHostHandoffKind::OpenPickupRoster, 4395 window, 4396 cx, 4397 ) 4398 }), 4399 cx.listener(|this, _, window, cx| { 4400 this.start_pack_day_host_handoff( 4401 PackDayHostHandoffKind::OpenCustomerLabels, 4402 window, 4403 cx, 4404 ) 4405 }), 4406 cx.listener(|this, _, window, cx| this.start_pack_day_batch_print(window, cx)), 4407 cx.listener(|this, _, window, cx| { 4408 this.start_pack_day_print(PackDayPrintKind::PrintPackSheet, window, cx) 4409 }), 4410 cx.listener(|this, _, window, cx| { 4411 this.start_pack_day_print(PackDayPrintKind::PrintPickupRoster, window, cx) 4412 }), 4413 cx.listener(|this, _, window, cx| { 4414 this.start_pack_day_print(PackDayPrintKind::PrintCustomerLabels, window, cx) 4415 }), 4416 cx, 4417 )) 4418 .when(!projection.reminders.is_empty(), |this| { 4419 this.child(self.render_reminder_feed_card( 4420 "pack-day-reminders", 4421 AppTextKey::PackDayRemindersTitle, 4422 &projection.reminders.items, 4423 cx, 4424 )) 4425 }) 4426 .child(pack_day_window_summary_card(fulfillment_window)) 4427 .when(!projection.totals_by_product.is_empty(), |this| { 4428 this.child(pack_day_totals_card(&projection.totals_by_product)) 4429 }) 4430 .when(!projection.pack_list.is_empty(), |this| { 4431 this.child(pack_day_pack_list_card(&projection.pack_list)) 4432 }) 4433 .when(!projection.pickup_roster.is_empty(), |this| { 4434 this.child(pack_day_pickup_roster_card(&projection.pickup_roster)) 4435 }) 4436 .when(projection.is_empty(), |this| { 4437 this.child(home_empty_state_card( 4438 AppTextKey::PackDayEmptyTitle, 4439 AppTextKey::PackDayEmptyBody, 4440 )) 4441 }) 4442 .into_any_element() 4443 } 4444 4445 fn render_today_reminder_strip( 4446 &mut self, 4447 reminders: &[ReminderDeadlineProjection], 4448 cx: &mut Context<Self>, 4449 ) -> AnyElement { 4450 app_surface_card( 4451 app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) 4452 .w_full() 4453 .child(app_text_label(app_shared_text( 4454 AppTextKey::HomeTodayRemindersTitle, 4455 ))) 4456 .child( 4457 app_cluster(APP_UI_THEME.foundation.spacing.tight_px) 4458 .w_full() 4459 .items_start() 4460 .children( 4461 reminders 4462 .iter() 4463 .enumerate() 4464 .map(|(index, reminder)| { 4465 self.render_today_reminder_chip(index, reminder, cx) 4466 }) 4467 .collect::<Vec<_>>(), 4468 ), 4469 ), 4470 ) 4471 .into_any_element() 4472 } 4473 4474 fn render_today_reminder_chip( 4475 &mut self, 4476 index: usize, 4477 reminder: &ReminderDeadlineProjection, 4478 cx: &mut Context<Self>, 4479 ) -> AnyElement { 4480 let content = div() 4481 .w_full() 4482 .min_w_0() 4483 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 4484 .flex() 4485 .flex_col() 4486 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4487 .child( 4488 div() 4489 .w_full() 4490 .min_w_0() 4491 .flex() 4492 .items_start() 4493 .justify_between() 4494 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4495 .child( 4496 div() 4497 .flex_1() 4498 .min_w_0() 4499 .flex() 4500 .items_center() 4501 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4502 .child(status_indicator(reminder_urgency_color(reminder.urgency))) 4503 .child( 4504 div() 4505 .flex_1() 4506 .min_w_0() 4507 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 4508 .font_weight(gpui::FontWeight::SEMIBOLD) 4509 .line_height(relative(1.2)) 4510 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 4511 .child(reminder.title.clone()), 4512 ), 4513 ) 4514 .child(reminder_urgency_badge(reminder.urgency)), 4515 ) 4516 .child( 4517 div() 4518 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 4519 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 4520 .child(reminder_deadline_text(reminder)), 4521 ); 4522 let shell = div().min_w(px(244.0)).max_w(px(296.0)).flex_1(); 4523 4524 match reminder_action_target(reminder) { 4525 Some(ReminderActionTarget::OrderDetail(order_id)) => shell 4526 .child(app_button_card( 4527 ("today-reminder-chip", index), 4528 false, 4529 cx.listener(move |this, _, _, cx| this.open_order_detail(order_id, cx)), 4530 cx, 4531 content, 4532 )) 4533 .into_any_element(), 4534 Some(ReminderActionTarget::PackDay(fulfillment_window_id)) => shell 4535 .child(app_button_card( 4536 ("today-reminder-chip", index), 4537 false, 4538 cx.listener(move |this, _, _, cx| { 4539 this.open_pack_day(Some(fulfillment_window_id), cx) 4540 }), 4541 cx, 4542 content, 4543 )) 4544 .into_any_element(), 4545 None => shell 4546 .child( 4547 div() 4548 .w_full() 4549 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 4550 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 4551 .child(content), 4552 ) 4553 .into_any_element(), 4554 } 4555 } 4556 4557 fn render_reminder_feed_card( 4558 &mut self, 4559 scope: &'static str, 4560 title_key: AppTextKey, 4561 reminders: &[ReminderDeadlineProjection], 4562 cx: &mut Context<Self>, 4563 ) -> AnyElement { 4564 let mut rows = Vec::with_capacity(reminders.len().saturating_mul(2)); 4565 for (index, reminder) in reminders.iter().enumerate() { 4566 rows.push(self.render_reminder_feed_row(scope, index, reminder, cx)); 4567 if index + 1 < reminders.len() { 4568 rows.push(section_divider().into_any_element()); 4569 } 4570 } 4571 4572 home_card( 4573 app_shared_text(title_key), 4574 div() 4575 .w_full() 4576 .flex() 4577 .flex_col() 4578 .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) 4579 .children(rows), 4580 ) 4581 .into_any_element() 4582 } 4583 4584 fn render_orders_reminder_log_card(&self, reminder_log: &ReminderLogProjection) -> AnyElement { 4585 let body = if reminder_log.entries.is_empty() { 4586 home_body_text(app_shared_text(AppTextKey::OrdersReminderLogEmptyBody)) 4587 .into_any_element() 4588 } else { 4589 let mut rows = Vec::with_capacity(reminder_log.entries.len().saturating_mul(2)); 4590 for (index, entry) in reminder_log.entries.iter().enumerate() { 4591 rows.push(self.render_orders_reminder_log_row(entry)); 4592 if index + 1 < reminder_log.entries.len() { 4593 rows.push(section_divider().into_any_element()); 4594 } 4595 } 4596 4597 div() 4598 .w_full() 4599 .flex() 4600 .flex_col() 4601 .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) 4602 .children(rows) 4603 .into_any_element() 4604 }; 4605 4606 home_card(app_shared_text(AppTextKey::OrdersReminderLogTitle), body).into_any_element() 4607 } 4608 4609 fn render_orders_reminder_log_row(&self, entry: &ReminderLogEntryProjection) -> AnyElement { 4610 app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) 4611 .w_full() 4612 .child( 4613 div() 4614 .w_full() 4615 .min_w_0() 4616 .flex() 4617 .items_start() 4618 .justify_between() 4619 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4620 .child( 4621 div() 4622 .flex_1() 4623 .min_w_0() 4624 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 4625 .font_weight(gpui::FontWeight::SEMIBOLD) 4626 .line_height(relative(1.2)) 4627 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 4628 .child(entry.title.clone()), 4629 ) 4630 .child(reminder_delivery_state_badge(entry.delivery_state)), 4631 ) 4632 .when_some( 4633 entry 4634 .detail 4635 .as_ref() 4636 .map(|detail| detail.trim()) 4637 .filter(|detail| !detail.is_empty()), 4638 |this, detail| this.child(home_body_text(detail.to_owned())), 4639 ) 4640 .child( 4641 div() 4642 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 4643 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 4644 .child(entry.recorded_at.clone()), 4645 ) 4646 .into_any_element() 4647 } 4648 4649 fn render_reminder_feed_row( 4650 &mut self, 4651 scope: &'static str, 4652 index: usize, 4653 reminder: &ReminderDeadlineProjection, 4654 cx: &mut Context<Self>, 4655 ) -> AnyElement { 4656 let action = self.render_reminder_action(scope, index, reminder, cx); 4657 4658 app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) 4659 .w_full() 4660 .child( 4661 div() 4662 .w_full() 4663 .min_w_0() 4664 .flex() 4665 .items_start() 4666 .justify_between() 4667 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4668 .child( 4669 div() 4670 .flex_1() 4671 .min_w_0() 4672 .flex() 4673 .items_center() 4674 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4675 .child(status_indicator(reminder_urgency_color(reminder.urgency))) 4676 .child( 4677 div() 4678 .flex_1() 4679 .min_w_0() 4680 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 4681 .font_weight(gpui::FontWeight::SEMIBOLD) 4682 .line_height(relative(1.2)) 4683 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 4684 .child(reminder.title.clone()), 4685 ), 4686 ) 4687 .child(reminder_urgency_badge(reminder.urgency)), 4688 ) 4689 .child(home_body_text(reminder.detail.clone())) 4690 .child( 4691 div() 4692 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 4693 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 4694 .child(reminder_deadline_text(reminder)), 4695 ) 4696 .when_some(action, |this, action| this.child(div().child(action))) 4697 .into_any_element() 4698 } 4699 4700 fn render_reminder_action( 4701 &mut self, 4702 scope: &'static str, 4703 index: usize, 4704 reminder: &ReminderDeadlineProjection, 4705 cx: &mut Context<Self>, 4706 ) -> Option<AnyElement> { 4707 let label = reminder.action_label.clone()?; 4708 4709 match reminder_action_target(reminder) { 4710 Some(ReminderActionTarget::OrderDetail(order_id)) => Some( 4711 action_button_compact( 4712 (scope, index), 4713 SharedString::from(label), 4714 cx.listener(move |this, _, _, cx| this.open_order_detail(order_id, cx)), 4715 cx, 4716 ) 4717 .into_any_element(), 4718 ), 4719 Some(ReminderActionTarget::PackDay(fulfillment_window_id)) => Some( 4720 action_button_compact( 4721 (scope, index), 4722 SharedString::from(label), 4723 cx.listener(move |this, _, _, cx| { 4724 this.open_pack_day(Some(fulfillment_window_id), cx) 4725 }), 4726 cx, 4727 ) 4728 .into_any_element(), 4729 ), 4730 None => None, 4731 } 4732 } 4733 4734 fn render_products_table_card( 4735 &mut self, 4736 rows: &[ProductsListRow], 4737 cx: &mut Context<Self>, 4738 ) -> AnyElement { 4739 let mut items = Vec::with_capacity(rows.len().saturating_mul(2)); 4740 for (index, row) in rows.iter().enumerate() { 4741 items.push(self.render_products_table_entry(index, row, cx)); 4742 if index + 1 < rows.len() { 4743 items.push(section_divider().into_any_element()); 4744 } 4745 } 4746 4747 home_card( 4748 app_shared_text(AppTextKey::ProductsTableTitle), 4749 div() 4750 .w_full() 4751 .flex() 4752 .flex_col() 4753 .gap(px(12.0)) 4754 .child(products_table_header()) 4755 .child(section_divider()) 4756 .children(items), 4757 ) 4758 .into_any_element() 4759 } 4760 4761 fn render_orders_table_card( 4762 &mut self, 4763 rows: &[OrdersListRow], 4764 selected_order_id: Option<OrderId>, 4765 cx: &mut Context<Self>, 4766 ) -> AnyElement { 4767 let mut items = Vec::with_capacity(rows.len().saturating_mul(2)); 4768 for (index, row) in rows.iter().enumerate() { 4769 items.push(self.render_orders_table_entry(index, row, selected_order_id, cx)); 4770 if index + 1 < rows.len() { 4771 items.push(section_divider().into_any_element()); 4772 } 4773 } 4774 4775 home_card( 4776 app_shared_text(AppTextKey::OrdersTableTitle), 4777 div() 4778 .w_full() 4779 .flex() 4780 .flex_col() 4781 .gap(px(12.0)) 4782 .child(orders_table_header()) 4783 .child(section_divider()) 4784 .children(items), 4785 ) 4786 .into_any_element() 4787 } 4788 4789 fn render_order_detail_card( 4790 &mut self, 4791 detail: &OrderDetailProjection, 4792 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 4793 cx: &mut Context<Self>, 4794 ) -> AnyElement { 4795 app_focused_detail_view( 4796 app_shared_text(AppTextKey::OrdersDetailTitle), 4797 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 4798 .w_full() 4799 .child(app_heading_section(detail.order_number.clone())) 4800 .child(home_body_text(detail.customer_display_name.clone())) 4801 .child(trade_workflow_detail_badge_strip(&detail.workflow)) 4802 .child(label_value_list([ 4803 LabelValueRow::new( 4804 app_shared_text(AppTextKey::OrdersDetailCustomerLabel), 4805 detail.customer_display_name.clone(), 4806 ), 4807 LabelValueRow::new( 4808 app_shared_text(AppTextKey::OrdersDetailWindowLabel), 4809 order_optional_text(detail.fulfillment_window_label.as_deref()), 4810 ), 4811 LabelValueRow::new( 4812 app_shared_text(AppTextKey::OrdersDetailPickupLabel), 4813 order_optional_text(detail.pickup_location_label.as_deref()), 4814 ), 4815 LabelValueRow::new( 4816 app_shared_text(AppTextKey::OrdersDetailTotalLabel), 4817 trade_economics_total_text(&detail.workflow.economics), 4818 ), 4819 ])) 4820 .when(!detail.validation_receipts.is_empty(), |this| { 4821 this.child(validation_receipts_summary_section( 4822 &detail.validation_receipts, 4823 )) 4824 }) 4825 .child(app_form_section( 4826 app_shared_text(AppTextKey::OrdersDetailItemsTitle), 4827 div() 4828 .w_full() 4829 .flex() 4830 .flex_col() 4831 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 4832 .children( 4833 detail 4834 .items 4835 .iter() 4836 .map(order_detail_item_row) 4837 .collect::<Vec<_>>(), 4838 ) 4839 .when(detail.items.is_empty(), |this| { 4840 this.child(home_body_text(app_shared_text(AppTextKey::ValueNone))) 4841 }), 4842 )), 4843 text_button( 4844 "orders-detail-back", 4845 app_shared_text(AppTextKey::PersonalDetailBackAction), 4846 on_close, 4847 cx, 4848 ), 4849 ) 4850 } 4851 4852 fn render_products_table_entry( 4853 &mut self, 4854 index: usize, 4855 row: &ProductsListRow, 4856 cx: &mut Context<Self>, 4857 ) -> AnyElement { 4858 let is_open = self 4859 .product_editor_form 4860 .as_ref() 4861 .map(|form| form.product_id == row.product_id) 4862 .unwrap_or(false); 4863 let is_editing = self 4864 .products_stock_editor 4865 .as_ref() 4866 .map(|editor| editor.product_id == row.product_id) 4867 .unwrap_or(false); 4868 let product = list_row_button( 4869 ("products-row-open", index), 4870 product_display_title(row.title.as_str()), 4871 row.subtitle.clone().map(SharedString::from), 4872 is_open, 4873 cx.listener({ 4874 let product_id = row.product_id; 4875 move |this, _, _, cx| this.open_existing_product_editor(product_id, cx) 4876 }), 4877 cx, 4878 ) 4879 .into_any_element(); 4880 let action = if is_editing { 4881 action_button_compact( 4882 "products-stock-editor-cancel", 4883 app_shared_text(AppTextKey::ProductsStockEditorCancelAction), 4884 cx.listener(|this, _, _, cx| this.close_products_stock_editor(cx)), 4885 cx, 4886 ) 4887 .into_any_element() 4888 } else { 4889 action_button_compact( 4890 ("products-row-stock-action", index), 4891 app_shared_text(AppTextKey::ProductsUpdateStockAction), 4892 cx.listener({ 4893 let product_id = row.product_id; 4894 let stock_quantity = row.stock.quantity; 4895 move |this, _, window, cx| { 4896 this.open_products_stock_editor(product_id, stock_quantity, window, cx) 4897 } 4898 }), 4899 cx, 4900 ) 4901 .into_any_element() 4902 }; 4903 4904 div() 4905 .w_full() 4906 .flex() 4907 .flex_col() 4908 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 4909 .child(products_table_row(product, row, action)) 4910 .when(is_editing, |this| { 4911 this.when_some(self.products_stock_editor.as_ref(), |this, editor| { 4912 this.child(products_stock_editor_card( 4913 row, 4914 editor, 4915 cx.listener(|this, _, _, cx| this.save_products_stock_editor(cx)), 4916 cx.listener(|this, _, _, cx| this.close_products_stock_editor(cx)), 4917 cx, 4918 )) 4919 }) 4920 }) 4921 .into_any_element() 4922 } 4923 4924 fn render_orders_table_entry( 4925 &mut self, 4926 index: usize, 4927 row: &OrdersListRow, 4928 selected_order_id: Option<OrderId>, 4929 cx: &mut Context<Self>, 4930 ) -> AnyElement { 4931 let is_selected = selected_order_id.is_some_and(|order_id| order_id == row.order_id); 4932 let order = list_row_button( 4933 ("orders-row-open", index), 4934 row.order_number.clone(), 4935 Some(SharedString::from(row.customer_display_name.clone())), 4936 is_selected, 4937 cx.listener({ 4938 let order_id = row.order_id; 4939 move |this, _, _, cx| this.open_order_detail(order_id, cx) 4940 }), 4941 cx, 4942 ) 4943 .into_any_element(); 4944 let action = orders_table_action( 4945 index, 4946 row, 4947 cx.listener({ 4948 let order_id = row.order_id; 4949 move |this, _, _, cx| this.open_order_detail(order_id, cx) 4950 }), 4951 cx, 4952 ); 4953 4954 div() 4955 .w_full() 4956 .child(orders_table_row(order, row, action)) 4957 .into_any_element() 4958 } 4959 } 4960 4961 impl Render for HomeView { 4962 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 4963 let runtime_summary = self.runtime.summary(); 4964 self.sync_startup_signer_entry(&runtime_summary, window, cx); 4965 self.sync_farm_setup_form(&runtime_summary, window, cx); 4966 self.sync_personal_search(&runtime_summary, window, cx); 4967 self.sync_buyer_order_review_form(&runtime_summary, window, cx); 4968 self.sync_products_search(&runtime_summary, window, cx); 4969 self.sync_products_stock_editor(&runtime_summary); 4970 self.sync_product_editor_form(&runtime_summary, window, cx); 4971 self.apply_auto_focus(&runtime_summary, window, cx); 4972 match home_stage(&runtime_summary) { 4973 HomeStage::Setup => self 4974 .startup_view 4975 .render( 4976 &runtime_summary, 4977 self.startup_signer_entry.as_ref(), 4978 &self.startup_signer_connect_state, 4979 cx.listener(|this, _, _, cx| this.show_startup_identity_choice(cx)), 4980 cx.listener(|this, _, _, cx| { 4981 this.select_personal_section(PersonalSection::Browse, cx) 4982 }), 4983 cx.listener(|this, _, window, cx| this.start_generate_key(window, cx)), 4984 cx.listener(|this, _, _, cx| this.show_startup_signer_entry(cx)), 4985 cx.listener(|this, _, window, cx| this.submit_startup_signer(window, cx)), 4986 cx.listener(|this, _, _, cx| this.back_out_of_startup_signer_entry(cx)), 4987 cx, 4988 ) 4989 .into_any_element(), 4990 HomeStage::AccountWorkspace => { 4991 self.render_account_workspace(&runtime_summary, window, cx) 4992 } 4993 HomeStage::BuyerWorkspace => self.render_buyer_workspace(&runtime_summary, cx), 4994 HomeStage::FarmerWorkspace => self.render_farmer_workspace(&runtime_summary, cx), 4995 } 4996 } 4997 } 4998 4999 impl HomeView { 5000 fn render_account_workspace( 5001 &mut self, 5002 runtime: &DesktopAppRuntimeSummary, 5003 window: &mut Window, 5004 cx: &mut Context<Self>, 5005 ) -> AnyElement { 5006 let sidebar = if runtime.shell_projection.active_surface == ActiveSurface::Farmer { 5007 home_sidebar( 5008 runtime, 5009 cx.listener(|this, _, _, cx| this.select_farmer_section(FarmerSection::Today, cx)), 5010 cx.listener(|this, _, _, cx| { 5011 this.select_farmer_section(FarmerSection::Products, cx) 5012 }), 5013 cx.listener(|this, _, _, cx| this.open_orders(cx)), 5014 cx.listener(|this, _, _, cx| this.open_pack_day(None, cx)), 5015 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::Profile, cx)), 5016 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::FarmDetails, cx)), 5017 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::Preferences, cx)), 5018 cx.listener(|this, _, _, cx| this.open_account_tab(AccountTab::Settings, cx)), 5019 cx, 5020 ) 5021 .into_any_element() 5022 } else { 5023 buyer_sidebar( 5024 runtime, 5025 cx.listener(|this, _, _, cx| { 5026 this.select_personal_section(PersonalSection::Browse, cx) 5027 }), 5028 cx.listener(|this, _, _, cx| { 5029 this.select_personal_section(PersonalSection::Search, cx) 5030 }), 5031 cx.listener(|this, _, _, cx| { 5032 this.select_personal_section(PersonalSection::Cart, cx) 5033 }), 5034 cx.listener(|this, _, _, cx| { 5035 this.select_personal_section(PersonalSection::Orders, cx) 5036 }), 5037 cx, 5038 ) 5039 .into_any_element() 5040 }; 5041 5042 app_split_shell( 5043 sidebar, 5044 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 5045 .size_full() 5046 .child(shared_shell_header( 5047 runtime, 5048 cx.listener(|this, _, _, cx| this.switch_to_marketplace(cx)), 5049 cx.listener(|this, _, _, cx| this.switch_to_farmer_workspace(cx)), 5050 cx.listener(|this, _, _, cx| this.open_account_entry(cx)), 5051 cx, 5052 )) 5053 .child( 5054 div() 5055 .flex_1() 5056 .w_full() 5057 .overflow_hidden() 5058 .child(self.render_account_content(window, cx)), 5059 ) 5060 .into_any_element(), 5061 ) 5062 .into_any_element() 5063 } 5064 5065 fn render_account_content( 5066 &mut self, 5067 window: &mut Window, 5068 cx: &mut Context<Self>, 5069 ) -> AnyElement { 5070 let selected_tab = self.selected_account_tab; 5071 let tabs = AccountTab::ORDERED 5072 .into_iter() 5073 .map(|tab| AppUnderlineTabSpec::new(app_shared_text(tab.text_key()))); 5074 let (heading_key, heading_actions, fixed_subheader, panel): ( 5075 AppTextKey, 5076 Option<AnyElement>, 5077 Option<AnyElement>, 5078 AnyElement, 5079 ) = match selected_tab { 5080 AccountTab::Profile => { 5081 let form = self.account_profile_form(window, cx).clone(); 5082 ( 5083 AppTextKey::AccountProfilePersonalDetailsTitle, 5084 None, 5085 None, 5086 account_profile_panel(&form, cx).into_any_element(), 5087 ) 5088 } 5089 AccountTab::FarmDetails => { 5090 self.prepare_account_farm_profile_textarea_wrap(window, cx); 5091 let form = self.account_farm_profile_form(window, cx).clone(); 5092 let selected_farm_details_tab = self.selected_account_farm_details_tab; 5093 let farm_details_tabs = AccountFarmDetailsTab::ORDERED 5094 .into_iter() 5095 .map(|tab| AppPillTabSpec::new(app_shared_text(tab.text_key()))); 5096 ( 5097 AppTextKey::AccountFarmDetailsTitle, 5098 Some( 5099 account_form_heading_actions( 5100 "account-farm-save-draft", 5101 "account-farm-save", 5102 form.is_dirty(cx), 5103 cx, 5104 ) 5105 .into_any_element(), 5106 ), 5107 Some( 5108 app_pill_tabs( 5109 "account-farm-details-tabs", 5110 farm_details_tabs, 5111 selected_farm_details_tab.selected_index(), 5112 cx.listener(|this, index: &usize, _, cx| { 5113 this.select_account_farm_details_tab( 5114 AccountFarmDetailsTab::from_index(*index), 5115 cx, 5116 ) 5117 }), 5118 cx, 5119 ) 5120 .into_any_element(), 5121 ), 5122 account_farm_profile_panel( 5123 &form, 5124 selected_farm_details_tab, 5125 self.account_farm_profile_textarea_wrap_ready, 5126 cx, 5127 ) 5128 .into_any_element(), 5129 ) 5130 } 5131 AccountTab::Preferences => ( 5132 AppTextKey::AccountTabPreferences, 5133 None, 5134 None, 5135 account_placeholder_panel(selected_tab.panel_text_key()).into_any_element(), 5136 ), 5137 AccountTab::Settings => { 5138 let form = self.account_settings_form(window, cx).clone(); 5139 ( 5140 AppTextKey::AccountSettingsTitle, 5141 Some( 5142 account_form_heading_actions( 5143 "account-settings-save-draft", 5144 "account-settings-save", 5145 form.is_dirty(cx), 5146 cx, 5147 ) 5148 .into_any_element(), 5149 ), 5150 None, 5151 account_settings_panel(&form, cx).into_any_element(), 5152 ) 5153 } 5154 }; 5155 let panel_uses_inner_scroll = selected_tab == AccountTab::FarmDetails; 5156 5157 account_tab_frame( 5158 tabs, 5159 selected_tab.selected_index(), 5160 cx.listener(|this, index: &usize, _, cx| { 5161 this.select_account_tab(AccountTab::from_index(*index), cx) 5162 }), 5163 heading_key, 5164 heading_actions, 5165 fixed_subheader, 5166 panel, 5167 panel_uses_inner_scroll, 5168 ) 5169 .into_any_element() 5170 } 5171 5172 fn account_profile_form( 5173 &mut self, 5174 window: &mut Window, 5175 cx: &mut Context<Self>, 5176 ) -> &AccountProfileFormState { 5177 if self.account_profile_form.is_none() { 5178 self.account_profile_form = Some(AccountProfileFormState::new(window, cx)); 5179 } 5180 5181 let Some(form) = self.account_profile_form.as_ref() else { 5182 unreachable!(); 5183 }; 5184 form 5185 } 5186 fn account_farm_profile_form( 5187 &mut self, 5188 window: &mut Window, 5189 cx: &mut Context<Self>, 5190 ) -> &AccountFarmProfileFormState { 5191 if self.account_farm_profile_form.is_none() { 5192 self.account_farm_profile_form = Some(AccountFarmProfileFormState::new(window, cx)); 5193 } 5194 5195 let Some(form) = self.account_farm_profile_form.as_ref() else { 5196 unreachable!(); 5197 }; 5198 form 5199 } 5200 5201 fn account_settings_form( 5202 &mut self, 5203 window: &mut Window, 5204 cx: &mut Context<Self>, 5205 ) -> &AccountSettingsFormState { 5206 if self.account_settings_form.is_none() { 5207 self.account_settings_form = Some(AccountSettingsFormState::new(window, cx)); 5208 } 5209 5210 let Some(form) = self.account_settings_form.as_ref() else { 5211 unreachable!(); 5212 }; 5213 form 5214 } 5215 5216 fn prepare_account_farm_profile_textarea_wrap( 5217 &mut self, 5218 window: &mut Window, 5219 cx: &mut Context<Self>, 5220 ) { 5221 if self.account_farm_profile_textarea_wrap_ready 5222 || self.account_farm_profile_textarea_wrap_requested 5223 { 5224 return; 5225 } 5226 5227 self.account_farm_profile_textarea_wrap_requested = true; 5228 cx.spawn_in(window, async move |this, cx| { 5229 Timer::after(Duration::from_millis(16)).await; 5230 let _ = this.update(cx, |this, cx| { 5231 this.account_farm_profile_textarea_wrap_ready = true; 5232 cx.notify(); 5233 }); 5234 }) 5235 .detach(); 5236 } 5237 } 5238 5239 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 5240 enum FarmSetupSaveState { 5241 AutosavesLocally, 5242 SavedLocally, 5243 SaveFailed, 5244 } 5245 5246 struct FarmSetupFormState { 5247 account_id: String, 5248 draft: FarmSetupDraft, 5249 farm_name_input: Entity<InputState>, 5250 location_input: Entity<InputState>, 5251 _farm_name_subscription: Subscription, 5252 _location_subscription: Subscription, 5253 save_state: FarmSetupSaveState, 5254 } 5255 5256 impl FarmSetupFormState { 5257 fn new( 5258 account_id: String, 5259 draft: FarmSetupDraft, 5260 window: &mut Window, 5261 cx: &mut Context<HomeView>, 5262 ) -> Self { 5263 let farm_name_input = 5264 cx.new(|cx| InputState::new(window, cx).default_value(draft.farm_name.clone())); 5265 let location_input = cx.new(|cx| { 5266 InputState::new(window, cx).default_value(draft.location_or_service_area.clone()) 5267 }); 5268 let farm_name_subscription = cx.subscribe_in( 5269 &farm_name_input, 5270 window, 5271 HomeView::handle_farm_name_input_event, 5272 ); 5273 let location_subscription = cx.subscribe_in( 5274 &location_input, 5275 window, 5276 HomeView::handle_location_input_event, 5277 ); 5278 let save_state = if draft.is_empty() { 5279 FarmSetupSaveState::AutosavesLocally 5280 } else { 5281 FarmSetupSaveState::SavedLocally 5282 }; 5283 5284 Self { 5285 account_id, 5286 draft, 5287 farm_name_input, 5288 location_input, 5289 _farm_name_subscription: farm_name_subscription, 5290 _location_subscription: location_subscription, 5291 save_state, 5292 } 5293 } 5294 } 5295 5296 struct PersonalSearchState { 5297 workspace_id: String, 5298 input: Entity<InputState>, 5299 _input_subscription: Subscription, 5300 } 5301 5302 impl PersonalSearchState { 5303 fn new( 5304 workspace_id: String, 5305 search_query: &str, 5306 window: &mut Window, 5307 cx: &mut Context<HomeView>, 5308 ) -> Self { 5309 let input = cx.new(|cx| { 5310 InputState::new(window, cx) 5311 .placeholder(app_shared_text(AppTextKey::PersonalSearchPlaceholder)) 5312 .default_value(search_query.to_owned()) 5313 }); 5314 let input_subscription = 5315 cx.subscribe_in(&input, window, HomeView::handle_personal_search_input_event); 5316 5317 Self { 5318 workspace_id, 5319 input, 5320 _input_subscription: input_subscription, 5321 } 5322 } 5323 5324 fn sync(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<HomeView>) { 5325 if self.input.read(cx).value().as_ref() == search_query { 5326 return; 5327 } 5328 5329 self.input.update(cx, |input, cx| { 5330 input.set_value(search_query.to_owned(), window, cx); 5331 }); 5332 } 5333 } 5334 5335 struct BuyerOrderReviewFormState { 5336 workspace_id: String, 5337 name_input: Entity<InputState>, 5338 email_input: Entity<InputState>, 5339 phone_input: Entity<InputState>, 5340 order_note_input: Entity<InputState>, 5341 _name_subscription: Subscription, 5342 _email_subscription: Subscription, 5343 _phone_subscription: Subscription, 5344 _order_note_subscription: Subscription, 5345 } 5346 5347 impl BuyerOrderReviewFormState { 5348 fn new( 5349 workspace_id: String, 5350 draft: &BuyerOrderReviewDraft, 5351 window: &mut Window, 5352 cx: &mut Context<HomeView>, 5353 ) -> Self { 5354 let name_input = cx.new(|cx| InputState::new(window, cx).default_value(draft.name.clone())); 5355 let email_input = 5356 cx.new(|cx| InputState::new(window, cx).default_value(draft.email.clone())); 5357 let phone_input = 5358 cx.new(|cx| InputState::new(window, cx).default_value(draft.phone.clone())); 5359 let order_note_input = 5360 cx.new(|cx| InputState::new(window, cx).default_value(draft.order_note.clone())); 5361 let name_subscription = cx.subscribe_in( 5362 &name_input, 5363 window, 5364 HomeView::handle_buyer_order_review_input_event, 5365 ); 5366 let email_subscription = cx.subscribe_in( 5367 &email_input, 5368 window, 5369 HomeView::handle_buyer_order_review_input_event, 5370 ); 5371 let phone_subscription = cx.subscribe_in( 5372 &phone_input, 5373 window, 5374 HomeView::handle_buyer_order_review_input_event, 5375 ); 5376 let order_note_subscription = cx.subscribe_in( 5377 &order_note_input, 5378 window, 5379 HomeView::handle_buyer_order_review_input_event, 5380 ); 5381 5382 Self { 5383 workspace_id, 5384 name_input, 5385 email_input, 5386 phone_input, 5387 order_note_input, 5388 _name_subscription: name_subscription, 5389 _email_subscription: email_subscription, 5390 _phone_subscription: phone_subscription, 5391 _order_note_subscription: order_note_subscription, 5392 } 5393 } 5394 5395 fn sync( 5396 &mut self, 5397 draft: &BuyerOrderReviewDraft, 5398 window: &mut Window, 5399 cx: &mut Context<HomeView>, 5400 ) { 5401 sync_order_review_input(&self.name_input, draft.name.as_str(), window, cx); 5402 sync_order_review_input(&self.email_input, draft.email.as_str(), window, cx); 5403 sync_order_review_input(&self.phone_input, draft.phone.as_str(), window, cx); 5404 sync_order_review_input( 5405 &self.order_note_input, 5406 draft.order_note.as_str(), 5407 window, 5408 cx, 5409 ); 5410 } 5411 5412 fn current_draft(&self, cx: &App) -> BuyerOrderReviewDraft { 5413 BuyerOrderReviewDraft { 5414 name: self.name_input.read(cx).value().to_string(), 5415 email: self.email_input.read(cx).value().to_string(), 5416 phone: self.phone_input.read(cx).value().to_string(), 5417 order_note: self.order_note_input.read(cx).value().to_string(), 5418 } 5419 } 5420 } 5421 5422 fn sync_order_review_input( 5423 input: &Entity<InputState>, 5424 value: &str, 5425 window: &mut Window, 5426 cx: &mut Context<HomeView>, 5427 ) { 5428 if input.read(cx).value().as_ref() == value { 5429 return; 5430 } 5431 5432 input.update(cx, |input, cx| { 5433 input.set_value(value.to_owned(), window, cx); 5434 }); 5435 } 5436 5437 struct ProductsSearchState { 5438 account_id: String, 5439 input: Entity<InputState>, 5440 _input_subscription: Subscription, 5441 } 5442 5443 impl ProductsSearchState { 5444 fn new( 5445 account_id: String, 5446 search_query: &str, 5447 window: &mut Window, 5448 cx: &mut Context<HomeView>, 5449 ) -> Self { 5450 let input = cx.new(|cx| { 5451 InputState::new(window, cx) 5452 .placeholder(app_shared_text(AppTextKey::ProductsSearchPlaceholder)) 5453 .default_value(search_query.to_owned()) 5454 }); 5455 let input_subscription = 5456 cx.subscribe_in(&input, window, HomeView::handle_products_search_input_event); 5457 5458 Self { 5459 account_id, 5460 input, 5461 _input_subscription: input_subscription, 5462 } 5463 } 5464 5465 fn sync(&mut self, search_query: &str, window: &mut Window, cx: &mut Context<HomeView>) { 5466 if self.input.read(cx).value().as_ref() == search_query { 5467 return; 5468 } 5469 5470 self.input.update(cx, |input, cx| { 5471 input.set_value(search_query.to_owned(), window, cx); 5472 }); 5473 } 5474 } 5475 5476 struct StartupSignerEntryState { 5477 input: Entity<InputState>, 5478 _input_subscription: Subscription, 5479 } 5480 5481 impl StartupSignerEntryState { 5482 fn new(source_input: &str, window: &mut Window, cx: &mut Context<HomeView>) -> Self { 5483 let input = cx.new(|cx| { 5484 InputState::new(window, cx) 5485 .placeholder(app_shared_text( 5486 AppTextKey::HomeSetupSignerSourcePlaceholder, 5487 )) 5488 .default_value(source_input.to_owned()) 5489 }); 5490 let input_subscription = 5491 cx.subscribe_in(&input, window, HomeView::handle_startup_signer_input_event); 5492 5493 Self { 5494 input, 5495 _input_subscription: input_subscription, 5496 } 5497 } 5498 5499 fn sync(&mut self, source_input: &str, window: &mut Window, cx: &mut Context<HomeView>) { 5500 if self.input.read(cx).value().as_ref() == source_input { 5501 return; 5502 } 5503 5504 self.input.update(cx, |input, cx| { 5505 input.set_value(source_input.to_owned(), window, cx); 5506 }); 5507 } 5508 } 5509 5510 struct ProductsStockEditorState { 5511 account_id: String, 5512 product_id: ProductId, 5513 initial_stock_quantity: Option<u32>, 5514 input: Entity<InputState>, 5515 _input_subscription: Subscription, 5516 save_issue: Option<ProductsStockEditorSaveIssue>, 5517 } 5518 5519 impl ProductsStockEditorState { 5520 fn new( 5521 account_id: String, 5522 product_id: ProductId, 5523 stock_quantity: Option<u32>, 5524 window: &mut Window, 5525 cx: &mut Context<HomeView>, 5526 ) -> Self { 5527 let input = cx.new(|cx| { 5528 InputState::new(window, cx) 5529 .placeholder(app_shared_text(AppTextKey::ProductsStockEditorFieldLabel)) 5530 .default_value( 5531 stock_quantity 5532 .map(|quantity| quantity.to_string()) 5533 .unwrap_or_else(|| "0".to_owned()), 5534 ) 5535 }); 5536 let input_subscription = 5537 cx.subscribe_in(&input, window, HomeView::handle_products_stock_input_event); 5538 5539 Self { 5540 account_id, 5541 product_id, 5542 initial_stock_quantity: stock_quantity, 5543 input, 5544 _input_subscription: input_subscription, 5545 save_issue: None, 5546 } 5547 } 5548 5549 fn parsed_stock_quantity(&self, cx: &App) -> Option<u32> { 5550 parse_products_stock_quantity(self.input.read(cx).value().as_ref()) 5551 } 5552 5553 fn has_changes(&self, cx: &App) -> bool { 5554 self.parsed_stock_quantity(cx) 5555 .map(|stock_quantity| Some(stock_quantity) != self.initial_stock_quantity) 5556 .unwrap_or(false) 5557 } 5558 } 5559 5560 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 5561 enum ProductsStockEditorSaveIssue { 5562 SaveFailed, 5563 PublishQueueFailed, 5564 } 5565 5566 impl ProductsStockEditorSaveIssue { 5567 fn from_runtime_error(error: &DesktopAppRuntimeProductStockUpdateError) -> Self { 5568 if error.is_listing_publish_sdk_enqueue_failed() { 5569 Self::PublishQueueFailed 5570 } else { 5571 Self::SaveFailed 5572 } 5573 } 5574 5575 fn text_key(self) -> AppTextKey { 5576 match self { 5577 Self::SaveFailed => AppTextKey::ProductsStockEditorSaveFailed, 5578 Self::PublishQueueFailed => AppTextKey::ProductsStockEditorPublishQueueFailed, 5579 } 5580 } 5581 } 5582 5583 struct ProductEditorFormState { 5584 account_id: String, 5585 product_id: ProductId, 5586 initial_draft: ProductEditorDraft, 5587 status: ProductStatus, 5588 selected_availability_window_id: Option<FulfillmentWindowId>, 5589 title_input: Entity<InputState>, 5590 subtitle_input: Entity<InputState>, 5591 category_input: Entity<InputState>, 5592 unit_input: Entity<InputState>, 5593 price_input: Entity<InputState>, 5594 stock_input: Entity<InputState>, 5595 _title_subscription: Subscription, 5596 _subtitle_subscription: Subscription, 5597 _category_subscription: Subscription, 5598 _unit_subscription: Subscription, 5599 _price_subscription: Subscription, 5600 _stock_subscription: Subscription, 5601 save_issue: Option<ProductEditorSaveIssue>, 5602 } 5603 5604 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 5605 enum ProductEditorSaveIssue { 5606 SaveFailed, 5607 PublishQueueFailed, 5608 } 5609 5610 impl ProductEditorSaveIssue { 5611 fn from_runtime_error(error: &DesktopAppRuntimeProductEditorSaveError) -> Self { 5612 if error.is_listing_publish_sdk_enqueue_failed() { 5613 Self::PublishQueueFailed 5614 } else { 5615 Self::SaveFailed 5616 } 5617 } 5618 5619 fn text_key(self) -> AppTextKey { 5620 match self { 5621 Self::SaveFailed => AppTextKey::ProductsEditorSaveFailed, 5622 Self::PublishQueueFailed => AppTextKey::ProductsEditorPublishQueueFailed, 5623 } 5624 } 5625 } 5626 5627 impl ProductEditorFormState { 5628 fn new( 5629 account_id: String, 5630 product_id: ProductId, 5631 draft: ProductEditorDraft, 5632 window: &mut Window, 5633 cx: &mut Context<HomeView>, 5634 ) -> Self { 5635 let selected_availability_window_id = draft.availability_window_id; 5636 let title_input = 5637 cx.new(|cx| InputState::new(window, cx).default_value(draft.title.clone())); 5638 let subtitle_input = 5639 cx.new(|cx| InputState::new(window, cx).default_value(draft.subtitle.clone())); 5640 let category_input = 5641 cx.new(|cx| InputState::new(window, cx).default_value(draft.category.clone())); 5642 let unit_input = 5643 cx.new(|cx| InputState::new(window, cx).default_value(draft.unit_label.clone())); 5644 let price_input = cx.new(|cx| { 5645 InputState::new(window, cx) 5646 .default_value(product_editor_price_input_value(draft.price_minor_units)) 5647 }); 5648 let stock_input = cx.new(|cx| { 5649 InputState::new(window, cx).default_value( 5650 draft 5651 .stock_quantity 5652 .map(|quantity| quantity.to_string()) 5653 .unwrap_or_default(), 5654 ) 5655 }); 5656 let title_subscription = cx.subscribe_in( 5657 &title_input, 5658 window, 5659 HomeView::handle_product_editor_input_event, 5660 ); 5661 let subtitle_subscription = cx.subscribe_in( 5662 &subtitle_input, 5663 window, 5664 HomeView::handle_product_editor_input_event, 5665 ); 5666 let category_subscription = cx.subscribe_in( 5667 &category_input, 5668 window, 5669 HomeView::handle_product_editor_input_event, 5670 ); 5671 let unit_subscription = cx.subscribe_in( 5672 &unit_input, 5673 window, 5674 HomeView::handle_product_editor_input_event, 5675 ); 5676 let price_subscription = cx.subscribe_in( 5677 &price_input, 5678 window, 5679 HomeView::handle_product_editor_input_event, 5680 ); 5681 let stock_subscription = cx.subscribe_in( 5682 &stock_input, 5683 window, 5684 HomeView::handle_product_editor_input_event, 5685 ); 5686 5687 Self { 5688 account_id, 5689 product_id, 5690 status: draft.status, 5691 selected_availability_window_id, 5692 initial_draft: draft, 5693 title_input, 5694 subtitle_input, 5695 category_input, 5696 unit_input, 5697 price_input, 5698 stock_input, 5699 _title_subscription: title_subscription, 5700 _subtitle_subscription: subtitle_subscription, 5701 _category_subscription: category_subscription, 5702 _unit_subscription: unit_subscription, 5703 _price_subscription: price_subscription, 5704 _stock_subscription: stock_subscription, 5705 save_issue: None, 5706 } 5707 } 5708 5709 fn current_draft(&self, cx: &App) -> Option<ProductEditorDraft> { 5710 Some(ProductEditorDraft { 5711 title: self.title_input.read(cx).value().to_string(), 5712 subtitle: self.subtitle_input.read(cx).value().to_string(), 5713 category: self.category_input.read(cx).value().to_string(), 5714 unit_label: self.unit_input.read(cx).value().to_string(), 5715 price_minor_units: parse_product_editor_price_input( 5716 self.price_input.read(cx).value().as_ref(), 5717 )?, 5718 price_currency: "USD".to_owned(), 5719 stock_quantity: parse_optional_product_editor_stock_input( 5720 self.stock_input.read(cx).value().as_ref(), 5721 )?, 5722 availability_window_id: self.selected_availability_window_id, 5723 status: self.status, 5724 }) 5725 } 5726 5727 fn has_changes(&self, cx: &App) -> bool { 5728 self.current_draft(cx) 5729 .map(|draft| draft != self.initial_draft) 5730 .unwrap_or(false) 5731 } 5732 } 5733 5734 struct StartupHomeView { 5735 startup_notice: Option<String>, 5736 } 5737 5738 impl StartupHomeView { 5739 fn new() -> Self { 5740 Self { 5741 startup_notice: None, 5742 } 5743 } 5744 5745 fn set_notice(&mut self, notice: String) { 5746 self.startup_notice = Some(notice); 5747 } 5748 5749 fn clear_notice(&mut self) { 5750 self.startup_notice = None; 5751 } 5752 5753 fn render( 5754 &self, 5755 runtime: &DesktopAppRuntimeSummary, 5756 signer_entry: Option<&StartupSignerEntryState>, 5757 connect_state: &StartupSignerConnectState, 5758 on_continue: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 5759 on_browse_marketplace: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 5760 on_generate_key: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 5761 on_connect_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 5762 on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 5763 on_back: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 5764 cx: &App, 5765 ) -> impl IntoElement { 5766 startup_home_shell( 5767 runtime, 5768 self.startup_notice.as_deref(), 5769 signer_entry, 5770 connect_state, 5771 on_continue, 5772 on_browse_marketplace, 5773 on_generate_key, 5774 on_connect_signer, 5775 on_submit_signer, 5776 on_back, 5777 cx, 5778 ) 5779 } 5780 } 5781 5782 struct SettingsPickupLocationFormState { 5783 pickup_location_id: PickupLocationId, 5784 label_input: Entity<InputState>, 5785 address_input: Entity<InputState>, 5786 directions_input: Entity<InputState>, 5787 is_default: bool, 5788 can_remove: bool, 5789 _label_subscription: Subscription, 5790 _address_subscription: Subscription, 5791 _directions_subscription: Subscription, 5792 } 5793 5794 impl SettingsPickupLocationFormState { 5795 fn new( 5796 record: &PickupLocationRecord, 5797 can_remove: bool, 5798 window: &mut Window, 5799 cx: &mut Context<SettingsWindowView>, 5800 ) -> Self { 5801 let label_input = 5802 cx.new(|cx| InputState::new(window, cx).default_value(record.label.clone())); 5803 let address_input = 5804 cx.new(|cx| InputState::new(window, cx).default_value(record.address_line.clone())); 5805 let directions_input = cx.new(|cx| { 5806 InputState::new(window, cx).default_value(record.directions.clone().unwrap_or_default()) 5807 }); 5808 let label_subscription = cx.subscribe_in( 5809 &label_input, 5810 window, 5811 SettingsWindowView::handle_farm_rules_input_event, 5812 ); 5813 let address_subscription = cx.subscribe_in( 5814 &address_input, 5815 window, 5816 SettingsWindowView::handle_farm_rules_input_event, 5817 ); 5818 let directions_subscription = cx.subscribe_in( 5819 &directions_input, 5820 window, 5821 SettingsWindowView::handle_farm_rules_input_event, 5822 ); 5823 5824 Self { 5825 pickup_location_id: record.pickup_location_id, 5826 label_input, 5827 address_input, 5828 directions_input, 5829 is_default: record.is_default, 5830 can_remove, 5831 _label_subscription: label_subscription, 5832 _address_subscription: address_subscription, 5833 _directions_subscription: directions_subscription, 5834 } 5835 } 5836 5837 fn current_draft(&self, cx: &App) -> SettingsPickupLocationDraft { 5838 SettingsPickupLocationDraft { 5839 pickup_location_id: self.pickup_location_id, 5840 label: self.label_input.read(cx).value().to_string(), 5841 address_line: self.address_input.read(cx).value().to_string(), 5842 directions: self.directions_input.read(cx).value().to_string(), 5843 is_default: self.is_default, 5844 } 5845 } 5846 } 5847 5848 #[derive(Clone, Debug, Eq, PartialEq)] 5849 struct SettingsPickupLocationDraft { 5850 pickup_location_id: PickupLocationId, 5851 label: String, 5852 address_line: String, 5853 directions: String, 5854 is_default: bool, 5855 } 5856 5857 impl SettingsPickupLocationDraft { 5858 fn from_record(record: &PickupLocationRecord) -> Self { 5859 Self { 5860 pickup_location_id: record.pickup_location_id, 5861 label: record.label.clone(), 5862 address_line: record.address_line.clone(), 5863 directions: record.directions.clone().unwrap_or_default(), 5864 is_default: record.is_default, 5865 } 5866 } 5867 5868 fn into_record(self, farm_id: FarmId) -> PickupLocationRecord { 5869 let directions = self.directions.trim().to_owned(); 5870 5871 PickupLocationRecord { 5872 pickup_location_id: self.pickup_location_id, 5873 farm_id, 5874 label: self.label.trim().to_owned(), 5875 address_line: self.address_line.trim().to_owned(), 5876 directions: (!directions.is_empty()).then_some(directions), 5877 is_default: self.is_default, 5878 } 5879 } 5880 } 5881 5882 struct SettingsOperatingRulesFormState { 5883 promise_lead_hours_input: Entity<InputState>, 5884 substitution_policy_input: Entity<InputState>, 5885 _promise_lead_hours_subscription: Subscription, 5886 _substitution_policy_subscription: Subscription, 5887 } 5888 5889 impl SettingsOperatingRulesFormState { 5890 fn new( 5891 record: Option<&FarmOperatingRulesRecord>, 5892 window: &mut Window, 5893 cx: &mut Context<SettingsWindowView>, 5894 ) -> Self { 5895 let promise_lead_hours_input = cx.new(|cx| { 5896 InputState::new(window, cx).default_value( 5897 record 5898 .map(|record| record.promise_lead_hours.to_string()) 5899 .unwrap_or_default(), 5900 ) 5901 }); 5902 let substitution_policy_input = cx.new(|cx| { 5903 InputState::new(window, cx).default_value( 5904 record 5905 .map(|record| record.substitution_policy.clone()) 5906 .unwrap_or_default(), 5907 ) 5908 }); 5909 let promise_lead_hours_subscription = cx.subscribe_in( 5910 &promise_lead_hours_input, 5911 window, 5912 SettingsWindowView::handle_farm_rules_input_event, 5913 ); 5914 let substitution_policy_subscription = cx.subscribe_in( 5915 &substitution_policy_input, 5916 window, 5917 SettingsWindowView::handle_farm_rules_input_event, 5918 ); 5919 5920 Self { 5921 promise_lead_hours_input, 5922 substitution_policy_input, 5923 _promise_lead_hours_subscription: promise_lead_hours_subscription, 5924 _substitution_policy_subscription: substitution_policy_subscription, 5925 } 5926 } 5927 5928 fn current_draft(&self, cx: &App) -> SettingsOperatingRulesDraft { 5929 SettingsOperatingRulesDraft { 5930 promise_lead_hours: self.promise_lead_hours_input.read(cx).value().to_string(), 5931 substitution_policy: self.substitution_policy_input.read(cx).value().to_string(), 5932 } 5933 } 5934 } 5935 5936 #[derive(Clone, Debug, Eq, PartialEq)] 5937 struct SettingsOperatingRulesDraft { 5938 promise_lead_hours: String, 5939 substitution_policy: String, 5940 } 5941 5942 impl SettingsOperatingRulesDraft { 5943 fn from_record(record: Option<&FarmOperatingRulesRecord>) -> Self { 5944 Self { 5945 promise_lead_hours: record 5946 .map(|record| record.promise_lead_hours.to_string()) 5947 .unwrap_or_default(), 5948 substitution_policy: record 5949 .map(|record| record.substitution_policy.clone()) 5950 .unwrap_or_default(), 5951 } 5952 } 5953 5954 fn is_empty(&self) -> bool { 5955 self.promise_lead_hours.trim().is_empty() && self.substitution_policy.trim().is_empty() 5956 } 5957 } 5958 5959 struct SettingsFulfillmentWindowFormState { 5960 fulfillment_window_id: FulfillmentWindowId, 5961 selected_pickup_location_id: Option<PickupLocationId>, 5962 label_input: Entity<InputState>, 5963 starts_at_input: Entity<InputState>, 5964 ends_at_input: Entity<InputState>, 5965 order_cutoff_input: Entity<InputState>, 5966 _label_subscription: Subscription, 5967 _starts_at_subscription: Subscription, 5968 _ends_at_subscription: Subscription, 5969 _order_cutoff_subscription: Subscription, 5970 } 5971 5972 impl SettingsFulfillmentWindowFormState { 5973 fn new( 5974 draft: &SettingsFulfillmentWindowDraft, 5975 window: &mut Window, 5976 cx: &mut Context<SettingsWindowView>, 5977 ) -> Self { 5978 let label_input = 5979 cx.new(|cx| InputState::new(window, cx).default_value(draft.label.clone())); 5980 let starts_at_input = 5981 cx.new(|cx| InputState::new(window, cx).default_value(draft.starts_at.clone())); 5982 let ends_at_input = 5983 cx.new(|cx| InputState::new(window, cx).default_value(draft.ends_at.clone())); 5984 let order_cutoff_input = 5985 cx.new(|cx| InputState::new(window, cx).default_value(draft.order_cutoff_at.clone())); 5986 let label_subscription = cx.subscribe_in( 5987 &label_input, 5988 window, 5989 SettingsWindowView::handle_farm_rules_input_event, 5990 ); 5991 let starts_at_subscription = cx.subscribe_in( 5992 &starts_at_input, 5993 window, 5994 SettingsWindowView::handle_farm_rules_input_event, 5995 ); 5996 let ends_at_subscription = cx.subscribe_in( 5997 &ends_at_input, 5998 window, 5999 SettingsWindowView::handle_farm_rules_input_event, 6000 ); 6001 let order_cutoff_subscription = cx.subscribe_in( 6002 &order_cutoff_input, 6003 window, 6004 SettingsWindowView::handle_farm_rules_input_event, 6005 ); 6006 6007 Self { 6008 fulfillment_window_id: draft.fulfillment_window_id, 6009 selected_pickup_location_id: draft.selected_pickup_location_id, 6010 label_input, 6011 starts_at_input, 6012 ends_at_input, 6013 order_cutoff_input, 6014 _label_subscription: label_subscription, 6015 _starts_at_subscription: starts_at_subscription, 6016 _ends_at_subscription: ends_at_subscription, 6017 _order_cutoff_subscription: order_cutoff_subscription, 6018 } 6019 } 6020 6021 fn current_draft(&self, cx: &App) -> SettingsFulfillmentWindowDraft { 6022 SettingsFulfillmentWindowDraft { 6023 fulfillment_window_id: self.fulfillment_window_id, 6024 selected_pickup_location_id: self.selected_pickup_location_id, 6025 label: self.label_input.read(cx).value().to_string(), 6026 starts_at: self.starts_at_input.read(cx).value().to_string(), 6027 ends_at: self.ends_at_input.read(cx).value().to_string(), 6028 order_cutoff_at: self.order_cutoff_input.read(cx).value().to_string(), 6029 } 6030 } 6031 } 6032 6033 #[derive(Clone, Debug, Eq, PartialEq)] 6034 struct SettingsFulfillmentWindowDraft { 6035 fulfillment_window_id: FulfillmentWindowId, 6036 selected_pickup_location_id: Option<PickupLocationId>, 6037 label: String, 6038 starts_at: String, 6039 ends_at: String, 6040 order_cutoff_at: String, 6041 } 6042 6043 impl SettingsFulfillmentWindowDraft { 6044 fn from_record(record: &FulfillmentWindowRecord) -> Self { 6045 Self { 6046 fulfillment_window_id: record.fulfillment_window_id, 6047 selected_pickup_location_id: Some(record.pickup_location_id), 6048 label: record.label.clone(), 6049 starts_at: record.starts_at.clone(), 6050 ends_at: record.ends_at.clone(), 6051 order_cutoff_at: record.order_cutoff_at.clone(), 6052 } 6053 } 6054 } 6055 6056 struct SettingsBlackoutPeriodFormState { 6057 blackout_period_id: BlackoutPeriodId, 6058 label_input: Entity<InputState>, 6059 starts_at_input: Entity<InputState>, 6060 ends_at_input: Entity<InputState>, 6061 _label_subscription: Subscription, 6062 _starts_at_subscription: Subscription, 6063 _ends_at_subscription: Subscription, 6064 } 6065 6066 impl SettingsBlackoutPeriodFormState { 6067 fn new( 6068 draft: &SettingsBlackoutPeriodDraft, 6069 window: &mut Window, 6070 cx: &mut Context<SettingsWindowView>, 6071 ) -> Self { 6072 let label_input = 6073 cx.new(|cx| InputState::new(window, cx).default_value(draft.label.clone())); 6074 let starts_at_input = 6075 cx.new(|cx| InputState::new(window, cx).default_value(draft.starts_at.clone())); 6076 let ends_at_input = 6077 cx.new(|cx| InputState::new(window, cx).default_value(draft.ends_at.clone())); 6078 let label_subscription = cx.subscribe_in( 6079 &label_input, 6080 window, 6081 SettingsWindowView::handle_farm_rules_input_event, 6082 ); 6083 let starts_at_subscription = cx.subscribe_in( 6084 &starts_at_input, 6085 window, 6086 SettingsWindowView::handle_farm_rules_input_event, 6087 ); 6088 let ends_at_subscription = cx.subscribe_in( 6089 &ends_at_input, 6090 window, 6091 SettingsWindowView::handle_farm_rules_input_event, 6092 ); 6093 6094 Self { 6095 blackout_period_id: draft.blackout_period_id, 6096 label_input, 6097 starts_at_input, 6098 ends_at_input, 6099 _label_subscription: label_subscription, 6100 _starts_at_subscription: starts_at_subscription, 6101 _ends_at_subscription: ends_at_subscription, 6102 } 6103 } 6104 6105 fn current_draft(&self, cx: &App) -> SettingsBlackoutPeriodDraft { 6106 SettingsBlackoutPeriodDraft { 6107 blackout_period_id: self.blackout_period_id, 6108 label: self.label_input.read(cx).value().to_string(), 6109 starts_at: self.starts_at_input.read(cx).value().to_string(), 6110 ends_at: self.ends_at_input.read(cx).value().to_string(), 6111 } 6112 } 6113 } 6114 6115 #[derive(Clone, Debug, Eq, PartialEq)] 6116 struct SettingsBlackoutPeriodDraft { 6117 blackout_period_id: BlackoutPeriodId, 6118 label: String, 6119 starts_at: String, 6120 ends_at: String, 6121 } 6122 6123 impl SettingsBlackoutPeriodDraft { 6124 fn from_record(record: &BlackoutPeriodRecord) -> Self { 6125 Self { 6126 blackout_period_id: record.blackout_period_id, 6127 label: record.label.clone(), 6128 starts_at: record.starts_at.clone(), 6129 ends_at: record.ends_at.clone(), 6130 } 6131 } 6132 } 6133 6134 #[derive(Clone, Debug, Eq, PartialEq)] 6135 struct SettingsFarmRulesDraft { 6136 farm_profile: FarmProfileRecord, 6137 pickup_locations: Vec<SettingsPickupLocationDraft>, 6138 operating_rules: SettingsOperatingRulesDraft, 6139 fulfillment_windows: Vec<SettingsFulfillmentWindowDraft>, 6140 blackout_periods: Vec<SettingsBlackoutPeriodDraft>, 6141 } 6142 6143 impl SettingsFarmRulesDraft { 6144 fn from_projection(farm_id: FarmId, projection: &FarmRulesProjection) -> Self { 6145 let farm_profile = projection 6146 .farm_profile 6147 .as_ref() 6148 .cloned() 6149 .unwrap_or(FarmProfileRecord { 6150 farm_id, 6151 display_name: String::new(), 6152 timezone: String::new(), 6153 currency_code: String::new(), 6154 }); 6155 6156 Self { 6157 farm_profile, 6158 pickup_locations: projection 6159 .pickup_locations 6160 .iter() 6161 .map(SettingsPickupLocationDraft::from_record) 6162 .collect(), 6163 operating_rules: SettingsOperatingRulesDraft::from_record( 6164 projection.operating_rules.as_ref(), 6165 ), 6166 fulfillment_windows: projection 6167 .fulfillment_windows 6168 .iter() 6169 .map(SettingsFulfillmentWindowDraft::from_record) 6170 .collect(), 6171 blackout_periods: projection 6172 .blackout_periods 6173 .iter() 6174 .map(SettingsBlackoutPeriodDraft::from_record) 6175 .collect(), 6176 } 6177 } 6178 } 6179 6180 struct SettingsFarmRulesEvaluation { 6181 projection: FarmRulesProjection, 6182 operating_rules_validation_keys: Vec<AppTextKey>, 6183 fulfillment_window_validation_keys: Vec<Vec<AppTextKey>>, 6184 blackout_period_validation_keys: Vec<Vec<AppTextKey>>, 6185 blocking_keys: Vec<AppTextKey>, 6186 readiness_keys: Vec<AppTextKey>, 6187 } 6188 6189 impl SettingsFarmRulesEvaluation { 6190 fn has_blocking_errors(&self) -> bool { 6191 !self.blocking_keys.is_empty() 6192 } 6193 } 6194 6195 fn push_unique_text_key(keys: &mut Vec<AppTextKey>, key: AppTextKey) { 6196 if !keys.contains(&key) { 6197 keys.push(key); 6198 } 6199 } 6200 6201 struct SettingsFarmPanelState { 6202 account_id: String, 6203 farm_id: FarmId, 6204 initial_draft: SettingsFarmRulesDraft, 6205 farm_name_input: Entity<InputState>, 6206 timezone_input: Entity<InputState>, 6207 currency_input: Entity<InputState>, 6208 pickup_locations: Vec<SettingsPickupLocationFormState>, 6209 operating_rules: SettingsOperatingRulesFormState, 6210 fulfillment_windows: Vec<SettingsFulfillmentWindowFormState>, 6211 blackout_periods: Vec<SettingsBlackoutPeriodFormState>, 6212 _farm_name_subscription: Subscription, 6213 _timezone_subscription: Subscription, 6214 _currency_subscription: Subscription, 6215 save_failed: bool, 6216 } 6217 6218 impl SettingsFarmPanelState { 6219 fn new( 6220 account_id: String, 6221 projection: FarmRulesProjection, 6222 window: &mut Window, 6223 cx: &mut Context<SettingsWindowView>, 6224 ) -> Self { 6225 let farm_id = projection 6226 .farm_profile 6227 .as_ref() 6228 .map(|farm_profile| farm_profile.farm_id) 6229 .unwrap_or_else(FarmId::new); 6230 let initial_draft = SettingsFarmRulesDraft::from_projection(farm_id, &projection); 6231 let farm_name_input = cx.new(|cx| { 6232 InputState::new(window, cx) 6233 .default_value(initial_draft.farm_profile.display_name.clone()) 6234 }); 6235 let timezone_input = cx.new(|cx| { 6236 InputState::new(window, cx).default_value(initial_draft.farm_profile.timezone.clone()) 6237 }); 6238 let currency_input = cx.new(|cx| { 6239 InputState::new(window, cx) 6240 .default_value(initial_draft.farm_profile.currency_code.clone()) 6241 }); 6242 let farm_name_subscription = cx.subscribe_in( 6243 &farm_name_input, 6244 window, 6245 SettingsWindowView::handle_farm_rules_input_event, 6246 ); 6247 let timezone_subscription = cx.subscribe_in( 6248 &timezone_input, 6249 window, 6250 SettingsWindowView::handle_farm_rules_input_event, 6251 ); 6252 let currency_subscription = cx.subscribe_in( 6253 ¤cy_input, 6254 window, 6255 SettingsWindowView::handle_farm_rules_input_event, 6256 ); 6257 let pickup_locations = projection 6258 .pickup_locations 6259 .iter() 6260 .map(|record| { 6261 let can_remove = projection.fulfillment_windows.iter().all(|window_record| { 6262 window_record.pickup_location_id != record.pickup_location_id 6263 }); 6264 SettingsPickupLocationFormState::new(record, can_remove, window, cx) 6265 }) 6266 .collect(); 6267 let operating_rules = 6268 SettingsOperatingRulesFormState::new(projection.operating_rules.as_ref(), window, cx); 6269 let fulfillment_windows = projection 6270 .fulfillment_windows 6271 .iter() 6272 .map(|record| { 6273 SettingsFulfillmentWindowFormState::new( 6274 &SettingsFulfillmentWindowDraft::from_record(record), 6275 window, 6276 cx, 6277 ) 6278 }) 6279 .collect(); 6280 let blackout_periods = projection 6281 .blackout_periods 6282 .iter() 6283 .map(|record| { 6284 SettingsBlackoutPeriodFormState::new( 6285 &SettingsBlackoutPeriodDraft::from_record(record), 6286 window, 6287 cx, 6288 ) 6289 }) 6290 .collect(); 6291 let mut state = Self { 6292 account_id, 6293 farm_id, 6294 initial_draft, 6295 farm_name_input, 6296 timezone_input, 6297 currency_input, 6298 pickup_locations, 6299 operating_rules, 6300 fulfillment_windows, 6301 blackout_periods, 6302 _farm_name_subscription: farm_name_subscription, 6303 _timezone_subscription: timezone_subscription, 6304 _currency_subscription: currency_subscription, 6305 save_failed: false, 6306 }; 6307 state.sync_pickup_location_removability(); 6308 state 6309 } 6310 6311 fn add_pickup_location(&mut self, window: &mut Window, cx: &mut Context<SettingsWindowView>) { 6312 let record = PickupLocationRecord { 6313 pickup_location_id: PickupLocationId::new(), 6314 farm_id: self.farm_id, 6315 label: String::new(), 6316 address_line: String::new(), 6317 directions: None, 6318 is_default: self.pickup_locations.is_empty(), 6319 }; 6320 let pickup_location = SettingsPickupLocationFormState::new(&record, true, window, cx); 6321 6322 self.pickup_locations.push(pickup_location); 6323 self.sync_pickup_location_removability(); 6324 self.save_failed = false; 6325 } 6326 6327 fn set_default_pickup_location(&mut self, pickup_location_id: PickupLocationId) { 6328 for pickup_location in &mut self.pickup_locations { 6329 pickup_location.is_default = pickup_location.pickup_location_id == pickup_location_id; 6330 } 6331 self.save_failed = false; 6332 } 6333 6334 fn remove_pickup_location(&mut self, pickup_location_id: PickupLocationId) { 6335 self.pickup_locations 6336 .retain(|pickup_location| pickup_location.pickup_location_id != pickup_location_id); 6337 if !self 6338 .pickup_locations 6339 .iter() 6340 .any(|pickup_location| pickup_location.is_default) 6341 { 6342 if let Some(first_pickup_location) = self.pickup_locations.first_mut() { 6343 first_pickup_location.is_default = true; 6344 } 6345 } 6346 self.sync_pickup_location_removability(); 6347 self.save_failed = false; 6348 } 6349 6350 fn add_fulfillment_window( 6351 &mut self, 6352 window: &mut Window, 6353 cx: &mut Context<SettingsWindowView>, 6354 ) { 6355 let selected_pickup_location_id = self 6356 .pickup_locations 6357 .iter() 6358 .find(|pickup_location| pickup_location.is_default) 6359 .or_else(|| self.pickup_locations.first()) 6360 .map(|pickup_location| pickup_location.pickup_location_id); 6361 let fulfillment_window = SettingsFulfillmentWindowFormState::new( 6362 &SettingsFulfillmentWindowDraft { 6363 fulfillment_window_id: FulfillmentWindowId::new(), 6364 selected_pickup_location_id, 6365 label: String::new(), 6366 starts_at: String::new(), 6367 ends_at: String::new(), 6368 order_cutoff_at: String::new(), 6369 }, 6370 window, 6371 cx, 6372 ); 6373 6374 self.fulfillment_windows.push(fulfillment_window); 6375 self.sync_pickup_location_removability(); 6376 self.save_failed = false; 6377 } 6378 6379 fn select_fulfillment_window_pickup_location( 6380 &mut self, 6381 fulfillment_window_id: FulfillmentWindowId, 6382 pickup_location_id: PickupLocationId, 6383 ) { 6384 if let Some(fulfillment_window) = 6385 self.fulfillment_windows 6386 .iter_mut() 6387 .find(|fulfillment_window| { 6388 fulfillment_window.fulfillment_window_id == fulfillment_window_id 6389 }) 6390 { 6391 fulfillment_window.selected_pickup_location_id = Some(pickup_location_id); 6392 self.sync_pickup_location_removability(); 6393 self.save_failed = false; 6394 } 6395 } 6396 6397 fn remove_fulfillment_window(&mut self, fulfillment_window_id: FulfillmentWindowId) { 6398 self.fulfillment_windows.retain(|fulfillment_window| { 6399 fulfillment_window.fulfillment_window_id != fulfillment_window_id 6400 }); 6401 self.sync_pickup_location_removability(); 6402 self.save_failed = false; 6403 } 6404 6405 fn add_blackout_period(&mut self, window: &mut Window, cx: &mut Context<SettingsWindowView>) { 6406 let blackout_period = SettingsBlackoutPeriodFormState::new( 6407 &SettingsBlackoutPeriodDraft { 6408 blackout_period_id: BlackoutPeriodId::new(), 6409 label: String::new(), 6410 starts_at: String::new(), 6411 ends_at: String::new(), 6412 }, 6413 window, 6414 cx, 6415 ); 6416 6417 self.blackout_periods.push(blackout_period); 6418 self.save_failed = false; 6419 } 6420 6421 fn remove_blackout_period(&mut self, blackout_period_id: BlackoutPeriodId) { 6422 self.blackout_periods 6423 .retain(|blackout_period| blackout_period.blackout_period_id != blackout_period_id); 6424 self.save_failed = false; 6425 } 6426 6427 fn current_draft(&self, cx: &App) -> SettingsFarmRulesDraft { 6428 SettingsFarmRulesDraft { 6429 farm_profile: FarmProfileRecord { 6430 farm_id: self.farm_id, 6431 display_name: self.farm_name_input.read(cx).value().to_string(), 6432 timezone: self.timezone_input.read(cx).value().to_string(), 6433 currency_code: self.currency_input.read(cx).value().to_string(), 6434 }, 6435 pickup_locations: self 6436 .pickup_locations 6437 .iter() 6438 .map(|pickup_location| pickup_location.current_draft(cx)) 6439 .collect(), 6440 operating_rules: self.operating_rules.current_draft(cx), 6441 fulfillment_windows: self 6442 .fulfillment_windows 6443 .iter() 6444 .map(|fulfillment_window| fulfillment_window.current_draft(cx)) 6445 .collect(), 6446 blackout_periods: self 6447 .blackout_periods 6448 .iter() 6449 .map(|blackout_period| blackout_period.current_draft(cx)) 6450 .collect(), 6451 } 6452 } 6453 6454 fn evaluate(&self, cx: &App) -> SettingsFarmRulesEvaluation { 6455 let draft = self.current_draft(cx); 6456 let farm_profile = FarmProfileRecord { 6457 farm_id: self.farm_id, 6458 display_name: draft.farm_profile.display_name.trim().to_owned(), 6459 timezone: draft.farm_profile.timezone.trim().to_owned(), 6460 currency_code: draft.farm_profile.currency_code.trim().to_owned(), 6461 }; 6462 let pickup_locations = draft 6463 .pickup_locations 6464 .clone() 6465 .into_iter() 6466 .map(|pickup_location| pickup_location.into_record(self.farm_id)) 6467 .collect(); 6468 let mut operating_rules_validation_keys = Vec::new(); 6469 let operating_rules = if draft.operating_rules.is_empty() { 6470 None 6471 } else { 6472 let promise_lead_hours = match draft 6473 .operating_rules 6474 .promise_lead_hours 6475 .trim() 6476 .parse::<u16>() 6477 { 6478 Ok(promise_lead_hours) => promise_lead_hours, 6479 Err(_) if draft.operating_rules.promise_lead_hours.trim().is_empty() => 0, 6480 Err(_) => { 6481 push_unique_text_key( 6482 &mut operating_rules_validation_keys, 6483 AppTextKey::SettingsOperatingRulesInvalidPromiseLeadTime, 6484 ); 6485 0 6486 } 6487 }; 6488 6489 Some(FarmOperatingRulesRecord { 6490 farm_id: self.farm_id, 6491 promise_lead_hours, 6492 substitution_policy: draft.operating_rules.substitution_policy.trim().to_owned(), 6493 }) 6494 }; 6495 let mut fulfillment_windows = Vec::new(); 6496 let mut fulfillment_window_validation_keys = 6497 Vec::with_capacity(draft.fulfillment_windows.len()); 6498 for fulfillment_window in &draft.fulfillment_windows { 6499 let label = fulfillment_window.label.trim().to_owned(); 6500 let starts_at = fulfillment_window.starts_at.trim().to_owned(); 6501 let ends_at = fulfillment_window.ends_at.trim().to_owned(); 6502 let order_cutoff_at = fulfillment_window.order_cutoff_at.trim().to_owned(); 6503 let mut row_validation_keys = Vec::new(); 6504 let missing_required_fields = label.is_empty() 6505 || starts_at.is_empty() 6506 || ends_at.is_empty() 6507 || order_cutoff_at.is_empty(); 6508 6509 if missing_required_fields { 6510 push_unique_text_key( 6511 &mut row_validation_keys, 6512 AppTextKey::SettingsFulfillmentWindowsValidationCompleteBeforeSave, 6513 ); 6514 } else if fulfillment_window.selected_pickup_location_id.is_none() { 6515 push_unique_text_key( 6516 &mut row_validation_keys, 6517 AppTextKey::SettingsFulfillmentWindowsValidationChoosePickupLocation, 6518 ); 6519 } 6520 6521 if let Some(pickup_location_id) = fulfillment_window.selected_pickup_location_id { 6522 if !missing_required_fields { 6523 if ends_at <= starts_at { 6524 push_unique_text_key( 6525 &mut row_validation_keys, 6526 AppTextKey::SettingsReadinessFieldFulfillmentWindowEndsBeforeStart, 6527 ); 6528 } 6529 if order_cutoff_at >= starts_at { 6530 push_unique_text_key( 6531 &mut row_validation_keys, 6532 AppTextKey::SettingsReadinessFieldFulfillmentWindowCutoffAfterStart, 6533 ); 6534 } 6535 fulfillment_windows.push(FulfillmentWindowRecord { 6536 fulfillment_window_id: fulfillment_window.fulfillment_window_id, 6537 farm_id: self.farm_id, 6538 pickup_location_id, 6539 label, 6540 starts_at, 6541 ends_at, 6542 order_cutoff_at, 6543 }); 6544 } 6545 } 6546 6547 fulfillment_window_validation_keys.push(row_validation_keys); 6548 } 6549 let mut blackout_periods = Vec::new(); 6550 let mut blackout_period_validation_keys = Vec::with_capacity(draft.blackout_periods.len()); 6551 for blackout_period in &draft.blackout_periods { 6552 let label = blackout_period.label.trim().to_owned(); 6553 let starts_at = blackout_period.starts_at.trim().to_owned(); 6554 let ends_at = blackout_period.ends_at.trim().to_owned(); 6555 let mut row_validation_keys = Vec::new(); 6556 6557 if label.is_empty() || starts_at.is_empty() || ends_at.is_empty() { 6558 push_unique_text_key( 6559 &mut row_validation_keys, 6560 AppTextKey::SettingsBlackoutPeriodsValidationCompleteBeforeSave, 6561 ); 6562 } else { 6563 if ends_at <= starts_at { 6564 push_unique_text_key( 6565 &mut row_validation_keys, 6566 AppTextKey::SettingsReadinessFieldBlackoutPeriodEndsBeforeStart, 6567 ); 6568 } 6569 blackout_periods.push(BlackoutPeriodRecord { 6570 blackout_period_id: blackout_period.blackout_period_id, 6571 farm_id: self.farm_id, 6572 label, 6573 starts_at, 6574 ends_at, 6575 }); 6576 } 6577 6578 blackout_period_validation_keys.push(row_validation_keys); 6579 } 6580 6581 let mut projection = FarmRulesProjection { 6582 farm_profile: Some(farm_profile), 6583 pickup_locations, 6584 operating_rules, 6585 fulfillment_windows, 6586 blackout_periods, 6587 readiness: FarmRulesReadiness::ready(), 6588 }; 6589 projection.readiness = derive_farm_rules_readiness(&projection); 6590 6591 let mut blocking_keys = operating_rules_validation_keys.clone(); 6592 for row_validation_keys in &fulfillment_window_validation_keys { 6593 for validation_key in row_validation_keys { 6594 push_unique_text_key(&mut blocking_keys, *validation_key); 6595 } 6596 } 6597 for row_validation_keys in &blackout_period_validation_keys { 6598 for validation_key in row_validation_keys { 6599 push_unique_text_key(&mut blocking_keys, *validation_key); 6600 } 6601 } 6602 for timing_conflict in &projection.readiness.timing_conflicts { 6603 push_unique_text_key( 6604 &mut blocking_keys, 6605 settings_timing_conflict_key(timing_conflict.kind), 6606 ); 6607 } 6608 6609 let mut readiness_keys = projection 6610 .readiness 6611 .blockers 6612 .iter() 6613 .copied() 6614 .map(settings_readiness_key) 6615 .collect::<Vec<_>>(); 6616 for blocking_key in &blocking_keys { 6617 push_unique_text_key(&mut readiness_keys, *blocking_key); 6618 } 6619 6620 SettingsFarmRulesEvaluation { 6621 projection, 6622 operating_rules_validation_keys, 6623 fulfillment_window_validation_keys, 6624 blackout_period_validation_keys, 6625 blocking_keys, 6626 readiness_keys, 6627 } 6628 } 6629 6630 fn current_projection(&self, cx: &App) -> FarmRulesProjection { 6631 self.evaluate(cx).projection 6632 } 6633 6634 fn has_changes(&self, cx: &App) -> bool { 6635 self.current_draft(cx) != self.initial_draft 6636 } 6637 6638 fn save_ready(&self, cx: &App) -> bool { 6639 let evaluation = self.evaluate(cx); 6640 self.has_changes(cx) && !evaluation.has_blocking_errors() 6641 } 6642 6643 fn save_status_key(&self, cx: &App) -> AppTextKey { 6644 if self.save_failed { 6645 AppTextKey::SettingsFarmSaveFailed 6646 } else if self.has_changes(cx) { 6647 let evaluation = self.evaluate(cx); 6648 if evaluation.has_blocking_errors() { 6649 AppTextKey::SettingsFarmSaveBlocked 6650 } else { 6651 AppTextKey::SettingsFarmSavePending 6652 } 6653 } else { 6654 AppTextKey::SettingsFarmSaveSaved 6655 } 6656 } 6657 6658 fn sync_pickup_location_removability(&mut self) { 6659 let selected_pickup_location_ids = self 6660 .fulfillment_windows 6661 .iter() 6662 .filter_map(|fulfillment_window| fulfillment_window.selected_pickup_location_id) 6663 .collect::<Vec<_>>(); 6664 6665 for pickup_location in &mut self.pickup_locations { 6666 pickup_location.can_remove = 6667 !selected_pickup_location_ids.contains(&pickup_location.pickup_location_id); 6668 } 6669 } 6670 } 6671 6672 pub struct SettingsWindowView { 6673 runtime: DesktopAppRuntime, 6674 farm_panel_state: Option<SettingsFarmPanelState>, 6675 farm_panel_error: Option<String>, 6676 about_panel_notice: Option<String>, 6677 } 6678 6679 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 6680 enum SettingsAutoFocusTarget { 6681 Navigation(SettingsPanelViewKey), 6682 AccountAdd, 6683 FarmNameInput, 6684 AboutRefresh, 6685 } 6686 6687 fn settings_preferences_general_row_state( 6688 runtime: &DesktopAppRuntimeSummary, 6689 ) -> SettingsPreferencesGeneralRowState { 6690 let general = &runtime.shell_projection.settings.general; 6691 SettingsPreferencesGeneralRowState { 6692 allow_relay_connections: general.allow_relay_connections, 6693 use_media_servers: general.use_media_servers, 6694 use_nip05: general.use_nip05, 6695 launch_at_login: general.launch_at_login, 6696 } 6697 } 6698 6699 impl SettingsWindowView { 6700 pub fn new(runtime: DesktopAppRuntime, initial_view: SettingsPanelViewKey) -> Self { 6701 let _ = initial_view; 6702 Self { 6703 runtime, 6704 farm_panel_state: None, 6705 farm_panel_error: None, 6706 about_panel_notice: None, 6707 } 6708 } 6709 6710 fn select_view(&mut self, view: SettingsPanelViewKey, cx: &mut Context<Self>) { 6711 self.about_panel_notice = None; 6712 if self.runtime.select_settings_section(view) { 6713 cx.notify(); 6714 } 6715 } 6716 6717 fn selected_view(&self) -> SettingsPanelViewKey { 6718 self.runtime.selected_settings_section() 6719 } 6720 6721 fn select_account(&mut self, account_id: String, cx: &mut Context<Self>) { 6722 match self.runtime.select_local_account(account_id.as_str()) { 6723 Ok(changed) => { 6724 if changed { 6725 cx.refresh_windows(); 6726 } 6727 cx.notify(); 6728 } 6729 Err(runtime_error) => { 6730 error!( 6731 target: "settings", 6732 event = "settings.account.select_failed", 6733 error = %runtime_error, 6734 "failed to select account from settings panel" 6735 ); 6736 } 6737 } 6738 } 6739 6740 fn handle_farm_rules_input_event( 6741 &mut self, 6742 _: &Entity<InputState>, 6743 event: &InputEvent, 6744 _: &mut Window, 6745 cx: &mut Context<Self>, 6746 ) { 6747 if !matches!(event, InputEvent::Change) { 6748 return; 6749 } 6750 6751 if let Some(form) = self.farm_panel_state.as_mut() { 6752 form.save_failed = false; 6753 } 6754 6755 cx.notify(); 6756 } 6757 6758 fn sync_farm_panel_state(&mut self, window: &mut Window, cx: &mut Context<Self>) { 6759 let runtime = self.runtime.summary(); 6760 let Some((account_id, farm_id)) = settings_panel_farm_context(&runtime) else { 6761 self.farm_panel_state = None; 6762 self.farm_panel_error = None; 6763 return; 6764 }; 6765 6766 if self 6767 .farm_panel_state 6768 .as_ref() 6769 .is_some_and(|form| form.account_id == account_id && form.farm_id == farm_id) 6770 { 6771 return; 6772 } 6773 6774 match self.runtime.load_farm_rules_projection() { 6775 Ok(projection) => { 6776 self.farm_panel_state = Some(SettingsFarmPanelState::new( 6777 account_id, projection, window, cx, 6778 )); 6779 self.farm_panel_error = None; 6780 } 6781 Err(runtime_error) => { 6782 error!( 6783 target: "settings", 6784 event = "settings.farm.load_failed", 6785 error = %runtime_error, 6786 "failed to load farm settings projection" 6787 ); 6788 self.farm_panel_state = None; 6789 self.farm_panel_error = Some(runtime_error.to_string()); 6790 } 6791 } 6792 } 6793 6794 fn add_pickup_location(&mut self, window: &mut Window, cx: &mut Context<Self>) { 6795 let Some(form) = self.farm_panel_state.as_mut() else { 6796 return; 6797 }; 6798 6799 form.add_pickup_location(window, cx); 6800 cx.notify(); 6801 } 6802 6803 fn select_default_pickup_location( 6804 &mut self, 6805 pickup_location_id: PickupLocationId, 6806 cx: &mut Context<Self>, 6807 ) { 6808 let Some(form) = self.farm_panel_state.as_mut() else { 6809 return; 6810 }; 6811 6812 form.set_default_pickup_location(pickup_location_id); 6813 cx.notify(); 6814 } 6815 6816 fn remove_pickup_location( 6817 &mut self, 6818 pickup_location_id: PickupLocationId, 6819 cx: &mut Context<Self>, 6820 ) { 6821 let Some(form) = self.farm_panel_state.as_mut() else { 6822 return; 6823 }; 6824 6825 form.remove_pickup_location(pickup_location_id); 6826 cx.notify(); 6827 } 6828 6829 fn add_fulfillment_window(&mut self, window: &mut Window, cx: &mut Context<Self>) { 6830 let Some(form) = self.farm_panel_state.as_mut() else { 6831 return; 6832 }; 6833 6834 form.add_fulfillment_window(window, cx); 6835 cx.notify(); 6836 } 6837 6838 fn select_fulfillment_window_pickup_location( 6839 &mut self, 6840 fulfillment_window_id: FulfillmentWindowId, 6841 pickup_location_id: PickupLocationId, 6842 cx: &mut Context<Self>, 6843 ) { 6844 let Some(form) = self.farm_panel_state.as_mut() else { 6845 return; 6846 }; 6847 6848 form.select_fulfillment_window_pickup_location(fulfillment_window_id, pickup_location_id); 6849 cx.notify(); 6850 } 6851 6852 fn remove_fulfillment_window( 6853 &mut self, 6854 fulfillment_window_id: FulfillmentWindowId, 6855 cx: &mut Context<Self>, 6856 ) { 6857 let Some(form) = self.farm_panel_state.as_mut() else { 6858 return; 6859 }; 6860 6861 form.remove_fulfillment_window(fulfillment_window_id); 6862 cx.notify(); 6863 } 6864 6865 fn add_blackout_period(&mut self, window: &mut Window, cx: &mut Context<Self>) { 6866 let Some(form) = self.farm_panel_state.as_mut() else { 6867 return; 6868 }; 6869 6870 form.add_blackout_period(window, cx); 6871 cx.notify(); 6872 } 6873 6874 fn remove_blackout_period( 6875 &mut self, 6876 blackout_period_id: BlackoutPeriodId, 6877 cx: &mut Context<Self>, 6878 ) { 6879 let Some(form) = self.farm_panel_state.as_mut() else { 6880 return; 6881 }; 6882 6883 form.remove_blackout_period(blackout_period_id); 6884 cx.notify(); 6885 } 6886 6887 fn save_farm_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) { 6888 let Some((current_projection, save_ready)) = self 6889 .farm_panel_state 6890 .as_ref() 6891 .map(|form| (form.current_projection(cx), form.save_ready(cx))) 6892 else { 6893 return; 6894 }; 6895 if !save_ready { 6896 return; 6897 } 6898 6899 match self.runtime.save_farm_rules_projection(current_projection) { 6900 Ok(saved_projection) => { 6901 let account_id = self 6902 .farm_panel_state 6903 .as_ref() 6904 .map(|form| form.account_id.clone()) 6905 .unwrap_or_default(); 6906 self.farm_panel_state = Some(SettingsFarmPanelState::new( 6907 account_id, 6908 saved_projection, 6909 window, 6910 cx, 6911 )); 6912 self.farm_panel_error = None; 6913 cx.notify(); 6914 } 6915 Err(runtime_error) => { 6916 error!( 6917 target: "settings", 6918 event = "settings.farm.save_failed", 6919 error = %runtime_error, 6920 "failed to save farm settings projection" 6921 ); 6922 if let Some(form) = self.farm_panel_state.as_mut() { 6923 form.save_failed = true; 6924 } 6925 cx.notify(); 6926 } 6927 } 6928 } 6929 6930 fn refresh_about_sync(&mut self, cx: &mut Context<Self>) { 6931 match self.runtime.sync_on_manual_refresh() { 6932 Ok(changed) => { 6933 if changed { 6934 self.about_panel_notice = None; 6935 cx.refresh_windows(); 6936 } else { 6937 self.about_panel_notice = Some(app_text(about_conflict_review_body_key( 6938 &self.runtime.summary().sync_status, 6939 ))); 6940 } 6941 cx.notify(); 6942 } 6943 Err(runtime_error) => { 6944 error!( 6945 target: "settings", 6946 event = "settings.about.sync_refresh_failed", 6947 error = %runtime_error, 6948 "failed to refresh sync from the about panel" 6949 ); 6950 self.about_panel_notice = Some(runtime_error.to_string()); 6951 cx.notify(); 6952 } 6953 } 6954 } 6955 6956 fn resolve_about_conflict( 6957 &mut self, 6958 conflict_id: String, 6959 resolution: SyncConflictResolutionStatus, 6960 cx: &mut Context<Self>, 6961 ) { 6962 match self 6963 .runtime 6964 .resolve_sync_conflict(conflict_id.as_str(), resolution) 6965 { 6966 Ok(changed) => { 6967 if changed { 6968 self.about_panel_notice = None; 6969 cx.refresh_windows(); 6970 } else { 6971 self.about_panel_notice = Some(app_text(about_conflict_review_body_key( 6972 &self.runtime.summary().sync_status, 6973 ))); 6974 } 6975 cx.notify(); 6976 } 6977 Err(runtime_error) => { 6978 error!( 6979 target: "settings", 6980 event = "settings.about.conflict_resolution_failed", 6981 conflict_id = %conflict_id, 6982 error = %runtime_error, 6983 "failed to resolve sync conflict from the about panel" 6984 ); 6985 self.about_panel_notice = Some(runtime_error.to_string()); 6986 cx.notify(); 6987 } 6988 } 6989 } 6990 6991 fn about_conflict_card( 6992 &mut self, 6993 conflict_index: usize, 6994 conflict: &DesktopAppSyncConflictSummary, 6995 cx: &mut Context<Self>, 6996 ) -> impl IntoElement { 6997 let action_specs = about_conflict_action_specs(&conflict.conflict); 6998 6999 app_surface_panel( 7000 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 7001 .w_full() 7002 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 7003 .child(app_text_value(about_conflict_aggregate_text( 7004 &conflict.conflict, 7005 ))) 7006 .child(label_value_list(about_conflict_detail_rows(conflict))) 7007 .when(!action_specs.is_empty(), |this| { 7008 this.child( 7009 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 7010 .w_full() 7011 .children( 7012 action_specs 7013 .into_iter() 7014 .enumerate() 7015 .map(|(action_index, (key, resolution))| { 7016 action_button_compact( 7017 ( 7018 gpui::ElementId::from(( 7019 "settings-about-conflict-action", 7020 conflict_index, 7021 )), 7022 action_index.to_string(), 7023 ), 7024 app_shared_text(key), 7025 cx.listener({ 7026 let conflict_id = conflict.conflict_id.clone(); 7027 move |this, _, _, cx| { 7028 this.resolve_about_conflict( 7029 conflict_id.clone(), 7030 resolution, 7031 cx, 7032 ) 7033 } 7034 }), 7035 cx, 7036 ) 7037 .into_any_element() 7038 }) 7039 .collect::<Vec<_>>(), 7040 ), 7041 ) 7042 }), 7043 ) 7044 } 7045 7046 fn navigation_button( 7047 &mut self, 7048 view: SettingsPanelViewKey, 7049 cx: &mut Context<Self>, 7050 ) -> impl IntoElement { 7051 let (navigation_id, navigation_icon) = settings_panel_spec(view); 7052 icon_segment_button( 7053 IconSegmentButtonSpec::new( 7054 navigation_id, 7055 app_shared_text(settings_panel_label_key(view)), 7056 navigation_icon, 7057 ), 7058 self.selected_view() == view, 7059 cx.listener(move |this, _, _, cx| this.select_view(view, cx)), 7060 cx, 7061 ) 7062 } 7063 7064 fn account_panel(&self, cx: &mut Context<Self>) -> impl IntoElement { 7065 let runtime = self.runtime.summary(); 7066 let projection = &runtime.settings_account_projection; 7067 let detail_text_px = APP_UI_THEME 7068 .foundation 7069 .typography 7070 .settings_account_detail_text_px; 7071 let detail_account = settings_account_detail_account(projection); 7072 let selected_account_id = projection 7073 .selected_account 7074 .as_ref() 7075 .map(|account| account.account.account_id.as_str()); 7076 let account_rows = projection 7077 .roster 7078 .iter() 7079 .enumerate() 7080 .map(|(index, account)| { 7081 let account_id = account.account_id.clone(); 7082 let is_selected = selected_account_id 7083 .is_some_and(|selected_account_id| selected_account_id == account.account_id); 7084 7085 account_selector_row( 7086 ("settings-account-row", index), 7087 account_display_name(account), 7088 SharedString::from(abbreviated_npub(account.npub.as_str())), 7089 is_selected, 7090 cx.listener(move |this, _, _, cx| this.select_account(account_id.clone(), cx)), 7091 cx, 7092 ) 7093 .into_any_element() 7094 }) 7095 .collect::<Vec<_>>(); 7096 7097 div() 7098 .size_full() 7099 .flex() 7100 .child( 7101 div() 7102 .h_full() 7103 .w(px(APP_UI_THEME.shells.settings_account_sidebar_width_px)) 7104 .p(px(APP_UI_THEME.shells.settings_account_sidebar_padding_px)) 7105 .flex() 7106 .flex_col() 7107 .justify_between() 7108 .child( 7109 app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) 7110 .w_full() 7111 .rounded(px(APP_UI_THEME 7112 .shells 7113 .settings_account_sidebar_button_corner_radius_px)) 7114 .children(account_rows) 7115 .when(projection.roster.is_empty(), |this| { 7116 this.child( 7117 div() 7118 .flex() 7119 .flex_col() 7120 .gap(px(2.0)) 7121 .child( 7122 div() 7123 .text_size(px(APP_UI_THEME 7124 .foundation 7125 .typography 7126 .settings_account_identity_text_px)) 7127 .font_weight(gpui::FontWeight::MEDIUM) 7128 .text_color(rgb( 7129 APP_UI_THEME.foundation.text.primary, 7130 )) 7131 .child(app_shared_text( 7132 AppTextKey::SettingsAccountNoSelectionTitle, 7133 )), 7134 ) 7135 .child( 7136 div() 7137 .text_size(px(APP_UI_THEME 7138 .foundation 7139 .typography 7140 .settings_account_identity_text_px)) 7141 .text_color(rgb( 7142 APP_UI_THEME.foundation.text.secondary, 7143 )) 7144 .line_height(relative(1.2)) 7145 .child(app_shared_text( 7146 AppTextKey::SettingsAccountNoSelectionBody, 7147 )), 7148 ), 7149 ) 7150 }), 7151 ) 7152 .child( 7153 div() 7154 .w_full() 7155 .pt(px(APP_UI_THEME 7156 .shells 7157 .settings_account_sidebar_footer_padding_top_px)) 7158 .flex() 7159 .flex_col() 7160 .gap(px(APP_UI_THEME 7161 .shells 7162 .settings_account_sidebar_footer_row_gap_px)) 7163 .child(section_divider()) 7164 .child( 7165 div() 7166 .w_full() 7167 .flex() 7168 .items_center() 7169 .justify_between() 7170 .gap(px(APP_UI_THEME 7171 .shells 7172 .settings_account_sidebar_footer_button_gap_px)) 7173 .child(action_button( 7174 "account-add", 7175 app_shared_text(AppTextKey::SettingsAccountAddAction), 7176 cx.listener(|_, _, _, _| {}), 7177 cx, 7178 )) 7179 .child(settings_account_more_actions_button(cx)), 7180 ), 7181 ), 7182 ) 7183 .child( 7184 div() 7185 .h_full() 7186 .w(px(APP_UI_THEME.foundation.borders.divider_thickness_px)) 7187 .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)), 7188 ) 7189 .child( 7190 div() 7191 .flex_1() 7192 .h_full() 7193 .p(px(APP_UI_THEME.shells.settings_account_main_padding_px)) 7194 .flex() 7195 .flex_col() 7196 .items_center() 7197 .justify_start() 7198 .child( 7199 div() 7200 .w_full() 7201 .max_w(px(APP_UI_THEME 7202 .shells 7203 .settings_account_content_max_width_px)) 7204 .flex() 7205 .flex_col() 7206 .items_start() 7207 .gap(px(APP_UI_THEME.shells.settings_account_main_stack_gap_px)) 7208 .child( 7209 div() 7210 .w_full() 7211 .flex() 7212 .flex_col() 7213 .items_center() 7214 .gap(px(APP_UI_THEME.shells.settings_account_main_stack_gap_px)) 7215 .child( 7216 div() 7217 .size(px(APP_UI_THEME 7218 .shells 7219 .settings_account_profile_avatar_size_px)) 7220 .bg(rgb(APP_UI_THEME 7221 .foundation 7222 .surfaces 7223 .card_background)) 7224 .rounded(px(APP_UI_THEME 7225 .shells 7226 .settings_account_profile_avatar_size_px 7227 / 2.0)), 7228 ) 7229 .child( 7230 div() 7231 .text_size(px(detail_text_px)) 7232 .font_weight(gpui::FontWeight::MEDIUM) 7233 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 7234 .child( 7235 detail_account 7236 .map(account_display_name) 7237 .unwrap_or_else(|| { 7238 app_shared_text( 7239 AppTextKey::SettingsAccountNoSelectionTitle, 7240 ) 7241 .to_string() 7242 }), 7243 ), 7244 ), 7245 ) 7246 .child( 7247 div() 7248 .w_full() 7249 .flex() 7250 .flex_col() 7251 .gap(px(APP_UI_THEME.shells.settings_account_detail_row_gap_px)) 7252 .child(app_detail_row( 7253 app_shared_label_text( 7254 AppTextKey::SettingsAccountProfileLabel, 7255 ), 7256 div() 7257 .text_size(px(detail_text_px)) 7258 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 7259 .child( 7260 detail_account 7261 .map(account_display_name) 7262 .unwrap_or_else(|| { 7263 app_shared_text(AppTextKey::ValueNone) 7264 .to_string() 7265 }), 7266 ), 7267 )) 7268 .child(app_detail_row( 7269 app_shared_label_text( 7270 AppTextKey::SettingsAccountStatusLabel, 7271 ), 7272 div() 7273 .flex() 7274 .items_center() 7275 .gap(px(APP_UI_THEME 7276 .shells 7277 .settings_account_status_gap_px)) 7278 .child(status_indicator(settings_account_status_color( 7279 detail_account, 7280 selected_account_id, 7281 ))) 7282 .child( 7283 div() 7284 .text_size(px(detail_text_px)) 7285 .text_color(rgb(APP_UI_THEME 7286 .foundation 7287 .text 7288 .primary)) 7289 .child(app_shared_text( 7290 settings_account_status_key( 7291 detail_account, 7292 selected_account_id, 7293 ), 7294 )), 7295 ), 7296 )) 7297 .child( 7298 div() 7299 .w_full() 7300 .flex() 7301 .min_w_0() 7302 .items_center() 7303 .gap(px(APP_UI_THEME 7304 .shells 7305 .settings_account_action_row_gap_px)) 7306 .child(div().child(action_button( 7307 "account-log-out", 7308 app_shared_text( 7309 AppTextKey::SettingsAccountLogOutAction, 7310 ), 7311 cx.listener(|_, _, _, _| {}), 7312 cx, 7313 ))) 7314 .child(div().child(action_button( 7315 "account-open-workspace", 7316 app_shared_text( 7317 AppTextKey::SettingsAccountOpenWorkspaceAction, 7318 ), 7319 cx.listener(|_, _, _, _| {}), 7320 cx, 7321 ))), 7322 ) 7323 .child(app_detail_row( 7324 app_shared_label_text( 7325 AppTextKey::SettingsAccountCustodyLabel, 7326 ), 7327 div() 7328 .text_size(px(detail_text_px)) 7329 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 7330 .child(app_shared_text( 7331 detail_account 7332 .map(|account| { 7333 account_custody_key(account.custody) 7334 }) 7335 .unwrap_or(AppTextKey::ValueNone), 7336 )), 7337 )) 7338 .child(app_detail_row( 7339 app_shared_label_text( 7340 AppTextKey::SettingsAccountSurfaceLabel, 7341 ), 7342 div() 7343 .text_size(px(detail_text_px)) 7344 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 7345 .child(app_shared_text(settings_account_surface_key( 7346 projection, 7347 detail_account, 7348 ))), 7349 )) 7350 .child(app_detail_row( 7351 app_shared_label_text( 7352 AppTextKey::SettingsAccountActivationLabel, 7353 ), 7354 div() 7355 .text_size(px(detail_text_px)) 7356 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 7357 .child(app_shared_text( 7358 settings_account_activation_key( 7359 projection, 7360 detail_account, 7361 ), 7362 )), 7363 )) 7364 .when(detail_account.is_none(), |this| { 7365 this.child(home_body_text(app_shared_text( 7366 AppTextKey::SettingsAccountNoSelectionBody, 7367 ))) 7368 }), 7369 ), 7370 ), 7371 ) 7372 } 7373 7374 fn settings_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 7375 self.sync_farm_panel_state(window, cx); 7376 let runtime = self.runtime.summary(); 7377 7378 let mut cards = Vec::new(); 7379 7380 if let Some(error) = self.farm_panel_error.as_ref() { 7381 cards.push( 7382 home_card( 7383 app_shared_text(AppTextKey::SettingsNavSettings), 7384 home_body_text(error.clone()), 7385 ) 7386 .into_any_element(), 7387 ); 7388 } else if let Some(form) = self.farm_panel_state.as_ref() { 7389 let evaluation = form.evaluate(cx); 7390 let save_ready = form.has_changes(cx) && !evaluation.has_blocking_errors(); 7391 let save_action = if save_ready { 7392 action_button_primary( 7393 "settings-farm-save", 7394 app_shared_text(AppTextKey::SettingsFarmSaveAction), 7395 cx.listener(|this, _, window, cx| this.save_farm_panel(window, cx)), 7396 cx, 7397 ) 7398 .into_any_element() 7399 } else { 7400 action_button_primary_disabled( 7401 "settings-farm-save", 7402 app_shared_text(AppTextKey::SettingsFarmSaveAction), 7403 cx, 7404 ) 7405 .into_any_element() 7406 }; 7407 7408 cards.push( 7409 home_card( 7410 app_shared_text(AppTextKey::SettingsOperatingRulesSectionLabel), 7411 app_stack_v(12.0) 7412 .w_full() 7413 .child(app_form_input_text( 7414 AppFormFieldSpec::new( 7415 app_shared_text( 7416 AppTextKey::SettingsOperatingRulesFieldPromiseLeadTime, 7417 ), 7418 Option::<SharedString>::None, 7419 ), 7420 &form.operating_rules.promise_lead_hours_input, 7421 false, 7422 )) 7423 .child(app_form_input_text( 7424 AppFormFieldSpec::new( 7425 app_shared_text( 7426 AppTextKey::SettingsOperatingRulesFieldSubstitutionPolicy, 7427 ), 7428 Option::<SharedString>::None, 7429 ), 7430 &form.operating_rules.substitution_policy_input, 7431 false, 7432 )) 7433 .children( 7434 evaluation 7435 .operating_rules_validation_keys 7436 .iter() 7437 .copied() 7438 .map(|key| home_body_text(app_shared_text(key)).into_any_element()) 7439 .collect::<Vec<_>>(), 7440 ), 7441 ) 7442 .into_any_element(), 7443 ); 7444 cards.push( 7445 home_card( 7446 app_shared_text(AppTextKey::SettingsFulfillmentWindowsSectionLabel), 7447 div() 7448 .w_full() 7449 .flex() 7450 .flex_col() 7451 .gap(px(12.0)) 7452 .when(form.fulfillment_windows.is_empty(), |this| { 7453 this.child(home_body_text(app_shared_text( 7454 AppTextKey::SettingsFulfillmentWindowsEmptyBody, 7455 ))) 7456 }) 7457 .when(form.pickup_locations.is_empty(), |this| { 7458 this.child(home_body_text(app_shared_text( 7459 AppTextKey::SettingsFulfillmentWindowsPickupLocationsBody, 7460 ))) 7461 }) 7462 .children( 7463 form.fulfillment_windows 7464 .iter() 7465 .enumerate() 7466 .map(|(index, fulfillment_window)| { 7467 let fulfillment_window_id = 7468 fulfillment_window.fulfillment_window_id; 7469 let pickup_location_options = form 7470 .pickup_locations 7471 .iter() 7472 .enumerate() 7473 .map(|(pickup_index, pickup_location)| { 7474 let pickup_location_id = 7475 pickup_location.pickup_location_id; 7476 let is_selected = fulfillment_window 7477 .selected_pickup_location_id 7478 .is_some_and(|selected_pickup_location_id| { 7479 selected_pickup_location_id 7480 == pickup_location_id 7481 }); 7482 choice_button( 7483 ( 7484 "settings-fulfillment-window-pickup-location", 7485 index * 100 + pickup_index, 7486 ), 7487 settings_pickup_location_title( 7488 pickup_index, 7489 pickup_location, 7490 cx, 7491 ), 7492 is_selected, 7493 cx.listener(move |this, _, _, cx| { 7494 this.select_fulfillment_window_pickup_location( 7495 fulfillment_window_id, 7496 pickup_location_id, 7497 cx, 7498 ) 7499 }), 7500 cx, 7501 ) 7502 .into_any_element() 7503 }) 7504 .collect::<Vec<_>>(); 7505 let validation_keys = evaluation 7506 .fulfillment_window_validation_keys 7507 .get(index) 7508 .cloned() 7509 .unwrap_or_default(); 7510 7511 settings_fulfillment_window_card( 7512 index, 7513 fulfillment_window, 7514 pickup_location_options, 7515 &validation_keys, 7516 cx.listener(move |this, _, _, cx| { 7517 this.remove_fulfillment_window( 7518 fulfillment_window_id, 7519 cx, 7520 ) 7521 }), 7522 cx, 7523 ) 7524 .into_any_element() 7525 }) 7526 .collect::<Vec<_>>(), 7527 ) 7528 .child( 7529 action_button_compact( 7530 "settings-add-fulfillment-window", 7531 app_shared_text(AppTextKey::SettingsFulfillmentWindowsAddAction), 7532 cx.listener(|this, _, window, cx| { 7533 this.add_fulfillment_window(window, cx) 7534 }), 7535 cx, 7536 ) 7537 .into_any_element(), 7538 ), 7539 ) 7540 .into_any_element(), 7541 ); 7542 cards.push( 7543 home_card( 7544 app_shared_text(AppTextKey::SettingsBlackoutPeriodsSectionLabel), 7545 div() 7546 .w_full() 7547 .flex() 7548 .flex_col() 7549 .gap(px(12.0)) 7550 .when(form.blackout_periods.is_empty(), |this| { 7551 this.child(home_body_text(app_shared_text( 7552 AppTextKey::SettingsBlackoutPeriodsEmptyBody, 7553 ))) 7554 }) 7555 .children( 7556 form.blackout_periods 7557 .iter() 7558 .enumerate() 7559 .map(|(index, blackout_period)| { 7560 let blackout_period_id = blackout_period.blackout_period_id; 7561 let validation_keys = evaluation 7562 .blackout_period_validation_keys 7563 .get(index) 7564 .cloned() 7565 .unwrap_or_default(); 7566 7567 settings_blackout_period_card( 7568 index, 7569 blackout_period, 7570 &validation_keys, 7571 cx.listener(move |this, _, _, cx| { 7572 this.remove_blackout_period(blackout_period_id, cx) 7573 }), 7574 cx, 7575 ) 7576 .into_any_element() 7577 }) 7578 .collect::<Vec<_>>(), 7579 ) 7580 .child( 7581 action_button_compact( 7582 "settings-add-blackout-period", 7583 app_shared_text(AppTextKey::SettingsBlackoutPeriodsAddAction), 7584 cx.listener(|this, _, window, cx| { 7585 this.add_blackout_period(window, cx) 7586 }), 7587 cx, 7588 ) 7589 .into_any_element(), 7590 ), 7591 ) 7592 .into_any_element(), 7593 ); 7594 cards.push( 7595 home_card( 7596 app_shared_text(AppTextKey::SettingsReadinessSectionLabel), 7597 div() 7598 .w_full() 7599 .flex() 7600 .flex_col() 7601 .gap(px(12.0)) 7602 .children(settings_farm_readiness_rows(&evaluation)) 7603 .child(section_divider()) 7604 .child(home_body_text(app_shared_text(form.save_status_key(cx)))) 7605 .child(div().child(save_action)), 7606 ) 7607 .into_any_element(), 7608 ); 7609 } else { 7610 cards.push( 7611 home_card( 7612 app_shared_text(AppTextKey::SettingsNavSettings), 7613 home_body_text(app_shared_text(AppTextKey::SettingsFarmUnavailableBody)), 7614 ) 7615 .into_any_element(), 7616 ); 7617 } 7618 7619 cards.push( 7620 home_card( 7621 app_shared_text(AppTextKey::SettingsGeneralSectionLabel), 7622 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 7623 .w_full() 7624 .child(label_value_list(settings_preferences_general_rows( 7625 settings_preferences_general_row_state(&runtime), 7626 ))), 7627 ) 7628 .into_any_element(), 7629 ); 7630 7631 app_scroll_panel( 7632 "settings-panel-scroll", 7633 APP_UI_THEME.shells.settings_content_padding_px, 7634 Some(APP_UI_THEME.shells.settings_panel_content_max_width_px), 7635 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7636 .w_full() 7637 .child(home_body_text(app_shared_text( 7638 AppTextKey::SettingsSettingsPanelBody, 7639 ))) 7640 .children(cards), 7641 ) 7642 } 7643 7644 fn farm_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 7645 self.sync_farm_panel_state(window, cx); 7646 7647 let mut cards = Vec::new(); 7648 7649 if let Some(error) = self.farm_panel_error.as_ref() { 7650 cards.push( 7651 home_card( 7652 app_shared_text(AppTextKey::SettingsNavFarm), 7653 home_body_text(error.clone()), 7654 ) 7655 .into_any_element(), 7656 ); 7657 return app_scroll_panel( 7658 "settings-panel-scroll", 7659 APP_UI_THEME.shells.settings_content_padding_px, 7660 Some(APP_UI_THEME.shells.settings_panel_content_max_width_px), 7661 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7662 .w_full() 7663 .child(home_body_text(app_shared_text( 7664 AppTextKey::SettingsFarmPanelBody, 7665 ))) 7666 .children(cards), 7667 ); 7668 } 7669 7670 let Some(form) = self.farm_panel_state.as_ref() else { 7671 cards.push( 7672 home_card( 7673 app_shared_text(AppTextKey::SettingsNavFarm), 7674 home_body_text(app_shared_text(AppTextKey::SettingsFarmUnavailableBody)), 7675 ) 7676 .into_any_element(), 7677 ); 7678 return app_scroll_panel( 7679 "settings-panel-scroll", 7680 APP_UI_THEME.shells.settings_content_padding_px, 7681 Some(APP_UI_THEME.shells.settings_panel_content_max_width_px), 7682 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7683 .w_full() 7684 .child(home_body_text(app_shared_text( 7685 AppTextKey::SettingsFarmPanelBody, 7686 ))) 7687 .children(cards), 7688 ); 7689 }; 7690 7691 let evaluation = form.evaluate(cx); 7692 let save_action = if form.has_changes(cx) && !evaluation.has_blocking_errors() { 7693 action_button_primary( 7694 "settings-farm-save", 7695 app_shared_text(AppTextKey::SettingsFarmSaveAction), 7696 cx.listener(|this, _, window, cx| this.save_farm_panel(window, cx)), 7697 cx, 7698 ) 7699 .into_any_element() 7700 } else { 7701 action_button_primary_disabled( 7702 "settings-farm-save", 7703 app_shared_text(AppTextKey::SettingsFarmSaveAction), 7704 cx, 7705 ) 7706 .into_any_element() 7707 }; 7708 7709 cards.push( 7710 home_card( 7711 app_shared_text(AppTextKey::HomeFarmSetupSectionFarm), 7712 app_stack_v(12.0) 7713 .w_full() 7714 .child(app_form_input_text( 7715 AppFormFieldSpec::new( 7716 app_shared_text(AppTextKey::HomeFarmSetupFieldFarmName), 7717 Option::<SharedString>::None, 7718 ), 7719 &form.farm_name_input, 7720 false, 7721 )) 7722 .child(app_form_input_text( 7723 AppFormFieldSpec::new( 7724 app_shared_text(AppTextKey::SettingsFarmFieldTimezone), 7725 Option::<SharedString>::None, 7726 ), 7727 &form.timezone_input, 7728 false, 7729 )) 7730 .child(app_form_input_text( 7731 AppFormFieldSpec::new( 7732 app_shared_text(AppTextKey::SettingsFarmFieldCurrency), 7733 Option::<SharedString>::None, 7734 ), 7735 &form.currency_input, 7736 false, 7737 )), 7738 ) 7739 .into_any_element(), 7740 ); 7741 cards.push( 7742 home_card( 7743 app_shared_text(AppTextKey::SettingsPickupLocationsSectionLabel), 7744 div() 7745 .w_full() 7746 .flex() 7747 .flex_col() 7748 .gap(px(12.0)) 7749 .when(form.pickup_locations.is_empty(), |this| { 7750 this.child(home_body_text(app_shared_text( 7751 AppTextKey::SettingsPickupLocationsEmptyBody, 7752 ))) 7753 }) 7754 .children( 7755 form.pickup_locations 7756 .iter() 7757 .enumerate() 7758 .map(|(index, pickup_location)| { 7759 let pickup_location_id = pickup_location.pickup_location_id; 7760 settings_pickup_location_card( 7761 index, 7762 pickup_location, 7763 cx.listener(move |this, _, _, cx| { 7764 this.select_default_pickup_location(pickup_location_id, cx) 7765 }), 7766 cx.listener(move |this, _, _, cx| { 7767 this.remove_pickup_location(pickup_location_id, cx) 7768 }), 7769 cx, 7770 ) 7771 .into_any_element() 7772 }) 7773 .collect::<Vec<_>>(), 7774 ) 7775 .child( 7776 action_button_compact( 7777 "settings-farm-add-pickup", 7778 app_shared_text(AppTextKey::SettingsPickupLocationsAddAction), 7779 cx.listener(|this, _, window, cx| this.add_pickup_location(window, cx)), 7780 cx, 7781 ) 7782 .into_any_element(), 7783 ) 7784 .child(section_divider()) 7785 .child(home_body_text(app_shared_text(form.save_status_key(cx)))) 7786 .child(div().child(save_action)), 7787 ) 7788 .into_any_element(), 7789 ); 7790 7791 app_scroll_panel( 7792 "settings-panel-scroll", 7793 APP_UI_THEME.shells.settings_content_padding_px, 7794 Some(APP_UI_THEME.shells.settings_panel_content_max_width_px), 7795 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7796 .w_full() 7797 .child(home_body_text(app_shared_text( 7798 AppTextKey::SettingsFarmPanelBody, 7799 ))) 7800 .children(cards), 7801 ) 7802 } 7803 7804 fn about_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement { 7805 let runtime = self.runtime.summary(); 7806 let sdk_diagnostics = self.runtime.sdk_diagnostics_summary(); 7807 let status_rows = about_status_rows(&runtime, sdk_diagnostics.as_ref()); 7808 let runtime_rows = about_runtime_rows(&runtime); 7809 let manual_refresh_enabled = about_manual_refresh_enabled(&runtime.sync_status); 7810 let conflict_cards = runtime 7811 .sync_status 7812 .conflicts 7813 .iter() 7814 .enumerate() 7815 .map(|(conflict_index, conflict)| { 7816 self.about_conflict_card(conflict_index, conflict, cx) 7817 .into_any_element() 7818 }) 7819 .collect::<Vec<_>>(); 7820 7821 app_scroll_panel( 7822 "settings-panel-scroll", 7823 APP_UI_THEME.shells.settings_content_padding_px, 7824 None, 7825 app_stack_v(APP_UI_THEME.shells.settings_account_main_stack_gap_px) 7826 .size_full() 7827 .py_12() 7828 .child(settings_about_product_section(cx)) 7829 .child(app_surface_card( 7830 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7831 .w_full() 7832 .child(app_heading_section(app_shared_text( 7833 AppTextKey::SettingsAboutStatusSectionLabel, 7834 ))) 7835 .child(label_value_list(status_rows)) 7836 .child(if manual_refresh_enabled { 7837 action_button_primary( 7838 "settings-about-refresh-sync", 7839 app_shared_text(AppTextKey::SettingsAboutRefreshAction), 7840 cx.listener(|this, _, _, cx| this.refresh_about_sync(cx)), 7841 cx, 7842 ) 7843 .into_any_element() 7844 } else { 7845 action_button_primary_disabled( 7846 "settings-about-refresh-sync-disabled", 7847 app_shared_text(AppTextKey::SettingsAboutRefreshAction), 7848 cx, 7849 ) 7850 .into_any_element() 7851 }), 7852 )) 7853 .child(app_surface_card( 7854 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7855 .w_full() 7856 .child(app_heading_section(app_shared_text( 7857 AppTextKey::SettingsAboutConflictReviewSectionLabel, 7858 ))) 7859 .child(home_body_text(app_text(about_conflict_review_body_key( 7860 &runtime.sync_status, 7861 )))) 7862 .when_some(self.about_panel_notice.as_deref(), |this, notice| { 7863 this.child(home_body_text(notice.to_owned())) 7864 }) 7865 .children(conflict_cards), 7866 )) 7867 .child(app_surface_card( 7868 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 7869 .w_full() 7870 .child(app_heading_section(app_shared_text( 7871 AppTextKey::SettingsAboutRuntimeSectionLabel, 7872 ))) 7873 .child(label_value_list(runtime_rows)), 7874 )), 7875 ) 7876 } 7877 7878 fn settings_panel_content( 7879 &mut self, 7880 window: &mut Window, 7881 cx: &mut Context<Self>, 7882 ) -> AnyElement { 7883 match self.selected_view() { 7884 SettingsPanelViewKey::Account => self.account_panel(cx).into_any_element(), 7885 SettingsPanelViewKey::Farm => self.farm_panel(window, cx).into_any_element(), 7886 SettingsPanelViewKey::Settings => self.settings_panel(window, cx).into_any_element(), 7887 SettingsPanelViewKey::About => self.about_panel(cx).into_any_element(), 7888 } 7889 } 7890 7891 fn apply_auto_focus(&mut self, window: &mut Window, cx: &mut Context<Self>) { 7892 let runtime = self.runtime.summary(); 7893 let desired_target = settings_auto_focus_target( 7894 self.selected_view(), 7895 self.farm_panel_state.as_ref(), 7896 &runtime, 7897 ); 7898 let focus_state = window.use_state(cx, |_, _| Option::<SettingsAutoFocusTarget>::None); 7899 let should_focus = { 7900 let last_target = focus_state.read(cx); 7901 last_target.as_ref().copied() != desired_target 7902 }; 7903 7904 if !should_focus { 7905 return; 7906 } 7907 7908 if let Some(target) = desired_target { 7909 match target { 7910 SettingsAutoFocusTarget::Navigation(view) => { 7911 let (navigation_id, _) = settings_panel_spec(view); 7912 focus_button(window, navigation_id, cx); 7913 } 7914 SettingsAutoFocusTarget::AccountAdd => { 7915 focus_button(window, "account-add", cx); 7916 } 7917 SettingsAutoFocusTarget::FarmNameInput => { 7918 if let Some(form) = self.farm_panel_state.as_ref() { 7919 form.farm_name_input 7920 .update(cx, |input, cx| input.focus(window, cx)); 7921 } 7922 } 7923 SettingsAutoFocusTarget::AboutRefresh => { 7924 focus_button(window, "settings-about-refresh-sync", cx); 7925 } 7926 } 7927 } 7928 7929 focus_state.update(cx, |last_target, _| *last_target = desired_target); 7930 } 7931 } 7932 7933 fn settings_account_more_actions_button(cx: &App) -> impl IntoElement { 7934 action_dropdown_button( 7935 "account-more", 7936 |menu, _, _| { 7937 menu.item( 7938 PopupMenuItem::new(app_text(AppTextKey::SettingsAccountImportFileAction)) 7939 .on_click(|_, _, _| {}), 7940 ) 7941 .item( 7942 PopupMenuItem::new(app_text(AppTextKey::SettingsAccountImportDatabaseAction)) 7943 .on_click(|_, _, _| {}), 7944 ) 7945 .item( 7946 PopupMenuItem::new(app_text( 7947 AppTextKey::SettingsAccountConnectRemoteBunkerAction, 7948 )) 7949 .on_click(|_, _, _| {}), 7950 ) 7951 }, 7952 cx, 7953 ) 7954 } 7955 7956 fn settings_about_product_section(cx: &mut Context<SettingsWindowView>) -> impl IntoElement { 7957 let app_icon = Arc::new(Image::from_bytes( 7958 ImageFormat::Png, 7959 include_bytes!("../../../platforms/macos/App/Resources/AppIconSource.png").to_vec(), 7960 )); 7961 let version = format!( 7962 "{} {}", 7963 app_text(AppTextKey::SettingsAboutVersionLabel), 7964 env!("CARGO_PKG_VERSION") 7965 ); 7966 7967 div() 7968 .w_full() 7969 .flex() 7970 .flex_col() 7971 .items_center() 7972 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 7973 .child( 7974 div() 7975 .w_full() 7976 .flex() 7977 .items_start() 7978 .justify_center() 7979 .gap(px(APP_UI_THEME.shells.settings_account_main_padding_px)) 7980 .child( 7981 img(app_icon) 7982 .w(px(128.0)) 7983 .h(px(128.0)) 7984 .object_fit(ObjectFit::Contain) 7985 .flex_shrink_0(), 7986 ) 7987 .child( 7988 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 7989 .min_w_0() 7990 .child( 7991 div() 7992 .text_size( 7993 px(APP_UI_THEME.foundation.typography.body_text_px * 1.7), 7994 ) 7995 .font_weight(gpui::FontWeight::SEMIBOLD) 7996 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 7997 .child(app_shared_text(AppTextKey::AppName)), 7998 ) 7999 .child( 8000 div() 8001 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 8002 .font_weight(gpui::FontWeight::MEDIUM) 8003 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 8004 .child(version), 8005 ) 8006 .child( 8007 div() 8008 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 8009 .font_weight(gpui::FontWeight::MEDIUM) 8010 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 8011 .child(app_shared_text(AppTextKey::SettingsAboutVariantLabel)), 8012 ) 8013 .child( 8014 div() 8015 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 8016 .font_weight(gpui::FontWeight::MEDIUM) 8017 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 8018 .child(app_shared_text(AppTextKey::SettingsAboutCompanyName)), 8019 ) 8020 .child(text_button( 8021 "settings-about-acknowledgements", 8022 app_shared_text(AppTextKey::SettingsAboutAcknowledgementsAction), 8023 cx.listener(|_, _, _, _| {}), 8024 cx, 8025 )) 8026 .child(text_button( 8027 "settings-about-privacy-policy", 8028 app_shared_text(AppTextKey::SettingsAboutPrivacyPolicyAction), 8029 cx.listener(|_, _, _, _| {}), 8030 cx, 8031 )) 8032 .child(text_button( 8033 "settings-about-terms", 8034 app_shared_text(AppTextKey::SettingsAboutTermsAction), 8035 cx.listener(|_, _, _, _| {}), 8036 cx, 8037 )) 8038 .child(action_button( 8039 "settings-about-report-issue", 8040 app_shared_text(AppTextKey::SettingsAboutReportIssueAction), 8041 cx.listener(|_, _, _, _| {}), 8042 cx, 8043 )), 8044 ), 8045 ) 8046 .child( 8047 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 8048 .items_center() 8049 .child( 8050 div() 8051 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 8052 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 8053 .child(app_shared_text(AppTextKey::SettingsAboutCopyrightNotice)), 8054 ) 8055 .child( 8056 div() 8057 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 8058 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 8059 .child(app_shared_text(AppTextKey::SettingsAboutTrademarkNotice)), 8060 ), 8061 ) 8062 } 8063 8064 fn settings_account_detail_account( 8065 projection: &SettingsAccountProjection, 8066 ) -> Option<&AccountSummary> { 8067 projection 8068 .selected_account 8069 .as_ref() 8070 .map(|selected_account| &selected_account.account) 8071 .or_else(|| projection.roster.first()) 8072 } 8073 8074 fn account_display_name(account: &AccountSummary) -> String { 8075 account 8076 .label 8077 .as_deref() 8078 .map(str::trim) 8079 .filter(|label| !label.is_empty()) 8080 .map(ToOwned::to_owned) 8081 .unwrap_or_else(|| abbreviated_npub(account.npub.as_str())) 8082 } 8083 8084 fn abbreviated_npub(npub: &str) -> String { 8085 let trimmed = npub.trim(); 8086 if trimmed.chars().count() <= 20 { 8087 return trimmed.to_owned(); 8088 } 8089 8090 let prefix = trimmed.chars().take(10).collect::<String>(); 8091 let suffix = trimmed 8092 .chars() 8093 .rev() 8094 .take(6) 8095 .collect::<Vec<_>>() 8096 .into_iter() 8097 .rev() 8098 .collect::<String>(); 8099 format!("{prefix}...{suffix}") 8100 } 8101 8102 fn settings_account_status_color( 8103 account: Option<&AccountSummary>, 8104 selected_account_id: Option<&str>, 8105 ) -> u32 { 8106 if settings_account_is_selected(account, selected_account_id) { 8107 APP_UI_THEME.components.app_status_indicator.online 8108 } else { 8109 APP_UI_THEME.components.app_status_indicator.offline 8110 } 8111 } 8112 8113 fn settings_account_status_key( 8114 account: Option<&AccountSummary>, 8115 selected_account_id: Option<&str>, 8116 ) -> AppTextKey { 8117 if settings_account_is_selected(account, selected_account_id) { 8118 AppTextKey::SettingsAccountStatusLoggedIn 8119 } else { 8120 AppTextKey::SettingsAccountStatusLoggedOut 8121 } 8122 } 8123 8124 fn settings_account_is_selected( 8125 account: Option<&AccountSummary>, 8126 selected_account_id: Option<&str>, 8127 ) -> bool { 8128 account 8129 .zip(selected_account_id) 8130 .is_some_and(|(account, selected_account_id)| account.account_id == selected_account_id) 8131 } 8132 8133 fn account_custody_key(custody: AccountCustody) -> AppTextKey { 8134 match custody { 8135 AccountCustody::LocalManaged => AppTextKey::SettingsAccountCustodyLocalManaged, 8136 AccountCustody::BrowserSigner => AppTextKey::SettingsAccountCustodyBrowserSigner, 8137 AccountCustody::RemoteSigner => AppTextKey::SettingsAccountCustodyRemoteSigner, 8138 } 8139 } 8140 8141 fn settings_account_surface_key( 8142 projection: &SettingsAccountProjection, 8143 account: Option<&AccountSummary>, 8144 ) -> AppTextKey { 8145 projection 8146 .selected_account 8147 .as_ref() 8148 .filter(|selected_account| { 8149 account.is_some_and(|account| account.account_id == selected_account.account.account_id) 8150 }) 8151 .map(|selected_account| active_surface_settings_key(selected_account.active_surface())) 8152 .unwrap_or(AppTextKey::ValueNone) 8153 } 8154 8155 fn active_surface_settings_key(surface: ActiveSurface) -> AppTextKey { 8156 match surface { 8157 ActiveSurface::Personal => AppTextKey::SettingsAccountSurfacePersonal, 8158 ActiveSurface::Farmer => AppTextKey::SettingsAccountSurfaceFarmer, 8159 } 8160 } 8161 8162 fn settings_account_activation_key( 8163 projection: &SettingsAccountProjection, 8164 account: Option<&AccountSummary>, 8165 ) -> AppTextKey { 8166 if projection 8167 .selected_account 8168 .as_ref() 8169 .filter(|selected_account| { 8170 account.is_some_and(|account| account.account_id == selected_account.account.account_id) 8171 }) 8172 .is_some_and(|selected_account| selected_account.farmer_activation.is_active()) 8173 { 8174 AppTextKey::SettingsAccountActivationActive 8175 } else { 8176 AppTextKey::SettingsAccountActivationInactive 8177 } 8178 } 8179 8180 fn about_status_rows( 8181 runtime: &DesktopAppRuntimeSummary, 8182 sdk_diagnostics: Option<&DesktopAppSdkDiagnosticsSummary>, 8183 ) -> Vec<LabelValueRow> { 8184 let mut rows = vec![ 8185 LabelValueRow::new( 8186 app_shared_text(AppTextKey::MetadataSelectedAccount), 8187 selected_account_label(runtime.sync_status.account_id.as_deref()), 8188 ), 8189 LabelValueRow::new( 8190 app_shared_text(AppTextKey::MetadataSyncRunStatus), 8191 about_sync_run_status_text(&runtime.sync_status), 8192 ), 8193 LabelValueRow::new( 8194 app_shared_text(AppTextKey::MetadataSyncCheckpointState), 8195 about_sync_checkpoint_state_text(&runtime.sync_status), 8196 ), 8197 LabelValueRow::new( 8198 app_shared_text(AppTextKey::MetadataSyncPendingWriteCount), 8199 runtime.sync_status.pending_write_count.to_string(), 8200 ), 8201 LabelValueRow::new( 8202 app_shared_text(AppTextKey::MetadataSyncConflictCount), 8203 runtime 8204 .sync_status 8205 .projection 8206 .conflict_status 8207 .unresolved_count 8208 .to_string(), 8209 ), 8210 ]; 8211 8212 if runtime 8213 .sync_status 8214 .projection 8215 .conflict_status 8216 .blocking_count 8217 > 0 8218 { 8219 rows.push(LabelValueRow::new( 8220 app_shared_text(AppTextKey::MetadataSyncBlockingConflictCount), 8221 runtime 8222 .sync_status 8223 .projection 8224 .conflict_status 8225 .blocking_count 8226 .to_string(), 8227 )); 8228 } 8229 8230 rows.push(LabelValueRow::new( 8231 app_shared_text(AppTextKey::MetadataStartupIssue), 8232 runtime 8233 .startup_issue 8234 .as_deref() 8235 .map(startup_issue_summary_text) 8236 .unwrap_or_else(|| app_text(AppTextKey::ValueNone)), 8237 )); 8238 8239 append_sdk_status_rows(&mut rows, runtime.sdk_status.as_ref(), sdk_diagnostics); 8240 8241 rows 8242 } 8243 8244 fn append_sdk_status_rows( 8245 rows: &mut Vec<LabelValueRow>, 8246 sdk_status: Option<&DesktopAppSdkStatusSummary>, 8247 sdk_diagnostics: Option<&DesktopAppSdkDiagnosticsSummary>, 8248 ) { 8249 let status = sdk_diagnostics 8250 .map(|diagnostics| &diagnostics.status) 8251 .or(sdk_status); 8252 let Some(status) = status else { 8253 rows.push(LabelValueRow::new( 8254 app_shared_text(AppTextKey::MetadataSdkLifecycleState), 8255 app_text(AppTextKey::ValueDisabled), 8256 )); 8257 rows.push(LabelValueRow::new( 8258 app_shared_text(AppTextKey::MetadataSdkDiagnosticState), 8259 app_text(AppTextKey::ValueSdkUnavailable), 8260 )); 8261 return; 8262 }; 8263 8264 rows.push(LabelValueRow::new( 8265 app_shared_text(AppTextKey::MetadataSdkLifecycleState), 8266 sdk_lifecycle_state_text(status.lifecycle_state), 8267 )); 8268 rows.push(LabelValueRow::new( 8269 app_shared_text(AppTextKey::MetadataSdkProjectionLifecycleState), 8270 sdk_projection_lifecycle_state_text(status.projection_lifecycle_state), 8271 )); 8272 rows.push(LabelValueRow::new( 8273 app_shared_text(AppTextKey::MetadataSdkRelayTargetCount), 8274 status.relay_target_count.to_string(), 8275 )); 8276 8277 match sdk_diagnostics.map(|diagnostics| &diagnostics.state) { 8278 Some(DesktopAppSdkDiagnosticsState::Ready(ready)) => { 8279 append_ready_sdk_rows(rows, ready); 8280 append_sdk_issue_rows(rows, status.last_issue.as_ref()); 8281 } 8282 Some(DesktopAppSdkDiagnosticsState::Blocked(issue)) => { 8283 rows.push(LabelValueRow::new( 8284 app_shared_text(AppTextKey::MetadataSdkDiagnosticState), 8285 app_text(AppTextKey::ValueSdkDiagnosticsBlocked), 8286 )); 8287 append_sdk_issue_rows(rows, Some(issue)); 8288 } 8289 None => { 8290 rows.push(LabelValueRow::new( 8291 app_shared_text(AppTextKey::MetadataSdkDiagnosticState), 8292 app_text(AppTextKey::ValueNone), 8293 )); 8294 append_sdk_issue_rows(rows, status.last_issue.as_ref()); 8295 } 8296 } 8297 } 8298 8299 fn append_ready_sdk_rows( 8300 rows: &mut Vec<LabelValueRow>, 8301 ready: &DesktopAppSdkReadyDiagnosticsSummary, 8302 ) { 8303 rows.push(LabelValueRow::new( 8304 app_shared_text(AppTextKey::MetadataSdkDiagnosticState), 8305 app_text(AppTextKey::ValueSdkDiagnosticsReady), 8306 )); 8307 rows.push(LabelValueRow::new( 8308 app_shared_text(AppTextKey::MetadataSdkStorageKind), 8309 sdk_storage_kind_text(ready.storage_kind.as_str()), 8310 )); 8311 rows.push(LabelValueRow::new( 8312 app_shared_text(AppTextKey::MetadataSdkEventCount), 8313 ready.event_store_total_events.to_string(), 8314 )); 8315 rows.push(LabelValueRow::new( 8316 app_shared_text(AppTextKey::MetadataSdkOutboxCount), 8317 ready.outbox_total_events.to_string(), 8318 )); 8319 rows.push(LabelValueRow::new( 8320 app_shared_text(AppTextKey::MetadataSdkOutboxPendingCount), 8321 ready.outbox_pending_events.to_string(), 8322 )); 8323 rows.push(LabelValueRow::new( 8324 app_shared_text(AppTextKey::MetadataSdkOutboxFailedCount), 8325 ready.outbox_failed_terminal_events.to_string(), 8326 )); 8327 rows.push(LabelValueRow::new( 8328 app_shared_text(AppTextKey::MetadataSdkIntegrityStatus), 8329 sdk_integrity_status_text(ready.integrity_ok()), 8330 )); 8331 rows.push(LabelValueRow::new( 8332 app_shared_text(AppTextKey::MetadataSdkSyncStatus), 8333 app_text(AppTextKey::ValueSdkDiagnosticsReady), 8334 )); 8335 } 8336 8337 fn append_sdk_issue_rows(rows: &mut Vec<LabelValueRow>, issue: Option<&DesktopAppSdkIssueSummary>) { 8338 rows.push(LabelValueRow::new( 8339 app_shared_text(AppTextKey::MetadataSdkLastIssueCode), 8340 issue 8341 .map(|issue| issue.code.clone()) 8342 .unwrap_or_else(|| app_text(AppTextKey::ValueNone)), 8343 )); 8344 if let Some(issue) = issue { 8345 rows.push(LabelValueRow::new( 8346 app_shared_text(AppTextKey::MetadataSdkLastIssueClass), 8347 issue.class.clone(), 8348 )); 8349 rows.push(LabelValueRow::new( 8350 app_shared_text(AppTextKey::MetadataSdkIssueRetryable), 8351 yes_no_text(issue.retryable), 8352 )); 8353 rows.push(LabelValueRow::new( 8354 app_shared_text(AppTextKey::MetadataSdkRecoveryAction), 8355 sdk_recovery_actions_text(&issue.recovery_actions), 8356 )); 8357 } 8358 } 8359 8360 fn about_conflict_review_body_key(sync_status: &DesktopAppSyncStatusSummary) -> AppTextKey { 8361 if !sync_status.is_enabled() { 8362 AppTextKey::SettingsAboutConflictReviewUnavailable 8363 } else if sync_status 8364 .projection 8365 .conflict_status 8366 .has_blocking_conflicts() 8367 { 8368 AppTextKey::SettingsAboutConflictReviewBlocking 8369 } else if sync_status.projection.conflict_status.requires_attention() { 8370 AppTextKey::SettingsAboutConflictReviewNeedsAttention 8371 } else { 8372 AppTextKey::SettingsAboutConflictReviewClear 8373 } 8374 } 8375 8376 fn about_manual_refresh_enabled(sync_status: &DesktopAppSyncStatusSummary) -> bool { 8377 sync_status.is_enabled() 8378 && !sync_status 8379 .projection 8380 .conflict_status 8381 .has_blocking_conflicts() 8382 } 8383 8384 fn about_conflict_action_specs( 8385 conflict: &SyncConflict, 8386 ) -> Vec<(AppTextKey, SyncConflictResolutionStatus)> { 8387 if !conflict.is_unresolved() { 8388 return Vec::new(); 8389 } 8390 8391 let mut actions = vec![ 8392 ( 8393 AppTextKey::SettingsAboutConflictAcceptLocalAction, 8394 SyncConflictResolutionStatus::AcceptedLocal, 8395 ), 8396 ( 8397 AppTextKey::SettingsAboutConflictAcceptRemoteAction, 8398 SyncConflictResolutionStatus::AcceptedRemote, 8399 ), 8400 ]; 8401 if !matches!( 8402 conflict.severity, 8403 radroots_app_sync::SyncConflictSeverity::Blocking 8404 ) { 8405 actions.push(( 8406 AppTextKey::SettingsAboutConflictDismissAction, 8407 SyncConflictResolutionStatus::Dismissed, 8408 )); 8409 } 8410 8411 actions 8412 } 8413 8414 fn about_conflict_detail_rows(conflict: &DesktopAppSyncConflictSummary) -> Vec<LabelValueRow> { 8415 vec![ 8416 LabelValueRow::new( 8417 app_shared_text(AppTextKey::MetadataSyncConflictAggregate), 8418 about_conflict_aggregate_text(&conflict.conflict), 8419 ), 8420 LabelValueRow::new( 8421 app_shared_text(AppTextKey::MetadataSyncConflictKind), 8422 about_conflict_kind_text(&conflict.conflict), 8423 ), 8424 LabelValueRow::new( 8425 app_shared_text(AppTextKey::MetadataSyncConflictSeverity), 8426 about_conflict_severity_text(&conflict.conflict), 8427 ), 8428 LabelValueRow::new( 8429 app_shared_text(AppTextKey::MetadataSyncConflictDetectedAt), 8430 conflict.conflict.detected_at.clone(), 8431 ), 8432 LabelValueRow::new( 8433 app_shared_text(AppTextKey::MetadataSyncConflictResolution), 8434 about_conflict_resolution_text(&conflict.conflict), 8435 ), 8436 ] 8437 } 8438 8439 fn about_conflict_aggregate_text(conflict: &SyncConflict) -> String { 8440 let (aggregate_kind_key, aggregate_id) = match &conflict.aggregate { 8441 SyncAggregateRef::Farm(farm_id) => ( 8442 AppTextKey::ValueSyncConflictAggregateFarm, 8443 farm_id.to_string(), 8444 ), 8445 SyncAggregateRef::FulfillmentWindow(fulfillment_window_id) => ( 8446 AppTextKey::ValueSyncConflictAggregateFulfillmentWindow, 8447 fulfillment_window_id.to_string(), 8448 ), 8449 SyncAggregateRef::Product(product_id) => ( 8450 AppTextKey::ValueSyncConflictAggregateProduct, 8451 product_id.to_string(), 8452 ), 8453 SyncAggregateRef::Order(order_id) => ( 8454 AppTextKey::ValueSyncConflictAggregateOrder, 8455 order_id.to_string(), 8456 ), 8457 }; 8458 8459 format!("{}: {}", app_text(aggregate_kind_key), aggregate_id) 8460 } 8461 8462 fn about_conflict_kind_text(conflict: &SyncConflict) -> String { 8463 app_text(match conflict.kind { 8464 SyncConflictKind::RevisionMismatch => AppTextKey::ValueSyncConflictKindRevisionMismatch, 8465 SyncConflictKind::RemoteDelete => AppTextKey::ValueSyncConflictKindRemoteDelete, 8466 SyncConflictKind::RemoteValidationReject => { 8467 AppTextKey::ValueSyncConflictKindRemoteValidationReject 8468 } 8469 }) 8470 } 8471 8472 fn about_conflict_severity_text(conflict: &SyncConflict) -> String { 8473 match conflict.severity { 8474 SyncConflictSeverity::ReviewRequired => { 8475 app_text(AppTextKey::ValueSyncConflictSeverityReviewRequired) 8476 } 8477 SyncConflictSeverity::Blocking => app_text(AppTextKey::ValueSyncConflictSeverityBlocking), 8478 } 8479 } 8480 8481 fn about_conflict_resolution_text(conflict: &SyncConflict) -> String { 8482 match conflict.resolution { 8483 SyncConflictResolutionStatus::Unresolved => { 8484 app_text(AppTextKey::ValueSyncConflictResolutionUnresolved) 8485 } 8486 SyncConflictResolutionStatus::AcceptedLocal => { 8487 app_text(AppTextKey::ValueSyncConflictResolutionAcceptedLocal) 8488 } 8489 SyncConflictResolutionStatus::AcceptedRemote => { 8490 app_text(AppTextKey::ValueSyncConflictResolutionAcceptedRemote) 8491 } 8492 SyncConflictResolutionStatus::Dismissed => { 8493 app_text(AppTextKey::ValueSyncConflictResolutionDismissed) 8494 } 8495 } 8496 } 8497 8498 fn about_runtime_rows(runtime: &DesktopAppRuntimeSummary) -> Vec<LabelValueRow> { 8499 let mut rows = runtime_metadata_rows(&runtime.runtime_metadata.snapshot); 8500 rows.push(LabelValueRow::new( 8501 app_shared_text(AppTextKey::MetadataDataRoot), 8502 path_or_none(runtime.runtime_metadata.data_root.as_ref()), 8503 )); 8504 rows.push(LabelValueRow::new( 8505 app_shared_text(AppTextKey::MetadataLogsRoot), 8506 path_or_none(runtime.runtime_metadata.logs_root.as_ref()), 8507 )); 8508 rows.push(LabelValueRow::new( 8509 app_shared_text(AppTextKey::MetadataDatabasePath), 8510 path_or_none(runtime.runtime_metadata.database_path.as_ref()), 8511 )); 8512 rows.push(LabelValueRow::new( 8513 app_shared_text(AppTextKey::MetadataDatabaseSchemaVersion), 8514 runtime 8515 .runtime_metadata 8516 .database_schema_version 8517 .map(|version| version.to_string()) 8518 .unwrap_or_else(|| app_text(AppTextKey::ValueNone)), 8519 )); 8520 rows.push(LabelValueRow::new( 8521 app_shared_text(AppTextKey::MetadataShellSection), 8522 runtime 8523 .shell_projection 8524 .selected_section 8525 .storage_key() 8526 .to_owned(), 8527 )); 8528 if let Some(sdk_status) = runtime.sdk_status.as_ref() { 8529 rows.push(LabelValueRow::new( 8530 app_shared_text(AppTextKey::MetadataSdkStorageRoot), 8531 sdk_status.storage_root.display().to_string(), 8532 )); 8533 rows.push(LabelValueRow::new( 8534 app_shared_text(AppTextKey::MetadataSdkEventStorePath), 8535 path_or_none(sdk_status.event_store_path.as_ref()), 8536 )); 8537 rows.push(LabelValueRow::new( 8538 app_shared_text(AppTextKey::MetadataSdkOutboxPath), 8539 path_or_none(sdk_status.outbox_path.as_ref()), 8540 )); 8541 rows.push(LabelValueRow::new( 8542 app_shared_text(AppTextKey::MetadataSdkRelayUrlPolicy), 8543 sdk_relay_url_policy_text(sdk_status.relay_url_policy), 8544 )); 8545 } 8546 rows 8547 } 8548 8549 fn selected_account_label(account_id: Option<&str>) -> String { 8550 account_id 8551 .map(ToOwned::to_owned) 8552 .unwrap_or_else(|| app_text(AppTextKey::ValueNone)) 8553 } 8554 8555 fn about_sync_run_status_text(sync_status: &DesktopAppSyncStatusSummary) -> String { 8556 if !sync_status.is_enabled() { 8557 return app_text(AppTextKey::ValueDisabled); 8558 } 8559 8560 match sync_status.projection.run_status { 8561 AppSyncRunStatus::Idle => app_text(AppTextKey::ValueSyncRunStatusIdle), 8562 AppSyncRunStatus::Syncing => app_text(AppTextKey::ValueSyncRunStatusSyncing), 8563 AppSyncRunStatus::Succeeded => app_text(AppTextKey::ValueSyncRunStatusSucceeded), 8564 AppSyncRunStatus::Conflicted => app_text(AppTextKey::ValueSyncRunStatusConflicted), 8565 AppSyncRunStatus::Failed => app_text(AppTextKey::ValueSyncRunStatusFailed), 8566 } 8567 } 8568 8569 fn about_sync_checkpoint_state_text(sync_status: &DesktopAppSyncStatusSummary) -> String { 8570 if !sync_status.is_enabled() { 8571 return app_text(AppTextKey::ValueNone); 8572 } 8573 8574 match sync_status.projection.checkpoint.state { 8575 SyncCheckpointState::NeverSynced => app_text(AppTextKey::ValueSyncCheckpointNeverSynced), 8576 SyncCheckpointState::Syncing => app_text(AppTextKey::ValueSyncCheckpointSyncing), 8577 SyncCheckpointState::Current => app_text(AppTextKey::ValueSyncCheckpointCurrent), 8578 SyncCheckpointState::Failed => app_text(AppTextKey::ValueSyncCheckpointFailed), 8579 } 8580 } 8581 8582 fn path_or_none(path: Option<&PathBuf>) -> String { 8583 path.map(|value| value.display().to_string()) 8584 .unwrap_or_else(|| app_text(AppTextKey::ValueNone)) 8585 } 8586 8587 fn sdk_lifecycle_state_text(state: AppSdkLifecycleState) -> String { 8588 app_text(match state { 8589 AppSdkLifecycleState::Starting => AppTextKey::ValueSdkLifecycleStarting, 8590 AppSdkLifecycleState::Ready => AppTextKey::ValueSdkLifecycleReady, 8591 AppSdkLifecycleState::Degraded => AppTextKey::ValueSdkLifecycleDegraded, 8592 AppSdkLifecycleState::Pausing => AppTextKey::ValueSdkLifecyclePausing, 8593 AppSdkLifecycleState::Paused => AppTextKey::ValueSdkLifecyclePaused, 8594 AppSdkLifecycleState::Restoring => AppTextKey::ValueSdkLifecycleRestoring, 8595 AppSdkLifecycleState::RebuildingProjections => { 8596 AppTextKey::ValueSdkLifecycleRebuildingProjections 8597 } 8598 AppSdkLifecycleState::ShuttingDown => AppTextKey::ValueSdkLifecycleShuttingDown, 8599 AppSdkLifecycleState::Stopped => AppTextKey::ValueSdkLifecycleStopped, 8600 }) 8601 } 8602 8603 fn sdk_projection_lifecycle_state_text(state: AppSdkProjectionLifecycleState) -> String { 8604 app_text(match state { 8605 AppSdkProjectionLifecycleState::Current => AppTextKey::ValueSdkProjectionCurrent, 8606 AppSdkProjectionLifecycleState::Stale => AppTextKey::ValueSdkProjectionStale, 8607 AppSdkProjectionLifecycleState::Rebuilding => AppTextKey::ValueSdkProjectionRebuilding, 8608 }) 8609 } 8610 8611 fn sdk_relay_url_policy_text(policy: AppSdkRelayUrlPolicy) -> String { 8612 app_text(match policy { 8613 AppSdkRelayUrlPolicy::Public => AppTextKey::ValueSdkRelayPolicyPublic, 8614 AppSdkRelayUrlPolicy::Localhost => AppTextKey::ValueSdkRelayPolicyLocalhost, 8615 }) 8616 } 8617 8618 fn sdk_storage_kind_text(kind: &str) -> String { 8619 match kind { 8620 "directory" => app_text(AppTextKey::ValueSdkStorageKindDirectory), 8621 _ => app_text(AppTextKey::ValueSdkStorageKindUnknown), 8622 } 8623 } 8624 8625 fn sdk_integrity_status_text(ok: bool) -> String { 8626 if ok { 8627 app_text(AppTextKey::ValueSdkIntegrityOk) 8628 } else { 8629 app_text(AppTextKey::ValueSdkIntegrityFailed) 8630 } 8631 } 8632 8633 fn yes_no_text(value: bool) -> String { 8634 if value { 8635 app_text(AppTextKey::ValueYes) 8636 } else { 8637 app_text(AppTextKey::ValueNo) 8638 } 8639 } 8640 8641 fn sdk_recovery_actions_text(actions: &[String]) -> String { 8642 if actions.is_empty() { 8643 return app_text(AppTextKey::ValueNone); 8644 } 8645 8646 actions 8647 .iter() 8648 .map(|action| app_text(sdk_recovery_action_key(action))) 8649 .collect::<Vec<_>>() 8650 .join(", ") 8651 } 8652 8653 fn sdk_recovery_action_key(action: &str) -> AppTextKey { 8654 match action { 8655 "configure_relay_targets" => AppTextKey::ValueSdkRecoveryConfigureRelayTargets, 8656 "retry_startup" => AppTextKey::ValueSdkRecoveryRetryStartup, 8657 "wait_for_sdk_lifecycle" => AppTextKey::ValueSdkRecoveryWaitForLifecycle, 8658 "retry_status_refresh" => AppTextKey::ValueSdkRecoveryRetryStatusRefresh, 8659 "review_runtime_configuration" => AppTextKey::ValueSdkRecoveryReviewRuntimeConfiguration, 8660 _ => AppTextKey::ValueSdkRecoveryReviewStatus, 8661 } 8662 } 8663 8664 fn focus_button<V>(window: &mut Window, id: impl Into<ElementId>, cx: &mut Context<V>) { 8665 let focus_handle = window 8666 .use_keyed_state(id, cx, |_, cx| cx.focus_handle()) 8667 .read(cx) 8668 .clone(); 8669 focus_handle.focus(window); 8670 } 8671 8672 fn home_auto_focus_target( 8673 runtime: &DesktopAppRuntimeSummary, 8674 state: HomeAutoFocusState, 8675 ) -> Option<HomeAutoFocusTarget> { 8676 match home_stage(runtime) { 8677 HomeStage::Setup => startup_auto_focus_target(runtime, state), 8678 HomeStage::AccountWorkspace => None, 8679 HomeStage::BuyerWorkspace => buyer_auto_focus_target(runtime, state), 8680 HomeStage::FarmerWorkspace => farmer_auto_focus_target(runtime, state), 8681 } 8682 } 8683 8684 fn startup_auto_focus_target( 8685 runtime: &DesktopAppRuntimeSummary, 8686 state: HomeAutoFocusState, 8687 ) -> Option<HomeAutoFocusTarget> { 8688 match startup_home_surface(runtime) { 8689 StartupHomeSurface::ContinuePrompt => Some(HomeAutoFocusTarget::StartupContinue), 8690 StartupHomeSurface::IdentityChoice => Some(HomeAutoFocusTarget::StartupGenerateKey), 8691 StartupHomeSurface::GenerateKeyStarting | StartupHomeSurface::IssueCard => None, 8692 StartupHomeSurface::SignerEntry => { 8693 if state.has_startup_signer_input && state.startup_signer_input_is_editable { 8694 Some(HomeAutoFocusTarget::StartupSignerInput) 8695 } else { 8696 Some(HomeAutoFocusTarget::StartupSignerBack) 8697 } 8698 } 8699 } 8700 } 8701 8702 fn buyer_auto_focus_target( 8703 runtime: &DesktopAppRuntimeSummary, 8704 state: HomeAutoFocusState, 8705 ) -> Option<HomeAutoFocusTarget> { 8706 match selected_personal_section(runtime) { 8707 PersonalSection::Browse => { 8708 if runtime.personal_projection.browse.detail.is_some() { 8709 Some(HomeAutoFocusTarget::BuyerDetailBack) 8710 } else if !runtime.personal_projection.browse.listings.rows.is_empty() { 8711 Some(HomeAutoFocusTarget::BuyerListingOpenFirst) 8712 } else { 8713 None 8714 } 8715 } 8716 PersonalSection::Search => { 8717 if runtime.personal_projection.search.detail.is_some() { 8718 Some(HomeAutoFocusTarget::BuyerDetailBack) 8719 } else if state.has_personal_search_input { 8720 Some(HomeAutoFocusTarget::BuyerSearchInput) 8721 } else if !runtime.personal_projection.search.listings.rows.is_empty() { 8722 Some(HomeAutoFocusTarget::BuyerListingOpenFirst) 8723 } else { 8724 None 8725 } 8726 } 8727 PersonalSection::Cart => { 8728 if state.has_buyer_order_review_form { 8729 Some(HomeAutoFocusTarget::BuyerOrderReviewNameInput) 8730 } else if !runtime.personal_projection.cart.cart.lines.is_empty() { 8731 Some(HomeAutoFocusTarget::BuyerCartOpenOrderReview) 8732 } else { 8733 None 8734 } 8735 } 8736 PersonalSection::Orders => { 8737 if let Some(detail) = runtime.personal_projection.orders.detail.as_ref() { 8738 let replace_confirmation = runtime 8739 .personal_projection 8740 .cart 8741 .cart 8742 .replace_confirmation 8743 .as_ref() 8744 .is_some_and(|confirmation| { 8745 confirmation.incoming_farm_display_name == detail.farm_display_name 8746 }); 8747 if replace_confirmation { 8748 Some(HomeAutoFocusTarget::BuyerOrderConfirmReplace) 8749 } else if detail.repeat_demand.as_ref().is_some_and(|repeat_demand| { 8750 repeat_demand.eligibility != RepeatDemandEligibility::Unavailable 8751 }) { 8752 Some(HomeAutoFocusTarget::BuyerOrderRepeatDemand) 8753 } else if !runtime.personal_projection.orders.list.rows.is_empty() { 8754 Some(HomeAutoFocusTarget::BuyerOrderOpenFirst) 8755 } else { 8756 None 8757 } 8758 } else if !runtime.personal_projection.orders.list.rows.is_empty() { 8759 Some(HomeAutoFocusTarget::BuyerOrderOpenFirst) 8760 } else { 8761 None 8762 } 8763 } 8764 } 8765 } 8766 8767 fn farmer_auto_focus_target( 8768 runtime: &DesktopAppRuntimeSummary, 8769 state: HomeAutoFocusState, 8770 ) -> Option<HomeAutoFocusTarget> { 8771 if let Some(reminder) = presented_farmer_reminder(runtime) { 8772 if reminder.action_label.is_some() { 8773 return Some(HomeAutoFocusTarget::FarmerReminderPrimary); 8774 } 8775 return Some(HomeAutoFocusTarget::FarmerReminderDismiss); 8776 } 8777 8778 match selected_farmer_section(runtime) { 8779 FarmerSection::Today | FarmerSection::Farm => today_auto_focus_target(runtime, state), 8780 FarmerSection::Products if farmer_products_available(runtime) => { 8781 if state.has_product_editor_form { 8782 Some(HomeAutoFocusTarget::ProductEditorTitleInput) 8783 } else if state.has_products_stock_editor { 8784 Some(HomeAutoFocusTarget::ProductsStockInput) 8785 } else if state.has_products_search_input { 8786 Some(HomeAutoFocusTarget::ProductsSearchInput) 8787 } else if !runtime.products_projection.list.rows.is_empty() { 8788 Some(HomeAutoFocusTarget::ProductsRowOpenFirst) 8789 } else { 8790 None 8791 } 8792 } 8793 FarmerSection::Orders if farmer_products_available(runtime) => { 8794 if !runtime.orders_projection.list.rows.is_empty() { 8795 Some(HomeAutoFocusTarget::OrdersRowOpenFirst) 8796 } else { 8797 None 8798 } 8799 } 8800 FarmerSection::PackDay if farmer_pack_day_available(runtime) => None, 8801 FarmerSection::Products | FarmerSection::Orders | FarmerSection::PackDay => { 8802 today_auto_focus_target(runtime, state) 8803 } 8804 } 8805 } 8806 8807 fn today_auto_focus_target( 8808 runtime: &DesktopAppRuntimeSummary, 8809 state: HomeAutoFocusState, 8810 ) -> Option<HomeAutoFocusTarget> { 8811 let projection = &runtime.today_projection; 8812 8813 if state.has_farm_setup_form { 8814 return Some(HomeAutoFocusTarget::FarmerSetupFarmNameInput); 8815 } 8816 8817 if let Some(spec) = farm_setup_onboarding_card_spec(runtime.home_route) { 8818 if spec.action_key.is_some() { 8819 return Some(HomeAutoFocusTarget::FarmerSetupStart); 8820 } 8821 } else if projection.needs_setup() 8822 && farmer_home_farm_state(runtime) == FarmerHomeFarmState::IncompleteFarm 8823 { 8824 return Some(HomeAutoFocusTarget::FarmerSetupContinue); 8825 } 8826 8827 if projection 8828 .reminders 8829 .items 8830 .iter() 8831 .any(|reminder| reminder_action_target(reminder).is_some()) 8832 { 8833 return Some(HomeAutoFocusTarget::FarmerTodayReminderChipFirst); 8834 } 8835 if projection.next_fulfillment_window.is_some() { 8836 return Some(HomeAutoFocusTarget::FarmerTodayOpenPackDay); 8837 } 8838 if !projection.orders_needing_action.is_empty() { 8839 return Some(HomeAutoFocusTarget::FarmerTodayOpenOrders); 8840 } 8841 if !projection.low_stock_products.is_empty() { 8842 return Some(HomeAutoFocusTarget::FarmerTodayOpenProductsLowStock); 8843 } 8844 if !projection.draft_products.is_empty() { 8845 return Some(HomeAutoFocusTarget::FarmerTodayOpenProductsDrafts); 8846 } 8847 8848 None 8849 } 8850 8851 fn settings_auto_focus_target( 8852 selected_view: SettingsPanelViewKey, 8853 farm_panel_state: Option<&SettingsFarmPanelState>, 8854 runtime: &DesktopAppRuntimeSummary, 8855 ) -> Option<SettingsAutoFocusTarget> { 8856 match selected_view { 8857 SettingsPanelViewKey::Account => Some(SettingsAutoFocusTarget::AccountAdd), 8858 SettingsPanelViewKey::Farm => farm_panel_state 8859 .map(|_| SettingsAutoFocusTarget::FarmNameInput) 8860 .or(Some(SettingsAutoFocusTarget::Navigation( 8861 SettingsPanelViewKey::Farm, 8862 ))), 8863 SettingsPanelViewKey::Settings => Some(SettingsAutoFocusTarget::Navigation( 8864 SettingsPanelViewKey::Settings, 8865 )), 8866 SettingsPanelViewKey::About => { 8867 if about_manual_refresh_enabled(&runtime.sync_status) { 8868 Some(SettingsAutoFocusTarget::AboutRefresh) 8869 } else { 8870 Some(SettingsAutoFocusTarget::Navigation( 8871 SettingsPanelViewKey::About, 8872 )) 8873 } 8874 } 8875 } 8876 } 8877 8878 impl Render for SettingsWindowView { 8879 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 8880 let navigation_buttons = SETTINGS_NAVIGATION_ORDER 8881 .iter() 8882 .copied() 8883 .map(|view| self.navigation_button(view, cx).into_any_element()) 8884 .collect::<Vec<_>>(); 8885 let panel_content = self.settings_panel_content(window, cx); 8886 self.apply_auto_focus(window, cx); 8887 8888 app_window_shell( 8889 APP_UI_THEME.foundation.surfaces.panel_background, 8890 app_stack_v(0.0) 8891 .size_full() 8892 .bg(rgb(APP_UI_THEME.foundation.surfaces.panel_background)) 8893 .overflow_hidden() 8894 .child( 8895 app_stack_v(0.0) 8896 .w_full() 8897 .h(px(APP_UI_THEME.shells.settings_chrome_height_px)) 8898 .bg(rgb(APP_UI_THEME.foundation.surfaces.chrome_background)) 8899 .child(utility_title_row(app_shared_text( 8900 AppTextKey::SettingsTitle, 8901 ))) 8902 .child( 8903 app_cluster(APP_UI_THEME.shells.settings_navigation_row_gap_px) 8904 .w_full() 8905 .justify_center() 8906 .pt(px(APP_UI_THEME.shells.settings_navigation_row_padding_px)) 8907 .pb(px(APP_UI_THEME.shells.settings_navigation_row_padding_px)) 8908 .children(navigation_buttons), 8909 ), 8910 ) 8911 .child(section_divider()) 8912 .child(div().flex_1().overflow_hidden().child(panel_content)), 8913 ) 8914 } 8915 } 8916 8917 fn settings_panel_label_key(view: SettingsPanelViewKey) -> AppTextKey { 8918 match view { 8919 SettingsPanelViewKey::Account => AppTextKey::SettingsNavAccounts, 8920 SettingsPanelViewKey::Farm => AppTextKey::SettingsNavFarm, 8921 SettingsPanelViewKey::Settings => AppTextKey::SettingsNavSettings, 8922 SettingsPanelViewKey::About => AppTextKey::SettingsNavAbout, 8923 } 8924 } 8925 8926 fn settings_panel_spec(view: SettingsPanelViewKey) -> (&'static str, IconName) { 8927 match view { 8928 SettingsPanelViewKey::Account => ("settings-nav-accounts", IconName::CircleUser), 8929 SettingsPanelViewKey::Farm => ("settings-nav-farm", IconName::Map), 8930 SettingsPanelViewKey::Settings => ("settings-nav-settings", IconName::Settings2), 8931 SettingsPanelViewKey::About => ("settings-nav-about", IconName::Info), 8932 } 8933 } 8934 8935 #[derive(Clone, Copy)] 8936 struct HomeStatusPresentation { 8937 indicator_color: u32, 8938 label_key: AppTextKey, 8939 } 8940 8941 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 8942 struct FarmSetupOnboardingCardSpec { 8943 title_key: AppTextKey, 8944 body_key: AppTextKey, 8945 action_key: Option<AppTextKey>, 8946 } 8947 8948 #[cfg(test)] 8949 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 8950 struct SettingsInventorySectionSpec { 8951 title_key: AppTextKey, 8952 field_keys: &'static [AppTextKey], 8953 } 8954 8955 const SETTINGS_NAVIGATION_ORDER: &[SettingsPanelViewKey] = &[ 8956 SettingsPanelViewKey::Account, 8957 SettingsPanelViewKey::Farm, 8958 SettingsPanelViewKey::Settings, 8959 SettingsPanelViewKey::About, 8960 ]; 8961 8962 #[cfg(test)] 8963 const SETTINGS_FARM_SECTION_FIELDS: &[AppTextKey] = &[ 8964 AppTextKey::HomeFarmSetupFieldFarmName, 8965 AppTextKey::SettingsFarmFieldTimezone, 8966 AppTextKey::SettingsFarmFieldCurrency, 8967 ]; 8968 8969 #[cfg(test)] 8970 const SETTINGS_PICKUP_LOCATIONS_SECTION_FIELDS: &[AppTextKey] = &[ 8971 AppTextKey::SettingsPickupLocationsFieldLabel, 8972 AppTextKey::SettingsPickupLocationsFieldAddress, 8973 AppTextKey::SettingsPickupLocationsFieldDirections, 8974 AppTextKey::SettingsPickupLocationsFieldDefault, 8975 ]; 8976 8977 #[cfg(test)] 8978 const SETTINGS_OPERATING_RULES_SECTION_FIELDS: &[AppTextKey] = &[ 8979 AppTextKey::SettingsOperatingRulesFieldPromiseLeadTime, 8980 AppTextKey::SettingsOperatingRulesFieldSubstitutionPolicy, 8981 ]; 8982 8983 #[cfg(test)] 8984 const SETTINGS_FULFILLMENT_WINDOWS_SECTION_FIELDS: &[AppTextKey] = &[ 8985 AppTextKey::SettingsFulfillmentWindowsFieldLabel, 8986 AppTextKey::SettingsFulfillmentWindowsFieldPickupLocation, 8987 AppTextKey::SettingsFulfillmentWindowsFieldStartsAt, 8988 AppTextKey::SettingsFulfillmentWindowsFieldEndsAt, 8989 AppTextKey::SettingsFulfillmentWindowsFieldOrderCutoff, 8990 ]; 8991 8992 #[cfg(test)] 8993 const SETTINGS_BLACKOUT_PERIODS_SECTION_FIELDS: &[AppTextKey] = &[ 8994 AppTextKey::SettingsBlackoutPeriodsFieldLabel, 8995 AppTextKey::SettingsBlackoutPeriodsFieldStartsAt, 8996 AppTextKey::SettingsBlackoutPeriodsFieldEndsAt, 8997 ]; 8998 8999 #[cfg(test)] 9000 const SETTINGS_READINESS_SECTION_FIELDS: &[AppTextKey] = &[ 9001 AppTextKey::SettingsReadinessFieldMissingProfileBasics, 9002 AppTextKey::SettingsReadinessFieldMissingPickupLocation, 9003 AppTextKey::SettingsReadinessFieldMissingFulfillmentWindow, 9004 AppTextKey::SettingsReadinessFieldMissingOperatingRules, 9005 AppTextKey::SettingsReadinessFieldInvalidTimingConflicts, 9006 ]; 9007 9008 #[cfg(test)] 9009 const SETTINGS_FARM_PANEL_SECTIONS: &[SettingsInventorySectionSpec] = &[ 9010 SettingsInventorySectionSpec { 9011 title_key: AppTextKey::HomeFarmSetupSectionFarm, 9012 field_keys: SETTINGS_FARM_SECTION_FIELDS, 9013 }, 9014 SettingsInventorySectionSpec { 9015 title_key: AppTextKey::SettingsPickupLocationsSectionLabel, 9016 field_keys: SETTINGS_PICKUP_LOCATIONS_SECTION_FIELDS, 9017 }, 9018 ]; 9019 9020 #[cfg(test)] 9021 const SETTINGS_OPERATIONS_PANEL_SECTIONS: &[SettingsInventorySectionSpec] = &[ 9022 SettingsInventorySectionSpec { 9023 title_key: AppTextKey::SettingsOperatingRulesSectionLabel, 9024 field_keys: SETTINGS_OPERATING_RULES_SECTION_FIELDS, 9025 }, 9026 SettingsInventorySectionSpec { 9027 title_key: AppTextKey::SettingsFulfillmentWindowsSectionLabel, 9028 field_keys: SETTINGS_FULFILLMENT_WINDOWS_SECTION_FIELDS, 9029 }, 9030 SettingsInventorySectionSpec { 9031 title_key: AppTextKey::SettingsBlackoutPeriodsSectionLabel, 9032 field_keys: SETTINGS_BLACKOUT_PERIODS_SECTION_FIELDS, 9033 }, 9034 SettingsInventorySectionSpec { 9035 title_key: AppTextKey::SettingsReadinessSectionLabel, 9036 field_keys: SETTINGS_READINESS_SECTION_FIELDS, 9037 }, 9038 ]; 9039 9040 fn shared_shell_header( 9041 runtime: &DesktopAppRuntimeSummary, 9042 on_select_marketplace: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 9043 on_select_farm: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 9044 on_open_account: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 9045 cx: &App, 9046 ) -> impl IntoElement { 9047 let can_enter_farmer_workspace = runtime.personal_projection.entry.can_enter_farmer_workspace; 9048 let active_mode = shell_header_active_mode(runtime); 9049 let is_account_active = active_mode == ShellHeaderActiveMode::Account; 9050 let is_farm_active = active_mode == ShellHeaderActiveMode::Farm; 9051 let is_marketplace_active = active_mode == ShellHeaderActiveMode::Marketplace; 9052 let farm_name = home_saved_farm(runtime).map(|farm| farm.display_name.clone()); 9053 let account_label = shell_account_label(runtime); 9054 9055 app_surface_panel( 9056 div() 9057 .w_full() 9058 .px(px(APP_UI_THEME.shells.home_card_padding_px)) 9059 .py(px(APP_UI_THEME.foundation.spacing.small_px)) 9060 .flex() 9061 .justify_between() 9062 .items_center() 9063 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 9064 .child( 9065 div() 9066 .flex() 9067 .flex_col() 9068 .gap(px(2.0)) 9069 .child(app_text_label(app_shared_text(AppTextKey::AppName))) 9070 .when_some(farm_name, |this, farm_name| { 9071 this.child(home_body_text(farm_name)) 9072 }), 9073 ) 9074 .child( 9075 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 9076 .items_center() 9077 .when(can_enter_farmer_workspace, |this| { 9078 this.child( 9079 shared_shell_mode_button( 9080 "shell-mode-marketplace", 9081 AppTextKey::HomeHeaderMarketplaceMode, 9082 is_marketplace_active, 9083 on_select_marketplace, 9084 cx, 9085 ) 9086 .into_any_element(), 9087 ) 9088 .child( 9089 shared_shell_mode_button( 9090 "shell-mode-farm", 9091 AppTextKey::HomeHeaderFarmMode, 9092 is_farm_active, 9093 on_select_farm, 9094 cx, 9095 ) 9096 .into_any_element(), 9097 ) 9098 }) 9099 .child(shell_account_entry( 9100 runtime, 9101 account_label, 9102 is_account_active, 9103 on_open_account, 9104 cx, 9105 )), 9106 ), 9107 ) 9108 } 9109 9110 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 9111 enum ShellHeaderActiveMode { 9112 Marketplace, 9113 Farm, 9114 Account, 9115 } 9116 9117 fn shell_header_active_mode(runtime: &DesktopAppRuntimeSummary) -> ShellHeaderActiveMode { 9118 if matches!( 9119 runtime.shell_projection.selected_section, 9120 ShellSection::Account 9121 ) { 9122 ShellHeaderActiveMode::Account 9123 } else if runtime.shell_projection.active_surface == ActiveSurface::Farmer { 9124 ShellHeaderActiveMode::Farm 9125 } else { 9126 ShellHeaderActiveMode::Marketplace 9127 } 9128 } 9129 9130 fn shared_shell_mode_button( 9131 id: &'static str, 9132 key: AppTextKey, 9133 is_active: bool, 9134 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 9135 cx: &App, 9136 ) -> AnyElement { 9137 choice_button(id, app_shared_text(key), is_active, on_click, cx).into_any_element() 9138 } 9139 9140 fn shell_account_entry( 9141 runtime: &DesktopAppRuntimeSummary, 9142 account_label: String, 9143 is_active: bool, 9144 on_open_account: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 9145 cx: &App, 9146 ) -> AnyElement { 9147 if runtime.personal_projection.entry.state == PersonalEntryState::Guest { 9148 choice_button( 9149 "shell-account-entry", 9150 app_shared_text(AppTextKey::HomeHeaderAccountSetupAction), 9151 is_active, 9152 on_open_account, 9153 cx, 9154 ) 9155 .into_any_element() 9156 } else { 9157 choice_button( 9158 "shell-account-entry", 9159 account_label, 9160 is_active, 9161 on_open_account, 9162 cx, 9163 ) 9164 .into_any_element() 9165 } 9166 } 9167 9168 fn shell_account_label(runtime: &DesktopAppRuntimeSummary) -> String { 9169 runtime 9170 .settings_account_projection 9171 .selected_account 9172 .as_ref() 9173 .and_then(|account| { 9174 account 9175 .account 9176 .label 9177 .as_ref() 9178 .map(|label| label.trim().to_owned()) 9179 .filter(|label| !label.is_empty()) 9180 .or_else(|| Some(app_shared_text(AppTextKey::HomeHeaderAccountLabel).to_string())) 9181 }) 9182 .unwrap_or_else(|| app_shared_text(AppTextKey::HomeHeaderGuestLabel).to_string()) 9183 } 9184 9185 fn buyer_workspace_title_block(title_key: AppTextKey, body_key: AppTextKey) -> impl IntoElement { 9186 div() 9187 .w_full() 9188 .flex() 9189 .flex_col() 9190 .gap(px(4.0)) 9191 .child( 9192 div() 9193 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) 9194 .font_weight(gpui::FontWeight::BOLD) 9195 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 9196 .child(app_shared_text(title_key)), 9197 ) 9198 .child( 9199 div() 9200 .w_full() 9201 .min_w_0() 9202 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 9203 .font_weight(gpui::FontWeight::MEDIUM) 9204 .line_height(relative(1.2)) 9205 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 9206 .child(app_shared_text(body_key)), 9207 ) 9208 } 9209 9210 fn account_tab_frame( 9211 tabs: impl IntoIterator<Item = AppUnderlineTabSpec>, 9212 selected_index: usize, 9213 on_select_tab: impl Fn(&usize, &mut Window, &mut App) + 'static, 9214 heading_key: AppTextKey, 9215 heading_actions: Option<AnyElement>, 9216 fixed_subheader: Option<AnyElement>, 9217 panel: AnyElement, 9218 panel_uses_inner_scroll: bool, 9219 ) -> AnyElement { 9220 div() 9221 .size_full() 9222 .overflow_hidden() 9223 .flex() 9224 .flex_col() 9225 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 9226 .child( 9227 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9228 .w_full() 9229 .flex_none() 9230 .child(app_text_value(app_shared_text(AppTextKey::AccountTitle))) 9231 .child(app_underline_tabs( 9232 "account-tabs", 9233 tabs, 9234 selected_index, 9235 on_select_tab, 9236 )) 9237 .child(account_section_heading_row(heading_key, heading_actions)), 9238 ) 9239 .when_some(fixed_subheader, |this, fixed_subheader| { 9240 this.child(div().w_full().flex_none().child(fixed_subheader)) 9241 }) 9242 .child(if panel_uses_inner_scroll { 9243 div() 9244 .flex_1() 9245 .w_full() 9246 .overflow_hidden() 9247 .child(panel) 9248 .into_any_element() 9249 } else { 9250 div() 9251 .flex_1() 9252 .w_full() 9253 .overflow_hidden() 9254 .child(app_scroll_panel("account-scroll", 0.0, None, panel)) 9255 .into_any_element() 9256 }) 9257 .into_any_element() 9258 } 9259 9260 fn account_placeholder_panel(text_key: AppTextKey) -> impl IntoElement { 9261 div() 9262 .w_full() 9263 .min_h(px(320.0)) 9264 .flex() 9265 .items_center() 9266 .justify_center() 9267 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 9268 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 9269 .child(app_shared_text(text_key)) 9270 } 9271 9272 fn account_profile_panel( 9273 form: &AccountProfileFormState, 9274 cx: &mut Context<HomeView>, 9275 ) -> impl IntoElement { 9276 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9277 .w_full() 9278 .child(account_profile_details_card(form, cx)) 9279 } 9280 9281 fn account_section_heading_row( 9282 label_key: AppTextKey, 9283 actions: Option<impl IntoElement>, 9284 ) -> impl IntoElement { 9285 div() 9286 .w_full() 9287 .flex() 9288 .items_center() 9289 .justify_between() 9290 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 9291 .child( 9292 div() 9293 .min_w_0() 9294 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 1.5)) 9295 .font_weight(gpui::FontWeight::BOLD) 9296 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 9297 .child(app_shared_text(label_key)), 9298 ) 9299 .when_some(actions, |this, actions| { 9300 this.child(div().flex_none().child(actions)) 9301 }) 9302 } 9303 9304 fn account_form_heading_actions( 9305 draft_id: &'static str, 9306 save_id: &'static str, 9307 save_is_active: bool, 9308 cx: &mut Context<HomeView>, 9309 ) -> impl IntoElement { 9310 let save_button = if save_is_active { 9311 action_button_primary_compact( 9312 save_id, 9313 app_shared_text(AppTextKey::AccountFormSaveAction), 9314 |_, _, _| {}, 9315 cx, 9316 ) 9317 .into_any_element() 9318 } else { 9319 action_button_primary_compact_disabled( 9320 save_id, 9321 app_shared_text(AppTextKey::AccountFormSaveAction), 9322 cx, 9323 ) 9324 .into_any_element() 9325 }; 9326 9327 div() 9328 .flex() 9329 .items_center() 9330 .gap(px(APP_UI_THEME.foundation.spacing.small_px)) 9331 .child(action_button_compact( 9332 draft_id, 9333 app_shared_text(AppTextKey::AccountFormSaveDraftAction), 9334 |_, _, _| {}, 9335 cx, 9336 )) 9337 .child(save_button) 9338 } 9339 9340 fn account_profile_details_card( 9341 form: &AccountProfileFormState, 9342 cx: &mut Context<HomeView>, 9343 ) -> impl IntoElement { 9344 div() 9345 .w_full() 9346 .border_1() 9347 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 9348 .rounded(px(APP_UI_THEME.foundation.radii.large_px)) 9349 .bg(transparent_black()) 9350 .child( 9351 div() 9352 .w_full() 9353 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 9354 .flex() 9355 .items_start() 9356 .gap(px(APP_UI_THEME.shells.home_card_padding_px)) 9357 .child(account_profile_photo_actions(cx)) 9358 .child( 9359 account_profile_field_column() 9360 .child(account_profile_input_field( 9361 AppTextKey::AccountProfileFullNameLabel, 9362 &form.full_name_input, 9363 )) 9364 .child(account_profile_input_field( 9365 AppTextKey::AccountProfilePhoneLabel, 9366 &form.phone_input, 9367 )) 9368 .child(account_profile_select_field( 9369 AppTextKey::AccountProfileTimeZoneLabel, 9370 &form.time_zone_select, 9371 )), 9372 ) 9373 .child( 9374 account_profile_field_column() 9375 .child(account_profile_input_field( 9376 AppTextKey::AccountProfileEmailLabel, 9377 &form.email_input, 9378 )) 9379 .child(account_profile_select_field( 9380 AppTextKey::AccountProfileRoleLabel, 9381 &form.role_select, 9382 )) 9383 .child(account_profile_select_field( 9384 AppTextKey::AccountProfileLanguageLabel, 9385 &form.language_select, 9386 )), 9387 ), 9388 ) 9389 } 9390 9391 fn account_profile_photo_actions(cx: &mut Context<HomeView>) -> impl IntoElement { 9392 app_stack_v(10.0) 9393 .flex_none() 9394 .flex_basis(relative(0.24)) 9395 .min_w(px(136.0)) 9396 .child( 9397 div() 9398 .w_full() 9399 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 9400 .font_weight(gpui::FontWeight::SEMIBOLD) 9401 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 9402 .child(app_shared_text(AppTextKey::AccountProfilePictureLabel)), 9403 ) 9404 .child( 9405 div().w_full().flex().justify_center().child( 9406 div() 9407 .size(px(72.0)) 9408 .rounded(px(36.0)) 9409 .border_1() 9410 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 9411 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 9412 .flex() 9413 .items_center() 9414 .justify_center() 9415 .child( 9416 Icon::new(IconName::CircleUser) 9417 .with_size(gpui_component::Size::Size(px(34.0))) 9418 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), 9419 ), 9420 ), 9421 ) 9422 .child( 9423 app_stack_v(8.0) 9424 .w_full() 9425 .child(action_button_primary_full_width( 9426 "account-profile-change-photo", 9427 app_shared_text(AppTextKey::AccountProfileChangePhotoAction), 9428 |_, _, _| {}, 9429 cx, 9430 )) 9431 .child(action_button_full_width( 9432 "account-profile-remove-photo", 9433 app_shared_text(AppTextKey::AccountProfileRemovePhotoAction), 9434 |_, _, _| {}, 9435 cx, 9436 )), 9437 ) 9438 } 9439 9440 fn account_profile_field_column() -> gpui::Div { 9441 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9442 .w_full() 9443 .flex_1() 9444 .min_w_0() 9445 } 9446 9447 fn account_profile_input_field( 9448 label_key: AppTextKey, 9449 input: &Entity<InputState>, 9450 ) -> impl IntoElement { 9451 account_profile_labeled_control(label_key, account_form_text_input(input)) 9452 } 9453 9454 fn account_profile_select_field( 9455 label_key: AppTextKey, 9456 select: &Entity<AccountProfileSelectState>, 9457 ) -> impl IntoElement { 9458 account_profile_labeled_control(label_key, account_profile_select_input(select)) 9459 } 9460 9461 fn account_profile_labeled_control( 9462 label_key: AppTextKey, 9463 control: impl IntoElement, 9464 ) -> impl IntoElement { 9465 app_stack_v(6.0) 9466 .w_full() 9467 .min_w_0() 9468 .child( 9469 div() 9470 .w_full() 9471 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 9472 .font_weight(gpui::FontWeight::SEMIBOLD) 9473 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 9474 .child(app_shared_text(label_key)), 9475 ) 9476 .child(control) 9477 } 9478 9479 const ACCOUNT_FORM_CONTROL_HEIGHT_PX: f32 = 28.0; 9480 const ACCOUNT_FORM_CONTROL_RADIUS_PX: f32 = 8.0; 9481 const ACCOUNT_FARM_DETAILS_FIELD_MIN_WIDTH_PX: f32 = 220.0; 9482 const ACCOUNT_FARM_DETAILS_FULL_FIELD_MIN_WIDTH_PX: f32 = 464.0; 9483 const ACCOUNT_SETTINGS_DEFAULT_BLOSSOM_SERVER: &str = "http://localhost:8082"; 9484 const ACCOUNT_SETTINGS_RELAY_LOCALHOST_8080: &str = "ws://localhost:8080"; 9485 const ACCOUNT_SETTINGS_RELAY_LOCALHOST_8081: &str = "ws://localhost:8081"; 9486 9487 fn account_form_text_input(input: &Entity<InputState>) -> impl IntoElement { 9488 gpui::Styled::h( 9489 app_text_input(input, false) 9490 .with_size(Size::Small) 9491 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 9492 .font_weight(gpui::FontWeight::NORMAL) 9493 .rounded(px(ACCOUNT_FORM_CONTROL_RADIUS_PX)) 9494 .w_full(), 9495 px(ACCOUNT_FORM_CONTROL_HEIGHT_PX), 9496 ) 9497 } 9498 9499 fn account_form_select_input<D>(select: &Entity<SelectState<D>>) -> impl IntoElement 9500 where 9501 D: SelectDelegate + 'static, 9502 { 9503 Select::new(select) 9504 .with_size(Size::Small) 9505 .h(px(ACCOUNT_FORM_CONTROL_HEIGHT_PX)) 9506 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 9507 .font_weight(gpui::FontWeight::NORMAL) 9508 .rounded(px(ACCOUNT_FORM_CONTROL_RADIUS_PX)) 9509 .w_full() 9510 } 9511 9512 fn account_form_text_area_input_with_wrapped_preview( 9513 input: &Entity<InputState>, 9514 is_wrap_ready: bool, 9515 cx: &mut Context<HomeView>, 9516 ) -> impl IntoElement { 9517 let preview = input.read(cx).value(); 9518 9519 div() 9520 .relative() 9521 .w_full() 9522 .min_w(px(ACCOUNT_FARM_DETAILS_FULL_FIELD_MIN_WIDTH_PX)) 9523 .child( 9524 app_text_input(input, false) 9525 .with_size(Size::Small) 9526 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 9527 .font_weight(gpui::FontWeight::NORMAL) 9528 .rounded(px(ACCOUNT_FORM_CONTROL_RADIUS_PX)) 9529 .w_full() 9530 .opacity(if is_wrap_ready { 1.0 } else { 0.0 }), 9531 ) 9532 .when(!is_wrap_ready, |this| { 9533 this.child( 9534 div() 9535 .absolute() 9536 .top_0() 9537 .left_0() 9538 .right_0() 9539 .px(px(8.0)) 9540 .py(px(2.0)) 9541 .line_height(relative(1.25)) 9542 .whitespace_normal() 9543 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 9544 .font_weight(gpui::FontWeight::NORMAL) 9545 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 9546 .child(preview), 9547 ) 9548 }) 9549 } 9550 9551 fn account_profile_select_input(select: &Entity<AccountProfileSelectState>) -> impl IntoElement { 9552 account_form_select_input(select) 9553 } 9554 9555 fn account_farm_profile_select_input( 9556 select: &Entity<AccountFarmProfileSelectState>, 9557 ) -> impl IntoElement { 9558 account_form_select_input(select) 9559 } 9560 9561 fn account_farm_profile_panel( 9562 form: &AccountFarmProfileFormState, 9563 selected_tab: AccountFarmDetailsTab, 9564 is_textarea_wrap_ready: bool, 9565 cx: &mut Context<HomeView>, 9566 ) -> impl IntoElement { 9567 div() 9568 .size_full() 9569 .overflow_hidden() 9570 .child(account_farm_details_tab_panel( 9571 form, 9572 selected_tab, 9573 is_textarea_wrap_ready, 9574 cx, 9575 )) 9576 } 9577 9578 fn account_farm_details_tab_panel( 9579 form: &AccountFarmProfileFormState, 9580 selected_tab: AccountFarmDetailsTab, 9581 is_textarea_wrap_ready: bool, 9582 cx: &mut Context<HomeView>, 9583 ) -> AnyElement { 9584 match selected_tab { 9585 AccountFarmDetailsTab::Profile => account_farm_profile_section_row( 9586 account_farm_profile_main_card(form, is_textarea_wrap_ready, cx), 9587 account_farm_profile_summary_card(cx), 9588 ) 9589 .into_any_element(), 9590 AccountFarmDetailsTab::Location => account_farm_profile_section_row( 9591 account_farm_location_card(form), 9592 account_farm_location_preview_card(), 9593 ) 9594 .into_any_element(), 9595 AccountFarmDetailsTab::Operations => account_farm_profile_section_row( 9596 account_farm_operating_card(form, is_textarea_wrap_ready, cx), 9597 account_farm_profile_preview_card(cx), 9598 ) 9599 .into_any_element(), 9600 AccountFarmDetailsTab::Fulfilment => account_farm_profile_section_row( 9601 account_farm_fulfillment_card(form, is_textarea_wrap_ready, cx), 9602 account_farm_customer_experience_card(), 9603 ) 9604 .into_any_element(), 9605 } 9606 } 9607 9608 fn account_farm_profile_section_row( 9609 main: impl IntoElement, 9610 rail: impl IntoElement, 9611 ) -> impl IntoElement { 9612 div() 9613 .size_full() 9614 .overflow_hidden() 9615 .flex() 9616 .items_start() 9617 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 9618 .child(div().flex_1().h_full().min_w_0().child(main)) 9619 .child(div().w(px(336.0)).min_w(px(300.0)).flex_none().child(rail)) 9620 } 9621 9622 fn account_farm_profile_main_card( 9623 form: &AccountFarmProfileFormState, 9624 is_textarea_wrap_ready: bool, 9625 cx: &mut Context<HomeView>, 9626 ) -> impl IntoElement + use<> { 9627 account_farm_profile_card( 9628 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9629 .w_full() 9630 .child(account_farm_profile_title_block( 9631 AppTextKey::AccountFarmDetailsFarmProfileTitle, 9632 AppTextKey::AccountFarmDetailsFarmProfileIntro, 9633 )) 9634 .child(account_farm_profile_field_row( 9635 account_profile_input_field( 9636 AppTextKey::AccountFarmDetailsFarmNameLabel, 9637 &form.farm_name_input, 9638 ), 9639 account_profile_input_field( 9640 AppTextKey::AccountFarmDetailsPublicFarmNameLabel, 9641 &form.public_farm_name_input, 9642 ), 9643 )) 9644 .child(account_farm_profile_field_row( 9645 account_profile_input_field( 9646 AppTextKey::AccountFarmDetailsShortDescriptionLabel, 9647 &form.short_description_input, 9648 ), 9649 account_farm_profile_select_field( 9650 AppTextKey::AccountFarmDetailsFarmTypeLabel, 9651 &form.farm_type_select, 9652 ), 9653 )) 9654 .child(account_farm_profile_field_row( 9655 account_profile_input_field( 9656 AppTextKey::AccountFarmDetailsContactEmailLabel, 9657 &form.contact_email_input, 9658 ), 9659 account_profile_input_field( 9660 AppTextKey::AccountFarmDetailsPublicPhoneLabel, 9661 &form.public_phone_input, 9662 ), 9663 )) 9664 .child(account_farm_profile_field_row( 9665 account_profile_input_field( 9666 AppTextKey::AccountFarmDetailsWebsiteLabel, 9667 &form.website_input, 9668 ), 9669 account_profile_input_field( 9670 AppTextKey::AccountFarmDetailsEstablishedYearLabel, 9671 &form.established_year_input, 9672 ), 9673 )) 9674 .child(account_farm_profile_text_area_field( 9675 AppTextKey::AccountFarmDetailsAboutFarmLabel, 9676 &form.about_farm_input, 9677 is_textarea_wrap_ready, 9678 cx, 9679 )), 9680 ) 9681 } 9682 9683 fn account_farm_location_card(form: &AccountFarmProfileFormState) -> impl IntoElement { 9684 account_farm_profile_card( 9685 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9686 .w_full() 9687 .child(account_farm_profile_title_block( 9688 AppTextKey::AccountFarmDetailsLocationTitle, 9689 AppTextKey::AccountFarmDetailsLocationIntro, 9690 )) 9691 .child(account_farm_map_placeholder()) 9692 .child(account_farm_profile_field_row( 9693 account_profile_input_field( 9694 AppTextKey::AccountFarmDetailsStreetAddressLabel, 9695 &form.street_address_input, 9696 ), 9697 account_profile_input_field( 9698 AppTextKey::AccountFarmDetailsCityLabel, 9699 &form.city_input, 9700 ), 9701 )) 9702 .child(account_farm_profile_field_row( 9703 account_farm_profile_select_field( 9704 AppTextKey::AccountFarmDetailsProvinceLabel, 9705 &form.province_select, 9706 ), 9707 account_profile_input_field( 9708 AppTextKey::AccountFarmDetailsPostalCodeLabel, 9709 &form.postal_code_input, 9710 ), 9711 )) 9712 .child(account_farm_profile_field_row( 9713 account_farm_profile_select_field( 9714 AppTextKey::AccountFarmDetailsCountryLabel, 9715 &form.country_select, 9716 ), 9717 account_farm_profile_select_field( 9718 AppTextKey::AccountFarmDetailsServiceAreaLabel, 9719 &form.service_area_select, 9720 ), 9721 )) 9722 .child(account_farm_profile_helper_text( 9723 AppTextKey::AccountFarmDetailsServiceAreaHelper, 9724 )) 9725 .child(account_farm_toggle_preview_row( 9726 AppTextKey::AccountFarmDetailsExactAddressPublicLabel, 9727 AppTextKey::AccountFarmDetailsExactAddressPublicHelper, 9728 )), 9729 ) 9730 } 9731 9732 fn account_farm_map_placeholder() -> impl IntoElement { 9733 div() 9734 .w_full() 9735 .h(px(220.0)) 9736 .rounded(px(APP_UI_THEME.foundation.radii.large_px)) 9737 .border_1() 9738 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 9739 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 9740 .flex() 9741 .items_center() 9742 .justify_center() 9743 .child( 9744 div() 9745 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 9746 .font_weight(gpui::FontWeight::MEDIUM) 9747 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 9748 .child(app_shared_text( 9749 AppTextKey::AccountFarmDetailsMapNotImplemented, 9750 )), 9751 ) 9752 } 9753 9754 fn account_farm_location_preview_card() -> impl IntoElement { 9755 account_farm_profile_rail_card( 9756 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9757 .w_full() 9758 .child(account_farm_preview_title( 9759 AppTextKey::AccountFarmDetailsLocationPreviewTitle, 9760 )) 9761 .child(account_farm_icon_value_row( 9762 IconName::Map, 9763 AppTextKey::AccountFarmDetailsFarmLocationValue, 9764 AppTextKey::AccountFarmDetailsServiceAreaValue, 9765 )) 9766 .child(account_farm_service_area_preview()) 9767 .child(account_farm_profile_helper_text( 9768 AppTextKey::AccountFarmDetailsLocationPreviewHelper, 9769 )), 9770 ) 9771 } 9772 9773 fn account_farm_service_area_preview() -> impl IntoElement { 9774 div() 9775 .w_full() 9776 .h(px(132.0)) 9777 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 9778 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 9779 .border_1() 9780 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 9781 .flex() 9782 .items_center() 9783 .justify_center() 9784 .child( 9785 div() 9786 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 9787 .font_weight(gpui::FontWeight::MEDIUM) 9788 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 9789 .child(app_shared_text( 9790 AppTextKey::AccountFarmDetailsMapNotImplemented, 9791 )), 9792 ) 9793 } 9794 9795 fn account_farm_operating_card( 9796 form: &AccountFarmProfileFormState, 9797 is_textarea_wrap_ready: bool, 9798 cx: &mut Context<HomeView>, 9799 ) -> impl IntoElement + use<> { 9800 account_farm_profile_card( 9801 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9802 .w_full() 9803 .child(account_farm_profile_title_block( 9804 AppTextKey::AccountFarmDetailsOperatingTitle, 9805 AppTextKey::AccountFarmDetailsOperatingIntro, 9806 )) 9807 .child(account_farm_profile_field_row( 9808 account_farm_profile_select_field( 9809 AppTextKey::AccountFarmDetailsGrowingPracticesLabel, 9810 &form.growing_practices_select, 9811 ), 9812 account_farm_static_chip_group( 9813 AppTextKey::AccountFarmDetailsProductionMethodsLabel, 9814 &[ 9815 AppTextKey::AccountFarmDetailsProductionMethodOrganicPractices, 9816 AppTextKey::AccountFarmDetailsProductionMethodNoSpray, 9817 AppTextKey::AccountFarmDetailsGrowingPracticeRegenerative, 9818 ], 9819 3, 9820 ), 9821 )) 9822 .child(account_farm_profile_field_row( 9823 account_farm_date_range_fields( 9824 AppTextKey::AccountFarmDetailsSeasonDatesLabel, 9825 &form.season_start_input, 9826 &form.season_end_input, 9827 ), 9828 account_farm_day_chip_group(), 9829 )) 9830 .child(account_farm_profile_text_area_field( 9831 AppTextKey::AccountFarmDetailsAboutProductsLabel, 9832 &form.about_products_input, 9833 is_textarea_wrap_ready, 9834 cx, 9835 )) 9836 .child(account_farm_static_chip_group_with_helper( 9837 AppTextKey::AccountFarmDetailsCertificationsTitle, 9838 AppTextKey::AccountFarmDetailsCertificationsHelper, 9839 &[ 9840 AppTextKey::AccountFarmDetailsCertificationCertifiedOrganic, 9841 AppTextKey::AccountFarmDetailsCertificationNaturallyGrown, 9842 AppTextKey::AccountFarmDetailsCertificationSmallFamilyFarm, 9843 AppTextKey::AccountFarmDetailsCertificationDeliveryAvailable, 9844 ], 9845 3, 9846 )) 9847 .child(account_farm_profile_text_area_field_with_helper( 9848 AppTextKey::AccountFarmDetailsCustomerNoteTitle, 9849 AppTextKey::AccountFarmDetailsCustomerNoteHelper, 9850 &form.customer_note_input, 9851 is_textarea_wrap_ready, 9852 cx, 9853 )), 9854 ) 9855 } 9856 9857 fn account_farm_profile_preview_card(cx: &mut Context<HomeView>) -> impl IntoElement + use<> { 9858 account_farm_profile_rail_card( 9859 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9860 .w_full() 9861 .child(account_farm_preview_title( 9862 AppTextKey::AccountFarmDetailsProfilePreviewTitle, 9863 )) 9864 .child(account_farm_icon_value_row( 9865 IconName::Building2, 9866 AppTextKey::AccountFarmDetailsFarmNameValue, 9867 AppTextKey::AccountFarmDetailsFarmLocationValue, 9868 )) 9869 .child(account_farm_profile_summary_row( 9870 AppTextKey::AccountFarmDetailsGrowingPracticesSummaryLabel, 9871 AppTextKey::AccountFarmDetailsGrowingPracticeRegenerative, 9872 )) 9873 .child(account_farm_profile_summary_row( 9874 AppTextKey::AccountFarmDetailsSeasonSummaryLabel, 9875 AppTextKey::AccountFarmDetailsSeasonStartValue, 9876 )) 9877 .child(account_farm_profile_summary_row( 9878 AppTextKey::AccountFarmDetailsOrderDaysSummaryLabel, 9879 AppTextKey::AccountFarmDetailsOrderDaysSummaryValue, 9880 )) 9881 .child(account_farm_profile_summary_row( 9882 AppTextKey::AccountFarmDetailsFarmTypeSummaryLabel, 9883 AppTextKey::AccountFarmDetailsFarmTypeVegetableFarm, 9884 )) 9885 .child(action_button_full_width( 9886 "account-farm-profile-preview", 9887 app_shared_text(AppTextKey::AccountFarmDetailsViewFarmProfileAction), 9888 |_, _, _| {}, 9889 cx, 9890 )), 9891 ) 9892 } 9893 9894 fn account_farm_fulfillment_card( 9895 form: &AccountFarmProfileFormState, 9896 is_textarea_wrap_ready: bool, 9897 cx: &mut Context<HomeView>, 9898 ) -> impl IntoElement + use<> { 9899 account_farm_profile_card( 9900 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 9901 .w_full() 9902 .child(account_farm_profile_title_block( 9903 AppTextKey::AccountFarmDetailsPickupFulfillmentTitle, 9904 AppTextKey::AccountFarmDetailsPickupFulfillmentIntro, 9905 )) 9906 .child(account_farm_static_chip_group( 9907 AppTextKey::AccountFarmDetailsFulfillmentModeLabel, 9908 &[ 9909 AppTextKey::AccountFarmDetailsFulfillmentPickupOnly, 9910 AppTextKey::AccountFarmDetailsFulfillmentDelivery, 9911 AppTextKey::AccountFarmDetailsFulfillmentBoth, 9912 ], 9913 2, 9914 )) 9915 .child(account_farm_profile_labeled_control_with_helper( 9916 AppTextKey::AccountFarmDetailsPrimaryPickupLocationLabel, 9917 account_farm_profile_select_input(&form.primary_pickup_location_select), 9918 Some(AppTextKey::AccountFarmDetailsPrimaryPickupLocationAddressValue), 9919 )) 9920 .child(account_farm_profile_text_area_field_with_helper( 9921 AppTextKey::AccountFarmDetailsPickupInstructionsLabel, 9922 AppTextKey::AccountFarmDetailsPickupInstructionsHelper, 9923 &form.pickup_instructions_input, 9924 is_textarea_wrap_ready, 9925 cx, 9926 )) 9927 .child(account_farm_pickup_schedule_row(form, cx)) 9928 .child(account_farm_delivery_radius_row( 9929 &form.delivery_radius_input, 9930 )), 9931 ) 9932 } 9933 9934 fn account_farm_pickup_schedule_row( 9935 form: &AccountFarmProfileFormState, 9936 cx: &mut Context<HomeView>, 9937 ) -> impl IntoElement { 9938 div() 9939 .w_full() 9940 .flex() 9941 .items_start() 9942 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 9943 .child( 9944 app_stack_v(8.0) 9945 .flex_1() 9946 .min_w_0() 9947 .child(account_farm_field_label( 9948 AppTextKey::AccountFarmDetailsPickupWindowsLabel, 9949 )) 9950 .child(account_farm_pickup_windows_table()) 9951 .child(div().w(px(180.0)).child(action_button_compact( 9952 "account-farm-add-pickup-window", 9953 app_shared_text(AppTextKey::AccountFarmDetailsAddPickupWindowAction), 9954 |_, _, _| {}, 9955 cx, 9956 ))), 9957 ) 9958 .child( 9959 div() 9960 .w(px(176.0)) 9961 .child(account_farm_profile_labeled_control_with_helper( 9962 AppTextKey::AccountFarmDetailsOrderCutoffLabel, 9963 account_farm_profile_select_input(&form.order_cutoff_select), 9964 Some(AppTextKey::AccountFarmDetailsOrderCutoffHelper), 9965 )), 9966 ) 9967 } 9968 9969 fn account_farm_pickup_windows_table() -> impl IntoElement { 9970 app_stack_v(0.0) 9971 .w_full() 9972 .border_1() 9973 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 9974 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 9975 .overflow_hidden() 9976 .child(account_farm_pickup_window_table_row( 9977 AppTextKey::AccountFarmDetailsPickupWindowDayHeader, 9978 AppTextKey::AccountFarmDetailsPickupWindowStartHeader, 9979 AppTextKey::AccountFarmDetailsPickupWindowEndHeader, 9980 false, 9981 )) 9982 .child(account_farm_pickup_window_table_row( 9983 AppTextKey::AccountFarmDetailsPickupWindowWednesday, 9984 AppTextKey::AccountFarmDetailsPickupWindowWednesdayStart, 9985 AppTextKey::AccountFarmDetailsPickupWindowWednesdayEnd, 9986 true, 9987 )) 9988 .child(account_farm_pickup_window_table_row( 9989 AppTextKey::AccountFarmDetailsPickupWindowSaturday, 9990 AppTextKey::AccountFarmDetailsPickupWindowSaturdayStart, 9991 AppTextKey::AccountFarmDetailsPickupWindowSaturdayEnd, 9992 true, 9993 )) 9994 } 9995 9996 fn account_farm_pickup_window_table_row( 9997 day_key: AppTextKey, 9998 start_key: AppTextKey, 9999 end_key: AppTextKey, 10000 action: bool, 10001 ) -> impl IntoElement { 10002 let bg = if action { 10003 APP_UI_THEME.foundation.surfaces.window_background 10004 } else { 10005 APP_UI_THEME.foundation.surfaces.card_background 10006 }; 10007 10008 div() 10009 .w_full() 10010 .bg(rgb(bg)) 10011 .px(px(10.0)) 10012 .py(px(7.0)) 10013 .flex() 10014 .items_center() 10015 .gap(px(10.0)) 10016 .child(account_farm_table_cell(day_key, 1.0)) 10017 .child(account_farm_table_cell(start_key, 1.0)) 10018 .child(account_farm_table_cell(end_key, 1.0)) 10019 .when(action, |this| { 10020 this.child( 10021 div() 10022 .flex_none() 10023 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10024 .child(Icon::new(IconName::Ellipsis).with_size(gpui_component::Size::Small)), 10025 ) 10026 }) 10027 } 10028 10029 fn account_farm_table_cell(label_key: AppTextKey, basis: f32) -> impl IntoElement { 10030 div() 10031 .flex_basis(relative(basis)) 10032 .min_w_0() 10033 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10034 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10035 .child(app_shared_text(label_key)) 10036 } 10037 10038 fn account_farm_delivery_radius_row(input: &Entity<InputState>) -> impl IntoElement { 10039 app_stack_v(8.0) 10040 .w_full() 10041 .pt(px(APP_UI_THEME.shells.home_stack_gap_px)) 10042 .child(account_farm_profile_title_block( 10043 AppTextKey::AccountFarmDetailsDeliveryRadiusTitle, 10044 AppTextKey::AccountFarmDetailsDeliveryRadiusHelper, 10045 )) 10046 .child( 10047 div() 10048 .w_full() 10049 .flex() 10050 .items_center() 10051 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10052 .child(account_farm_slider_preview()) 10053 .child(div().w(px(74.0)).child(account_form_text_input(input))) 10054 .child( 10055 div() 10056 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10057 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10058 .child(app_shared_text( 10059 AppTextKey::AccountFarmDetailsDeliveryRadiusUnit, 10060 )), 10061 ), 10062 ) 10063 .child(account_farm_profile_helper_text( 10064 AppTextKey::AccountFarmDetailsDeliveryRadiusNote, 10065 )) 10066 } 10067 10068 fn account_farm_slider_preview() -> impl IntoElement { 10069 div() 10070 .flex_1() 10071 .min_w_0() 10072 .h(px(4.0)) 10073 .rounded(px(2.0)) 10074 .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10075 .child(div().w(relative(0.38)).h(px(4.0)).rounded(px(2.0)).bg(rgb( 10076 APP_UI_THEME.components.app_button.primary_colors.background, 10077 ))) 10078 } 10079 10080 fn account_farm_customer_experience_card() -> impl IntoElement { 10081 account_farm_profile_rail_card( 10082 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 10083 .w_full() 10084 .child(account_farm_preview_title( 10085 AppTextKey::AccountFarmDetailsCustomerExperienceTitle, 10086 )) 10087 .child(account_farm_profile_helper_text( 10088 AppTextKey::AccountFarmDetailsCustomerExperienceIntro, 10089 )) 10090 .child(account_farm_customer_experience_panel( 10091 AppTextKey::AccountFarmDetailsCustomerExperiencePickupTitle, 10092 AppTextKey::AccountFarmDetailsPrimaryPickupLocationTitleValue, 10093 AppTextKey::AccountFarmDetailsPrimaryPickupLocationAddressValue, 10094 )) 10095 .child(account_farm_customer_experience_panel( 10096 AppTextKey::AccountFarmDetailsCustomerExperienceDeliveryTitle, 10097 AppTextKey::AccountFarmDetailsCustomerExperienceDeliveryBody, 10098 AppTextKey::AccountFarmDetailsServiceAreaValue, 10099 )), 10100 ) 10101 } 10102 10103 fn account_farm_customer_experience_panel( 10104 title_key: AppTextKey, 10105 primary_key: AppTextKey, 10106 secondary_key: AppTextKey, 10107 ) -> impl IntoElement { 10108 div() 10109 .w_full() 10110 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 10111 .bg(rgb(APP_UI_THEME.foundation.surfaces.card_background)) 10112 .p(px(10.0)) 10113 .child( 10114 app_stack_v(4.0) 10115 .w_full() 10116 .child( 10117 div() 10118 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10119 .font_weight(gpui::FontWeight::BOLD) 10120 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10121 .child(app_shared_text(title_key)), 10122 ) 10123 .child( 10124 div() 10125 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10126 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10127 .child(app_shared_text(primary_key)), 10128 ) 10129 .child( 10130 div() 10131 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10132 .line_height(relative(1.25)) 10133 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10134 .child(app_shared_text(secondary_key)), 10135 ), 10136 ) 10137 } 10138 10139 fn account_farm_profile_card(content: impl IntoElement) -> impl IntoElement { 10140 div() 10141 .size_full() 10142 .overflow_hidden() 10143 .border_1() 10144 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10145 .rounded(px(APP_UI_THEME.foundation.radii.large_px)) 10146 .bg(transparent_black()) 10147 .child(app_scroll_panel( 10148 "account-farm-card-scroll", 10149 APP_UI_THEME.shells.home_card_padding_px, 10150 None, 10151 content, 10152 )) 10153 } 10154 10155 fn account_farm_profile_rail_card(content: impl IntoElement) -> impl IntoElement { 10156 div() 10157 .w_full() 10158 .border_1() 10159 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10160 .rounded(px(APP_UI_THEME.foundation.radii.large_px)) 10161 .bg(transparent_black()) 10162 .child( 10163 div() 10164 .w_full() 10165 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 10166 .child(content), 10167 ) 10168 } 10169 10170 fn account_farm_profile_title_block( 10171 title_key: AppTextKey, 10172 body_key: AppTextKey, 10173 ) -> impl IntoElement { 10174 app_stack_v(8.0) 10175 .w_full() 10176 .child( 10177 div() 10178 .w_full() 10179 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 1.1)) 10180 .font_weight(gpui::FontWeight::BOLD) 10181 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10182 .child(app_shared_text(title_key)), 10183 ) 10184 .child( 10185 div() 10186 .w_full() 10187 .line_height(relative(1.35)) 10188 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 10189 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10190 .child(app_shared_text(body_key)), 10191 ) 10192 } 10193 10194 fn account_farm_profile_field_row( 10195 first: impl IntoElement, 10196 second: impl IntoElement, 10197 ) -> impl IntoElement { 10198 div() 10199 .w_full() 10200 .flex() 10201 .items_start() 10202 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10203 .child( 10204 div() 10205 .flex_1() 10206 .min_w(px(ACCOUNT_FARM_DETAILS_FIELD_MIN_WIDTH_PX)) 10207 .child(first), 10208 ) 10209 .child( 10210 div() 10211 .flex_1() 10212 .min_w(px(ACCOUNT_FARM_DETAILS_FIELD_MIN_WIDTH_PX)) 10213 .child(second), 10214 ) 10215 } 10216 10217 fn account_farm_field_label(label_key: AppTextKey) -> impl IntoElement { 10218 div() 10219 .w_full() 10220 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 10221 .font_weight(gpui::FontWeight::SEMIBOLD) 10222 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10223 .child(app_shared_text(label_key)) 10224 } 10225 10226 fn account_farm_profile_helper_text(text_key: AppTextKey) -> impl IntoElement { 10227 div() 10228 .w_full() 10229 .line_height(relative(1.3)) 10230 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10231 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10232 .child(app_shared_text(text_key)) 10233 } 10234 10235 fn account_farm_profile_labeled_control_with_helper( 10236 label_key: AppTextKey, 10237 control: impl IntoElement, 10238 helper_key: Option<AppTextKey>, 10239 ) -> impl IntoElement { 10240 app_stack_v(6.0) 10241 .w_full() 10242 .child(account_farm_field_label(label_key)) 10243 .child(control) 10244 .when_some(helper_key, |this, helper_key| { 10245 this.child(account_farm_profile_helper_text(helper_key)) 10246 }) 10247 } 10248 10249 fn account_farm_profile_select_field( 10250 label_key: AppTextKey, 10251 select: &Entity<AccountFarmProfileSelectState>, 10252 ) -> impl IntoElement { 10253 account_profile_labeled_control(label_key, account_farm_profile_select_input(select)) 10254 } 10255 10256 fn account_farm_profile_text_area_field( 10257 label_key: AppTextKey, 10258 input: &Entity<InputState>, 10259 is_wrap_ready: bool, 10260 cx: &mut Context<HomeView>, 10261 ) -> impl IntoElement { 10262 account_profile_labeled_control( 10263 label_key, 10264 account_form_text_area_input_with_wrapped_preview(input, is_wrap_ready, cx), 10265 ) 10266 } 10267 10268 fn account_farm_profile_text_area_field_with_helper( 10269 label_key: AppTextKey, 10270 helper_key: AppTextKey, 10271 input: &Entity<InputState>, 10272 is_wrap_ready: bool, 10273 cx: &mut Context<HomeView>, 10274 ) -> impl IntoElement { 10275 account_farm_profile_labeled_control_with_helper( 10276 label_key, 10277 account_form_text_area_input_with_wrapped_preview(input, is_wrap_ready, cx), 10278 Some(helper_key), 10279 ) 10280 } 10281 10282 fn account_farm_toggle_preview_row( 10283 label_key: AppTextKey, 10284 helper_key: AppTextKey, 10285 ) -> impl IntoElement { 10286 div() 10287 .w_full() 10288 .flex() 10289 .items_start() 10290 .gap(px(10.0)) 10291 .child( 10292 div() 10293 .flex_none() 10294 .w(px(34.0)) 10295 .h(px(20.0)) 10296 .rounded(px(10.0)) 10297 .bg(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10298 .p(px(2.0)) 10299 .child( 10300 div() 10301 .size(px(16.0)) 10302 .rounded(px(8.0)) 10303 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)), 10304 ), 10305 ) 10306 .child( 10307 app_stack_v(3.0) 10308 .flex_1() 10309 .min_w_0() 10310 .child( 10311 div() 10312 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10313 .font_weight(gpui::FontWeight::MEDIUM) 10314 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10315 .child(app_shared_text(label_key)), 10316 ) 10317 .child(account_farm_profile_helper_text(helper_key)), 10318 ) 10319 } 10320 10321 fn account_farm_preview_title(title_key: AppTextKey) -> impl IntoElement { 10322 div() 10323 .w_full() 10324 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 1.1)) 10325 .font_weight(gpui::FontWeight::BOLD) 10326 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10327 .child(app_shared_text(title_key)) 10328 } 10329 10330 fn account_farm_icon_value_row( 10331 icon_name: IconName, 10332 title_key: AppTextKey, 10333 subtitle_key: AppTextKey, 10334 ) -> impl IntoElement { 10335 div() 10336 .w_full() 10337 .flex() 10338 .items_center() 10339 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10340 .child( 10341 div() 10342 .size(px(46.0)) 10343 .rounded(px(23.0)) 10344 .bg(rgb(0xA7F3B8)) 10345 .flex() 10346 .items_center() 10347 .justify_center() 10348 .child( 10349 Icon::new(icon_name) 10350 .with_size(gpui_component::Size::Size(px(24.0))) 10351 .text_color(rgb(APP_UI_THEME.foundation.text.primary)), 10352 ), 10353 ) 10354 .child( 10355 app_stack_v(3.0) 10356 .flex_1() 10357 .min_w_0() 10358 .child( 10359 div() 10360 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 10361 .font_weight(gpui::FontWeight::BOLD) 10362 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10363 .child(app_shared_text(title_key)), 10364 ) 10365 .child( 10366 div() 10367 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10368 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10369 .child(app_shared_text(subtitle_key)), 10370 ), 10371 ) 10372 } 10373 10374 fn account_farm_static_chip_group( 10375 label_key: AppTextKey, 10376 chips: &[AppTextKey], 10377 selected_count: usize, 10378 ) -> impl IntoElement { 10379 account_farm_profile_labeled_control_with_helper( 10380 label_key, 10381 account_farm_chip_wrap(chips, selected_count), 10382 None, 10383 ) 10384 } 10385 10386 fn account_farm_static_chip_group_with_helper( 10387 label_key: AppTextKey, 10388 helper_key: AppTextKey, 10389 chips: &[AppTextKey], 10390 selected_count: usize, 10391 ) -> impl IntoElement { 10392 account_farm_profile_labeled_control_with_helper( 10393 label_key, 10394 account_farm_chip_wrap(chips, selected_count), 10395 Some(helper_key), 10396 ) 10397 } 10398 10399 fn account_farm_chip_wrap(chips: &[AppTextKey], selected_count: usize) -> impl IntoElement { 10400 div().w_full().flex().flex_wrap().gap(px(8.0)).children( 10401 chips 10402 .iter() 10403 .enumerate() 10404 .map(|(index, key)| account_farm_chip(*key, index < selected_count).into_any_element()), 10405 ) 10406 } 10407 10408 fn account_farm_chip(label_key: AppTextKey, selected: bool) -> impl IntoElement { 10409 let border = if selected { 10410 APP_UI_THEME.components.app_button.primary_colors.background 10411 } else { 10412 APP_UI_THEME.foundation.surfaces.divider 10413 }; 10414 let text = if selected { 10415 APP_UI_THEME.components.app_button.primary_colors.background 10416 } else { 10417 APP_UI_THEME.foundation.text.primary 10418 }; 10419 10420 div() 10421 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 10422 .border_1() 10423 .border_color(rgb(border)) 10424 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) 10425 .px(px(10.0)) 10426 .py(px(6.0)) 10427 .flex() 10428 .items_center() 10429 .gap(px(6.0)) 10430 .when(selected, |this| { 10431 this.child( 10432 Icon::new(IconName::Check) 10433 .with_size(gpui_component::Size::Small) 10434 .text_color(rgb(text)), 10435 ) 10436 }) 10437 .child( 10438 div() 10439 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10440 .font_weight(gpui::FontWeight::MEDIUM) 10441 .text_color(rgb(text)) 10442 .child(app_shared_text(label_key)), 10443 ) 10444 } 10445 10446 fn account_farm_day_chip_group() -> impl IntoElement { 10447 account_farm_static_chip_group( 10448 AppTextKey::AccountFarmDetailsOrderDaysLabel, 10449 &[ 10450 AppTextKey::AccountFarmDetailsDayMon, 10451 AppTextKey::AccountFarmDetailsDayTue, 10452 AppTextKey::AccountFarmDetailsDayWed, 10453 AppTextKey::AccountFarmDetailsDayThu, 10454 AppTextKey::AccountFarmDetailsDayFri, 10455 AppTextKey::AccountFarmDetailsDaySat, 10456 AppTextKey::AccountFarmDetailsDaySun, 10457 ], 10458 5, 10459 ) 10460 } 10461 10462 fn account_farm_date_range_fields( 10463 label_key: AppTextKey, 10464 start_input: &Entity<InputState>, 10465 end_input: &Entity<InputState>, 10466 ) -> impl IntoElement { 10467 app_stack_v(6.0) 10468 .w_full() 10469 .child(account_farm_field_label(label_key)) 10470 .child( 10471 div() 10472 .w_full() 10473 .flex() 10474 .items_center() 10475 .gap(px(8.0)) 10476 .child( 10477 div() 10478 .flex_1() 10479 .min_w_0() 10480 .child(account_form_text_input(start_input)), 10481 ) 10482 .child( 10483 Icon::new(IconName::ArrowRight) 10484 .with_size(gpui_component::Size::Small) 10485 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), 10486 ) 10487 .child( 10488 div() 10489 .flex_1() 10490 .min_w_0() 10491 .child(account_form_text_input(end_input)), 10492 ), 10493 ) 10494 } 10495 10496 fn account_farm_profile_summary_card(cx: &mut Context<HomeView>) -> impl IntoElement { 10497 account_farm_profile_rail_card( 10498 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 10499 .w_full() 10500 .child( 10501 div() 10502 .w_full() 10503 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 1.1)) 10504 .font_weight(gpui::FontWeight::BOLD) 10505 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10506 .child(app_shared_text(AppTextKey::AccountFarmDetailsSummaryTitle)), 10507 ) 10508 .child( 10509 div() 10510 .w_full() 10511 .flex() 10512 .items_center() 10513 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10514 .child( 10515 div() 10516 .size(px(56.0)) 10517 .rounded(px(28.0)) 10518 .bg(rgb(0xA7F3B8)) 10519 .flex() 10520 .items_center() 10521 .justify_center() 10522 .child( 10523 Icon::new(IconName::Building2) 10524 .with_size(gpui_component::Size::Size(px(28.0))) 10525 .text_color(rgb(APP_UI_THEME.foundation.text.primary)), 10526 ), 10527 ) 10528 .child( 10529 app_stack_v(4.0) 10530 .flex_1() 10531 .min_w_0() 10532 .child( 10533 div() 10534 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 10535 .font_weight(gpui::FontWeight::BOLD) 10536 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10537 .child(app_shared_text( 10538 AppTextKey::AccountFarmDetailsFarmNameValue, 10539 )), 10540 ) 10541 .child( 10542 div() 10543 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 10544 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10545 .child(app_shared_text( 10546 AppTextKey::AccountFarmDetailsFarmLocationValue, 10547 )), 10548 ), 10549 ), 10550 ) 10551 .child(account_farm_profile_summary_row( 10552 AppTextKey::AccountFarmDetailsFarmTypeSummaryLabel, 10553 AppTextKey::AccountFarmDetailsFarmTypeVegetableFarm, 10554 )) 10555 .child(account_farm_profile_summary_row( 10556 AppTextKey::AccountFarmDetailsEstablishedSummaryLabel, 10557 AppTextKey::AccountFarmDetailsEstablishedYearValue, 10558 )) 10559 .child(action_button_full_width( 10560 "account-farm-view-profile", 10561 app_shared_text(AppTextKey::AccountFarmDetailsViewFarmProfileAction), 10562 |_, _, _| {}, 10563 cx, 10564 )), 10565 ) 10566 } 10567 10568 fn account_farm_profile_summary_row( 10569 label_key: AppTextKey, 10570 value_key: AppTextKey, 10571 ) -> impl IntoElement { 10572 div() 10573 .w_full() 10574 .flex() 10575 .items_center() 10576 .justify_between() 10577 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10578 .child( 10579 div() 10580 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 10581 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10582 .child(app_shared_text(label_key)), 10583 ) 10584 .child( 10585 div() 10586 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 10587 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10588 .child(app_shared_text(value_key)), 10589 ) 10590 } 10591 10592 fn account_settings_panel( 10593 form: &AccountSettingsFormState, 10594 cx: &mut Context<HomeView>, 10595 ) -> impl IntoElement { 10596 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 10597 .w_full() 10598 .child( 10599 div() 10600 .w_full() 10601 .flex() 10602 .items_start() 10603 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10604 .child( 10605 div() 10606 .flex_1() 10607 .min_w_0() 10608 .child(account_settings_nostr_relays_card(form, cx)), 10609 ) 10610 .child( 10611 div() 10612 .flex_basis(relative(0.4)) 10613 .min_w(px(320.0)) 10614 .child(account_settings_blossom_server_card(form, cx)), 10615 ), 10616 ) 10617 } 10618 10619 fn account_settings_nostr_relays_card( 10620 form: &AccountSettingsFormState, 10621 cx: &mut Context<HomeView>, 10622 ) -> impl IntoElement { 10623 account_settings_card( 10624 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 10625 .w_full() 10626 .child(account_farm_profile_title_block( 10627 AppTextKey::AccountSettingsNostrRelaysTitle, 10628 AppTextKey::AccountSettingsNostrRelaysHelper, 10629 )) 10630 .child(account_settings_relay_list(cx)) 10631 .child(account_settings_add_relay_controls(form, cx)) 10632 .child(div().w(px(160.0)).child(action_button_full_width( 10633 "account-settings-reset-relays", 10634 app_shared_text(AppTextKey::AccountSettingsResetRelaysAction), 10635 |_, _, _| {}, 10636 cx, 10637 ))) 10638 .child(account_settings_helper_note( 10639 AppTextKey::AccountSettingsDefaultRelaysNote, 10640 )), 10641 ) 10642 } 10643 10644 fn account_settings_relay_list(cx: &mut Context<HomeView>) -> impl IntoElement { 10645 app_stack_v(0.0) 10646 .w_full() 10647 .border_1() 10648 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10649 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 10650 .overflow_hidden() 10651 .child(account_settings_relay_row( 10652 "account-settings-relay-localhost-8080", 10653 "account-settings-relay-menu-localhost-8080", 10654 ACCOUNT_SETTINGS_RELAY_LOCALHOST_8080, 10655 AppTextKey::AccountSettingsRelayAccessReadWrite, 10656 APP_UI_THEME.components.app_status_indicator.online, 10657 cx, 10658 )) 10659 .child(account_settings_relay_row( 10660 "account-settings-relay-localhost-8081", 10661 "account-settings-relay-menu-localhost-8081", 10662 ACCOUNT_SETTINGS_RELAY_LOCALHOST_8081, 10663 AppTextKey::AccountSettingsRelayAccessReadOnly, 10664 APP_UI_THEME.components.app_status_indicator.offline, 10665 cx, 10666 )) 10667 } 10668 10669 fn account_settings_relay_row( 10670 row_id: &'static str, 10671 menu_id: &'static str, 10672 relay_url: &'static str, 10673 access_key: AppTextKey, 10674 status_color: u32, 10675 cx: &mut Context<HomeView>, 10676 ) -> impl IntoElement { 10677 div() 10678 .id(row_id) 10679 .w_full() 10680 .min_h(px(52.0)) 10681 .px(px(APP_UI_THEME.foundation.spacing.medium_px)) 10682 .py(px(APP_UI_THEME.foundation.spacing.small_px)) 10683 .flex() 10684 .items_center() 10685 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10686 .child( 10687 div() 10688 .flex_1() 10689 .min_w_0() 10690 .flex() 10691 .items_center() 10692 .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) 10693 .child(account_settings_relay_status_indicator(status_color)) 10694 .child( 10695 div() 10696 .flex_1() 10697 .min_w_0() 10698 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10699 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10700 .child(relay_url), 10701 ), 10702 ) 10703 .child( 10704 div() 10705 .w(px(104.0)) 10706 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10707 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10708 .child(app_shared_text(access_key)), 10709 ) 10710 .child(account_settings_relay_menu_button(menu_id, cx)) 10711 } 10712 10713 fn account_settings_relay_status_indicator(status_color: u32) -> impl IntoElement { 10714 div().flex_none().child(status_indicator(status_color)) 10715 } 10716 10717 fn account_settings_relay_menu_button( 10718 id: &'static str, 10719 cx: &mut Context<HomeView>, 10720 ) -> impl IntoElement { 10721 action_ellipsis_menu( 10722 id, 10723 |menu, _, _| { 10724 menu.item(PopupMenuItem::new(app_text( 10725 AppTextKey::AccountSettingsRelayMenuAbout, 10726 ))) 10727 .item(PopupMenuItem::new(app_text( 10728 AppTextKey::AccountSettingsRelayMenuView, 10729 ))) 10730 .item( 10731 PopupMenuItem::new(app_text( 10732 AppTextKey::AccountSettingsRelayMenuCheckConnection, 10733 )) 10734 .on_click(|_, _, _| {}), 10735 ) 10736 .separator() 10737 .item(account_settings_copy_menu_item()) 10738 .item(account_settings_remove_menu_item()) 10739 }, 10740 cx, 10741 ) 10742 } 10743 10744 fn account_settings_copy_menu_item() -> PopupMenuItem { 10745 PopupMenuItem::element(|_, _| { 10746 div() 10747 .w(px(180.0)) 10748 .flex() 10749 .items_center() 10750 .justify_between() 10751 .gap(px(APP_UI_THEME.foundation.spacing.large_px)) 10752 .child( 10753 div() 10754 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10755 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10756 .child(app_shared_text(AppTextKey::AccountSettingsRelayMenuCopy)), 10757 ) 10758 .child( 10759 div() 10760 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 10761 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10762 .child(app_shared_text( 10763 AppTextKey::AccountSettingsRelayMenuCopyShortcut, 10764 )), 10765 ) 10766 }) 10767 .on_click(|_, _, _| {}) 10768 } 10769 10770 fn account_settings_remove_menu_item() -> PopupMenuItem { 10771 PopupMenuItem::element(|_, _| { 10772 div() 10773 .w(px(180.0)) 10774 .text_size(px(APP_UI_THEME.foundation.typography.settings_row_text_px)) 10775 .text_color(rgb(APP_UI_THEME.components.app_status_indicator.attention)) 10776 .child(app_shared_text( 10777 AppTextKey::AccountSettingsRemoveRelayAction, 10778 )) 10779 }) 10780 .on_click(|_, _, _| {}) 10781 } 10782 10783 fn account_settings_add_relay_controls( 10784 form: &AccountSettingsFormState, 10785 cx: &mut Context<HomeView>, 10786 ) -> impl IntoElement { 10787 app_stack_v(APP_UI_THEME.foundation.spacing.tight_px) 10788 .w_full() 10789 .child( 10790 div() 10791 .w_full() 10792 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 10793 .font_weight(gpui::FontWeight::SEMIBOLD) 10794 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10795 .child(app_shared_text(AppTextKey::AccountSettingsAddRelayLabel)), 10796 ) 10797 .child( 10798 div() 10799 .w_full() 10800 .flex() 10801 .items_center() 10802 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10803 .child( 10804 div() 10805 .flex_1() 10806 .min_w_0() 10807 .child(account_form_text_input(&form.add_relay_input)), 10808 ) 10809 .child(action_button( 10810 "account-settings-add-relay", 10811 app_shared_text(AppTextKey::AccountSettingsAddRelayAction), 10812 |_, _, _| {}, 10813 cx, 10814 )), 10815 ) 10816 } 10817 10818 fn account_settings_blossom_server_card( 10819 form: &AccountSettingsFormState, 10820 cx: &mut Context<HomeView>, 10821 ) -> impl IntoElement { 10822 account_settings_card( 10823 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 10824 .w_full() 10825 .child(account_farm_profile_title_block( 10826 AppTextKey::AccountSettingsBlossomServerTitle, 10827 AppTextKey::AccountSettingsBlossomServerHelper, 10828 )) 10829 .child(account_profile_labeled_control( 10830 AppTextKey::AccountSettingsBlossomServerUrlLabel, 10831 account_form_text_input(&form.blossom_server_input), 10832 )) 10833 .child(app_checkbox_field( 10834 AppCheckboxFieldSpec::new( 10835 "account-settings-blossom-product-photos", 10836 app_shared_text(AppTextKey::AccountSettingsBlossomProductPhotosLabel), 10837 Option::<SharedString>::None, 10838 ), 10839 true, 10840 cx, 10841 |_, _, _| {}, 10842 )) 10843 .child(app_checkbox_field( 10844 AppCheckboxFieldSpec::new( 10845 "account-settings-blossom-profile-farm-media", 10846 app_shared_text(AppTextKey::AccountSettingsBlossomProfileFarmMediaLabel), 10847 Option::<SharedString>::None, 10848 ), 10849 true, 10850 cx, 10851 |_, _, _| {}, 10852 )) 10853 .child(div().w(px(172.0)).child(action_button_full_width( 10854 "account-settings-reset-blossom", 10855 app_shared_text(AppTextKey::AccountSettingsResetBlossomServerAction), 10856 |_, _, _| {}, 10857 cx, 10858 ))) 10859 .child(account_settings_blossom_health_card(form, cx)), 10860 ) 10861 } 10862 10863 fn account_settings_blossom_health_card( 10864 form: &AccountSettingsFormState, 10865 cx: &mut Context<HomeView>, 10866 ) -> impl IntoElement { 10867 let status = 10868 account_settings_blossom_status(form.blossom_server_input.read(cx).value().as_ref()); 10869 10870 div() 10871 .w_full() 10872 .border_1() 10873 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10874 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 10875 .p(px(APP_UI_THEME.foundation.spacing.large_px)) 10876 .flex() 10877 .items_center() 10878 .justify_between() 10879 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 10880 .child( 10881 div() 10882 .flex() 10883 .items_center() 10884 .gap(px(APP_UI_THEME.foundation.spacing.medium_px)) 10885 .child(status_indicator(status.indicator_color)) 10886 .child( 10887 app_stack_v(APP_UI_THEME.foundation.spacing.micro_px) 10888 .min_w_0() 10889 .child( 10890 div() 10891 .text_size(px(APP_UI_THEME 10892 .foundation 10893 .typography 10894 .settings_row_text_px)) 10895 .font_weight(gpui::FontWeight::MEDIUM) 10896 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 10897 .child(app_shared_text(status.title_key)), 10898 ) 10899 .child( 10900 div() 10901 .text_size(px(APP_UI_THEME 10902 .foundation 10903 .typography 10904 .settings_row_text_px)) 10905 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10906 .child(app_shared_text(status.body_key)), 10907 ), 10908 ), 10909 ) 10910 .child( 10911 Icon::new(status.icon_name) 10912 .with_size(gpui_component::Size::Size(px(18.0))) 10913 .text_color(rgb(status.indicator_color)), 10914 ) 10915 } 10916 10917 #[derive(Clone)] 10918 struct AccountSettingsBlossomStatusPresentation { 10919 indicator_color: u32, 10920 icon_name: IconName, 10921 title_key: AppTextKey, 10922 body_key: AppTextKey, 10923 } 10924 10925 fn account_settings_blossom_status(server_url: &str) -> AccountSettingsBlossomStatusPresentation { 10926 let trimmed = server_url.trim(); 10927 let status = APP_UI_THEME.components.app_status_indicator; 10928 10929 if trimmed.is_empty() || !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) { 10930 return AccountSettingsBlossomStatusPresentation { 10931 indicator_color: status.attention, 10932 icon_name: IconName::CircleX, 10933 title_key: AppTextKey::AccountSettingsBlossomConnectionInvalid, 10934 body_key: AppTextKey::AccountSettingsBlossomUploadsUnavailable, 10935 }; 10936 } 10937 10938 if trimmed.contains("localhost") || trimmed.contains("127.0.0.1") || trimmed.contains("[::1]") { 10939 return AccountSettingsBlossomStatusPresentation { 10940 indicator_color: status.offline, 10941 icon_name: IconName::TriangleAlert, 10942 title_key: AppTextKey::AccountSettingsBlossomConnectionLocal, 10943 body_key: AppTextKey::AccountSettingsBlossomUploadsPending, 10944 }; 10945 } 10946 10947 AccountSettingsBlossomStatusPresentation { 10948 indicator_color: status.online, 10949 icon_name: IconName::CircleCheck, 10950 title_key: AppTextKey::AccountSettingsBlossomConnectionHealthy, 10951 body_key: AppTextKey::AccountSettingsBlossomUploadsAvailable, 10952 } 10953 } 10954 10955 fn account_settings_card(content: impl IntoElement) -> impl IntoElement { 10956 div() 10957 .w_full() 10958 .border_1() 10959 .border_color(rgb(APP_UI_THEME.foundation.surfaces.divider)) 10960 .rounded(px(APP_UI_THEME.foundation.radii.large_px)) 10961 .bg(transparent_black()) 10962 .child( 10963 div() 10964 .w_full() 10965 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 10966 .child(content), 10967 ) 10968 } 10969 10970 fn account_settings_helper_note(text_key: AppTextKey) -> impl IntoElement { 10971 div() 10972 .w_full() 10973 .flex() 10974 .items_center() 10975 .gap(px(APP_UI_THEME.foundation.spacing.small_px)) 10976 .child( 10977 Icon::new(IconName::Info) 10978 .with_size(gpui_component::Size::Size(px(14.0))) 10979 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)), 10980 ) 10981 .child( 10982 div() 10983 .flex_1() 10984 .min_w_0() 10985 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 10986 .line_height(relative(1.25)) 10987 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 10988 .child(app_shared_text(text_key)), 10989 ) 10990 } 10991 10992 fn buyer_listings_feed( 10993 section: PersonalSection, 10994 rows: &[BuyerListingRow], 10995 selected_product_id: Option<ProductId>, 10996 cx: &mut Context<HomeView>, 10997 ) -> impl IntoElement { 10998 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 10999 .w_full() 11000 .children( 11001 rows.iter() 11002 .enumerate() 11003 .map(|(index, row)| { 11004 buyer_listing_card( 11005 index, 11006 section, 11007 row, 11008 selected_product_id == Some(row.product_id), 11009 cx, 11010 ) 11011 }) 11012 .collect::<Vec<_>>(), 11013 ) 11014 } 11015 11016 fn buyer_listing_card( 11017 index: usize, 11018 section: PersonalSection, 11019 row: &BuyerListingRow, 11020 is_selected: bool, 11021 cx: &mut Context<HomeView>, 11022 ) -> AnyElement { 11023 let subtitle = row 11024 .subtitle 11025 .as_deref() 11026 .map(str::trim) 11027 .filter(|subtitle| !subtitle.is_empty()) 11028 .map(str::to_owned); 11029 app_button_card( 11030 ("buyer-listing-open", index), 11031 is_selected, 11032 cx.listener({ 11033 let product_id = row.product_id; 11034 move |this, _, _, cx| this.open_personal_product_detail(section, product_id, cx) 11035 }), 11036 cx, 11037 div() 11038 .w_full() 11039 .min_w_0() 11040 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11041 .flex() 11042 .flex_col() 11043 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11044 .child( 11045 div() 11046 .w_full() 11047 .min_w_0() 11048 .flex() 11049 .items_start() 11050 .justify_between() 11051 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11052 .child( 11053 div() 11054 .flex_1() 11055 .min_w_0() 11056 .flex() 11057 .flex_col() 11058 .gap(px(4.0)) 11059 .child( 11060 div() 11061 .w_full() 11062 .min_w_0() 11063 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 11064 .font_weight(gpui::FontWeight::BOLD) 11065 .line_height(relative(1.2)) 11066 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 11067 .child(product_display_title(row.title.as_str())), 11068 ) 11069 .child( 11070 div() 11071 .w_full() 11072 .min_w_0() 11073 .text_size(px(APP_UI_THEME 11074 .foundation 11075 .typography 11076 .utility_title_text_px)) 11077 .font_weight(gpui::FontWeight::SEMIBOLD) 11078 .text_color(rgb(APP_UI_THEME.foundation.text.accent)) 11079 .child(row.farm_display_name.clone()), 11080 ) 11081 .when_some(subtitle, |this, subtitle| { 11082 this.child( 11083 div() 11084 .w_full() 11085 .min_w_0() 11086 .text_size(px(APP_UI_THEME 11087 .foundation 11088 .typography 11089 .utility_title_text_px)) 11090 .line_height(relative(1.2)) 11091 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 11092 .child(subtitle), 11093 ) 11094 }), 11095 ) 11096 .child( 11097 div() 11098 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 11099 .font_weight(gpui::FontWeight::SEMIBOLD) 11100 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 11101 .child(buyer_listing_price_text(&row.price)), 11102 ), 11103 ) 11104 .child( 11105 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 11106 .w_full() 11107 .child(buyer_listing_chip(buyer_listing_next_window_text(row))) 11108 .child(buyer_listing_chip(buyer_listing_fulfillment_methods_text( 11109 &row.fulfillment_methods, 11110 ))) 11111 .child(buyer_listing_chip( 11112 buyer_listing_stock_or_availability_text(row), 11113 )), 11114 ), 11115 ) 11116 .into_any_element() 11117 } 11118 11119 fn buyer_listing_chip(content: impl Into<SharedString>) -> impl IntoElement { 11120 div() 11121 .flex() 11122 .items_center() 11123 .min_w_0() 11124 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) 11125 .rounded(px(APP_UI_THEME.foundation.radii.small_px)) 11126 .px(px(8.0)) 11127 .py(px(6.0)) 11128 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 11129 .font_weight(gpui::FontWeight::MEDIUM) 11130 .line_height(relative(1.1)) 11131 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 11132 .child(content.into()) 11133 } 11134 11135 fn buyer_listing_next_window_text(row: &BuyerListingRow) -> String { 11136 row.next_fulfillment_window_label 11137 .clone() 11138 .unwrap_or_else(|| row.availability.label.clone()) 11139 } 11140 11141 fn buyer_listing_fulfillment_methods_text(methods: &BTreeSet<FarmOrderMethod>) -> String { 11142 if methods.is_empty() { 11143 return app_shared_text(AppTextKey::ValueNone).to_string(); 11144 } 11145 11146 methods 11147 .iter() 11148 .map(|method| app_shared_text(home_farm_order_method_label_key(*method)).to_string()) 11149 .collect::<Vec<_>>() 11150 .join(", ") 11151 } 11152 11153 fn buyer_listing_stock_or_availability_text(row: &BuyerListingRow) -> String { 11154 match row.stock.quantity { 11155 Some(quantity) => match row.stock.unit_label.as_deref() { 11156 Some(unit_label) if !unit_label.trim().is_empty() => format!("{quantity} {unit_label}"), 11157 Some(_) | None => quantity.to_string(), 11158 }, 11159 None => row.availability.label.clone(), 11160 } 11161 } 11162 11163 fn buyer_listing_price_text(price: &ProductPricePresentation) -> String { 11164 let dollars = price.amount_minor_units / 100; 11165 let cents = price.amount_minor_units % 100; 11166 11167 format!("${dollars}.{cents:02} / {}", price.unit_label) 11168 } 11169 11170 fn buyer_product_detail_card( 11171 detail: &BuyerProductDetailProjection, 11172 replace_confirmation: Option<&BuyerCartReplaceConfirmationProjection>, 11173 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11174 on_decrease_quantity: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11175 on_increase_quantity: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11176 on_add_to_cart: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11177 on_confirm_replace: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11178 on_keep_current_cart: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11179 cx: &App, 11180 ) -> impl IntoElement { 11181 app_focused_detail_view( 11182 product_display_title(detail.listing.title.as_str()), 11183 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 11184 .w_full() 11185 .child(settings_badge_text( 11186 detail.listing.farm_display_name.clone(), 11187 )) 11188 .when_some( 11189 detail 11190 .detail_text 11191 .as_deref() 11192 .map(str::trim) 11193 .filter(|value| !value.is_empty()) 11194 .map(str::to_owned), 11195 |this, detail_text| this.child(home_body_text(detail_text)), 11196 ) 11197 .child( 11198 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 11199 .w_full() 11200 .child(buyer_listing_chip(buyer_listing_price_text( 11201 &detail.listing.price, 11202 ))) 11203 .child(buyer_listing_chip(buyer_listing_next_window_text( 11204 &detail.listing, 11205 ))) 11206 .child(buyer_listing_chip(buyer_listing_fulfillment_methods_text( 11207 &detail.listing.fulfillment_methods, 11208 ))) 11209 .child(buyer_listing_chip( 11210 buyer_listing_stock_or_availability_text(&detail.listing), 11211 )), 11212 ) 11213 .child( 11214 div() 11215 .w_full() 11216 .flex() 11217 .items_center() 11218 .justify_between() 11219 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11220 .child(app_text_label(app_shared_text( 11221 AppTextKey::PersonalDetailQuantityLabel, 11222 ))) 11223 .child( 11224 app_stack_h(APP_UI_THEME.foundation.spacing.small_px) 11225 .child(action_button_compact( 11226 "buyer-detail-quantity-decrease", 11227 SharedString::from("-"), 11228 on_decrease_quantity, 11229 cx, 11230 )) 11231 .child( 11232 div() 11233 .min_w(px(36.0)) 11234 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 11235 .font_weight(gpui::FontWeight::SEMIBOLD) 11236 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 11237 .child(detail.selected_quantity.to_string()), 11238 ) 11239 .child(action_button_compact( 11240 "buyer-detail-quantity-increase", 11241 SharedString::from("+"), 11242 on_increase_quantity, 11243 cx, 11244 )), 11245 ), 11246 ) 11247 .when_some(replace_confirmation, |this, replace_confirmation| { 11248 this.child(app_surface_panel( 11249 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11250 .w_full() 11251 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11252 .child(app_text_label(app_shared_text( 11253 AppTextKey::PersonalDetailReplaceCartTitle, 11254 ))) 11255 .child(home_body_text(format!( 11256 "{} {} {}.", 11257 replace_confirmation.current_farm_display_name, 11258 app_shared_text(AppTextKey::PersonalDetailReplaceCartBody), 11259 replace_confirmation.incoming_farm_display_name, 11260 ))) 11261 .child( 11262 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 11263 .w_full() 11264 .child(action_button_primary( 11265 "buyer-detail-confirm-replace", 11266 app_shared_text(AppTextKey::PersonalDetailReplaceCartAction), 11267 on_confirm_replace, 11268 cx, 11269 )) 11270 .child(action_button_compact( 11271 "buyer-detail-keep-current", 11272 app_shared_text( 11273 AppTextKey::PersonalDetailKeepCurrentCartAction, 11274 ), 11275 on_keep_current_cart, 11276 cx, 11277 )), 11278 ), 11279 )) 11280 }) 11281 .child(action_button_primary( 11282 "buyer-detail-add-to-cart", 11283 app_shared_text(AppTextKey::PersonalDetailAddToCartAction), 11284 on_add_to_cart, 11285 cx, 11286 )), 11287 text_button( 11288 "buyer-detail-back", 11289 app_shared_text(AppTextKey::PersonalDetailBackAction), 11290 on_close, 11291 cx, 11292 ), 11293 ) 11294 } 11295 11296 fn buyer_cart_card( 11297 cart: &BuyerCartProjection, 11298 summary: &BuyerOrderReviewSummaryProjection, 11299 order_review_open: bool, 11300 cx: &mut Context<HomeView>, 11301 ) -> impl IntoElement { 11302 app_surface_card( 11303 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 11304 .w_full() 11305 .children( 11306 cart.lines 11307 .iter() 11308 .enumerate() 11309 .map(|(index, line)| buyer_cart_line_card(index, line, cx).into_any_element()) 11310 .collect::<Vec<_>>(), 11311 ) 11312 .child(app_surface_panel( 11313 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11314 .w_full() 11315 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11316 .child(app_text_label(app_shared_text( 11317 AppTextKey::PersonalOrderSummaryTitle, 11318 ))) 11319 .child(label_value_list(buyer_order_summary_rows(summary))), 11320 )) 11321 .when(!order_review_open, |this| { 11322 this.child(action_button_primary( 11323 "buyer-cart-open-order-review", 11324 app_shared_text(AppTextKey::PersonalCartReviewOrderAction), 11325 cx.listener(|this, _, window, cx| this.open_personal_order_review(window, cx)), 11326 cx, 11327 )) 11328 }), 11329 ) 11330 } 11331 11332 fn buyer_cart_line_card( 11333 index: usize, 11334 line: &radroots_app_view::BuyerCartLineProjection, 11335 cx: &mut Context<HomeView>, 11336 ) -> impl IntoElement { 11337 app_surface_panel( 11338 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11339 .w_full() 11340 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11341 .child( 11342 div() 11343 .w_full() 11344 .flex() 11345 .items_start() 11346 .justify_between() 11347 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11348 .child( 11349 app_stack_v(4.0) 11350 .flex_1() 11351 .min_w_0() 11352 .child(app_text_label(product_display_title(line.title.as_str()))) 11353 .child(settings_badge_text(line.farm_display_name.clone())), 11354 ) 11355 .child(action_button_compact( 11356 ("buyer-cart-remove-line", index), 11357 app_shared_text(AppTextKey::PersonalCartRemoveLineAction), 11358 cx.listener({ 11359 let product_id = line.product_id; 11360 move |this, _, _, cx| this.remove_personal_cart_line(product_id, cx) 11361 }), 11362 cx, 11363 )), 11364 ) 11365 .child(label_value_list(vec![ 11366 LabelValueRow::new( 11367 app_shared_text(AppTextKey::PersonalCartLineQuantityLabel), 11368 line.quantity.to_string(), 11369 ), 11370 LabelValueRow::new( 11371 app_shared_text(AppTextKey::PersonalCartLineUnitPriceLabel), 11372 buyer_listing_price_text(&line.unit_price), 11373 ), 11374 LabelValueRow::new( 11375 app_shared_text(AppTextKey::PersonalCartLineTotalLabel), 11376 buyer_money_text( 11377 line.line_total_minor_units, 11378 line.unit_price.currency_code.as_str(), 11379 ), 11380 ), 11381 ])) 11382 .child(buyer_listing_chip(line.fulfillment_summary.clone())), 11383 ) 11384 } 11385 11386 fn buyer_order_review_card( 11387 form: &BuyerOrderReviewFormState, 11388 order_review: &radroots_app_view::BuyerOrderReviewProjection, 11389 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11390 on_place_order: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11391 cx: &App, 11392 ) -> impl IntoElement { 11393 app_surface_card( 11394 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 11395 .w_full() 11396 .child( 11397 div() 11398 .w_full() 11399 .flex() 11400 .items_start() 11401 .justify_between() 11402 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11403 .child(app_text_value(app_shared_text( 11404 AppTextKey::PersonalOrderReviewTitle, 11405 ))) 11406 .child(text_button( 11407 "buyer-order-review-back", 11408 app_shared_text(AppTextKey::PersonalOrderReviewBackAction), 11409 on_close, 11410 cx, 11411 )), 11412 ) 11413 .child(home_body_text(app_shared_text( 11414 AppTextKey::PersonalOrderReviewLocalOnlyBody, 11415 ))) 11416 .child(app_surface_panel( 11417 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11418 .w_full() 11419 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11420 .child(app_text_label(app_shared_text( 11421 AppTextKey::PersonalOrderSummaryTitle, 11422 ))) 11423 .child(label_value_list(buyer_order_summary_rows( 11424 &order_review.summary, 11425 ))), 11426 )) 11427 .child(app_surface_panel( 11428 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11429 .w_full() 11430 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11431 .child(app_text_label(app_shared_text( 11432 AppTextKey::PersonalFulfillmentTitle, 11433 ))) 11434 .child(home_body_text( 11435 order_review 11436 .summary 11437 .fulfillment_summary 11438 .clone() 11439 .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()), 11440 )), 11441 )) 11442 .child(app_form_section( 11443 app_shared_text(AppTextKey::PersonalOrderReviewContactTitle), 11444 div() 11445 .w_full() 11446 .flex() 11447 .flex_col() 11448 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11449 .child(app_form_input_text( 11450 AppFormFieldSpec::new( 11451 app_shared_text(AppTextKey::PersonalOrderReviewFieldName), 11452 Option::<SharedString>::None, 11453 ), 11454 &form.name_input, 11455 false, 11456 )) 11457 .child(app_form_input_text( 11458 AppFormFieldSpec::new( 11459 app_shared_text(AppTextKey::PersonalOrderReviewFieldEmail), 11460 Option::<SharedString>::None, 11461 ), 11462 &form.email_input, 11463 false, 11464 )) 11465 .child(app_form_input_text( 11466 AppFormFieldSpec::new( 11467 app_shared_text(AppTextKey::PersonalOrderReviewFieldPhone), 11468 Option::<SharedString>::None, 11469 ), 11470 &form.phone_input, 11471 false, 11472 )) 11473 .child(app_form_input_text( 11474 AppFormFieldSpec::new( 11475 app_shared_text(AppTextKey::PersonalOrderReviewFieldOrderNote), 11476 Option::<SharedString>::None, 11477 ), 11478 &form.order_note_input, 11479 false, 11480 )), 11481 )) 11482 .child(if order_review.can_place_order { 11483 action_button_primary( 11484 "buyer-order-review-place-order", 11485 app_shared_text(AppTextKey::PersonalOrderReviewPlaceOrderAction), 11486 on_place_order, 11487 cx, 11488 ) 11489 .into_any_element() 11490 } else { 11491 action_button_primary_disabled( 11492 "buyer-order-review-place-order", 11493 app_shared_text(AppTextKey::PersonalOrderReviewPlaceOrderAction), 11494 cx, 11495 ) 11496 .into_any_element() 11497 }), 11498 ) 11499 } 11500 11501 fn buyer_order_summary_rows(summary: &BuyerOrderReviewSummaryProjection) -> Vec<LabelValueRow> { 11502 vec![ 11503 LabelValueRow::new( 11504 app_shared_text(AppTextKey::PersonalSummaryFarmLabel), 11505 summary 11506 .farm_display_name 11507 .clone() 11508 .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()), 11509 ), 11510 LabelValueRow::new( 11511 app_shared_text(AppTextKey::PersonalSummaryItemsLabel), 11512 summary.line_count.to_string(), 11513 ), 11514 LabelValueRow::new( 11515 app_shared_text(AppTextKey::PersonalSummarySubtotalLabel), 11516 summary 11517 .subtotal_minor_units 11518 .zip(summary.currency_code.as_deref()) 11519 .map(|(amount, currency_code)| buyer_money_text(amount, currency_code)) 11520 .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()), 11521 ), 11522 ] 11523 } 11524 11525 fn buyer_money_text(amount_minor_units: u32, currency_code: &str) -> String { 11526 let dollars = amount_minor_units / 100; 11527 let cents = amount_minor_units % 100; 11528 11529 if currency_code == "USD" { 11530 format!("${dollars}.{cents:02}") 11531 } else { 11532 format!("{currency_code} {dollars}.{cents:02}") 11533 } 11534 } 11535 11536 fn trade_economics_total_text(economics: &TradeEconomicsProjection) -> String { 11537 economics 11538 .total_minor_units 11539 .zip(economics.currency_code.as_deref()) 11540 .map(|(amount, currency_code)| buyer_money_text(amount, currency_code)) 11541 .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone).to_string()) 11542 } 11543 11544 fn trade_workflow_detail_badge_strip(workflow: &TradeWorkflowProjection) -> AnyElement { 11545 let mut badges = vec![ 11546 trade_workflow_labeled_key_badge( 11547 AppTextKey::TradeWorkflowAxisAgreement, 11548 trade_agreement_status_key(workflow.agreement), 11549 ), 11550 trade_workflow_labeled_key_badge( 11551 AppTextKey::TradeWorkflowAxisRevision, 11552 trade_revision_status_key(workflow.revision), 11553 ), 11554 ]; 11555 11556 badges.push(trade_workflow_labeled_key_badge( 11557 AppTextKey::TradeWorkflowAxisInventory, 11558 trade_inventory_status_key(workflow.inventory), 11559 )); 11560 if workflow.provenance.primary_source != TradeWorkflowSource::Unknown { 11561 badges.push(trade_workflow_labeled_key_badge( 11562 AppTextKey::TradeWorkflowAxisSource, 11563 trade_workflow_source_key(workflow.provenance.primary_source), 11564 )); 11565 } 11566 11567 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 11568 .w_full() 11569 .children(badges) 11570 .into_any_element() 11571 } 11572 11573 fn trade_workflow_list_badge_strip(workflow: &TradeWorkflowProjection) -> AnyElement { 11574 let mut badges = vec![trade_workflow_value_badge(trade_agreement_status_key( 11575 workflow.agreement, 11576 ))]; 11577 11578 if workflow.revision != TradeRevisionStatus::None { 11579 badges.push(trade_workflow_value_badge(trade_revision_status_key( 11580 workflow.revision, 11581 ))); 11582 } 11583 11584 badges.push(trade_workflow_value_badge(trade_inventory_status_key( 11585 workflow.inventory, 11586 ))); 11587 11588 app_cluster(APP_UI_THEME.foundation.spacing.tight_px) 11589 .w_full() 11590 .children(badges) 11591 .into_any_element() 11592 } 11593 11594 fn trade_workflow_status_stack(workflow: &TradeWorkflowProjection) -> AnyElement { 11595 app_stack_v(2.0) 11596 .min_w_0() 11597 .child(trade_workflow_value_badge(trade_agreement_status_key( 11598 workflow.agreement, 11599 ))) 11600 .child(trade_workflow_value_badge(trade_inventory_status_key( 11601 workflow.inventory, 11602 ))) 11603 .into_any_element() 11604 } 11605 11606 fn trade_workflow_labeled_key_badge(label_key: AppTextKey, value_key: AppTextKey) -> AnyElement { 11607 settings_badge_text(format!("{}: {}", app_text(label_key), app_text(value_key))) 11608 .into_any_element() 11609 } 11610 11611 fn trade_workflow_value_badge(value_key: AppTextKey) -> AnyElement { 11612 settings_badge_text(app_shared_text(value_key)).into_any_element() 11613 } 11614 11615 fn trade_agreement_status_key(status: TradeAgreementStatus) -> AppTextKey { 11616 match status { 11617 TradeAgreementStatus::Ordered => AppTextKey::TradeWorkflowAgreementOrdered, 11618 TradeAgreementStatus::Confirmed => AppTextKey::TradeWorkflowAgreementConfirmed, 11619 TradeAgreementStatus::Declined => AppTextKey::TradeWorkflowAgreementDeclined, 11620 TradeAgreementStatus::Cancelled => AppTextKey::TradeWorkflowAgreementCancelled, 11621 TradeAgreementStatus::NeedsReview => AppTextKey::TradeWorkflowAgreementNeedsReview, 11622 } 11623 } 11624 11625 fn trade_revision_status_key(status: TradeRevisionStatus) -> AppTextKey { 11626 match status { 11627 TradeRevisionStatus::None => AppTextKey::TradeWorkflowRevisionNone, 11628 TradeRevisionStatus::ChangeProposed => AppTextKey::TradeWorkflowRevisionChangeProposed, 11629 TradeRevisionStatus::Updated => AppTextKey::TradeWorkflowRevisionUpdated, 11630 TradeRevisionStatus::KeptAsPlaced => AppTextKey::TradeWorkflowRevisionKeptAsPlaced, 11631 } 11632 } 11633 11634 fn trade_inventory_status_key(status: TradeInventoryStatus) -> AppTextKey { 11635 match status { 11636 TradeInventoryStatus::Available => AppTextKey::TradeWorkflowInventoryAvailable, 11637 TradeInventoryStatus::Reserved => AppTextKey::TradeWorkflowInventoryReserved, 11638 TradeInventoryStatus::SoldOut => AppTextKey::TradeWorkflowInventorySoldOut, 11639 TradeInventoryStatus::NeedsReview => AppTextKey::TradeWorkflowInventoryNeedsReview, 11640 } 11641 } 11642 11643 fn trade_workflow_source_key(source: TradeWorkflowSource) -> AppTextKey { 11644 match source { 11645 TradeWorkflowSource::App => AppTextKey::TradeWorkflowProvenanceApp, 11646 TradeWorkflowSource::Cli => AppTextKey::TradeWorkflowProvenanceCli, 11647 TradeWorkflowSource::Relay => AppTextKey::TradeWorkflowProvenanceRelay, 11648 TradeWorkflowSource::LocalEvents => AppTextKey::TradeWorkflowProvenanceLocalEvents, 11649 TradeWorkflowSource::Unknown => AppTextKey::TradeWorkflowProvenanceUnknown, 11650 } 11651 } 11652 11653 fn buyer_orders_list_card( 11654 rows: &[BuyerOrdersListRow], 11655 selected_order_id: Option<OrderId>, 11656 cx: &mut Context<HomeView>, 11657 ) -> AnyElement { 11658 home_card( 11659 app_shared_text(AppTextKey::PersonalOrdersListTitle), 11660 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 11661 .w_full() 11662 .children( 11663 rows.iter() 11664 .enumerate() 11665 .map(|(index, row)| { 11666 buyer_orders_list_entry( 11667 index, 11668 row, 11669 selected_order_id == Some(row.order_id), 11670 cx, 11671 ) 11672 }) 11673 .collect::<Vec<_>>(), 11674 ), 11675 ) 11676 .into_any_element() 11677 } 11678 11679 fn buyer_orders_retry_action_visible(orders: &BuyerOrdersScreenProjection) -> bool { 11680 orders.has_recoverable_coordination 11681 } 11682 11683 fn buyer_orders_retry_card(cx: &mut Context<HomeView>) -> AnyElement { 11684 home_card( 11685 app_shared_text(AppTextKey::PersonalOrdersCoordinationRetryTitle), 11686 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11687 .w_full() 11688 .child(home_body_text(app_shared_text( 11689 AppTextKey::PersonalOrdersCoordinationRetryBody, 11690 ))) 11691 .child(action_button_primary( 11692 "buyer-orders-retry-coordination", 11693 app_shared_text(AppTextKey::PersonalOrdersCoordinationRetryAction), 11694 cx.listener(|this, _, _, cx| this.retry_pending_personal_order_coordination(cx)), 11695 cx, 11696 )), 11697 ) 11698 .into_any_element() 11699 } 11700 11701 fn buyer_orders_list_entry( 11702 index: usize, 11703 row: &BuyerOrdersListRow, 11704 is_selected: bool, 11705 cx: &mut Context<HomeView>, 11706 ) -> AnyElement { 11707 app_button_card( 11708 ("buyer-order-open", index), 11709 is_selected, 11710 cx.listener({ 11711 let order_id = row.order_id; 11712 move |this, _, _, cx| this.open_personal_order_detail(order_id, cx) 11713 }), 11714 cx, 11715 div() 11716 .w_full() 11717 .min_w_0() 11718 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11719 .flex() 11720 .flex_col() 11721 .gap(px(APP_UI_THEME.foundation.spacing.small_px)) 11722 .child( 11723 div() 11724 .w_full() 11725 .min_w_0() 11726 .flex() 11727 .items_start() 11728 .justify_between() 11729 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 11730 .child( 11731 app_stack_v(4.0) 11732 .flex_1() 11733 .min_w_0() 11734 .child(app_text_label(row.order_number.clone())) 11735 .child(settings_badge_text(row.farm_display_name.clone())) 11736 .child(settings_badge_text(trade_economics_total_text( 11737 &row.workflow.economics, 11738 ))), 11739 ) 11740 .child( 11741 div() 11742 .flex() 11743 .items_center() 11744 .gap(px(6.0)) 11745 .child(status_indicator(buyer_orders_status_color(row.status))) 11746 .child( 11747 div() 11748 .text_size(px(APP_UI_THEME 11749 .foundation 11750 .typography 11751 .utility_title_text_px)) 11752 .font_weight(gpui::FontWeight::MEDIUM) 11753 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 11754 .child(app_shared_text(trade_agreement_status_key( 11755 row.workflow.agreement, 11756 ))), 11757 ), 11758 ), 11759 ) 11760 .child(trade_workflow_list_badge_strip(&row.workflow)) 11761 .child(buyer_listing_chip(row.fulfillment_summary.clone())), 11762 ) 11763 .into_any_element() 11764 } 11765 11766 fn buyer_order_detail_card( 11767 detail: &BuyerOrderDetailProjection, 11768 replace_confirmation: Option<&BuyerCartReplaceConfirmationProjection>, 11769 on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 11770 cx: &mut Context<HomeView>, 11771 ) -> AnyElement { 11772 let repeat_confirmation = replace_confirmation 11773 .filter(|confirmation| confirmation.incoming_farm_display_name == detail.farm_display_name); 11774 11775 app_focused_detail_view( 11776 app_shared_text(AppTextKey::PersonalOrdersDetailTitle), 11777 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 11778 .w_full() 11779 .child(app_heading_section(detail.order_number.clone())) 11780 .child(settings_badge_text(detail.farm_display_name.clone())) 11781 .child(trade_workflow_detail_badge_strip(&detail.workflow)) 11782 .child(label_value_list([ 11783 LabelValueRow::new( 11784 app_shared_text(AppTextKey::PersonalOrdersDetailFarmLabel), 11785 detail.farm_display_name.clone(), 11786 ), 11787 LabelValueRow::new( 11788 app_shared_text(AppTextKey::PersonalOrdersDetailFulfillmentLabel), 11789 detail.fulfillment_summary.clone(), 11790 ), 11791 LabelValueRow::new( 11792 app_shared_text(AppTextKey::PersonalOrdersDetailTotalLabel), 11793 trade_economics_total_text(&detail.workflow.economics), 11794 ), 11795 LabelValueRow::new( 11796 app_shared_text(AppTextKey::PersonalOrdersDetailNoteLabel), 11797 order_optional_text(detail.order_note.as_deref()), 11798 ), 11799 ])) 11800 .when(!detail.validation_receipts.is_empty(), |this| { 11801 this.child(validation_receipts_summary_section( 11802 &detail.validation_receipts, 11803 )) 11804 }) 11805 .child(app_form_section( 11806 app_shared_text(AppTextKey::PersonalOrdersDetailItemsTitle), 11807 div() 11808 .w_full() 11809 .flex() 11810 .flex_col() 11811 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 11812 .children( 11813 detail 11814 .items 11815 .iter() 11816 .map(order_detail_item_row) 11817 .collect::<Vec<_>>(), 11818 ) 11819 .when(detail.items.is_empty(), |this| { 11820 this.child(home_body_text(app_shared_text(AppTextKey::ValueNone))) 11821 }), 11822 )) 11823 .when( 11824 detail.status == BuyerOrderStatus::Scheduled 11825 && detail.workflow.revision == TradeRevisionStatus::ChangeProposed, 11826 |this| { 11827 this.child( 11828 app_stack_h(APP_UI_THEME.foundation.spacing.small_px) 11829 .w_full() 11830 .child(action_button_primary( 11831 "buyer-order-accept-change", 11832 app_shared_text(AppTextKey::PersonalOrdersActionAcceptChange), 11833 cx.listener({ 11834 let order_id = detail.order_id; 11835 move |this, _, _, cx| { 11836 this.accept_buyer_order_revision(order_id, cx) 11837 } 11838 }), 11839 cx, 11840 )) 11841 .child(action_button_compact( 11842 "buyer-order-keep-order", 11843 app_shared_text(AppTextKey::PersonalOrdersActionKeepOrder), 11844 cx.listener({ 11845 let order_id = detail.order_id; 11846 move |this, _, _, cx| { 11847 this.decline_buyer_order_revision(order_id, cx) 11848 } 11849 }), 11850 cx, 11851 )), 11852 ) 11853 }, 11854 ) 11855 .when(detail.status == BuyerOrderStatus::Placed, |this| { 11856 this.child(action_button_compact( 11857 "buyer-order-cancel", 11858 app_shared_text(AppTextKey::PersonalOrdersActionCancel), 11859 cx.listener({ 11860 let order_id = detail.order_id; 11861 move |this, _, _, cx| this.cancel_buyer_order(order_id, cx) 11862 }), 11863 cx, 11864 )) 11865 }) 11866 .when_some(detail.repeat_demand.as_ref(), |this, repeat_demand| { 11867 this.child(app_form_section( 11868 app_shared_text(AppTextKey::PersonalOrdersRepeatDemandTitle), 11869 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11870 .w_full() 11871 .when_some(buyer_repeat_demand_note(repeat_demand), |this, note| { 11872 this.child(home_body_text(note)) 11873 }) 11874 .when_some(repeat_confirmation, |this, replace_confirmation| { 11875 this.child(app_surface_panel( 11876 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11877 .w_full() 11878 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11879 .child(app_text_label(app_shared_text( 11880 AppTextKey::PersonalDetailReplaceCartTitle, 11881 ))) 11882 .child(home_body_text(format!( 11883 "{} {} {}.", 11884 replace_confirmation.current_farm_display_name, 11885 app_shared_text(AppTextKey::PersonalDetailReplaceCartBody,), 11886 replace_confirmation.incoming_farm_display_name, 11887 ))) 11888 .child( 11889 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 11890 .w_full() 11891 .child(action_button_primary( 11892 "buyer-order-confirm-replace", 11893 app_shared_text( 11894 AppTextKey::PersonalDetailReplaceCartAction, 11895 ), 11896 cx.listener({ 11897 let order_id = detail.order_id; 11898 move |this, _, _, cx| { 11899 this.repeat_personal_order( 11900 order_id, true, cx, 11901 ) 11902 } 11903 }), 11904 cx, 11905 )) 11906 .child(action_button_compact( 11907 "buyer-order-keep-current", 11908 app_shared_text( 11909 AppTextKey::PersonalDetailKeepCurrentCartAction, 11910 ), 11911 cx.listener(|this, _, _, cx| { 11912 this.clear_personal_cart_replace_confirmation( 11913 cx, 11914 ) 11915 }), 11916 cx, 11917 )), 11918 ), 11919 )) 11920 }) 11921 .when( 11922 repeat_confirmation.is_none() 11923 && repeat_demand.eligibility 11924 != RepeatDemandEligibility::Unavailable, 11925 |this| { 11926 this.child(action_button_primary( 11927 "buyer-order-repeat-demand", 11928 buyer_repeat_demand_action_label(repeat_demand), 11929 cx.listener({ 11930 let order_id = detail.order_id; 11931 move |this, _, _, cx| { 11932 this.repeat_personal_order(order_id, false, cx) 11933 } 11934 }), 11935 cx, 11936 )) 11937 }, 11938 ), 11939 )) 11940 }), 11941 text_button( 11942 "buyer-order-detail-back", 11943 app_shared_text(AppTextKey::PersonalDetailBackAction), 11944 on_close, 11945 cx, 11946 ), 11947 ) 11948 } 11949 11950 fn validation_receipts_summary_section( 11951 receipts: &[TradeValidationReceiptProjection], 11952 ) -> AnyElement { 11953 app_form_section( 11954 app_shared_text(AppTextKey::TradeValidationReceiptSectionLabel), 11955 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11956 .w_full() 11957 .children( 11958 receipts 11959 .iter() 11960 .map(validation_receipt_summary_panel) 11961 .collect::<Vec<_>>(), 11962 ), 11963 ) 11964 .into_any_element() 11965 } 11966 11967 fn validation_receipt_summary_panel(receipt: &TradeValidationReceiptProjection) -> AnyElement { 11968 app_surface_panel( 11969 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 11970 .w_full() 11971 .p(px(APP_UI_THEME.shells.home_card_padding_px)) 11972 .child( 11973 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 11974 .w_full() 11975 .child(trade_workflow_value_badge(validation_receipt_result_key( 11976 receipt.result, 11977 ))) 11978 .child(trade_workflow_value_badge(validation_receipt_type_key( 11979 receipt.receipt_type, 11980 ))), 11981 ) 11982 .child(home_body_text(format!( 11983 "{} {}", 11984 app_shared_text(AppTextKey::TradeValidationReceiptRecordedAtLabel), 11985 receipt.recorded_at 11986 ))), 11987 ) 11988 .into_any_element() 11989 } 11990 11991 fn validation_receipt_result_key(result: TradeValidationReceiptResult) -> AppTextKey { 11992 match result { 11993 TradeValidationReceiptResult::Valid => AppTextKey::TradeValidationReceiptResultValid, 11994 TradeValidationReceiptResult::NeedsReview => { 11995 AppTextKey::TradeValidationReceiptResultNeedsReview 11996 } 11997 } 11998 } 11999 12000 fn validation_receipt_type_key(receipt_type: TradeValidationReceiptType) -> AppTextKey { 12001 match receipt_type { 12002 TradeValidationReceiptType::ListingValidation => { 12003 AppTextKey::TradeValidationReceiptTypeListingValidation 12004 } 12005 TradeValidationReceiptType::TradeTransition => { 12006 AppTextKey::TradeValidationReceiptTypeTradeTransition 12007 } 12008 TradeValidationReceiptType::InventoryState => { 12009 AppTextKey::TradeValidationReceiptTypeInventoryState 12010 } 12011 TradeValidationReceiptType::StateCheckpoint => { 12012 AppTextKey::TradeValidationReceiptTypeStateCheckpoint 12013 } 12014 } 12015 } 12016 12017 fn buyer_repeat_demand_action_label(repeat_demand: &RepeatDemandHandoffProjection) -> SharedString { 12018 match repeat_demand.eligibility { 12019 RepeatDemandEligibility::Eligible => { 12020 app_shared_text(AppTextKey::PersonalOrdersRepeatDemandActionEligible) 12021 } 12022 RepeatDemandEligibility::Partial => { 12023 app_shared_text(AppTextKey::PersonalOrdersRepeatDemandActionPartial) 12024 } 12025 RepeatDemandEligibility::Unavailable => { 12026 app_shared_text(AppTextKey::PersonalOrdersRepeatDemandActionEligible) 12027 } 12028 } 12029 } 12030 12031 fn buyer_repeat_demand_note(repeat_demand: &RepeatDemandHandoffProjection) -> Option<SharedString> { 12032 match repeat_demand.eligibility { 12033 RepeatDemandEligibility::Eligible => None, 12034 RepeatDemandEligibility::Partial if repeat_demand.unavailable_item_count == 1 => Some( 12035 app_shared_text(AppTextKey::PersonalOrdersRepeatDemandNotePartialSingle), 12036 ), 12037 RepeatDemandEligibility::Partial => Some(app_shared_text( 12038 AppTextKey::PersonalOrdersRepeatDemandNotePartialMultiple, 12039 )), 12040 RepeatDemandEligibility::Unavailable => Some(app_shared_text( 12041 AppTextKey::PersonalOrdersRepeatDemandNoteUnavailable, 12042 )), 12043 } 12044 } 12045 12046 fn buyer_orders_status_color(status: BuyerOrderStatus) -> u32 { 12047 match status { 12048 BuyerOrderStatus::Placed => APP_UI_THEME.components.app_status_indicator.attention, 12049 BuyerOrderStatus::Scheduled | BuyerOrderStatus::Ready => { 12050 APP_UI_THEME.components.app_status_indicator.online 12051 } 12052 BuyerOrderStatus::Completed 12053 | BuyerOrderStatus::Declined 12054 | BuyerOrderStatus::NeedsReview => APP_UI_THEME.components.app_status_indicator.offline, 12055 } 12056 } 12057 12058 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 12059 enum StartupHomeSurface { 12060 IssueCard, 12061 ContinuePrompt, 12062 IdentityChoice, 12063 GenerateKeyStarting, 12064 SignerEntry, 12065 } 12066 12067 fn startup_home_surface(runtime: &DesktopAppRuntimeSummary) -> StartupHomeSurface { 12068 if runtime.startup_issue.is_some() || runtime.startup_gate != AppStartupGate::SetupRequired { 12069 return StartupHomeSurface::IssueCard; 12070 } 12071 12072 match runtime.logged_out_startup.phase { 12073 LoggedOutStartupPhase::ContinuePrompt => StartupHomeSurface::ContinuePrompt, 12074 LoggedOutStartupPhase::IdentityChoice => StartupHomeSurface::IdentityChoice, 12075 LoggedOutStartupPhase::GenerateKeyStarting => StartupHomeSurface::GenerateKeyStarting, 12076 LoggedOutStartupPhase::SignerEntry => StartupHomeSurface::SignerEntry, 12077 } 12078 } 12079 12080 fn startup_home_shell( 12081 runtime: &DesktopAppRuntimeSummary, 12082 startup_notice: Option<&str>, 12083 signer_entry: Option<&StartupSignerEntryState>, 12084 connect_state: &StartupSignerConnectState, 12085 on_continue: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12086 on_browse_marketplace: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12087 on_generate_key: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12088 on_connect_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12089 on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12090 on_back: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12091 cx: &App, 12092 ) -> impl IntoElement { 12093 let surface = startup_home_surface(runtime); 12094 let startup_notice = startup_notice.map(startup_notice_text); 12095 12096 app_window_shell( 12097 APP_UI_THEME.foundation.surfaces.window_background, 12098 div() 12099 .size_full() 12100 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) 12101 .child( 12102 div() 12103 .size_full() 12104 .p(px(APP_UI_THEME.shells.home_window_padding_px)) 12105 .child( 12106 div() 12107 .size_full() 12108 .flex() 12109 .items_center() 12110 .justify_center() 12111 .child( 12112 app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) 12113 .w_full() 12114 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 12115 .mx_auto() 12116 .items_center() 12117 .child(startup_home_title(surface)) 12118 .child(startup_home_tagline()) 12119 .child(match surface { 12120 StartupHomeSurface::ContinuePrompt => app_stack_v( 12121 APP_UI_THEME.shells.startup_stack_gap_px, 12122 ) 12123 .items_center() 12124 .child(action_button_primary( 12125 "home-continue", 12126 app_shared_text(AppTextKey::HomeSetupContinueAction), 12127 on_continue, 12128 cx, 12129 )) 12130 .child(action_button( 12131 "home-browse-marketplace", 12132 app_shared_text( 12133 AppTextKey::HomeSetupBrowseMarketplaceAction, 12134 ), 12135 on_browse_marketplace, 12136 cx, 12137 )) 12138 .when_some(startup_notice, |this, error: String| { 12139 this.child( 12140 div() 12141 .w_full() 12142 .text_center() 12143 .child(home_body_text(error.to_owned())), 12144 ) 12145 }) 12146 .into_any_element(), 12147 StartupHomeSurface::IdentityChoice => { 12148 app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) 12149 .items_center() 12150 .child(action_button_primary( 12151 "home-generate-key", 12152 app_shared_text( 12153 AppTextKey::HomeSetupGenerateKeyAction, 12154 ), 12155 on_generate_key, 12156 cx, 12157 )) 12158 .child(action_button( 12159 "home-connect-signer", 12160 app_shared_text( 12161 AppTextKey::HomeSetupConnectSignerAction, 12162 ), 12163 on_connect_signer, 12164 cx, 12165 )) 12166 .when_some(startup_notice, |this, error: String| { 12167 this.child( 12168 div().w_full().text_center().child( 12169 home_body_text(error.to_owned()), 12170 ), 12171 ) 12172 }) 12173 .into_any_element() 12174 } 12175 StartupHomeSurface::GenerateKeyStarting => { 12176 app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) 12177 .items_center() 12178 .child(action_button_primary_disabled( 12179 "home-generate-key", 12180 app_shared_text( 12181 AppTextKey::HomeSetupGenerateKeyAction, 12182 ), 12183 cx, 12184 )) 12185 .into_any_element() 12186 } 12187 StartupHomeSurface::SignerEntry => { 12188 startup_signer_entry_surface( 12189 signer_entry, 12190 connect_state, 12191 startup_notice, 12192 on_submit_signer, 12193 on_back, 12194 cx, 12195 ) 12196 .into_any_element() 12197 } 12198 StartupHomeSurface::IssueCard => app_surface_card( 12199 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 12200 .w_full() 12201 .items_center() 12202 .child(app_heading_section(app_shared_text( 12203 AppTextKey::MetadataStartupIssue, 12204 ))) 12205 .child(startup_home_body(runtime)), 12206 ) 12207 .into_any_element(), 12208 }), 12209 ), 12210 ), 12211 ), 12212 ) 12213 } 12214 12215 fn startup_home_title(surface: StartupHomeSurface) -> impl IntoElement { 12216 let (animation_id, title_key) = if surface == StartupHomeSurface::GenerateKeyStarting { 12217 ("startup-title-starting", AppTextKey::HomeSetupStarting) 12218 } else { 12219 ("startup-title-radroots", AppTextKey::HomeSetupTitle) 12220 }; 12221 12222 div() 12223 .text_center() 12224 .child(app_heading_view(app_shared_text(title_key))) 12225 .with_animation( 12226 animation_id, 12227 Animation::new(Duration::from_millis(180)), 12228 |this, delta| this.opacity(delta), 12229 ) 12230 } 12231 12232 fn startup_home_tagline() -> impl IntoElement { 12233 div() 12234 .text_size(px(APP_UI_THEME 12235 .foundation 12236 .typography 12237 .startup_tagline_text_px)) 12238 .font_weight(gpui::FontWeight::SEMIBOLD) 12239 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 12240 .text_center() 12241 .child(app_shared_text(AppTextKey::HomeSetupTagline)) 12242 } 12243 12244 fn startup_signer_entry_surface( 12245 signer_entry: Option<&StartupSignerEntryState>, 12246 connect_state: &StartupSignerConnectState, 12247 startup_notice: Option<String>, 12248 on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12249 on_back: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12250 cx: &App, 12251 ) -> impl IntoElement { 12252 let source_input = signer_entry 12253 .map(|signer_entry| signer_entry.input.read(cx).value().to_string()) 12254 .unwrap_or_default(); 12255 let preview = 12256 startup_signer_preview_summary_for_connect_state(source_input.as_str(), connect_state); 12257 let parse_error = if source_input.trim().is_empty() 12258 || !matches!(connect_state, StartupSignerConnectState::Idle) 12259 { 12260 None 12261 } else { 12262 preview 12263 .as_ref() 12264 .err() 12265 .map(|error| startup_notice_text(error)) 12266 }; 12267 let submit_enabled = 12268 preview.is_ok() && matches!(connect_state, StartupSignerConnectState::Idle); 12269 let source_input_is_editable = startup_signer_source_input_is_editable(connect_state); 12270 12271 app_stack_v(APP_UI_THEME.shells.startup_stack_gap_px) 12272 .w_full() 12273 .items_center() 12274 .when_some(signer_entry, |this, signer_entry| { 12275 this.child( 12276 div() 12277 .w_full() 12278 .max_w(px(APP_UI_THEME.shells.home_card_max_width_px)) 12279 .id("home-signer-source-input") 12280 .child( 12281 app_text_input(&signer_entry.input, !source_input_is_editable) 12282 .disabled(!source_input_is_editable) 12283 .w_full(), 12284 ), 12285 ) 12286 }) 12287 .when_some(preview.as_ref().ok(), |this, preview| { 12288 this.child(app_surface_card( 12289 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 12290 .w_full() 12291 .items_center() 12292 .child(app_heading_section(app_shared_text( 12293 AppTextKey::HomeSetupSignerReviewTitle, 12294 ))) 12295 .child(label_value_list([ 12296 LabelValueRow::new( 12297 app_shared_text(AppTextKey::HomeSetupSignerSourceLabel), 12298 preview.source_label.clone(), 12299 ), 12300 LabelValueRow::new( 12301 app_shared_text(AppTextKey::HomeSetupSignerSignerLabel), 12302 preview.signer_npub.clone(), 12303 ), 12304 LabelValueRow::new( 12305 app_shared_text(AppTextKey::HomeSetupSignerRelaysLabel), 12306 preview.relays_label.clone(), 12307 ), 12308 LabelValueRow::new( 12309 app_shared_text(AppTextKey::HomeSetupSignerPermissionsLabel), 12310 preview.permissions_label.clone(), 12311 ), 12312 ])), 12313 )) 12314 }) 12315 .when_some(startup_signer_status_spec(connect_state), |this, status| { 12316 this.child(app_surface_card( 12317 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 12318 .w_full() 12319 .items_center() 12320 .child(app_heading_section(app_shared_text(status.0))) 12321 .child( 12322 status 12323 .1 12324 .map(|body| { 12325 div() 12326 .w_full() 12327 .text_center() 12328 .child(home_body_text(body)) 12329 .into_any_element() 12330 }) 12331 .unwrap_or_else(|| div().into_any_element()), 12332 ), 12333 )) 12334 }) 12335 .when_some(parse_error, |this, error| { 12336 this.child(div().w_full().text_center().child(home_body_text(error))) 12337 }) 12338 .child(if submit_enabled { 12339 action_button_primary( 12340 "home-connect-signer-submit", 12341 app_shared_text(AppTextKey::HomeSetupSignerConnectAction), 12342 on_submit_signer, 12343 cx, 12344 ) 12345 .into_any_element() 12346 } else { 12347 action_button_primary_disabled( 12348 "home-connect-signer-submit", 12349 app_shared_text(AppTextKey::HomeSetupSignerConnectAction), 12350 cx, 12351 ) 12352 .into_any_element() 12353 }) 12354 .child(text_button( 12355 "home-signer-back", 12356 app_shared_text(AppTextKey::HomeSetupBackAction), 12357 on_back, 12358 cx, 12359 )) 12360 .when_some(startup_notice, |this, notice: String| { 12361 this.child(div().w_full().text_center().child(home_body_text(notice))) 12362 }) 12363 } 12364 12365 fn startup_signer_preview_summary(input: &str) -> Result<StartupSignerPreviewSummary, String> { 12366 let target = radroots_app_remote_signer_preview(input).map_err(|error| error.to_string())?; 12367 12368 Ok(StartupSignerPreviewSummary { 12369 source_label: startup_signer_source_text(target.source), 12370 signer_npub: target.signer_identity.public_key_npub.clone(), 12371 relays_label: startup_signer_csv_or_none(target.relays.as_slice()), 12372 permissions_label: startup_signer_permissions_label(target.requested_permission_labels()), 12373 }) 12374 } 12375 12376 fn startup_signer_preview_summary_for_connect_state( 12377 input: &str, 12378 connect_state: &StartupSignerConnectState, 12379 ) -> Result<StartupSignerPreviewSummary, String> { 12380 let mut preview = startup_signer_preview_summary(input)?; 12381 12382 match connect_state { 12383 StartupSignerConnectState::Idle | StartupSignerConnectState::Connecting => {} 12384 StartupSignerConnectState::PendingApproval { 12385 pending_session, .. 12386 } => { 12387 preview.signer_npub = pending_session 12388 .record 12389 .signer_identity 12390 .public_key_npub 12391 .clone(); 12392 preview.relays_label = 12393 startup_signer_csv_or_none(pending_session.record.relays.as_slice()); 12394 preview.permissions_label = startup_signer_requested_permissions_label(); 12395 } 12396 StartupSignerConnectState::Approved { 12397 pending_session, 12398 approved_session, 12399 .. 12400 } => { 12401 preview.signer_npub = pending_session 12402 .record 12403 .signer_identity 12404 .public_key_npub 12405 .clone(); 12406 preview.relays_label = startup_signer_csv_or_none(approved_session.relays.as_slice()); 12407 preview.permissions_label = startup_signer_permissions_label( 12408 approved_session 12409 .approved_permissions 12410 .as_slice() 12411 .iter() 12412 .map(ToString::to_string) 12413 .collect(), 12414 ); 12415 } 12416 } 12417 12418 Ok(preview) 12419 } 12420 12421 fn startup_signer_source_input_is_editable(connect_state: &StartupSignerConnectState) -> bool { 12422 matches!(connect_state, StartupSignerConnectState::Idle) 12423 } 12424 12425 fn startup_signer_csv_or_none(values: &[String]) -> String { 12426 if values.is_empty() { 12427 return app_text(AppTextKey::ValueNone); 12428 } 12429 12430 values.join(", ") 12431 } 12432 12433 fn startup_signer_requested_permissions_label() -> String { 12434 startup_signer_permissions_label( 12435 radroots_app_remote_signer_requested_permissions() 12436 .as_slice() 12437 .iter() 12438 .map(ToString::to_string) 12439 .collect(), 12440 ) 12441 } 12442 12443 fn startup_signer_permissions_label(permissions: Vec<String>) -> String { 12444 if permissions.is_empty() { 12445 return app_text(AppTextKey::ValueNone); 12446 } 12447 12448 permissions 12449 .into_iter() 12450 .map(|permission| startup_signer_permission_text(permission.as_str())) 12451 .collect::<Vec<_>>() 12452 .join(", ") 12453 } 12454 12455 fn startup_signer_status_spec( 12456 connect_state: &StartupSignerConnectState, 12457 ) -> Option<(AppTextKey, Option<String>)> { 12458 match connect_state { 12459 StartupSignerConnectState::Idle => None, 12460 StartupSignerConnectState::Connecting => { 12461 Some((AppTextKey::HomeSetupSignerConnectingTitle, None)) 12462 } 12463 StartupSignerConnectState::PendingApproval { 12464 auth_challenge_url, .. 12465 } => Some(match auth_challenge_url { 12466 Some(url) => ( 12467 AppTextKey::HomeSetupSignerAuthChallengeTitle, 12468 Some(url.clone()), 12469 ), 12470 None => (AppTextKey::HomeSetupSignerPendingTitle, None), 12471 }), 12472 StartupSignerConnectState::Approved { 12473 auth_challenge_url, .. 12474 } => Some(( 12475 AppTextKey::HomeSetupSignerApprovedTitle, 12476 auth_challenge_url.clone(), 12477 )), 12478 } 12479 } 12480 12481 fn startup_signer_transport_failure_requires_notice(message: &str) -> bool { 12482 message != "remote signer did not respond yet" 12483 } 12484 12485 fn startup_issue_summary_text(_startup_issue: &str) -> String { 12486 app_text(AppTextKey::HomeSetupIssueUnavailableBody) 12487 } 12488 12489 fn startup_signer_source_text(source: RadrootsAppRemoteSignerSource) -> String { 12490 app_text(match source { 12491 RadrootsAppRemoteSignerSource::BunkerUri => AppTextKey::HomeSetupSignerSourceValueBunkerUri, 12492 RadrootsAppRemoteSignerSource::DiscoveryUrl => { 12493 AppTextKey::HomeSetupSignerSourceValueDiscoveryUrl 12494 } 12495 }) 12496 } 12497 12498 fn startup_signer_permission_text(permission: &str) -> String { 12499 app_text(match permission { 12500 "sign_event:kind:1" => AppTextKey::HomeSetupSignerPermissionSignEventKind1, 12501 "switch_relays" => AppTextKey::HomeSetupSignerPermissionSwitchRelays, 12502 _ => AppTextKey::HomeSetupSignerPermissionAdditional, 12503 }) 12504 } 12505 12506 fn startup_notice_text(message: &str) -> String { 12507 app_text(match message { 12508 "enter a bunker or discovery url to continue" => { 12509 AppTextKey::HomeSetupSignerErrorEnterSource 12510 } 12511 "discovery url does not contain a remote signer uri" => { 12512 AppTextKey::HomeSetupSignerErrorMissingDiscoveryUri 12513 } 12514 "a remote signer connection is already pending approval" => { 12515 AppTextKey::HomeSetupSignerErrorPendingApprovalExists 12516 } 12517 _ if message.contains("raw nostrconnect client uris are signer-side only") => { 12518 AppTextKey::HomeSetupSignerErrorUseSignerUri 12519 } 12520 _ if message.starts_with("invalid discovery url:") => { 12521 AppTextKey::HomeSetupSignerErrorInvalidDiscoveryUrl 12522 } 12523 _ if message.starts_with("invalid remote signer uri:") => { 12524 AppTextKey::HomeSetupSignerErrorInvalidRemoteSignerUri 12525 } 12526 _ if message.contains("remote signer") => AppTextKey::HomeSetupSignerErrorConnectionFailed, 12527 _ => AppTextKey::HomeSetupErrorStartupFailed, 12528 }) 12529 } 12530 12531 fn startup_home_body(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { 12532 let body = runtime.startup_issue.as_deref().map_or_else( 12533 || app_shared_text(AppTextKey::HomeTodayEmptySetupBody).to_string(), 12534 startup_issue_summary_text, 12535 ); 12536 12537 div().w_full().text_center().child(home_body_text(body)) 12538 } 12539 12540 async fn connect_configured_relays(relay_urls: Vec<String>) -> Result<RadrootsNostrClient, String> { 12541 let client = RadrootsNostrClient::new_signerless(); 12542 for relay_url in relay_urls { 12543 client 12544 .add_relay(relay_url.as_str()) 12545 .await 12546 .map_err(|error| format!("failed to add relay `{relay_url}`: {error}"))?; 12547 } 12548 client.connect().await; 12549 Ok(client) 12550 } 12551 12552 struct StartupAppInitResult { 12553 relay_client: RadrootsNostrClient, 12554 } 12555 12556 async fn run_startup_app_init(relay_urls: Vec<String>) -> Result<StartupAppInitResult, String> { 12557 let relay_client = connect_configured_relays(relay_urls).await?; 12558 Ok(StartupAppInitResult { relay_client }) 12559 } 12560 12561 async fn run_startup_signer_connect( 12562 source_input: String, 12563 ) -> Result<RadrootsAppRemoteSignerPendingSession, String> { 12564 radroots_app_remote_signer_connect_pending(source_input.as_str()) 12565 .await 12566 .map_err(|error| error.to_string()) 12567 } 12568 12569 async fn run_pack_day_host_handoff( 12570 plan: PackDayHostHandoffCommandPlan, 12571 ) -> Result<(), PackDayHostHandoffError> { 12572 execute_pack_day_host_handoff_plan(&plan) 12573 } 12574 12575 async fn run_pack_day_print(plan: PackDayPrintCommandPlan) -> Result<(), PackDayPrintError> { 12576 execute_pack_day_print_plan(&plan) 12577 } 12578 12579 async fn run_pack_day_batch_print( 12580 plan: PackDayBatchPrintCommandPlan, 12581 ) -> Result<(), PackDayBatchPrintError> { 12582 execute_pack_day_batch_print_plan(&plan) 12583 } 12584 12585 async fn run_startup_signer_pending_poll( 12586 record: radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord, 12587 client_secret_key_hex: String, 12588 ) -> StartupSignerPollCycleResult { 12589 let mut auth_challenge_url = None; 12590 let outcome = radroots_app_remote_signer_poll_pending_session_with_progress( 12591 &record, 12592 client_secret_key_hex.as_str(), 12593 |progress| match progress { 12594 radroots_app_remote_signer::RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { 12595 url, 12596 } => auth_challenge_url = Some(url), 12597 }, 12598 ) 12599 .await 12600 .map_err(|error| error.to_string()); 12601 12602 StartupSignerPollCycleResult { 12603 auth_challenge_url, 12604 outcome, 12605 } 12606 } 12607 12608 fn home_sidebar( 12609 runtime: &DesktopAppRuntimeSummary, 12610 on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12611 on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12612 on_select_orders: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12613 on_select_pack_day: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12614 on_select_account_profile: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12615 on_select_account_farm_details: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12616 on_select_account_preferences: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12617 on_select_account_settings: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12618 cx: &App, 12619 ) -> impl IntoElement { 12620 let selected_section = selected_farmer_section(runtime); 12621 let workspace_available = farmer_products_available(runtime); 12622 let pack_day_available = farmer_pack_day_available(runtime); 12623 let navigation_sections = 12624 home_sidebar_navigation_sections(selected_section, workspace_available, pack_day_available); 12625 let on_select_today = Arc::new(on_select_today); 12626 let on_select_products = Arc::new(on_select_products); 12627 let on_select_orders = Arc::new(on_select_orders); 12628 let on_select_pack_day = Arc::new(on_select_pack_day); 12629 let on_select_account_profile = Arc::new(on_select_account_profile); 12630 let on_select_account_farm_details = Arc::new(on_select_account_farm_details); 12631 let on_select_account_preferences = Arc::new(on_select_account_preferences); 12632 let on_select_account_settings = Arc::new(on_select_account_settings); 12633 let mut navigation_elements = Vec::with_capacity(navigation_sections.len()); 12634 for section in navigation_sections { 12635 let element = match section { 12636 FarmerSection::Today => { 12637 let on_click = Arc::clone(&on_select_today); 12638 home_sidebar_nav_button( 12639 "home-nav-today", 12640 AppTextKey::HomeNavToday, 12641 true, 12642 selected_section == FarmerSection::Today, 12643 move |event, window, app| on_click(event, window, app), 12644 cx, 12645 ) 12646 .into_any_element() 12647 } 12648 FarmerSection::Products => { 12649 let on_click = Arc::clone(&on_select_products); 12650 home_sidebar_nav_button( 12651 "home-nav-products", 12652 AppTextKey::HomeNavProducts, 12653 true, 12654 selected_section == FarmerSection::Products, 12655 move |event, window, app| on_click(event, window, app), 12656 cx, 12657 ) 12658 .into_any_element() 12659 } 12660 FarmerSection::Orders => { 12661 let on_click = Arc::clone(&on_select_orders); 12662 home_sidebar_nav_button( 12663 "home-nav-orders", 12664 AppTextKey::HomeNavOrders, 12665 true, 12666 selected_section == FarmerSection::Orders, 12667 move |event, window, app| on_click(event, window, app), 12668 cx, 12669 ) 12670 .into_any_element() 12671 } 12672 FarmerSection::PackDay => { 12673 let on_click = Arc::clone(&on_select_pack_day); 12674 home_sidebar_nav_button( 12675 "home-nav-pack-day", 12676 AppTextKey::PackDayTitle, 12677 true, 12678 selected_section == FarmerSection::PackDay, 12679 move |event, window, app| on_click(event, window, app), 12680 cx, 12681 ) 12682 .into_any_element() 12683 } 12684 FarmerSection::Farm => unreachable!(), 12685 }; 12686 navigation_elements.push(element); 12687 } 12688 12689 app_surface_sidebar( 12690 div() 12691 .h_full() 12692 .w(px(APP_UI_THEME.shells.home_sidebar_width_px)) 12693 .p(px(APP_UI_THEME.shells.home_window_padding_px)) 12694 .flex() 12695 .flex_col() 12696 .justify_between() 12697 .child( 12698 div() 12699 .flex_1() 12700 .flex() 12701 .flex_col() 12702 .justify_start() 12703 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 12704 .children(navigation_elements), 12705 ) 12706 .when_some(home_saved_farm(runtime), |this, farm| { 12707 this.child(home_sidebar_account_menu( 12708 farm.display_name.clone(), 12709 Arc::clone(&on_select_account_profile), 12710 Arc::clone(&on_select_account_farm_details), 12711 Arc::clone(&on_select_account_preferences), 12712 Arc::clone(&on_select_account_settings), 12713 cx, 12714 )) 12715 }), 12716 ) 12717 } 12718 12719 fn home_sidebar_account_menu<ProfileAction, FarmDetailsAction, PreferencesAction, SettingsAction>( 12720 label: impl Into<SharedString>, 12721 on_select_profile: Arc<ProfileAction>, 12722 on_select_farm_details: Arc<FarmDetailsAction>, 12723 on_select_preferences: Arc<PreferencesAction>, 12724 on_select_settings: Arc<SettingsAction>, 12725 cx: &App, 12726 ) -> impl IntoElement 12727 where 12728 ProfileAction: Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12729 FarmDetailsAction: Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12730 PreferencesAction: Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12731 SettingsAction: Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12732 { 12733 app_button_sidebar_account_menu( 12734 "home-sidebar-account-menu", 12735 label, 12736 move |menu, _, _| { 12737 let on_select_profile = Arc::clone(&on_select_profile); 12738 let on_select_farm_details = Arc::clone(&on_select_farm_details); 12739 let on_select_preferences = Arc::clone(&on_select_preferences); 12740 let on_select_settings = Arc::clone(&on_select_settings); 12741 12742 menu.item( 12743 PopupMenuItem::new(app_text(AccountTab::Profile.text_key())) 12744 .on_click(move |event, window, app| on_select_profile(event, window, app)), 12745 ) 12746 .item( 12747 PopupMenuItem::new(app_text(AccountTab::FarmDetails.text_key())) 12748 .on_click(move |event, window, app| on_select_farm_details(event, window, app)), 12749 ) 12750 .item( 12751 PopupMenuItem::new(app_text(AccountTab::Preferences.text_key())) 12752 .on_click(move |event, window, app| on_select_preferences(event, window, app)), 12753 ) 12754 .item( 12755 PopupMenuItem::new(app_text(AccountTab::Settings.text_key())) 12756 .on_click(move |event, window, app| on_select_settings(event, window, app)), 12757 ) 12758 }, 12759 cx, 12760 ) 12761 } 12762 12763 fn buyer_sidebar( 12764 runtime: &DesktopAppRuntimeSummary, 12765 on_select_browse: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12766 on_select_search: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12767 on_select_cart: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12768 on_select_orders: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12769 cx: &App, 12770 ) -> impl IntoElement { 12771 let selected_section = selected_personal_section(runtime); 12772 12773 app_surface_sidebar( 12774 div() 12775 .h_full() 12776 .w(px(APP_UI_THEME.shells.home_sidebar_width_px)) 12777 .p(px(APP_UI_THEME.shells.home_window_padding_px)) 12778 .flex() 12779 .flex_col() 12780 .justify_between() 12781 .child( 12782 div() 12783 .flex() 12784 .flex_col() 12785 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 12786 .child( 12787 buyer_sidebar_nav_button( 12788 "buyer-nav-browse", 12789 AppTextKey::HomeNavBrowse, 12790 selected_section == PersonalSection::Browse, 12791 on_select_browse, 12792 cx, 12793 ) 12794 .into_any_element(), 12795 ) 12796 .child( 12797 buyer_sidebar_nav_button( 12798 "buyer-nav-search", 12799 AppTextKey::HomeNavSearch, 12800 selected_section == PersonalSection::Search, 12801 on_select_search, 12802 cx, 12803 ) 12804 .into_any_element(), 12805 ) 12806 .child( 12807 buyer_sidebar_nav_button( 12808 "buyer-nav-cart", 12809 AppTextKey::HomeNavCart, 12810 selected_section == PersonalSection::Cart, 12811 on_select_cart, 12812 cx, 12813 ) 12814 .into_any_element(), 12815 ) 12816 .child( 12817 buyer_sidebar_nav_button( 12818 "buyer-nav-orders", 12819 AppTextKey::HomeNavOrders, 12820 selected_section == PersonalSection::Orders, 12821 on_select_orders, 12822 cx, 12823 ) 12824 .into_any_element(), 12825 ), 12826 ) 12827 .child( 12828 div().child(div().when_some(home_saved_farm(runtime), |this, farm| { 12829 this.child(home_body_text(farm.display_name.clone())) 12830 })), 12831 ), 12832 ) 12833 } 12834 12835 fn buyer_sidebar_nav_button( 12836 id: &'static str, 12837 key: AppTextKey, 12838 is_active: bool, 12839 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12840 cx: &App, 12841 ) -> AnyElement { 12842 choice_button(id, app_shared_text(key), is_active, on_click, cx).into_any_element() 12843 } 12844 12845 fn home_sidebar_navigation_sections( 12846 _selected_section: FarmerSection, 12847 workspace_available: bool, 12848 pack_day_available: bool, 12849 ) -> Vec<FarmerSection> { 12850 let mut sections = vec![FarmerSection::Today]; 12851 if workspace_available { 12852 sections.push(FarmerSection::Products); 12853 sections.push(FarmerSection::Orders); 12854 } 12855 if pack_day_available { 12856 sections.push(FarmerSection::PackDay); 12857 } 12858 12859 sections 12860 } 12861 12862 fn selected_farmer_section(runtime: &DesktopAppRuntimeSummary) -> FarmerSection { 12863 match runtime.shell_projection.selected_section { 12864 ShellSection::Farmer(section) => section, 12865 ShellSection::Home 12866 | ShellSection::Account 12867 | ShellSection::Personal(_) 12868 | ShellSection::Settings(_) => FarmerSection::Today, 12869 } 12870 } 12871 12872 fn selected_personal_section(runtime: &DesktopAppRuntimeSummary) -> PersonalSection { 12873 match runtime.shell_projection.selected_section { 12874 ShellSection::Personal(section) => section, 12875 ShellSection::Home 12876 | ShellSection::Account 12877 | ShellSection::Farmer(_) 12878 | ShellSection::Settings(_) => PersonalSection::Browse, 12879 } 12880 } 12881 12882 fn personal_workspace_id(runtime: &DesktopAppRuntimeSummary) -> String { 12883 runtime 12884 .settings_account_projection 12885 .selected_account 12886 .as_ref() 12887 .map(|account| account.account.account_id.clone()) 12888 .unwrap_or_else(|| "guest".to_owned()) 12889 } 12890 12891 fn farmer_products_available(runtime: &DesktopAppRuntimeSummary) -> bool { 12892 runtime.farm_setup_projection.has_saved_farm() 12893 } 12894 12895 fn farmer_pack_day_available(runtime: &DesktopAppRuntimeSummary) -> bool { 12896 runtime 12897 .pack_day_projection 12898 .projection 12899 .fulfillment_window 12900 .is_some() 12901 } 12902 12903 fn home_content_scroll_id(section: FarmerSection) -> &'static str { 12904 match section { 12905 FarmerSection::Products => "home-products-scroll", 12906 FarmerSection::Orders => "home-orders-scroll", 12907 FarmerSection::PackDay => "home-pack-day-scroll", 12908 FarmerSection::Today | FarmerSection::Farm => "home-today-scroll", 12909 } 12910 } 12911 12912 fn buyer_content_scroll_id(section: PersonalSection) -> &'static str { 12913 match section { 12914 PersonalSection::Browse => "buyer-browse-scroll", 12915 PersonalSection::Search => "buyer-search-scroll", 12916 PersonalSection::Cart => "buyer-cart-scroll", 12917 PersonalSection::Orders => "buyer-orders-scroll", 12918 } 12919 } 12920 12921 fn home_sidebar_nav_button( 12922 id: &'static str, 12923 key: AppTextKey, 12924 is_available: bool, 12925 is_active: bool, 12926 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12927 cx: &App, 12928 ) -> impl IntoElement { 12929 if !is_available { 12930 return div().id(id).into_any_element(); 12931 } 12932 12933 choice_button(id, app_shared_text(key), is_active, on_click, cx).into_any_element() 12934 } 12935 12936 fn products_title_row( 12937 runtime: &DesktopAppRuntimeSummary, 12938 add_product_action: AnyElement, 12939 ) -> impl IntoElement { 12940 app_stack_h(APP_UI_THEME.shells.home_stack_gap_px) 12941 .w_full() 12942 .items_end() 12943 .justify_between() 12944 .child( 12945 app_stack_v(4.0) 12946 .child( 12947 div() 12948 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) 12949 .font_weight(gpui::FontWeight::BOLD) 12950 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 12951 .child(app_shared_text(AppTextKey::ProductsTitle)), 12952 ) 12953 .child( 12954 div() 12955 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 12956 .font_weight(gpui::FontWeight::MEDIUM) 12957 .line_height(relative(1.2)) 12958 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 12959 .when_some(home_saved_farm(runtime), |this, farm| { 12960 this.child(farm.display_name.clone()) 12961 }), 12962 ), 12963 ) 12964 .child(add_product_action) 12965 } 12966 12967 fn products_controls_card( 12968 runtime: &DesktopAppRuntimeSummary, 12969 products_search: Option<&ProductsSearchState>, 12970 on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12971 on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12972 on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12973 on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12974 on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12975 on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12976 on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12977 on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12978 on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12979 on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12980 on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 12981 cx: &App, 12982 ) -> impl IntoElement { 12983 let selected_filter = runtime.products_projection.query.filter; 12984 let selected_sort = runtime.products_projection.query.sort; 12985 12986 home_card( 12987 app_shared_text(AppTextKey::ProductsFiltersTitle), 12988 div() 12989 .w_full() 12990 .flex() 12991 .flex_col() 12992 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 12993 .when_some(products_search, |this, products_search| { 12994 this.child( 12995 app_text_input(&products_search.input, false) 12996 .cleanable(true) 12997 .w_full(), 12998 ) 12999 }) 13000 .child( 13001 div() 13002 .w_full() 13003 .flex() 13004 .items_center() 13005 .gap(px(8.0)) 13006 .child(choice_button( 13007 "products-filter-all", 13008 app_shared_text(AppTextKey::ProductsFilterAll), 13009 selected_filter == ProductsFilter::All, 13010 on_select_all_products, 13011 cx, 13012 )) 13013 .child(choice_button( 13014 "products-filter-live", 13015 app_shared_text(AppTextKey::ProductsFilterLive), 13016 selected_filter == ProductsFilter::Live, 13017 on_select_live_products, 13018 cx, 13019 )) 13020 .child(choice_button( 13021 "products-filter-drafts", 13022 app_shared_text(AppTextKey::ProductsFilterDrafts), 13023 selected_filter == ProductsFilter::Drafts, 13024 on_select_draft_products, 13025 cx, 13026 )) 13027 .child(choice_button( 13028 "products-filter-need-attention", 13029 app_shared_text(AppTextKey::ProductsFilterNeedAttention), 13030 selected_filter == ProductsFilter::NeedAttention, 13031 on_select_products_needing_attention, 13032 cx, 13033 )) 13034 .child(choice_button( 13035 "products-filter-paused", 13036 app_shared_text(AppTextKey::ProductsFilterPaused), 13037 selected_filter == ProductsFilter::Paused, 13038 on_select_paused_products, 13039 cx, 13040 )) 13041 .child(choice_button( 13042 "products-filter-archived", 13043 app_shared_text(AppTextKey::ProductsFilterArchived), 13044 selected_filter == ProductsFilter::Archived, 13045 on_select_archived_products, 13046 cx, 13047 )), 13048 ) 13049 .child( 13050 div() 13051 .w_full() 13052 .flex() 13053 .items_center() 13054 .justify_between() 13055 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 13056 .child( 13057 div() 13058 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13059 .font_weight(gpui::FontWeight::SEMIBOLD) 13060 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 13061 .child(app_shared_text(AppTextKey::ProductsSortTitle)), 13062 ) 13063 .child( 13064 div() 13065 .flex() 13066 .items_center() 13067 .gap(px(8.0)) 13068 .child(choice_button( 13069 "products-sort-updated", 13070 app_shared_text(AppTextKey::ProductsSortUpdated), 13071 selected_sort == ProductsSort::Updated, 13072 on_sort_products_by_updated, 13073 cx, 13074 )) 13075 .child(choice_button( 13076 "products-sort-name", 13077 app_shared_text(AppTextKey::ProductsSortName), 13078 selected_sort == ProductsSort::Name, 13079 on_sort_products_by_name, 13080 cx, 13081 )) 13082 .child(choice_button( 13083 "products-sort-availability", 13084 app_shared_text(AppTextKey::ProductsSortAvailability), 13085 selected_sort == ProductsSort::Availability, 13086 on_sort_products_by_availability, 13087 cx, 13088 )) 13089 .child(choice_button( 13090 "products-sort-stock", 13091 app_shared_text(AppTextKey::ProductsSortStock), 13092 selected_sort == ProductsSort::Stock, 13093 on_sort_products_by_stock, 13094 cx, 13095 )) 13096 .child(choice_button( 13097 "products-sort-price", 13098 app_shared_text(AppTextKey::ProductsSortPrice), 13099 selected_sort == ProductsSort::Price, 13100 on_sort_products_by_price, 13101 cx, 13102 )), 13103 ), 13104 ), 13105 ) 13106 } 13107 13108 fn products_table_header() -> impl IntoElement { 13109 div() 13110 .w_full() 13111 .flex() 13112 .items_center() 13113 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 13114 .child(products_table_header_column( 13115 AppTextKey::ProductsColumnProduct, 13116 None, 13117 true, 13118 )) 13119 .child(products_table_header_column( 13120 AppTextKey::ProductsColumnStatus, 13121 Some(112.0), 13122 false, 13123 )) 13124 .child(products_table_header_column( 13125 AppTextKey::ProductsColumnAvailability, 13126 Some(192.0), 13127 false, 13128 )) 13129 .child(products_table_header_column( 13130 AppTextKey::ProductsColumnStock, 13131 Some(128.0), 13132 false, 13133 )) 13134 .child(products_table_header_column( 13135 AppTextKey::ProductsColumnPrice, 13136 Some(128.0), 13137 false, 13138 )) 13139 .child(products_table_header_column( 13140 AppTextKey::ProductsColumnUpdated, 13141 Some(164.0), 13142 false, 13143 )) 13144 .child(products_table_header_column( 13145 AppTextKey::ProductsColumnAction, 13146 Some(120.0), 13147 false, 13148 )) 13149 } 13150 13151 fn products_table_header_column( 13152 key: AppTextKey, 13153 width_px: Option<f32>, 13154 grows: bool, 13155 ) -> impl IntoElement { 13156 div() 13157 .when_some(width_px, |this, width_px| this.w(px(width_px))) 13158 .when(grows, |this| this.flex_1().min_w_0()) 13159 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13160 .font_weight(gpui::FontWeight::SEMIBOLD) 13161 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 13162 .child(app_shared_text(key)) 13163 } 13164 13165 fn products_table_row( 13166 product: AnyElement, 13167 row: &ProductsListRow, 13168 action: AnyElement, 13169 ) -> impl IntoElement { 13170 div() 13171 .w_full() 13172 .flex() 13173 .items_center() 13174 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 13175 .child(product) 13176 .child( 13177 div() 13178 .w(px(112.0)) 13179 .flex() 13180 .items_center() 13181 .gap(px(6.0)) 13182 .child(status_indicator(products_row_status_color(row))) 13183 .child( 13184 div() 13185 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13186 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13187 .child(app_shared_text(products_status_key(row.status))), 13188 ), 13189 ) 13190 .child( 13191 div() 13192 .w(px(192.0)) 13193 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13194 .line_height(relative(1.2)) 13195 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13196 .child(row.availability.label.clone()), 13197 ) 13198 .child( 13199 div() 13200 .w(px(128.0)) 13201 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13202 .line_height(relative(1.2)) 13203 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13204 .child(products_stock_text(row)), 13205 ) 13206 .child( 13207 div() 13208 .w(px(128.0)) 13209 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13210 .line_height(relative(1.2)) 13211 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13212 .child(products_price_text(row)), 13213 ) 13214 .child( 13215 div() 13216 .w(px(164.0)) 13217 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13218 .line_height(relative(1.2)) 13219 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 13220 .child(row.updated_at.clone()), 13221 ) 13222 .child(div().w(px(120.0)).flex().justify_end().child(action)) 13223 } 13224 13225 fn orders_table_header() -> impl IntoElement { 13226 div() 13227 .w_full() 13228 .flex() 13229 .items_center() 13230 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 13231 .child(products_table_header_column( 13232 AppTextKey::OrdersColumnOrder, 13233 None, 13234 true, 13235 )) 13236 .child(products_table_header_column( 13237 AppTextKey::OrdersColumnStatus, 13238 Some(144.0), 13239 false, 13240 )) 13241 .child(products_table_header_column( 13242 AppTextKey::OrdersDetailTotalLabel, 13243 Some(112.0), 13244 false, 13245 )) 13246 .child(products_table_header_column( 13247 AppTextKey::OrdersColumnWindow, 13248 Some(160.0), 13249 false, 13250 )) 13251 .child(products_table_header_column( 13252 AppTextKey::OrdersColumnPickup, 13253 Some(160.0), 13254 false, 13255 )) 13256 .child(products_table_header_column( 13257 AppTextKey::OrdersColumnAction, 13258 Some(120.0), 13259 false, 13260 )) 13261 } 13262 13263 fn orders_table_row( 13264 order: AnyElement, 13265 row: &OrdersListRow, 13266 action: AnyElement, 13267 ) -> impl IntoElement { 13268 div() 13269 .w_full() 13270 .flex() 13271 .items_center() 13272 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 13273 .child(order) 13274 .child( 13275 div() 13276 .w(px(144.0)) 13277 .flex() 13278 .items_start() 13279 .gap(px(6.0)) 13280 .child(status_indicator(orders_status_color(row.status))) 13281 .child(trade_workflow_status_stack(&row.workflow)), 13282 ) 13283 .child( 13284 div() 13285 .w(px(112.0)) 13286 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13287 .line_height(relative(1.2)) 13288 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13289 .child(trade_economics_total_text(&row.workflow.economics)), 13290 ) 13291 .child( 13292 div() 13293 .w(px(160.0)) 13294 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13295 .line_height(relative(1.2)) 13296 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13297 .child(order_optional_text(row.fulfillment_window_label.as_deref())), 13298 ) 13299 .child( 13300 div() 13301 .w(px(160.0)) 13302 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13303 .line_height(relative(1.2)) 13304 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13305 .child(order_optional_text(row.pickup_location_label.as_deref())), 13306 ) 13307 .child(div().w(px(120.0)).flex().justify_end().child(action)) 13308 } 13309 13310 fn orders_table_action( 13311 index: usize, 13312 row: &OrdersListRow, 13313 on_review: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13314 cx: &App, 13315 ) -> AnyElement { 13316 match row.primary_action { 13317 Some(OrderPrimaryAction::Review) => action_button_compact( 13318 ("orders-row-action-review", index), 13319 app_shared_text(AppTextKey::OrdersActionReview), 13320 on_review, 13321 cx, 13322 ) 13323 .into_any_element(), 13324 None => div() 13325 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 13326 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 13327 .child(app_shared_text(AppTextKey::ValueNone)) 13328 .into_any_element(), 13329 } 13330 } 13331 13332 fn orders_empty_state_card(filter: OrdersFilter) -> impl IntoElement { 13333 let (title_key, body_key) = if filter == OrdersFilter::NeedsAction { 13334 ( 13335 AppTextKey::OrdersEmptyNeedsActionTitle, 13336 AppTextKey::OrdersEmptyNeedsActionBody, 13337 ) 13338 } else { 13339 (AppTextKey::OrdersEmptyTitle, AppTextKey::OrdersEmptyBody) 13340 }; 13341 13342 home_empty_state_card(title_key, body_key) 13343 } 13344 13345 fn orders_status_color(status: OrderStatus) -> u32 { 13346 match status { 13347 OrderStatus::NeedsAction => APP_UI_THEME.components.app_status_indicator.attention, 13348 OrderStatus::Scheduled | OrderStatus::Packed => { 13349 APP_UI_THEME.components.app_status_indicator.online 13350 } 13351 OrderStatus::Completed | OrderStatus::Declined | OrderStatus::NeedsReview => { 13352 APP_UI_THEME.components.app_status_indicator.offline 13353 } 13354 } 13355 } 13356 13357 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13358 struct PackDayExportStatusPresentation { 13359 indicator_color: u32, 13360 title_key: AppTextKey, 13361 body_key: AppTextKey, 13362 } 13363 13364 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13365 struct PackDayHostHandoffActionPresentation { 13366 kind: PackDayHostHandoffKind, 13367 label_key: AppTextKey, 13368 enabled: bool, 13369 } 13370 13371 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13372 struct PackDayHostHandoffStatusPresentation { 13373 indicator_color: u32, 13374 title_key: AppTextKey, 13375 } 13376 13377 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13378 struct PackDayPrintActionPresentation { 13379 kind: PackDayPrintKind, 13380 label_key: AppTextKey, 13381 enabled: bool, 13382 } 13383 13384 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13385 struct PackDayPrintStatusPresentation { 13386 indicator_color: u32, 13387 title_key: AppTextKey, 13388 } 13389 13390 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13391 struct PackDayBatchPrintActionPresentation { 13392 label_key: AppTextKey, 13393 enabled: bool, 13394 } 13395 13396 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 13397 struct PackDayBatchPrintStatusPresentation { 13398 indicator_color: u32, 13399 title_key: AppTextKey, 13400 } 13401 13402 fn pack_day_export_card( 13403 runtime: &DesktopAppRuntimeSummary, 13404 on_export: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13405 on_reveal_bundle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13406 on_open_pack_sheet: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13407 on_open_pickup_roster: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13408 on_open_customer_labels: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13409 on_print_all: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13410 on_print_pack_sheet: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13411 on_print_pickup_roster: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13412 on_print_customer_labels: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 13413 cx: &App, 13414 ) -> impl IntoElement { 13415 let export = &runtime.pack_day_projection.export; 13416 let status = pack_day_export_status_presentation(runtime); 13417 let detail_rows = pack_day_export_detail_rows(export); 13418 let host_handoff_actions = pack_day_host_handoff_action_presentations(runtime); 13419 let host_handoff_status = pack_day_host_handoff_status_presentation(runtime); 13420 let batch_print_action = pack_day_batch_print_action_presentation(runtime); 13421 let batch_print_status = pack_day_batch_print_status_presentation(runtime); 13422 let print_actions = pack_day_print_action_presentations(runtime); 13423 let print_status = pack_day_print_status_presentation(runtime); 13424 let host_handoff_error_message = runtime 13425 .pack_day_projection 13426 .host_handoff 13427 .error_message 13428 .as_deref() 13429 .map(str::trim) 13430 .filter(|message| !message.is_empty()) 13431 .map(str::to_owned); 13432 let action = if pack_day_export_action_enabled(runtime) { 13433 action_button_primary( 13434 "pack-day-export", 13435 app_shared_text(AppTextKey::PackDayExportAction), 13436 on_export, 13437 cx, 13438 ) 13439 .into_any_element() 13440 } else { 13441 action_button_primary_disabled( 13442 "pack-day-export", 13443 app_shared_text(pack_day_export_action_label_key(export)), 13444 cx, 13445 ) 13446 .into_any_element() 13447 }; 13448 13449 home_card( 13450 app_shared_text(AppTextKey::PackDayExportTitle), 13451 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 13452 .w_full() 13453 .child( 13454 div() 13455 .w_full() 13456 .flex() 13457 .items_center() 13458 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 13459 .child(status_indicator(status.indicator_color)) 13460 .child( 13461 div() 13462 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 13463 .font_weight(gpui::FontWeight::SEMIBOLD) 13464 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 13465 .child(app_shared_text(status.title_key)), 13466 ), 13467 ) 13468 .child(home_body_text(app_shared_text(status.body_key))) 13469 .when(!detail_rows.is_empty(), |this| { 13470 this.child(label_value_list(detail_rows)) 13471 }) 13472 .child(div().child(action)) 13473 .when(!host_handoff_actions.is_empty(), |this| { 13474 let on_reveal_bundle = Arc::new(on_reveal_bundle); 13475 let on_open_pack_sheet = Arc::new(on_open_pack_sheet); 13476 let on_open_pickup_roster = Arc::new(on_open_pickup_roster); 13477 let on_open_customer_labels = Arc::new(on_open_customer_labels); 13478 this.child( 13479 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 13480 .w_full() 13481 .child( 13482 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 13483 .items_center() 13484 .children(host_handoff_actions.into_iter().map(move |action| { 13485 let button = match action.kind { 13486 PackDayHostHandoffKind::RevealBundle if action.enabled => { 13487 action_button( 13488 "pack-day-reveal-bundle", 13489 app_shared_text(action.label_key), 13490 { 13491 let on_reveal_bundle = 13492 Arc::clone(&on_reveal_bundle); 13493 move |event, window, cx| { 13494 (on_reveal_bundle)(event, window, cx) 13495 } 13496 }, 13497 cx, 13498 ) 13499 .into_any_element() 13500 } 13501 PackDayHostHandoffKind::OpenPackSheet if action.enabled => { 13502 action_button( 13503 "pack-day-open-pack-sheet", 13504 app_shared_text(action.label_key), 13505 { 13506 let on_open_pack_sheet = 13507 Arc::clone(&on_open_pack_sheet); 13508 move |event, window, cx| { 13509 (on_open_pack_sheet)(event, window, cx) 13510 } 13511 }, 13512 cx, 13513 ) 13514 .into_any_element() 13515 } 13516 PackDayHostHandoffKind::OpenPickupRoster 13517 if action.enabled => 13518 { 13519 action_button( 13520 "pack-day-open-pickup-roster", 13521 app_shared_text(action.label_key), 13522 { 13523 let on_open_pickup_roster = 13524 Arc::clone(&on_open_pickup_roster); 13525 move |event, window, cx| { 13526 (on_open_pickup_roster)(event, window, cx) 13527 } 13528 }, 13529 cx, 13530 ) 13531 .into_any_element() 13532 } 13533 PackDayHostHandoffKind::OpenCustomerLabels 13534 if action.enabled => 13535 { 13536 action_button( 13537 "pack-day-open-customer-labels", 13538 app_shared_text(action.label_key), 13539 { 13540 let on_open_customer_labels = 13541 Arc::clone(&on_open_customer_labels); 13542 move |event, window, cx| { 13543 (on_open_customer_labels)(event, window, cx) 13544 } 13545 }, 13546 cx, 13547 ) 13548 .into_any_element() 13549 } 13550 PackDayHostHandoffKind::RevealBundle => { 13551 action_button_disabled( 13552 "pack-day-reveal-bundle", 13553 app_shared_text(action.label_key), 13554 cx, 13555 ) 13556 .into_any_element() 13557 } 13558 PackDayHostHandoffKind::OpenPackSheet => { 13559 action_button_disabled( 13560 "pack-day-open-pack-sheet", 13561 app_shared_text(action.label_key), 13562 cx, 13563 ) 13564 .into_any_element() 13565 } 13566 PackDayHostHandoffKind::OpenPickupRoster => { 13567 action_button_disabled( 13568 "pack-day-open-pickup-roster", 13569 app_shared_text(action.label_key), 13570 cx, 13571 ) 13572 .into_any_element() 13573 } 13574 PackDayHostHandoffKind::OpenCustomerLabels => { 13575 action_button_disabled( 13576 "pack-day-open-customer-labels", 13577 app_shared_text(action.label_key), 13578 cx, 13579 ) 13580 .into_any_element() 13581 } 13582 }; 13583 button 13584 })), 13585 ) 13586 .when_some(host_handoff_status, |this, status| { 13587 this.child(pack_day_host_handoff_status_note( 13588 status, 13589 host_handoff_error_message.clone(), 13590 )) 13591 }), 13592 ) 13593 }) 13594 .when_some(batch_print_action, |this, action| { 13595 let button = if action.enabled { 13596 action_button( 13597 "pack-day-print-all", 13598 app_shared_text(action.label_key), 13599 on_print_all, 13600 cx, 13601 ) 13602 .into_any_element() 13603 } else { 13604 action_button_disabled( 13605 "pack-day-print-all", 13606 app_shared_text(action.label_key), 13607 cx, 13608 ) 13609 .into_any_element() 13610 }; 13611 this.child( 13612 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 13613 .w_full() 13614 .child(button) 13615 .when_some(batch_print_status, |this, status| { 13616 this.child(pack_day_batch_print_status_note(status)) 13617 }), 13618 ) 13619 }) 13620 .when(!print_actions.is_empty(), |this| { 13621 let on_print_pack_sheet = Arc::new(on_print_pack_sheet); 13622 let on_print_pickup_roster = Arc::new(on_print_pickup_roster); 13623 let on_print_customer_labels = Arc::new(on_print_customer_labels); 13624 this.child( 13625 app_stack_v(APP_UI_THEME.foundation.spacing.small_px) 13626 .w_full() 13627 .child( 13628 app_cluster(APP_UI_THEME.foundation.spacing.small_px) 13629 .items_center() 13630 .children(print_actions.into_iter().map(move |action| { 13631 match action.kind { 13632 PackDayPrintKind::PrintPackSheet if action.enabled => { 13633 action_button( 13634 "pack-day-print-pack-sheet", 13635 app_shared_text(action.label_key), 13636 { 13637 let on_print_pack_sheet = 13638 Arc::clone(&on_print_pack_sheet); 13639 move |event, window, cx| { 13640 (on_print_pack_sheet)(event, window, cx) 13641 } 13642 }, 13643 cx, 13644 ) 13645 .into_any_element() 13646 } 13647 PackDayPrintKind::PrintPickupRoster if action.enabled => { 13648 action_button( 13649 "pack-day-print-pickup-roster", 13650 app_shared_text(action.label_key), 13651 { 13652 let on_print_pickup_roster = 13653 Arc::clone(&on_print_pickup_roster); 13654 move |event, window, cx| { 13655 (on_print_pickup_roster)(event, window, cx) 13656 } 13657 }, 13658 cx, 13659 ) 13660 .into_any_element() 13661 } 13662 PackDayPrintKind::PrintCustomerLabels if action.enabled => { 13663 action_button( 13664 "pack-day-print-customer-labels", 13665 app_shared_text(action.label_key), 13666 { 13667 let on_print_customer_labels = 13668 Arc::clone(&on_print_customer_labels); 13669 move |event, window, cx| { 13670 (on_print_customer_labels)( 13671 event, window, cx, 13672 ) 13673 } 13674 }, 13675 cx, 13676 ) 13677 .into_any_element() 13678 } 13679 PackDayPrintKind::PrintPackSheet => action_button_disabled( 13680 "pack-day-print-pack-sheet", 13681 app_shared_text(action.label_key), 13682 cx, 13683 ) 13684 .into_any_element(), 13685 PackDayPrintKind::PrintPickupRoster => { 13686 action_button_disabled( 13687 "pack-day-print-pickup-roster", 13688 app_shared_text(action.label_key), 13689 cx, 13690 ) 13691 .into_any_element() 13692 } 13693 PackDayPrintKind::PrintCustomerLabels => { 13694 action_button_disabled( 13695 "pack-day-print-customer-labels", 13696 app_shared_text(action.label_key), 13697 cx, 13698 ) 13699 .into_any_element() 13700 } 13701 } 13702 })), 13703 ) 13704 .when_some(print_status, |this, status| { 13705 this.child(pack_day_print_status_note(status)) 13706 }), 13707 ) 13708 }), 13709 ) 13710 } 13711 13712 fn pack_day_host_handoff_action_presentations( 13713 runtime: &DesktopAppRuntimeSummary, 13714 ) -> Vec<PackDayHostHandoffActionPresentation> { 13715 let Some(bundle) = pack_day_export_succeeded_bundle(runtime) else { 13716 return Vec::new(); 13717 }; 13718 13719 let host_handoff = &runtime.pack_day_projection.host_handoff; 13720 let running_kind = (host_handoff.status == PackDayHostHandoffStatus::Running) 13721 .then(|| host_handoff.request.as_ref().map(|request| request.kind)) 13722 .flatten(); 13723 let print_running = runtime.pack_day_projection.print.status == PackDayPrintStatus::Running; 13724 let batch_print_running = 13725 runtime.pack_day_projection.batch_print.status == PackDayBatchPrintStatus::Running; 13726 13727 PackDayHostHandoffKind::all_v1() 13728 .into_iter() 13729 .map(|kind| PackDayHostHandoffActionPresentation { 13730 kind, 13731 label_key: pack_day_host_handoff_action_label_key(kind, running_kind), 13732 enabled: running_kind.is_none() 13733 && !print_running 13734 && !batch_print_running 13735 && pack_day_host_handoff_action_is_available(bundle, kind), 13736 }) 13737 .collect() 13738 } 13739 13740 fn pack_day_host_handoff_action_is_available( 13741 bundle: &PackDayExportBundle, 13742 kind: PackDayHostHandoffKind, 13743 ) -> bool { 13744 match kind.artifact_kind() { 13745 None => Path::new(&bundle.bundle_directory).is_dir(), 13746 Some(artifact_kind) => bundle 13747 .artifacts 13748 .iter() 13749 .find(|artifact| artifact.kind == artifact_kind) 13750 .and_then(|artifact| pack_day_export_artifact_path(bundle, &artifact.relative_path)) 13751 .is_some_and(|path| path.is_file()), 13752 } 13753 } 13754 13755 fn pack_day_export_artifact_path( 13756 bundle: &PackDayExportBundle, 13757 relative_path: &str, 13758 ) -> Option<PathBuf> { 13759 let relative_path = Path::new(relative_path); 13760 if relative_path.is_absolute() 13761 || relative_path.components().any(|component| { 13762 matches!( 13763 component, 13764 Component::ParentDir | Component::RootDir | Component::Prefix(_) 13765 ) 13766 }) 13767 { 13768 return None; 13769 } 13770 13771 Some(PathBuf::from(&bundle.bundle_directory).join(relative_path)) 13772 } 13773 13774 fn pack_day_batch_print_action_presentation( 13775 runtime: &DesktopAppRuntimeSummary, 13776 ) -> Option<PackDayBatchPrintActionPresentation> { 13777 let bundle = pack_day_export_succeeded_bundle(runtime)?; 13778 let batch_print = &runtime.pack_day_projection.batch_print; 13779 let batch_running = batch_print.status == PackDayBatchPrintStatus::Running; 13780 let print_running = runtime.pack_day_projection.print.status == PackDayPrintStatus::Running; 13781 let host_handoff_running = 13782 runtime.pack_day_projection.host_handoff.status == PackDayHostHandoffStatus::Running; 13783 let all_artifacts_available = PackDayPrintKind::all_v1() 13784 .into_iter() 13785 .all(|kind| pack_day_print_action_is_available(bundle, kind)); 13786 13787 Some(PackDayBatchPrintActionPresentation { 13788 label_key: if batch_running { 13789 AppTextKey::PackDayBatchPrintActionRunning 13790 } else { 13791 AppTextKey::PackDayBatchPrintAction 13792 }, 13793 enabled: !batch_running 13794 && !print_running 13795 && !host_handoff_running 13796 && all_artifacts_available, 13797 }) 13798 } 13799 13800 fn pack_day_batch_print_status_presentation( 13801 runtime: &DesktopAppRuntimeSummary, 13802 ) -> Option<PackDayBatchPrintStatusPresentation> { 13803 let batch_print = &runtime.pack_day_projection.batch_print; 13804 13805 let status = match ( 13806 batch_print.status, 13807 batch_print.failed_artifact, 13808 batch_print.failure, 13809 ) { 13810 (PackDayBatchPrintStatus::Idle, _, _) => return None, 13811 (PackDayBatchPrintStatus::Running, _, _) => PackDayBatchPrintStatusPresentation { 13812 indicator_color: APP_UI_THEME.foundation.text.accent, 13813 title_key: AppTextKey::PackDayBatchPrintQueuedTitle, 13814 }, 13815 (PackDayBatchPrintStatus::Succeeded, _, _) => PackDayBatchPrintStatusPresentation { 13816 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 13817 title_key: AppTextKey::PackDayBatchPrintSucceededTitle, 13818 }, 13819 ( 13820 PackDayBatchPrintStatus::Failed, 13821 _, 13822 Some(PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow), 13823 ) => PackDayBatchPrintStatusPresentation { 13824 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13825 title_key: AppTextKey::PackDayBatchPrintCustomerLabelsAvery5160OverflowFailedTitle, 13826 }, 13827 (PackDayBatchPrintStatus::Failed, Some(failed_artifact), _) => { 13828 PackDayBatchPrintStatusPresentation { 13829 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13830 title_key: pack_day_print_failed_title_key(failed_artifact.print_kind), 13831 } 13832 } 13833 (PackDayBatchPrintStatus::Failed, None, Some(PackDayBatchPrintFailureKind::Preflight)) => { 13834 PackDayBatchPrintStatusPresentation { 13835 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13836 title_key: AppTextKey::PackDayBatchPrintFailedPreflightTitle, 13837 } 13838 } 13839 ( 13840 PackDayBatchPrintStatus::Failed, 13841 None, 13842 Some(PackDayBatchPrintFailureKind::QueueLaunch), 13843 ) => PackDayBatchPrintStatusPresentation { 13844 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13845 title_key: AppTextKey::PackDayBatchPrintFailedQueueLaunchTitle, 13846 }, 13847 (PackDayBatchPrintStatus::Failed, None, Some(PackDayBatchPrintFailureKind::QueueExit)) => { 13848 PackDayBatchPrintStatusPresentation { 13849 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13850 title_key: AppTextKey::PackDayBatchPrintFailedQueueExitTitle, 13851 } 13852 } 13853 (PackDayBatchPrintStatus::Failed, None, _) => PackDayBatchPrintStatusPresentation { 13854 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13855 title_key: AppTextKey::PackDayBatchPrintFailedTitle, 13856 }, 13857 }; 13858 13859 Some(status) 13860 } 13861 13862 fn pack_day_print_action_presentations( 13863 runtime: &DesktopAppRuntimeSummary, 13864 ) -> Vec<PackDayPrintActionPresentation> { 13865 let Some(bundle) = pack_day_export_succeeded_bundle(runtime) else { 13866 return Vec::new(); 13867 }; 13868 13869 let print = &runtime.pack_day_projection.print; 13870 let running_kind = (print.status == PackDayPrintStatus::Running) 13871 .then(|| print.request.as_ref().map(|request| request.kind)) 13872 .flatten(); 13873 let host_handoff_running = 13874 runtime.pack_day_projection.host_handoff.status == PackDayHostHandoffStatus::Running; 13875 let batch_print_running = 13876 runtime.pack_day_projection.batch_print.status == PackDayBatchPrintStatus::Running; 13877 13878 PackDayPrintKind::all_v1() 13879 .into_iter() 13880 .map(|kind| PackDayPrintActionPresentation { 13881 kind, 13882 label_key: pack_day_print_action_label_key(kind, running_kind), 13883 enabled: running_kind.is_none() 13884 && !host_handoff_running 13885 && !batch_print_running 13886 && pack_day_print_action_is_available(bundle, kind), 13887 }) 13888 .collect() 13889 } 13890 13891 fn pack_day_print_action_is_available( 13892 bundle: &PackDayExportBundle, 13893 kind: PackDayPrintKind, 13894 ) -> bool { 13895 bundle 13896 .artifacts 13897 .iter() 13898 .find(|artifact| artifact.kind == kind.artifact_kind()) 13899 .and_then(|artifact| pack_day_export_artifact_path(bundle, &artifact.relative_path)) 13900 .is_some_and(|path| path.is_file()) 13901 } 13902 13903 fn pack_day_print_action_label_key( 13904 kind: PackDayPrintKind, 13905 running_kind: Option<PackDayPrintKind>, 13906 ) -> AppTextKey { 13907 match (kind, running_kind == Some(kind)) { 13908 (PackDayPrintKind::PrintPackSheet, true) => AppTextKey::PackDayPrintPackSheetActionRunning, 13909 (PackDayPrintKind::PrintPackSheet, false) => AppTextKey::PackDayPrintPackSheetAction, 13910 (PackDayPrintKind::PrintPickupRoster, true) => { 13911 AppTextKey::PackDayPrintPickupRosterActionRunning 13912 } 13913 (PackDayPrintKind::PrintPickupRoster, false) => AppTextKey::PackDayPrintPickupRosterAction, 13914 (PackDayPrintKind::PrintCustomerLabels, true) => { 13915 AppTextKey::PackDayPrintCustomerLabelsActionRunning 13916 } 13917 (PackDayPrintKind::PrintCustomerLabels, false) => { 13918 AppTextKey::PackDayPrintCustomerLabelsAction 13919 } 13920 } 13921 } 13922 13923 fn pack_day_print_failed_title_key(kind: PackDayPrintKind) -> AppTextKey { 13924 match kind { 13925 PackDayPrintKind::PrintPackSheet => AppTextKey::PackDayPrintPackSheetFailedTitle, 13926 PackDayPrintKind::PrintPickupRoster => AppTextKey::PackDayPrintPickupRosterFailedTitle, 13927 PackDayPrintKind::PrintCustomerLabels => AppTextKey::PackDayPrintCustomerLabelsFailedTitle, 13928 } 13929 } 13930 13931 fn pack_day_print_status_presentation( 13932 runtime: &DesktopAppRuntimeSummary, 13933 ) -> Option<PackDayPrintStatusPresentation> { 13934 let print = &runtime.pack_day_projection.print; 13935 let kind = print.request.as_ref()?.kind; 13936 let failure = print.failure; 13937 13938 let status = match (print.status, kind, failure) { 13939 (PackDayPrintStatus::Idle, _, _) => return None, 13940 (PackDayPrintStatus::Running, PackDayPrintKind::PrintPackSheet, _) => { 13941 PackDayPrintStatusPresentation { 13942 indicator_color: APP_UI_THEME.foundation.text.accent, 13943 title_key: AppTextKey::PackDayPrintPackSheetQueuedTitle, 13944 } 13945 } 13946 (PackDayPrintStatus::Running, PackDayPrintKind::PrintPickupRoster, _) => { 13947 PackDayPrintStatusPresentation { 13948 indicator_color: APP_UI_THEME.foundation.text.accent, 13949 title_key: AppTextKey::PackDayPrintPickupRosterQueuedTitle, 13950 } 13951 } 13952 (PackDayPrintStatus::Running, PackDayPrintKind::PrintCustomerLabels, _) => { 13953 PackDayPrintStatusPresentation { 13954 indicator_color: APP_UI_THEME.foundation.text.accent, 13955 title_key: AppTextKey::PackDayPrintCustomerLabelsQueuedTitle, 13956 } 13957 } 13958 (PackDayPrintStatus::Succeeded, PackDayPrintKind::PrintPackSheet, _) => { 13959 PackDayPrintStatusPresentation { 13960 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 13961 title_key: AppTextKey::PackDayPrintPackSheetSubmittedTitle, 13962 } 13963 } 13964 (PackDayPrintStatus::Succeeded, PackDayPrintKind::PrintPickupRoster, _) => { 13965 PackDayPrintStatusPresentation { 13966 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 13967 title_key: AppTextKey::PackDayPrintPickupRosterSubmittedTitle, 13968 } 13969 } 13970 (PackDayPrintStatus::Succeeded, PackDayPrintKind::PrintCustomerLabels, _) => { 13971 PackDayPrintStatusPresentation { 13972 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 13973 title_key: AppTextKey::PackDayPrintCustomerLabelsSubmittedTitle, 13974 } 13975 } 13976 (PackDayPrintStatus::Failed, PackDayPrintKind::PrintPackSheet, _) => { 13977 PackDayPrintStatusPresentation { 13978 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13979 title_key: AppTextKey::PackDayPrintPackSheetFailedTitle, 13980 } 13981 } 13982 (PackDayPrintStatus::Failed, PackDayPrintKind::PrintPickupRoster, _) => { 13983 PackDayPrintStatusPresentation { 13984 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13985 title_key: AppTextKey::PackDayPrintPickupRosterFailedTitle, 13986 } 13987 } 13988 ( 13989 PackDayPrintStatus::Failed, 13990 PackDayPrintKind::PrintCustomerLabels, 13991 Some(PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow), 13992 ) => PackDayPrintStatusPresentation { 13993 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13994 title_key: AppTextKey::PackDayPrintCustomerLabelsAvery5160OverflowFailedTitle, 13995 }, 13996 (PackDayPrintStatus::Failed, PackDayPrintKind::PrintCustomerLabels, _) => { 13997 PackDayPrintStatusPresentation { 13998 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 13999 title_key: AppTextKey::PackDayPrintCustomerLabelsFailedTitle, 14000 } 14001 } 14002 }; 14003 14004 Some(status) 14005 } 14006 14007 fn pack_day_host_handoff_action_label_key( 14008 kind: PackDayHostHandoffKind, 14009 running_kind: Option<PackDayHostHandoffKind>, 14010 ) -> AppTextKey { 14011 match (kind, running_kind == Some(kind)) { 14012 (PackDayHostHandoffKind::RevealBundle, true) => { 14013 AppTextKey::PackDayHostHandoffRevealActionRunning 14014 } 14015 (PackDayHostHandoffKind::RevealBundle, false) => AppTextKey::PackDayHostHandoffRevealAction, 14016 (PackDayHostHandoffKind::OpenPackSheet, true) => { 14017 AppTextKey::PackDayHostHandoffOpenPackSheetActionRunning 14018 } 14019 (PackDayHostHandoffKind::OpenPackSheet, false) => { 14020 AppTextKey::PackDayHostHandoffOpenPackSheetAction 14021 } 14022 (PackDayHostHandoffKind::OpenPickupRoster, true) => { 14023 AppTextKey::PackDayHostHandoffOpenPickupRosterActionRunning 14024 } 14025 (PackDayHostHandoffKind::OpenPickupRoster, false) => { 14026 AppTextKey::PackDayHostHandoffOpenPickupRosterAction 14027 } 14028 (PackDayHostHandoffKind::OpenCustomerLabels, true) => { 14029 AppTextKey::PackDayHostHandoffOpenCustomerLabelsActionRunning 14030 } 14031 (PackDayHostHandoffKind::OpenCustomerLabels, false) => { 14032 AppTextKey::PackDayHostHandoffOpenCustomerLabelsAction 14033 } 14034 } 14035 } 14036 14037 fn pack_day_host_handoff_status_presentation( 14038 runtime: &DesktopAppRuntimeSummary, 14039 ) -> Option<PackDayHostHandoffStatusPresentation> { 14040 let host_handoff = &runtime.pack_day_projection.host_handoff; 14041 let kind = host_handoff.request.as_ref()?.kind; 14042 14043 let status = match (host_handoff.status, kind) { 14044 (PackDayHostHandoffStatus::Idle, _) => return None, 14045 (PackDayHostHandoffStatus::Running, PackDayHostHandoffKind::RevealBundle) => { 14046 PackDayHostHandoffStatusPresentation { 14047 indicator_color: APP_UI_THEME.foundation.text.accent, 14048 title_key: AppTextKey::PackDayHostHandoffRevealRunningTitle, 14049 } 14050 } 14051 (PackDayHostHandoffStatus::Running, PackDayHostHandoffKind::OpenPackSheet) => { 14052 PackDayHostHandoffStatusPresentation { 14053 indicator_color: APP_UI_THEME.foundation.text.accent, 14054 title_key: AppTextKey::PackDayHostHandoffOpenPackSheetRunningTitle, 14055 } 14056 } 14057 (PackDayHostHandoffStatus::Running, PackDayHostHandoffKind::OpenPickupRoster) => { 14058 PackDayHostHandoffStatusPresentation { 14059 indicator_color: APP_UI_THEME.foundation.text.accent, 14060 title_key: AppTextKey::PackDayHostHandoffOpenPickupRosterRunningTitle, 14061 } 14062 } 14063 (PackDayHostHandoffStatus::Running, PackDayHostHandoffKind::OpenCustomerLabels) => { 14064 PackDayHostHandoffStatusPresentation { 14065 indicator_color: APP_UI_THEME.foundation.text.accent, 14066 title_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsRunningTitle, 14067 } 14068 } 14069 (PackDayHostHandoffStatus::Succeeded, PackDayHostHandoffKind::RevealBundle) => { 14070 PackDayHostHandoffStatusPresentation { 14071 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 14072 title_key: AppTextKey::PackDayHostHandoffRevealSucceededTitle, 14073 } 14074 } 14075 (PackDayHostHandoffStatus::Succeeded, PackDayHostHandoffKind::OpenPackSheet) => { 14076 PackDayHostHandoffStatusPresentation { 14077 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 14078 title_key: AppTextKey::PackDayHostHandoffOpenPackSheetSucceededTitle, 14079 } 14080 } 14081 (PackDayHostHandoffStatus::Succeeded, PackDayHostHandoffKind::OpenPickupRoster) => { 14082 PackDayHostHandoffStatusPresentation { 14083 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 14084 title_key: AppTextKey::PackDayHostHandoffOpenPickupRosterSucceededTitle, 14085 } 14086 } 14087 (PackDayHostHandoffStatus::Succeeded, PackDayHostHandoffKind::OpenCustomerLabels) => { 14088 PackDayHostHandoffStatusPresentation { 14089 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 14090 title_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsSucceededTitle, 14091 } 14092 } 14093 (PackDayHostHandoffStatus::Failed, PackDayHostHandoffKind::RevealBundle) => { 14094 PackDayHostHandoffStatusPresentation { 14095 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 14096 title_key: AppTextKey::PackDayHostHandoffRevealFailedTitle, 14097 } 14098 } 14099 (PackDayHostHandoffStatus::Failed, PackDayHostHandoffKind::OpenPackSheet) => { 14100 PackDayHostHandoffStatusPresentation { 14101 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 14102 title_key: AppTextKey::PackDayHostHandoffOpenPackSheetFailedTitle, 14103 } 14104 } 14105 (PackDayHostHandoffStatus::Failed, PackDayHostHandoffKind::OpenPickupRoster) => { 14106 PackDayHostHandoffStatusPresentation { 14107 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 14108 title_key: AppTextKey::PackDayHostHandoffOpenPickupRosterFailedTitle, 14109 } 14110 } 14111 (PackDayHostHandoffStatus::Failed, PackDayHostHandoffKind::OpenCustomerLabels) => { 14112 PackDayHostHandoffStatusPresentation { 14113 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 14114 title_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsFailedTitle, 14115 } 14116 } 14117 }; 14118 14119 Some(status) 14120 } 14121 14122 fn pack_day_host_handoff_status_note( 14123 status: PackDayHostHandoffStatusPresentation, 14124 error_message: Option<String>, 14125 ) -> impl IntoElement { 14126 app_stack_v(4.0) 14127 .w_full() 14128 .child( 14129 div() 14130 .w_full() 14131 .flex() 14132 .items_center() 14133 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 14134 .child(status_indicator(status.indicator_color)) 14135 .child( 14136 div() 14137 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14138 .font_weight(gpui::FontWeight::SEMIBOLD) 14139 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14140 .child(app_shared_text(status.title_key)), 14141 ), 14142 ) 14143 .when_some(error_message, |this, error_message| { 14144 this.child(home_body_text(error_message)) 14145 }) 14146 } 14147 14148 fn pack_day_print_status_note(status: PackDayPrintStatusPresentation) -> impl IntoElement { 14149 app_stack_v(4.0).w_full().child( 14150 div() 14151 .w_full() 14152 .flex() 14153 .items_center() 14154 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 14155 .child(status_indicator(status.indicator_color)) 14156 .child( 14157 div() 14158 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14159 .font_weight(gpui::FontWeight::SEMIBOLD) 14160 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14161 .child(app_shared_text(status.title_key)), 14162 ), 14163 ) 14164 } 14165 14166 fn pack_day_batch_print_status_note( 14167 status: PackDayBatchPrintStatusPresentation, 14168 ) -> impl IntoElement { 14169 app_stack_v(4.0).w_full().child( 14170 div() 14171 .w_full() 14172 .flex() 14173 .items_center() 14174 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 14175 .child(status_indicator(status.indicator_color)) 14176 .child( 14177 div() 14178 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14179 .font_weight(gpui::FontWeight::SEMIBOLD) 14180 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14181 .child(app_shared_text(status.title_key)), 14182 ), 14183 ) 14184 } 14185 14186 fn pack_day_export_succeeded_bundle( 14187 runtime: &DesktopAppRuntimeSummary, 14188 ) -> Option<&PackDayExportBundle> { 14189 (runtime.pack_day_projection.export.status == PackDayExportStatus::Succeeded) 14190 .then_some(runtime.pack_day_projection.export.bundle.as_ref()) 14191 .flatten() 14192 } 14193 14194 fn pack_day_export_has_exportable_context(runtime: &DesktopAppRuntimeSummary) -> bool { 14195 let projection = &runtime.pack_day_projection.projection; 14196 projection.fulfillment_window.is_some() && !projection.is_empty() 14197 } 14198 14199 fn pack_day_export_action_enabled(runtime: &DesktopAppRuntimeSummary) -> bool { 14200 pack_day_export_has_exportable_context(runtime) 14201 && runtime.pack_day_projection.export.status != PackDayExportStatus::Running 14202 } 14203 14204 fn pack_day_export_action_label_key(export: &PackDayExportProjection) -> AppTextKey { 14205 match export.status { 14206 PackDayExportStatus::Running => AppTextKey::PackDayExportActionRunning, 14207 PackDayExportStatus::Idle 14208 | PackDayExportStatus::Succeeded 14209 | PackDayExportStatus::Failed => AppTextKey::PackDayExportAction, 14210 } 14211 } 14212 14213 fn pack_day_export_status_presentation( 14214 runtime: &DesktopAppRuntimeSummary, 14215 ) -> PackDayExportStatusPresentation { 14216 match runtime.pack_day_projection.export.status { 14217 PackDayExportStatus::Running => PackDayExportStatusPresentation { 14218 indicator_color: APP_UI_THEME.foundation.text.accent, 14219 title_key: AppTextKey::PackDayExportRunningTitle, 14220 body_key: AppTextKey::PackDayExportRunningBody, 14221 }, 14222 PackDayExportStatus::Succeeded => PackDayExportStatusPresentation { 14223 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 14224 title_key: AppTextKey::PackDayExportSucceededTitle, 14225 body_key: AppTextKey::PackDayExportSucceededBody, 14226 }, 14227 PackDayExportStatus::Failed => PackDayExportStatusPresentation { 14228 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 14229 title_key: AppTextKey::PackDayExportFailedTitle, 14230 body_key: AppTextKey::PackDayExportFailedBody, 14231 }, 14232 PackDayExportStatus::Idle if pack_day_export_has_exportable_context(runtime) => { 14233 PackDayExportStatusPresentation { 14234 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 14235 title_key: AppTextKey::PackDayExportReadyTitle, 14236 body_key: AppTextKey::PackDayExportReadyBody, 14237 } 14238 } 14239 PackDayExportStatus::Idle => PackDayExportStatusPresentation { 14240 indicator_color: APP_UI_THEME.components.app_status_indicator.offline, 14241 title_key: AppTextKey::PackDayExportUnavailableTitle, 14242 body_key: AppTextKey::PackDayExportUnavailableBody, 14243 }, 14244 } 14245 } 14246 14247 fn pack_day_export_detail_rows(export: &PackDayExportProjection) -> Vec<LabelValueRow> { 14248 match export.status { 14249 PackDayExportStatus::Succeeded => export 14250 .bundle 14251 .as_ref() 14252 .map(pack_day_export_bundle_rows) 14253 .unwrap_or_default(), 14254 PackDayExportStatus::Failed => export 14255 .error_message 14256 .as_deref() 14257 .map(str::trim) 14258 .filter(|message| !message.is_empty()) 14259 .map(|message| { 14260 vec![LabelValueRow::new( 14261 app_shared_text(AppTextKey::PackDayExportErrorLabel), 14262 message.to_owned(), 14263 )] 14264 }) 14265 .unwrap_or_default(), 14266 PackDayExportStatus::Idle | PackDayExportStatus::Running => Vec::new(), 14267 } 14268 } 14269 14270 fn pack_day_export_bundle_rows(bundle: &PackDayExportBundle) -> Vec<LabelValueRow> { 14271 vec![ 14272 LabelValueRow::new( 14273 app_shared_text(AppTextKey::PackDayExportFolderLabel), 14274 bundle.bundle_directory.clone(), 14275 ), 14276 LabelValueRow::new( 14277 app_shared_text(AppTextKey::PackDayExportFilesLabel), 14278 pack_day_export_artifact_names(bundle), 14279 ), 14280 ] 14281 } 14282 14283 fn pack_day_export_artifact_names(bundle: &PackDayExportBundle) -> String { 14284 bundle 14285 .artifacts 14286 .iter() 14287 .map(|artifact| artifact.kind.file_name()) 14288 .collect::<Vec<_>>() 14289 .join(", ") 14290 } 14291 14292 fn pack_day_title_row(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { 14293 app_stack_v(4.0) 14294 .child( 14295 div() 14296 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) 14297 .font_weight(gpui::FontWeight::BOLD) 14298 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14299 .child(app_shared_text(AppTextKey::PackDayTitle)), 14300 ) 14301 .child( 14302 div() 14303 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 14304 .font_weight(gpui::FontWeight::MEDIUM) 14305 .line_height(relative(1.2)) 14306 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14307 .when_some(home_saved_farm(runtime), |this, farm| { 14308 this.child(farm.display_name.clone()) 14309 }), 14310 ) 14311 } 14312 14313 fn pack_day_window_summary_card(fulfillment_window: &FulfillmentWindowSummary) -> impl IntoElement { 14314 home_card( 14315 app_shared_text(AppTextKey::PackDayWindowSummaryTitle), 14316 label_value_list([ 14317 LabelValueRow::new( 14318 app_shared_text(AppTextKey::HomeTodayWindowStartsLabel), 14319 fulfillment_window.starts_at.clone(), 14320 ), 14321 LabelValueRow::new( 14322 app_shared_text(AppTextKey::HomeTodayWindowEndsLabel), 14323 fulfillment_window.ends_at.clone(), 14324 ), 14325 ]), 14326 ) 14327 } 14328 14329 fn pack_day_totals_card(rows: &[PackDayProductTotalRow]) -> impl IntoElement { 14330 home_card( 14331 app_shared_text(AppTextKey::PackDayTotalsTitle), 14332 div() 14333 .w_full() 14334 .flex() 14335 .flex_col() 14336 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 14337 .children( 14338 rows.iter() 14339 .map(pack_day_product_total_row) 14340 .collect::<Vec<_>>(), 14341 ), 14342 ) 14343 } 14344 14345 fn pack_day_pack_list_card(rows: &[PackDayPackListRow]) -> impl IntoElement { 14346 home_card( 14347 app_shared_text(AppTextKey::PackDayPackListTitle), 14348 div() 14349 .w_full() 14350 .flex() 14351 .flex_col() 14352 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 14353 .children(rows.iter().map(pack_day_pack_list_row).collect::<Vec<_>>()), 14354 ) 14355 } 14356 14357 fn pack_day_pickup_roster_card(rows: &[PackDayRosterRow]) -> impl IntoElement { 14358 home_card( 14359 app_shared_text(AppTextKey::PackDayPickupRosterTitle), 14360 div() 14361 .w_full() 14362 .flex() 14363 .flex_col() 14364 .gap(px(APP_UI_THEME.foundation.spacing.tight_px)) 14365 .children(rows.iter().map(pack_day_roster_row).collect::<Vec<_>>()), 14366 ) 14367 } 14368 14369 fn pack_day_product_total_row(row: &PackDayProductTotalRow) -> AnyElement { 14370 pack_day_label_value_row(row.title.as_str(), row.quantity_display.as_str()) 14371 } 14372 14373 fn pack_day_pack_list_row(row: &PackDayPackListRow) -> AnyElement { 14374 pack_day_label_value_row(row.title.as_str(), row.quantity_display.as_str()) 14375 } 14376 14377 fn pack_day_roster_row(row: &PackDayRosterRow) -> AnyElement { 14378 div() 14379 .w_full() 14380 .min_w_0() 14381 .flex() 14382 .items_center() 14383 .justify_between() 14384 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 14385 .child( 14386 div() 14387 .min_w_0() 14388 .flex() 14389 .flex_col() 14390 .gap(px(2.0)) 14391 .child( 14392 div() 14393 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 14394 .font_weight(gpui::FontWeight::SEMIBOLD) 14395 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14396 .child(row.order_number.clone()), 14397 ) 14398 .child( 14399 div() 14400 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14401 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14402 .child(row.customer_display_name.clone()), 14403 ), 14404 ) 14405 .into_any_element() 14406 } 14407 14408 fn pack_day_label_value_row(label: &str, value: &str) -> AnyElement { 14409 div() 14410 .w_full() 14411 .min_w_0() 14412 .flex() 14413 .items_center() 14414 .justify_between() 14415 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 14416 .child( 14417 div() 14418 .flex_1() 14419 .min_w_0() 14420 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 14421 .font_weight(gpui::FontWeight::MEDIUM) 14422 .line_height(relative(1.2)) 14423 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14424 .child(label.to_owned()), 14425 ) 14426 .child( 14427 div() 14428 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14429 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14430 .child(value.to_owned()), 14431 ) 14432 .into_any_element() 14433 } 14434 14435 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 14436 enum ReminderActionTarget { 14437 OrderDetail(OrderId), 14438 PackDay(FulfillmentWindowId), 14439 } 14440 14441 fn reminder_action_target(reminder: &ReminderDeadlineProjection) -> Option<ReminderActionTarget> { 14442 reminder 14443 .order_id 14444 .map(ReminderActionTarget::OrderDetail) 14445 .or_else(|| { 14446 reminder 14447 .fulfillment_window_id 14448 .map(ReminderActionTarget::PackDay) 14449 }) 14450 } 14451 14452 fn reminder_urgency_key(urgency: ReminderUrgency) -> AppTextKey { 14453 match urgency { 14454 ReminderUrgency::Upcoming => AppTextKey::ReminderUrgencyUpcoming, 14455 ReminderUrgency::DueSoon => AppTextKey::ReminderUrgencyDueSoon, 14456 ReminderUrgency::Overdue => AppTextKey::ReminderUrgencyOverdue, 14457 ReminderUrgency::Blocking => AppTextKey::ReminderUrgencyBlocking, 14458 } 14459 } 14460 14461 fn reminder_urgency_color(urgency: ReminderUrgency) -> u32 { 14462 match urgency { 14463 ReminderUrgency::Upcoming => APP_UI_THEME.components.app_status_indicator.offline, 14464 ReminderUrgency::DueSoon => APP_UI_THEME.foundation.text.accent, 14465 ReminderUrgency::Overdue | ReminderUrgency::Blocking => { 14466 APP_UI_THEME.components.app_status_indicator.attention 14467 } 14468 } 14469 } 14470 14471 fn reminder_urgency_badge(urgency: ReminderUrgency) -> AnyElement { 14472 div() 14473 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14474 .font_weight(gpui::FontWeight::SEMIBOLD) 14475 .text_color(rgb(reminder_urgency_color(urgency))) 14476 .child(app_shared_text(reminder_urgency_key(urgency))) 14477 .into_any_element() 14478 } 14479 14480 fn reminder_delivery_state_key(delivery_state: ReminderDeliveryState) -> AppTextKey { 14481 match delivery_state { 14482 ReminderDeliveryState::Scheduled => AppTextKey::ReminderDeliveryStateScheduled, 14483 ReminderDeliveryState::Presented => AppTextKey::ReminderDeliveryStatePresented, 14484 ReminderDeliveryState::Acknowledged => AppTextKey::ReminderDeliveryStateAcknowledged, 14485 ReminderDeliveryState::Resolved => AppTextKey::ReminderDeliveryStateResolved, 14486 } 14487 } 14488 14489 fn reminder_delivery_state_color(delivery_state: ReminderDeliveryState) -> u32 { 14490 match delivery_state { 14491 ReminderDeliveryState::Scheduled => APP_UI_THEME.components.app_status_indicator.offline, 14492 ReminderDeliveryState::Presented => APP_UI_THEME.foundation.text.accent, 14493 ReminderDeliveryState::Acknowledged => APP_UI_THEME.foundation.text.secondary, 14494 ReminderDeliveryState::Resolved => APP_UI_THEME.components.app_status_indicator.online, 14495 } 14496 } 14497 14498 fn reminder_delivery_state_badge(delivery_state: ReminderDeliveryState) -> AnyElement { 14499 div() 14500 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14501 .font_weight(gpui::FontWeight::SEMIBOLD) 14502 .text_color(rgb(reminder_delivery_state_color(delivery_state))) 14503 .child(app_shared_text(reminder_delivery_state_key(delivery_state))) 14504 .into_any_element() 14505 } 14506 14507 fn presented_farmer_reminder( 14508 runtime: &DesktopAppRuntimeSummary, 14509 ) -> Option<&ReminderDeadlineProjection> { 14510 runtime 14511 .today_projection 14512 .reminders 14513 .items 14514 .iter() 14515 .chain(runtime.orders_projection.reminders.items.iter()) 14516 .chain( 14517 runtime 14518 .pack_day_projection 14519 .projection 14520 .reminders 14521 .items 14522 .iter(), 14523 ) 14524 .filter(|reminder| reminder.delivery_state == ReminderDeliveryState::Presented) 14525 .min_by(|left, right| { 14526 reminder_urgency_priority(left.urgency) 14527 .cmp(&reminder_urgency_priority(right.urgency)) 14528 .then_with(|| left.deadline_at.cmp(&right.deadline_at)) 14529 .then_with(|| left.reminder_id.cmp(&right.reminder_id)) 14530 }) 14531 } 14532 14533 fn reminder_urgency_priority(urgency: ReminderUrgency) -> u8 { 14534 match urgency { 14535 ReminderUrgency::Blocking => 0, 14536 ReminderUrgency::Overdue => 1, 14537 ReminderUrgency::DueSoon => 2, 14538 ReminderUrgency::Upcoming => 3, 14539 } 14540 } 14541 14542 fn reminder_deadline_text(reminder: &ReminderDeadlineProjection) -> String { 14543 format!( 14544 "{}: {}", 14545 app_text(AppTextKey::ReminderDeadlineLabel), 14546 reminder.deadline_at 14547 ) 14548 } 14549 14550 fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement { 14551 let (title_key, body_key) = if filter == ProductsFilter::NeedAttention { 14552 ( 14553 AppTextKey::ProductsEmptyNeedAttentionTitle, 14554 AppTextKey::ProductsEmptyNeedAttentionBody, 14555 ) 14556 } else { 14557 ( 14558 AppTextKey::ProductsEmptyTitle, 14559 AppTextKey::ProductsEmptyBody, 14560 ) 14561 }; 14562 14563 home_empty_state_card(title_key, body_key) 14564 } 14565 14566 fn products_status_key(status: ProductStatus) -> AppTextKey { 14567 match status { 14568 ProductStatus::Draft => AppTextKey::ProductsStatusDraft, 14569 ProductStatus::Published => AppTextKey::ProductsStatusLive, 14570 ProductStatus::Paused => AppTextKey::ProductsStatusPaused, 14571 ProductStatus::Archived => AppTextKey::ProductsStatusArchived, 14572 } 14573 } 14574 14575 fn products_row_status_color(row: &ProductsListRow) -> u32 { 14576 if row.attention_state != ProductAttentionState::Healthy { 14577 APP_UI_THEME.components.app_status_indicator.attention 14578 } else { 14579 match row.status { 14580 ProductStatus::Published => APP_UI_THEME.components.app_status_indicator.online, 14581 ProductStatus::Draft | ProductStatus::Paused | ProductStatus::Archived => { 14582 APP_UI_THEME.components.app_status_indicator.offline 14583 } 14584 } 14585 } 14586 } 14587 14588 fn products_stock_text(row: &ProductsListRow) -> String { 14589 match row.stock.quantity { 14590 Some(quantity) => match row.stock.unit_label.as_ref() { 14591 Some(unit_label) => format!("{quantity} {unit_label}"), 14592 None => quantity.to_string(), 14593 }, 14594 None => app_shared_text(AppTextKey::ValueNone).to_string(), 14595 } 14596 } 14597 14598 fn products_price_text(row: &ProductsListRow) -> String { 14599 let Some(price) = row.price.as_ref() else { 14600 return app_shared_text(AppTextKey::ValueNone).to_string(); 14601 }; 14602 let dollars = price.amount_minor_units / 100; 14603 let cents = price.amount_minor_units % 100; 14604 14605 format!("${dollars}.{cents:02} / {}", price.unit_label) 14606 } 14607 14608 fn products_stock_editor_card( 14609 row: &ProductsListRow, 14610 editor: &ProductsStockEditorState, 14611 on_save: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 14612 on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 14613 cx: &App, 14614 ) -> impl IntoElement { 14615 let validation_key = products_stock_editor_validation_key(editor, cx); 14616 let save_ready = (editor.has_changes(cx) 14617 || matches!( 14618 editor.save_issue, 14619 Some(ProductsStockEditorSaveIssue::PublishQueueFailed) 14620 )) 14621 && editor.parsed_stock_quantity(cx).is_some(); 14622 14623 div() 14624 .w_full() 14625 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) 14626 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 14627 .p(px(16.0)) 14628 .flex() 14629 .flex_col() 14630 .gap(px(8.0)) 14631 .child( 14632 div() 14633 .w_full() 14634 .flex() 14635 .flex_col() 14636 .gap(px(2.0)) 14637 .child( 14638 div() 14639 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 14640 .font_weight(gpui::FontWeight::SEMIBOLD) 14641 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 14642 .child(app_shared_text(AppTextKey::ProductsStockEditorTitle)), 14643 ) 14644 .child( 14645 div() 14646 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14647 .line_height(relative(1.2)) 14648 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14649 .child(product_display_title(row.title.as_str())), 14650 ), 14651 ) 14652 .child( 14653 div() 14654 .w_full() 14655 .flex() 14656 .items_end() 14657 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 14658 .child( 14659 div() 14660 .flex_1() 14661 .min_w_0() 14662 .flex() 14663 .flex_col() 14664 .gap(px(6.0)) 14665 .child( 14666 div() 14667 .text_size(px(APP_UI_THEME 14668 .foundation 14669 .typography 14670 .utility_title_text_px)) 14671 .font_weight(gpui::FontWeight::SEMIBOLD) 14672 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14673 .child(app_shared_text(AppTextKey::ProductsStockEditorFieldLabel)), 14674 ) 14675 .child(app_text_input(&editor.input, false).w_full()) 14676 .when_some(validation_key, |this, key| { 14677 this.child( 14678 div() 14679 .text_size(px(APP_UI_THEME 14680 .foundation 14681 .typography 14682 .utility_title_text_px)) 14683 .line_height(relative(1.2)) 14684 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14685 .child(app_shared_text(key)), 14686 ) 14687 }) 14688 .when_some(editor.save_issue, |this, issue| { 14689 this.child( 14690 div() 14691 .text_size(px(APP_UI_THEME 14692 .foundation 14693 .typography 14694 .utility_title_text_px)) 14695 .line_height(relative(1.2)) 14696 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14697 .child(app_shared_text(issue.text_key())), 14698 ) 14699 }), 14700 ) 14701 .child( 14702 div() 14703 .flex() 14704 .items_center() 14705 .gap(px(8.0)) 14706 .child(action_button_compact( 14707 "products-stock-editor-close", 14708 app_shared_text(AppTextKey::ProductsStockEditorCancelAction), 14709 on_cancel, 14710 cx, 14711 )) 14712 .child(if save_ready { 14713 action_button_primary( 14714 "products-stock-editor-save", 14715 app_shared_text(AppTextKey::ProductsStockEditorSaveAction), 14716 on_save, 14717 cx, 14718 ) 14719 .into_any_element() 14720 } else { 14721 action_button_primary_disabled( 14722 "products-stock-editor-save", 14723 app_shared_text(AppTextKey::ProductsStockEditorSaveAction), 14724 cx, 14725 ) 14726 .into_any_element() 14727 }), 14728 ), 14729 ) 14730 } 14731 14732 fn products_stock_editor_validation_key( 14733 editor: &ProductsStockEditorState, 14734 cx: &App, 14735 ) -> Option<AppTextKey> { 14736 if editor.parsed_stock_quantity(cx).is_some() { 14737 return None; 14738 } 14739 14740 Some(AppTextKey::ProductsStockEditorInvalidQuantity) 14741 } 14742 14743 fn products_editor_surface( 14744 form: &ProductEditorFormState, 14745 runtime: &DesktopAppRuntimeSummary, 14746 cx: &mut Context<HomeView>, 14747 ) -> AnyElement { 14748 let validation_keys = products_editor_validation_keys(form, cx); 14749 let save_ready = (form.has_changes(cx) 14750 || matches!( 14751 form.save_issue, 14752 Some(ProductEditorSaveIssue::PublishQueueFailed) 14753 )) 14754 && validation_keys.is_empty(); 14755 14756 let save_action = if save_ready { 14757 action_button_primary( 14758 "products-editor-save", 14759 app_shared_text(AppTextKey::ProductsEditorSaveAction), 14760 cx.listener(|this, _, _, cx| this.save_product_editor(cx)), 14761 cx, 14762 ) 14763 .into_any_element() 14764 } else { 14765 action_button_primary_disabled( 14766 "products-editor-save", 14767 app_shared_text(AppTextKey::ProductsEditorSaveAction), 14768 cx, 14769 ) 14770 .into_any_element() 14771 }; 14772 14773 app_focused_task_view( 14774 app_shared_text(AppTextKey::ProductsEditorTitle), 14775 app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) 14776 .w_full() 14777 .child(home_body_text(app_shared_text( 14778 AppTextKey::ProductsEditorBody, 14779 ))) 14780 .child(app_form_input_text( 14781 AppFormFieldSpec::new( 14782 app_shared_text(AppTextKey::ProductsEditorFieldTitle), 14783 Option::<SharedString>::None, 14784 ), 14785 &form.title_input, 14786 false, 14787 )) 14788 .child(app_form_input_text( 14789 AppFormFieldSpec::new( 14790 app_shared_text(AppTextKey::ProductsEditorFieldSubtitle), 14791 Option::<SharedString>::None, 14792 ), 14793 &form.subtitle_input, 14794 false, 14795 )) 14796 .child(app_form_input_text( 14797 AppFormFieldSpec::new( 14798 app_shared_text(AppTextKey::ProductsEditorFieldCategory), 14799 Option::<SharedString>::None, 14800 ), 14801 &form.category_input, 14802 false, 14803 )) 14804 .child(app_form_input_text( 14805 AppFormFieldSpec::new( 14806 app_shared_text(AppTextKey::ProductsEditorFieldUnit), 14807 Option::<SharedString>::None, 14808 ), 14809 &form.unit_input, 14810 false, 14811 )) 14812 .child(app_form_input_text( 14813 AppFormFieldSpec::new( 14814 app_shared_text(AppTextKey::ProductsEditorFieldPrice), 14815 products_editor_invalid_price_key(form, cx).map(app_shared_text), 14816 ), 14817 &form.price_input, 14818 false, 14819 )) 14820 .child(app_form_input_text( 14821 AppFormFieldSpec::new( 14822 app_shared_text(AppTextKey::ProductsEditorFieldStock), 14823 products_editor_invalid_stock_key(form, cx).map(app_shared_text), 14824 ), 14825 &form.stock_input, 14826 false, 14827 )) 14828 .child(products_editor_availability_section( 14829 form, 14830 &runtime.farm_rules_projection.fulfillment_windows, 14831 cx, 14832 )) 14833 .child(products_editor_status_section( 14834 form.status, 14835 cx.listener(|this, _, _, cx| { 14836 this.select_product_editor_status(ProductStatus::Draft, cx) 14837 }), 14838 cx.listener(|this, _, _, cx| { 14839 this.select_product_editor_status(ProductStatus::Published, cx) 14840 }), 14841 cx.listener(|this, _, _, cx| { 14842 this.select_product_editor_status(ProductStatus::Paused, cx) 14843 }), 14844 cx.listener(|this, _, _, cx| { 14845 this.select_product_editor_status(ProductStatus::Archived, cx) 14846 }), 14847 cx, 14848 )) 14849 .child(products_editor_publish_readiness_section(form, runtime, cx)) 14850 .when_some(form.save_issue, |this, issue| { 14851 this.child(home_body_text(app_shared_text(issue.text_key()))) 14852 }) 14853 .child( 14854 div() 14855 .w_full() 14856 .flex() 14857 .items_center() 14858 .justify_between() 14859 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 14860 .child( 14861 div() 14862 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 14863 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 14864 .child(product_display_title( 14865 form.title_input.read(cx).value().as_ref(), 14866 )), 14867 ) 14868 .child(save_action), 14869 ), 14870 text_button( 14871 "products-editor-close", 14872 app_shared_text(AppTextKey::ProductsEditorCloseAction), 14873 cx.listener(|this, _, _, cx| this.close_product_editor(cx)), 14874 cx, 14875 ), 14876 ) 14877 } 14878 14879 fn products_editor_status_section( 14880 selected_status: ProductStatus, 14881 on_select_draft: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 14882 on_select_live: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 14883 on_select_paused: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 14884 on_select_archived: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 14885 cx: &App, 14886 ) -> impl IntoElement { 14887 div() 14888 .w_full() 14889 .flex() 14890 .flex_col() 14891 .items_start() 14892 .gap(px(8.0)) 14893 .child(home_farm_setup_field_label(app_shared_text( 14894 AppTextKey::ProductsEditorFieldStatus, 14895 ))) 14896 .child( 14897 div() 14898 .w_full() 14899 .flex() 14900 .items_center() 14901 .gap(px(8.0)) 14902 .child(choice_button( 14903 "products-editor-status-draft", 14904 app_shared_text(AppTextKey::ProductsStatusDraft), 14905 selected_status == ProductStatus::Draft, 14906 on_select_draft, 14907 cx, 14908 )) 14909 .child(choice_button( 14910 "products-editor-status-live", 14911 app_shared_text(AppTextKey::ProductsStatusLive), 14912 selected_status == ProductStatus::Published, 14913 on_select_live, 14914 cx, 14915 )) 14916 .child(choice_button( 14917 "products-editor-status-paused", 14918 app_shared_text(AppTextKey::ProductsStatusPaused), 14919 selected_status == ProductStatus::Paused, 14920 on_select_paused, 14921 cx, 14922 )) 14923 .child(choice_button( 14924 "products-editor-status-archived", 14925 app_shared_text(AppTextKey::ProductsStatusArchived), 14926 selected_status == ProductStatus::Archived, 14927 on_select_archived, 14928 cx, 14929 )), 14930 ) 14931 } 14932 14933 fn products_editor_availability_section( 14934 form: &ProductEditorFormState, 14935 fulfillment_windows: &[FulfillmentWindowRecord], 14936 cx: &mut Context<HomeView>, 14937 ) -> impl IntoElement { 14938 let choices = fulfillment_windows 14939 .iter() 14940 .enumerate() 14941 .map(|(index, fulfillment_window)| { 14942 let fulfillment_window_id = fulfillment_window.fulfillment_window_id; 14943 choice_button( 14944 ("products-editor-availability", index), 14945 SharedString::from(fulfillment_window.label.clone()), 14946 form.selected_availability_window_id == Some(fulfillment_window_id), 14947 cx.listener(move |this, _, _, cx| { 14948 this.select_product_editor_availability_window(fulfillment_window_id, cx) 14949 }), 14950 cx, 14951 ) 14952 .into_any_element() 14953 }) 14954 .collect::<Vec<_>>(); 14955 14956 div() 14957 .w_full() 14958 .flex() 14959 .flex_col() 14960 .items_start() 14961 .gap(px(APP_UI_THEME.foundation.spacing.small_px)) 14962 .child(home_farm_setup_field_label(app_shared_text( 14963 AppTextKey::ProductsEditorFieldAvailability, 14964 ))) 14965 .child(if choices.is_empty() { 14966 home_body_text(app_shared_text(AppTextKey::ProductsEditorAvailabilityEmpty)) 14967 .into_any_element() 14968 } else { 14969 app_cluster(APP_UI_THEME.foundation.spacing.tight_px) 14970 .w_full() 14971 .children(choices) 14972 .into_any_element() 14973 }) 14974 } 14975 14976 fn products_editor_publish_readiness_section( 14977 form: &ProductEditorFormState, 14978 runtime: &DesktopAppRuntimeSummary, 14979 cx: &App, 14980 ) -> impl IntoElement { 14981 let blockers = form 14982 .current_draft(cx) 14983 .map(|draft| { 14984 derive_product_publish_blockers( 14985 &draft, 14986 &runtime.farm_readiness_projection, 14987 &runtime.farm_rules_projection, 14988 ) 14989 }) 14990 .unwrap_or_default(); 14991 14992 div() 14993 .w_full() 14994 .flex() 14995 .flex_col() 14996 .items_start() 14997 .gap(px(8.0)) 14998 .child(home_farm_setup_field_label(app_shared_text( 14999 AppTextKey::ProductsEditorPublishReadinessTitle, 15000 ))) 15001 .child(if blockers.is_empty() { 15002 home_body_text(app_shared_text(AppTextKey::ProductsEditorReady)).into_any_element() 15003 } else { 15004 div() 15005 .w_full() 15006 .flex() 15007 .flex_col() 15008 .items_start() 15009 .gap(px(8.0)) 15010 .children( 15011 blockers 15012 .into_iter() 15013 .map(products_editor_publish_blocker_row) 15014 .collect::<Vec<_>>(), 15015 ) 15016 .into_any_element() 15017 }) 15018 } 15019 15020 fn products_editor_publish_blocker_row(blocker: ProductPublishBlocker) -> AnyElement { 15021 div() 15022 .w_full() 15023 .flex() 15024 .items_start() 15025 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 15026 .child(status_indicator( 15027 APP_UI_THEME.components.app_status_indicator.attention, 15028 )) 15029 .child(home_body_text(app_shared_text( 15030 products_editor_publish_blocker_key(blocker), 15031 ))) 15032 .into_any_element() 15033 } 15034 15035 fn products_editor_publish_blocker_key(blocker: ProductPublishBlocker) -> AppTextKey { 15036 match blocker { 15037 ProductPublishBlocker::AddProductName => AppTextKey::ProductsEditorBlockerAddProductName, 15038 ProductPublishBlocker::ChooseCategory => AppTextKey::ProductsEditorBlockerChooseCategory, 15039 ProductPublishBlocker::ChooseUnit => AppTextKey::ProductsEditorBlockerChooseUnit, 15040 ProductPublishBlocker::SetPrice => AppTextKey::ProductsEditorBlockerSetPrice, 15041 ProductPublishBlocker::SetStock => AppTextKey::ProductsEditorBlockerSetStock, 15042 ProductPublishBlocker::AttachAvailability => { 15043 AppTextKey::ProductsEditorBlockerAttachAvailability 15044 } 15045 ProductPublishBlocker::CompleteFarmProfile => { 15046 AppTextKey::ProductsEditorBlockerCompleteFarmProfile 15047 } 15048 ProductPublishBlocker::AddPickupLocation => { 15049 AppTextKey::ProductsEditorBlockerAddPickupLocation 15050 } 15051 ProductPublishBlocker::AddOperatingRules => { 15052 AppTextKey::ProductsEditorBlockerAddOperatingRules 15053 } 15054 ProductPublishBlocker::AddFulfillmentWindow => { 15055 AppTextKey::ProductsEditorBlockerAddFulfillmentWindow 15056 } 15057 ProductPublishBlocker::ResolveAvailabilityConflicts => { 15058 AppTextKey::ProductsEditorBlockerResolveAvailabilityConflicts 15059 } 15060 } 15061 } 15062 15063 fn products_editor_validation_keys(form: &ProductEditorFormState, cx: &App) -> Vec<AppTextKey> { 15064 let mut keys = Vec::new(); 15065 15066 if let Some(key) = products_editor_invalid_price_key(form, cx) { 15067 keys.push(key); 15068 } 15069 15070 if let Some(key) = products_editor_invalid_stock_key(form, cx) { 15071 keys.push(key); 15072 } 15073 15074 keys 15075 } 15076 15077 fn products_editor_invalid_price_key( 15078 form: &ProductEditorFormState, 15079 cx: &App, 15080 ) -> Option<AppTextKey> { 15081 parse_product_editor_price_input(form.price_input.read(cx).value().as_ref()) 15082 .is_none() 15083 .then_some(AppTextKey::ProductsEditorInvalidPrice) 15084 } 15085 15086 fn products_editor_invalid_stock_key( 15087 form: &ProductEditorFormState, 15088 cx: &App, 15089 ) -> Option<AppTextKey> { 15090 parse_optional_product_editor_stock_input(form.stock_input.read(cx).value().as_ref()) 15091 .is_none() 15092 .then_some(AppTextKey::ProductsEditorInvalidStock) 15093 } 15094 15095 fn parse_products_stock_quantity(input: &str) -> Option<u32> { 15096 input.trim().parse().ok() 15097 } 15098 15099 fn parse_product_editor_price_input(input: &str) -> Option<Option<u32>> { 15100 let trimmed = input.trim(); 15101 if trimmed.is_empty() { 15102 return Some(None); 15103 } 15104 15105 let parse_whole_dollars = |value: &str| -> Option<u32> { value.parse::<u32>().ok() }; 15106 15107 if let Some((dollars, cents)) = trimmed.split_once('.') { 15108 if trimmed.matches('.').count() != 1 || cents.is_empty() || cents.len() > 2 { 15109 return None; 15110 } 15111 15112 let dollars = if dollars.is_empty() { 15113 0 15114 } else { 15115 parse_whole_dollars(dollars)? 15116 }; 15117 let cents = match cents.len() { 15118 1 => cents.parse::<u32>().ok()?.checked_mul(10)?, 15119 2 => cents.parse::<u32>().ok()?, 15120 _ => return None, 15121 }; 15122 15123 return dollars 15124 .checked_mul(100) 15125 .and_then(|amount| amount.checked_add(cents)) 15126 .map(Some); 15127 } 15128 15129 parse_whole_dollars(trimmed) 15130 .and_then(|dollars| dollars.checked_mul(100)) 15131 .map(Some) 15132 } 15133 15134 fn parse_optional_product_editor_stock_input(input: &str) -> Option<Option<u32>> { 15135 let trimmed = input.trim(); 15136 if trimmed.is_empty() { 15137 return Some(None); 15138 } 15139 15140 trimmed.parse::<u32>().ok().map(Some) 15141 } 15142 15143 fn product_editor_price_input_value(price_minor_units: Option<u32>) -> String { 15144 price_minor_units 15145 .map(|amount_minor_units| { 15146 format!( 15147 "{}.{:02}", 15148 amount_minor_units / 100, 15149 amount_minor_units % 100 15150 ) 15151 }) 15152 .unwrap_or_default() 15153 } 15154 15155 fn product_display_title(title: &str) -> String { 15156 let trimmed = title.trim(); 15157 if trimmed.is_empty() { 15158 app_shared_text(AppTextKey::ProductsUntitledDraft).to_string() 15159 } else { 15160 trimmed.to_owned() 15161 } 15162 } 15163 15164 fn home_farm_setup_onboarding_card( 15165 spec: FarmSetupOnboardingCardSpec, 15166 on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15167 cx: &App, 15168 ) -> impl IntoElement { 15169 home_card( 15170 app_shared_text(spec.title_key), 15171 div() 15172 .w_full() 15173 .flex() 15174 .flex_col() 15175 .items_start() 15176 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15177 .child(home_body_text(app_shared_text(spec.body_key))) 15178 .when_some(spec.action_key, |this, action_key| { 15179 this.child(div().child(action_button_primary( 15180 "home-farm-setup-start", 15181 app_shared_text(action_key), 15182 on_open_farm_setup, 15183 cx, 15184 ))) 15185 }), 15186 ) 15187 } 15188 15189 fn home_farm_setup_form_card( 15190 form: &FarmSetupFormState, 15191 on_pickup_change: impl Fn(&bool, &mut Window, &mut App) + 'static, 15192 on_delivery_change: impl Fn(&bool, &mut Window, &mut App) + 'static, 15193 on_shipping_change: impl Fn(&bool, &mut Window, &mut App) + 'static, 15194 on_finish_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15195 cx: &App, 15196 ) -> impl IntoElement { 15197 let blockers = form.draft.blockers(); 15198 let finish_ready = blockers.is_empty(); 15199 15200 home_card( 15201 app_shared_text(AppTextKey::HomeFarmSetupOnboardingTitle), 15202 div() 15203 .w_full() 15204 .flex() 15205 .flex_col() 15206 .items_start() 15207 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15208 .child(home_body_text(app_shared_text( 15209 AppTextKey::HomeFarmSetupOnboardingBody, 15210 ))) 15211 .child(app_form_section( 15212 app_shared_text(AppTextKey::HomeFarmSetupSectionFarm), 15213 app_form_input_text( 15214 AppFormFieldSpec::new( 15215 app_shared_text(AppTextKey::HomeFarmSetupFieldFarmName), 15216 blockers 15217 .contains(&FarmSetupBlocker::AddFarmName) 15218 .then_some(AppTextKey::HomeFarmSetupBlockerAddFarmName) 15219 .map(app_shared_text), 15220 ), 15221 &form.farm_name_input, 15222 false, 15223 ), 15224 )) 15225 .child(app_form_section( 15226 app_shared_text(AppTextKey::HomeFarmSetupSectionLocation), 15227 app_form_input_text( 15228 AppFormFieldSpec::new( 15229 app_shared_text(AppTextKey::HomeFarmSetupFieldLocationOrServiceArea), 15230 blockers 15231 .contains(&FarmSetupBlocker::AddLocationOrServiceArea) 15232 .then_some(AppTextKey::HomeFarmSetupBlockerAddLocationOrServiceArea) 15233 .map(app_shared_text), 15234 ), 15235 &form.location_input, 15236 false, 15237 ), 15238 )) 15239 .child(home_farm_setup_order_method_section( 15240 form, 15241 blockers 15242 .contains(&FarmSetupBlocker::ChooseOrderMethod) 15243 .then_some(AppTextKey::HomeFarmSetupBlockerChooseOrderMethod), 15244 on_pickup_change, 15245 on_delivery_change, 15246 on_shipping_change, 15247 cx, 15248 )) 15249 .child( 15250 div() 15251 .w_full() 15252 .flex() 15253 .flex_col() 15254 .items_start() 15255 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15256 .child(home_body_text(app_shared_text(farm_setup_save_state_key( 15257 form.save_state, 15258 )))) 15259 .child(div().child(if finish_ready { 15260 action_button_primary( 15261 "home-farm-setup-finish", 15262 app_shared_text(AppTextKey::HomeFarmSetupFinishAction), 15263 on_finish_setup, 15264 cx, 15265 ) 15266 .into_any_element() 15267 } else { 15268 action_button_primary_disabled( 15269 "home-farm-setup-finish", 15270 app_shared_text(AppTextKey::HomeFarmSetupFinishAction), 15271 cx, 15272 ) 15273 .into_any_element() 15274 })), 15275 ), 15276 ) 15277 } 15278 15279 fn home_farm_setup_order_method_section( 15280 form: &FarmSetupFormState, 15281 blocker_key: Option<AppTextKey>, 15282 on_pickup_change: impl Fn(&bool, &mut Window, &mut App) + 'static, 15283 on_delivery_change: impl Fn(&bool, &mut Window, &mut App) + 'static, 15284 on_shipping_change: impl Fn(&bool, &mut Window, &mut App) + 'static, 15285 cx: &App, 15286 ) -> impl IntoElement { 15287 app_form_section( 15288 app_shared_text(AppTextKey::HomeFarmSetupSectionOrderMethods), 15289 div() 15290 .w_full() 15291 .flex() 15292 .flex_col() 15293 .items_start() 15294 .gap(px(8.0)) 15295 .child(app_checkbox_field( 15296 AppCheckboxFieldSpec::new( 15297 "home-farm-setup-pickup", 15298 app_shared_text(AppTextKey::HomeFarmSetupOrderMethodPickup), 15299 Option::<SharedString>::None, 15300 ), 15301 form.draft.order_methods.contains(&FarmOrderMethod::Pickup), 15302 cx, 15303 move |checked, window, cx| on_pickup_change(&checked, window, cx), 15304 )) 15305 .child(app_checkbox_field( 15306 AppCheckboxFieldSpec::new( 15307 "home-farm-setup-delivery", 15308 app_shared_text(AppTextKey::HomeFarmSetupOrderMethodDelivery), 15309 Option::<SharedString>::None, 15310 ), 15311 form.draft 15312 .order_methods 15313 .contains(&FarmOrderMethod::Delivery), 15314 cx, 15315 move |checked, window, cx| on_delivery_change(&checked, window, cx), 15316 )) 15317 .child(app_checkbox_field( 15318 AppCheckboxFieldSpec::new( 15319 "home-farm-setup-shipping", 15320 app_shared_text(AppTextKey::HomeFarmSetupOrderMethodShipping), 15321 Option::<SharedString>::None, 15322 ), 15323 form.draft 15324 .order_methods 15325 .contains(&FarmOrderMethod::Shipping), 15326 cx, 15327 move |checked, window, cx| on_shipping_change(&checked, window, cx), 15328 )) 15329 .when_some(blocker_key, |this, blocker_key| { 15330 this.child(home_body_text(app_shared_text(blocker_key))) 15331 }), 15332 ) 15333 } 15334 15335 fn settings_panel_farm_context(runtime: &DesktopAppRuntimeSummary) -> Option<(String, FarmId)> { 15336 let account_id = runtime 15337 .settings_account_projection 15338 .selected_account 15339 .as_ref()? 15340 .account 15341 .account_id 15342 .clone(); 15343 let farm_id = runtime 15344 .settings_account_projection 15345 .selected_account 15346 .as_ref() 15347 .and_then(|account| account.farmer_activation.farm_id) 15348 .or(runtime 15349 .farm_setup_projection 15350 .saved_farm 15351 .as_ref() 15352 .map(|farm| farm.farm_id))?; 15353 15354 Some((account_id, farm_id)) 15355 } 15356 15357 fn settings_pickup_location_title( 15358 index: usize, 15359 pickup_location: &SettingsPickupLocationFormState, 15360 cx: &App, 15361 ) -> String { 15362 let label = pickup_location 15363 .label_input 15364 .read(cx) 15365 .value() 15366 .trim() 15367 .to_owned(); 15368 if label.is_empty() { 15369 format!( 15370 "{} {}", 15371 app_shared_text(AppTextKey::SettingsPickupLocationsSectionLabel), 15372 index + 1 15373 ) 15374 } else { 15375 label 15376 } 15377 } 15378 15379 fn settings_pickup_location_card( 15380 index: usize, 15381 pickup_location: &SettingsPickupLocationFormState, 15382 on_make_default: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15383 on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15384 cx: &App, 15385 ) -> impl IntoElement { 15386 let title = settings_pickup_location_title(index, pickup_location, cx); 15387 let action_row = div() 15388 .flex() 15389 .items_center() 15390 .gap(px(8.0)) 15391 .child(if pickup_location.is_default { 15392 settings_badge_text(app_shared_text( 15393 AppTextKey::SettingsPickupLocationsDefaultBadge, 15394 )) 15395 .into_any_element() 15396 } else { 15397 action_button_compact( 15398 ("settings-farm-default-pickup", index), 15399 app_shared_text(AppTextKey::SettingsPickupLocationsMakeDefaultAction), 15400 on_make_default, 15401 cx, 15402 ) 15403 .into_any_element() 15404 }) 15405 .when(pickup_location.can_remove, |this| { 15406 this.child( 15407 action_button_compact( 15408 ("settings-farm-remove-pickup", index), 15409 app_shared_text(AppTextKey::SettingsPickupLocationsRemoveAction), 15410 on_remove, 15411 cx, 15412 ) 15413 .into_any_element(), 15414 ) 15415 }); 15416 15417 app_surface_panel( 15418 app_stack_v(10.0) 15419 .w_full() 15420 .p(px(12.0)) 15421 .child( 15422 div() 15423 .w_full() 15424 .flex() 15425 .items_start() 15426 .justify_between() 15427 .gap(px(8.0)) 15428 .child( 15429 div() 15430 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15431 .font_weight(gpui::FontWeight::SEMIBOLD) 15432 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15433 .child(title), 15434 ) 15435 .child(action_row), 15436 ) 15437 .child(app_form_input_text( 15438 AppFormFieldSpec::new( 15439 app_shared_text(AppTextKey::SettingsPickupLocationsFieldLabel), 15440 Option::<SharedString>::None, 15441 ), 15442 &pickup_location.label_input, 15443 false, 15444 )) 15445 .child(app_form_input_text( 15446 AppFormFieldSpec::new( 15447 app_shared_text(AppTextKey::SettingsPickupLocationsFieldAddress), 15448 Option::<SharedString>::None, 15449 ), 15450 &pickup_location.address_input, 15451 false, 15452 )) 15453 .child(app_form_input_text( 15454 AppFormFieldSpec::new( 15455 app_shared_text(AppTextKey::SettingsPickupLocationsFieldDirections), 15456 Option::<SharedString>::None, 15457 ), 15458 &pickup_location.directions_input, 15459 false, 15460 )), 15461 ) 15462 } 15463 15464 fn settings_fulfillment_window_title( 15465 index: usize, 15466 fulfillment_window: &SettingsFulfillmentWindowFormState, 15467 cx: &App, 15468 ) -> String { 15469 let label = fulfillment_window 15470 .label_input 15471 .read(cx) 15472 .value() 15473 .trim() 15474 .to_owned(); 15475 if label.is_empty() { 15476 format!( 15477 "{} {}", 15478 app_shared_text(AppTextKey::SettingsFulfillmentWindowsItemLabel), 15479 index + 1 15480 ) 15481 } else { 15482 label 15483 } 15484 } 15485 15486 fn settings_blackout_period_title( 15487 index: usize, 15488 blackout_period: &SettingsBlackoutPeriodFormState, 15489 cx: &App, 15490 ) -> String { 15491 let label = blackout_period 15492 .label_input 15493 .read(cx) 15494 .value() 15495 .trim() 15496 .to_owned(); 15497 if label.is_empty() { 15498 format!( 15499 "{} {}", 15500 app_shared_text(AppTextKey::SettingsBlackoutPeriodsItemLabel), 15501 index + 1 15502 ) 15503 } else { 15504 label 15505 } 15506 } 15507 15508 fn settings_fulfillment_window_card( 15509 index: usize, 15510 fulfillment_window: &SettingsFulfillmentWindowFormState, 15511 pickup_location_options: Vec<AnyElement>, 15512 validation_keys: &[AppTextKey], 15513 on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15514 cx: &App, 15515 ) -> impl IntoElement { 15516 app_surface_panel( 15517 app_stack_v(10.0) 15518 .w_full() 15519 .p(px(12.0)) 15520 .child( 15521 div() 15522 .w_full() 15523 .flex() 15524 .items_start() 15525 .justify_between() 15526 .gap(px(8.0)) 15527 .child( 15528 div() 15529 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15530 .font_weight(gpui::FontWeight::SEMIBOLD) 15531 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15532 .child(settings_fulfillment_window_title( 15533 index, 15534 fulfillment_window, 15535 cx, 15536 )), 15537 ) 15538 .child( 15539 action_button_compact( 15540 ("settings-remove-fulfillment-window", index), 15541 app_shared_text(AppTextKey::SettingsFulfillmentWindowsRemoveAction), 15542 on_remove, 15543 cx, 15544 ) 15545 .into_any_element(), 15546 ), 15547 ) 15548 .child(app_form_input_text( 15549 AppFormFieldSpec::new( 15550 app_shared_text(AppTextKey::SettingsFulfillmentWindowsFieldLabel), 15551 Option::<SharedString>::None, 15552 ), 15553 &fulfillment_window.label_input, 15554 false, 15555 )) 15556 .child(app_form_field( 15557 AppFormFieldSpec::new( 15558 app_shared_text(AppTextKey::SettingsFulfillmentWindowsFieldPickupLocation), 15559 Option::<SharedString>::None, 15560 ), 15561 div() 15562 .w_full() 15563 .flex() 15564 .flex_wrap() 15565 .gap(px(8.0)) 15566 .children(pickup_location_options), 15567 )) 15568 .child(app_form_input_text( 15569 AppFormFieldSpec::new( 15570 app_shared_text(AppTextKey::SettingsFulfillmentWindowsFieldStartsAt), 15571 Option::<SharedString>::None, 15572 ), 15573 &fulfillment_window.starts_at_input, 15574 false, 15575 )) 15576 .child(app_form_input_text( 15577 AppFormFieldSpec::new( 15578 app_shared_text(AppTextKey::SettingsFulfillmentWindowsFieldEndsAt), 15579 Option::<SharedString>::None, 15580 ), 15581 &fulfillment_window.ends_at_input, 15582 false, 15583 )) 15584 .child(app_form_input_text( 15585 AppFormFieldSpec::new( 15586 app_shared_text(AppTextKey::SettingsFulfillmentWindowsFieldOrderCutoff), 15587 Option::<SharedString>::None, 15588 ), 15589 &fulfillment_window.order_cutoff_input, 15590 false, 15591 )) 15592 .children( 15593 validation_keys 15594 .iter() 15595 .copied() 15596 .map(|key| home_body_text(app_shared_text(key)).into_any_element()) 15597 .collect::<Vec<_>>(), 15598 ), 15599 ) 15600 } 15601 15602 fn settings_blackout_period_card( 15603 index: usize, 15604 blackout_period: &SettingsBlackoutPeriodFormState, 15605 validation_keys: &[AppTextKey], 15606 on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15607 cx: &App, 15608 ) -> impl IntoElement { 15609 app_surface_panel( 15610 app_stack_v(10.0) 15611 .w_full() 15612 .p(px(12.0)) 15613 .child( 15614 div() 15615 .w_full() 15616 .flex() 15617 .items_start() 15618 .justify_between() 15619 .gap(px(8.0)) 15620 .child( 15621 div() 15622 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15623 .font_weight(gpui::FontWeight::SEMIBOLD) 15624 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15625 .child(settings_blackout_period_title(index, blackout_period, cx)), 15626 ) 15627 .child( 15628 action_button_compact( 15629 ("settings-remove-blackout-period", index), 15630 app_shared_text(AppTextKey::SettingsBlackoutPeriodsRemoveAction), 15631 on_remove, 15632 cx, 15633 ) 15634 .into_any_element(), 15635 ), 15636 ) 15637 .child(app_form_input_text( 15638 AppFormFieldSpec::new( 15639 app_shared_text(AppTextKey::SettingsBlackoutPeriodsFieldLabel), 15640 Option::<SharedString>::None, 15641 ), 15642 &blackout_period.label_input, 15643 false, 15644 )) 15645 .child(app_form_input_text( 15646 AppFormFieldSpec::new( 15647 app_shared_text(AppTextKey::SettingsBlackoutPeriodsFieldStartsAt), 15648 Option::<SharedString>::None, 15649 ), 15650 &blackout_period.starts_at_input, 15651 false, 15652 )) 15653 .child(app_form_input_text( 15654 AppFormFieldSpec::new( 15655 app_shared_text(AppTextKey::SettingsBlackoutPeriodsFieldEndsAt), 15656 Option::<SharedString>::None, 15657 ), 15658 &blackout_period.ends_at_input, 15659 false, 15660 )) 15661 .children( 15662 validation_keys 15663 .iter() 15664 .copied() 15665 .map(|key| home_body_text(app_shared_text(key)).into_any_element()) 15666 .collect::<Vec<_>>(), 15667 ), 15668 ) 15669 } 15670 15671 fn settings_farm_readiness_rows(evaluation: &SettingsFarmRulesEvaluation) -> Vec<AnyElement> { 15672 let readiness_keys = if evaluation.readiness_keys.is_empty() { 15673 vec![AppTextKey::SettingsReadinessReady] 15674 } else { 15675 evaluation.readiness_keys.clone() 15676 }; 15677 15678 readiness_keys 15679 .into_iter() 15680 .map(|key| { 15681 app_surface_panel( 15682 div() 15683 .px(px(12.0)) 15684 .py(px(10.0)) 15685 .child(home_farm_setup_field_label(app_shared_text(key))), 15686 ) 15687 .into_any_element() 15688 }) 15689 .collect() 15690 } 15691 15692 fn settings_readiness_key(blocker: FarmReadinessBlocker) -> AppTextKey { 15693 match blocker { 15694 FarmReadinessBlocker::MissingProfileBasics => { 15695 AppTextKey::SettingsReadinessFieldMissingProfileBasics 15696 } 15697 FarmReadinessBlocker::MissingPickupLocation => { 15698 AppTextKey::SettingsReadinessFieldMissingPickupLocation 15699 } 15700 FarmReadinessBlocker::MissingFulfillmentWindow => { 15701 AppTextKey::SettingsReadinessFieldMissingFulfillmentWindow 15702 } 15703 FarmReadinessBlocker::MissingOperatingRules => { 15704 AppTextKey::SettingsReadinessFieldMissingOperatingRules 15705 } 15706 } 15707 } 15708 15709 fn settings_timing_conflict_key(kind: FarmTimingConflictKind) -> AppTextKey { 15710 match kind { 15711 FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart => { 15712 AppTextKey::SettingsReadinessFieldFulfillmentWindowEndsBeforeStart 15713 } 15714 FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart => { 15715 AppTextKey::SettingsReadinessFieldFulfillmentWindowCutoffAfterStart 15716 } 15717 FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart => { 15718 AppTextKey::SettingsReadinessFieldBlackoutPeriodEndsBeforeStart 15719 } 15720 FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow => { 15721 AppTextKey::SettingsReadinessFieldBlackoutOverlapsFulfillmentWindow 15722 } 15723 } 15724 } 15725 15726 fn home_saved_farm_summary_card(runtime: &DesktopAppRuntimeSummary) -> Option<AnyElement> { 15727 let saved_farm = home_saved_farm(runtime)?; 15728 let location_or_service_area = if runtime 15729 .farm_setup_projection 15730 .draft 15731 .location_or_service_area 15732 .trim() 15733 .is_empty() 15734 { 15735 app_shared_text(AppTextKey::ValueNone).to_string() 15736 } else { 15737 runtime 15738 .farm_setup_projection 15739 .draft 15740 .location_or_service_area 15741 .clone() 15742 }; 15743 15744 Some( 15745 home_card( 15746 saved_farm.display_name.clone(), 15747 label_value_list(vec![ 15748 LabelValueRow::new( 15749 app_shared_text(AppTextKey::HomeFarmSetupFieldLocationOrServiceArea), 15750 location_or_service_area, 15751 ), 15752 LabelValueRow::new( 15753 app_shared_text(AppTextKey::HomeFarmSetupSectionOrderMethods), 15754 home_farm_order_methods_summary(&runtime.farm_setup_projection.draft), 15755 ), 15756 ]), 15757 ) 15758 .into_any_element(), 15759 ) 15760 } 15761 15762 fn home_status_row(status: &HomeStatusPresentation) -> impl IntoElement { 15763 div() 15764 .flex() 15765 .items_center() 15766 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 15767 .child(status_indicator(status.indicator_color)) 15768 .child( 15769 div() 15770 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15771 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 15772 .child(app_shared_text(status.label_key)), 15773 ) 15774 } 15775 15776 fn home_summary_card(summary: &radroots_app_view::TodaySummary) -> impl IntoElement { 15777 home_card( 15778 app_shared_text(AppTextKey::HomeTodayTitle), 15779 div() 15780 .w_full() 15781 .flex() 15782 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15783 .child(home_summary_metric( 15784 AppTextKey::HomeTodayOrdersNeedingAction, 15785 summary.orders_needing_action, 15786 )) 15787 .child(home_summary_metric( 15788 AppTextKey::HomeTodayLowStock, 15789 summary.low_stock_products, 15790 )) 15791 .child(home_summary_metric( 15792 AppTextKey::HomeTodayDraftProducts, 15793 summary.draft_products, 15794 )), 15795 ) 15796 } 15797 15798 fn home_summary_metric(label_key: AppTextKey, value: u32) -> impl IntoElement { 15799 div() 15800 .flex_1() 15801 .min_w_0() 15802 .bg(rgb(APP_UI_THEME.foundation.surfaces.window_background)) 15803 .rounded(px(APP_UI_THEME.foundation.radii.medium_px)) 15804 .p(px(16.0)) 15805 .flex() 15806 .flex_col() 15807 .gap(px(4.0)) 15808 .child( 15809 div() 15810 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px * 2.0)) 15811 .font_weight(gpui::FontWeight::BOLD) 15812 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15813 .child(value.to_string()), 15814 ) 15815 .child( 15816 div() 15817 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 15818 .line_height(relative(1.2)) 15819 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 15820 .child(app_shared_text(label_key)), 15821 ) 15822 } 15823 15824 fn home_setup_card( 15825 projection: &TodayAgendaProjection, 15826 continue_action: Option<AnyElement>, 15827 ) -> impl IntoElement { 15828 home_list_card( 15829 AppTextKey::HomeTodaySetupChecklist, 15830 projection 15831 .setup_checklist 15832 .iter() 15833 .map(home_setup_task_row) 15834 .collect::<Vec<_>>(), 15835 continue_action, 15836 ) 15837 } 15838 15839 fn home_next_fulfillment_window_card( 15840 next_window: &FulfillmentWindowSummary, 15841 action: Option<AnyElement>, 15842 ) -> impl IntoElement { 15843 home_card( 15844 app_shared_text(AppTextKey::HomeTodayNextFulfillmentWindow), 15845 div() 15846 .w_full() 15847 .flex() 15848 .flex_col() 15849 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15850 .child(label_value_list(vec![ 15851 LabelValueRow::new( 15852 app_shared_text(AppTextKey::HomeTodayWindowStartsLabel), 15853 next_window.starts_at.clone(), 15854 ), 15855 LabelValueRow::new( 15856 app_shared_text(AppTextKey::HomeTodayWindowEndsLabel), 15857 next_window.ends_at.clone(), 15858 ), 15859 ])) 15860 .when_some(action, |this, action| this.child(div().child(action))), 15861 ) 15862 } 15863 15864 fn home_list_card( 15865 title_key: AppTextKey, 15866 rows: Vec<AnyElement>, 15867 action: Option<AnyElement>, 15868 ) -> impl IntoElement { 15869 home_card( 15870 app_shared_text(title_key), 15871 div() 15872 .w_full() 15873 .flex() 15874 .flex_col() 15875 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15876 .children(rows) 15877 .when_some(action, |this, action| this.child(div().child(action))), 15878 ) 15879 } 15880 15881 fn order_detail_item_row(item: &OrderDetailItemRow) -> AnyElement { 15882 let unit_price = item.unit_price.as_ref().map(buyer_listing_price_text); 15883 let line_total = item.unit_price.as_ref().and_then(|unit_price| { 15884 item.line_total_minor_units 15885 .map(|amount| buyer_money_text(amount, unit_price.currency_code.as_str())) 15886 }); 15887 15888 div() 15889 .w_full() 15890 .min_w_0() 15891 .flex() 15892 .items_center() 15893 .justify_between() 15894 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15895 .child( 15896 div() 15897 .flex_1() 15898 .min_w_0() 15899 .flex() 15900 .flex_col() 15901 .gap(px(2.0)) 15902 .child( 15903 div() 15904 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15905 .font_weight(gpui::FontWeight::MEDIUM) 15906 .line_height(relative(1.2)) 15907 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15908 .child(item.title.clone()), 15909 ) 15910 .when_some(unit_price, |this, unit_price| { 15911 this.child( 15912 div() 15913 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 15914 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 15915 .child(unit_price), 15916 ) 15917 }), 15918 ) 15919 .child( 15920 div() 15921 .flex() 15922 .flex_col() 15923 .items_end() 15924 .gap(px(2.0)) 15925 .child( 15926 div() 15927 .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px)) 15928 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 15929 .child(item.quantity_display.clone()), 15930 ) 15931 .when_some(line_total, |this, line_total| { 15932 this.child( 15933 div() 15934 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15935 .font_weight(gpui::FontWeight::MEDIUM) 15936 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15937 .child(line_total), 15938 ) 15939 }), 15940 ) 15941 .into_any_element() 15942 } 15943 15944 fn order_optional_text(value: Option<&str>) -> SharedString { 15945 value 15946 .filter(|value| !value.trim().is_empty()) 15947 .map(|value| SharedString::from(value.to_owned())) 15948 .unwrap_or_else(|| app_shared_text(AppTextKey::ValueNone)) 15949 } 15950 15951 fn home_order_row( 15952 index: usize, 15953 order: &OrderListRow, 15954 on_open: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, 15955 cx: &App, 15956 ) -> AnyElement { 15957 div() 15958 .w_full() 15959 .min_w_0() 15960 .flex() 15961 .items_center() 15962 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15963 .child(list_row_button( 15964 ("home-today-order-open", index), 15965 order.order_number.clone(), 15966 Some(SharedString::from(order.customer_display_name.clone())), 15967 false, 15968 on_open, 15969 cx, 15970 )) 15971 .child(status_indicator( 15972 APP_UI_THEME.components.app_status_indicator.attention, 15973 )) 15974 .into_any_element() 15975 } 15976 15977 fn home_low_stock_row(product: &ProductListRow) -> AnyElement { 15978 div() 15979 .w_full() 15980 .min_w_0() 15981 .flex() 15982 .items_center() 15983 .justify_between() 15984 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 15985 .child( 15986 div() 15987 .min_w_0() 15988 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 15989 .font_weight(gpui::FontWeight::SEMIBOLD) 15990 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 15991 .child(product_display_title(product.title.as_str())), 15992 ) 15993 .child( 15994 div() 15995 .flex() 15996 .items_center() 15997 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 15998 .child(status_indicator( 15999 APP_UI_THEME.components.app_status_indicator.attention, 16000 )) 16001 .child( 16002 div() 16003 .flex() 16004 .items_center() 16005 .gap(px(4.0)) 16006 .child( 16007 div() 16008 .text_size(px(APP_UI_THEME 16009 .foundation 16010 .typography 16011 .utility_title_text_px)) 16012 .text_color(rgb(APP_UI_THEME.foundation.text.secondary)) 16013 .child(app_shared_label_text(AppTextKey::HomeTodayStockCountLabel)), 16014 ) 16015 .child( 16016 div() 16017 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 16018 .font_weight(gpui::FontWeight::SEMIBOLD) 16019 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 16020 .child(product.stock_count.to_string()), 16021 ), 16022 ), 16023 ) 16024 .into_any_element() 16025 } 16026 16027 fn home_draft_row(product: &ProductListRow) -> AnyElement { 16028 div() 16029 .w_full() 16030 .min_w_0() 16031 .flex() 16032 .items_center() 16033 .justify_between() 16034 .gap(px(APP_UI_THEME.shells.home_stack_gap_px)) 16035 .child( 16036 div() 16037 .min_w_0() 16038 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 16039 .font_weight(gpui::FontWeight::SEMIBOLD) 16040 .text_color(rgb(APP_UI_THEME.foundation.text.primary)) 16041 .child(product_display_title(product.title.as_str())), 16042 ) 16043 .child(status_indicator( 16044 APP_UI_THEME.components.app_status_indicator.offline, 16045 )) 16046 .into_any_element() 16047 } 16048 16049 fn home_setup_task_row(task: &radroots_app_view::TodaySetupTask) -> AnyElement { 16050 let is_complete = task.is_complete; 16051 16052 div() 16053 .w_full() 16054 .min_w_0() 16055 .flex() 16056 .items_center() 16057 .gap(px(APP_UI_THEME.shells.settings_account_status_gap_px)) 16058 .child(status_indicator(if is_complete { 16059 APP_UI_THEME.components.app_status_indicator.online 16060 } else { 16061 APP_UI_THEME.components.app_status_indicator.offline 16062 })) 16063 .child( 16064 div() 16065 .flex_1() 16066 .min_w_0() 16067 .text_size(px(APP_UI_THEME.foundation.typography.body_text_px)) 16068 .font_weight(gpui::FontWeight::MEDIUM) 16069 .line_height(relative(1.2)) 16070 .text_color(rgb(if is_complete { 16071 APP_UI_THEME.foundation.text.secondary 16072 } else { 16073 APP_UI_THEME.foundation.text.primary 16074 })) 16075 .child(app_shared_text(home_setup_task_label_key(task.kind))), 16076 ) 16077 .into_any_element() 16078 } 16079 16080 fn home_empty_state_card(title_key: AppTextKey, body_key: AppTextKey) -> impl IntoElement { 16081 home_card( 16082 app_shared_text(title_key), 16083 home_body_text(app_shared_text(body_key)), 16084 ) 16085 } 16086 16087 fn buyer_order_place_failure_notice(error: &AppSqliteError) -> BuyerWorkspaceNotice { 16088 match error { 16089 AppSqliteError::LocalEventsSql { .. } | AppSqliteError::LocalEvents { .. } => { 16090 BuyerWorkspaceNotice::OrderCoordinationFailed 16091 } 16092 _ => BuyerWorkspaceNotice::OrderPlaceFailed, 16093 } 16094 } 16095 16096 fn buyer_order_coordination_notice_forces_redraw(notice: BuyerWorkspaceNotice) -> bool { 16097 notice == BuyerWorkspaceNotice::OrderCoordinationFailed 16098 } 16099 16100 fn buyer_workspace_notice_card(notice: String) -> impl IntoElement { 16101 app_surface_card(home_body_text(notice)) 16102 } 16103 16104 fn farm_setup_onboarding_card_spec(home_route: HomeRoute) -> Option<FarmSetupOnboardingCardSpec> { 16105 match home_route { 16106 HomeRoute::FarmSetupOnboarding => Some(FarmSetupOnboardingCardSpec { 16107 title_key: AppTextKey::HomeFarmSetupOnboardingTitle, 16108 body_key: AppTextKey::HomeFarmSetupOnboardingBody, 16109 action_key: Some(AppTextKey::HomeFarmSetupOnboardingAction), 16110 }), 16111 HomeRoute::FarmSetupForm => Some(FarmSetupOnboardingCardSpec { 16112 title_key: AppTextKey::HomeFarmSetupOnboardingTitle, 16113 body_key: AppTextKey::HomeFarmSetupOnboardingBody, 16114 action_key: None, 16115 }), 16116 _ => None, 16117 } 16118 } 16119 16120 fn farm_setup_save_state_key(state: FarmSetupSaveState) -> AppTextKey { 16121 match state { 16122 FarmSetupSaveState::AutosavesLocally => AppTextKey::HomeFarmSetupSaveAutosavesLocally, 16123 FarmSetupSaveState::SavedLocally => AppTextKey::HomeFarmSetupSaveSavedLocally, 16124 FarmSetupSaveState::SaveFailed => AppTextKey::HomeFarmSetupSaveFailedLocally, 16125 } 16126 } 16127 16128 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 16129 enum FarmerHomeFarmState { 16130 NoFarm, 16131 IncompleteFarm, 16132 ConfiguredFarm, 16133 } 16134 16135 fn home_saved_farm(runtime: &DesktopAppRuntimeSummary) -> Option<&FarmSummary> { 16136 runtime 16137 .today_projection 16138 .farm 16139 .as_ref() 16140 .or(runtime.farm_setup_projection.saved_farm.as_ref()) 16141 } 16142 16143 fn farmer_home_farm_state(runtime: &DesktopAppRuntimeSummary) -> FarmerHomeFarmState { 16144 match runtime.farm_readiness_projection.status { 16145 FarmWorkspaceStatus::NoFarm => FarmerHomeFarmState::NoFarm, 16146 FarmWorkspaceStatus::SetupRequired => { 16147 if home_saved_farm(runtime).is_some() { 16148 FarmerHomeFarmState::IncompleteFarm 16149 } else { 16150 FarmerHomeFarmState::NoFarm 16151 } 16152 } 16153 FarmWorkspaceStatus::Ready => FarmerHomeFarmState::ConfiguredFarm, 16154 } 16155 } 16156 16157 fn home_farm_order_methods_summary(draft: &FarmSetupDraft) -> String { 16158 if draft.order_methods.is_empty() { 16159 return app_shared_text(AppTextKey::ValueNone).to_string(); 16160 } 16161 16162 draft 16163 .order_methods 16164 .iter() 16165 .copied() 16166 .map(home_farm_order_method_label_key) 16167 .map(app_shared_text) 16168 .map(|label| label.to_string()) 16169 .collect::<Vec<_>>() 16170 .join(", ") 16171 } 16172 16173 fn home_status_presentation(runtime: &DesktopAppRuntimeSummary) -> HomeStatusPresentation { 16174 if runtime.startup_issue.is_some() || runtime.startup_gate == AppStartupGate::Blocked { 16175 return HomeStatusPresentation { 16176 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 16177 label_key: AppTextKey::HomeTodayStatusStartupIssue, 16178 }; 16179 } 16180 16181 if runtime.startup_gate == AppStartupGate::SetupRequired { 16182 return HomeStatusPresentation { 16183 indicator_color: APP_UI_THEME.components.app_status_indicator.offline, 16184 label_key: AppTextKey::HomeTodayStatusSetup, 16185 }; 16186 } 16187 16188 match farmer_home_farm_state(runtime) { 16189 FarmerHomeFarmState::NoFarm => { 16190 return HomeStatusPresentation { 16191 indicator_color: APP_UI_THEME.components.app_status_indicator.offline, 16192 label_key: AppTextKey::HomeTodayStatusNoFarm, 16193 }; 16194 } 16195 FarmerHomeFarmState::IncompleteFarm => { 16196 return HomeStatusPresentation { 16197 indicator_color: APP_UI_THEME.components.app_status_indicator.offline, 16198 label_key: AppTextKey::HomeTodayStatusSetup, 16199 }; 16200 } 16201 FarmerHomeFarmState::ConfiguredFarm => {} 16202 } 16203 16204 if runtime.today_projection.has_attention_items() { 16205 return HomeStatusPresentation { 16206 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 16207 label_key: AppTextKey::HomeTodayStatusAttention, 16208 }; 16209 } 16210 16211 HomeStatusPresentation { 16212 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 16213 label_key: AppTextKey::HomeTodayStatusReady, 16214 } 16215 } 16216 16217 fn home_setup_task_label_key(kind: TodaySetupTaskKind) -> AppTextKey { 16218 match kind { 16219 TodaySetupTaskKind::CompleteFarmProfile => AppTextKey::HomeTodaySetupCompleteFarmProfile, 16220 TodaySetupTaskKind::AddPickupLocation => AppTextKey::HomeTodaySetupAddPickupLocation, 16221 TodaySetupTaskKind::AddOperatingRules => AppTextKey::HomeTodaySetupAddOperatingRules, 16222 TodaySetupTaskKind::AddFulfillmentWindow => AppTextKey::HomeTodaySetupAddFulfillmentWindow, 16223 TodaySetupTaskKind::ResolveAvailabilityConflicts => { 16224 AppTextKey::HomeTodaySetupResolveAvailabilityConflicts 16225 } 16226 TodaySetupTaskKind::PublishProduct => AppTextKey::HomeTodaySetupPublishProduct, 16227 } 16228 } 16229 16230 fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { 16231 match method { 16232 FarmOrderMethod::Pickup => AppTextKey::HomeFarmSetupOrderMethodPickup, 16233 FarmOrderMethod::Delivery => AppTextKey::HomeFarmSetupOrderMethodDelivery, 16234 FarmOrderMethod::Shipping => AppTextKey::HomeFarmSetupOrderMethodShipping, 16235 } 16236 } 16237 16238 #[cfg(test)] 16239 mod tests { 16240 use super::{ 16241 APP_UI_THEME, AppTextKey, BuyerWorkspaceNotice, FarmerHomeFarmState, HomeAutoFocusState, 16242 HomeAutoFocusTarget, HomeFocusedView, HomeStage, HomeView, LabelValueRow, 16243 PackDayBatchPrintActionPresentation, PackDayBatchPrintStatusPresentation, 16244 PackDayExportStatusPresentation, PackDayHostHandoffActionPresentation, 16245 PackDayHostHandoffStatusPresentation, PackDayPrintActionPresentation, 16246 PackDayPrintStatusPresentation, ReminderActionTarget, SETTINGS_FARM_PANEL_SECTIONS, 16247 SETTINGS_NAVIGATION_ORDER, SETTINGS_OPERATIONS_PANEL_SECTIONS, SettingsAutoFocusTarget, 16248 SettingsInventorySectionSpec, SettingsPanelViewKey, ShellHeaderActiveMode, 16249 StartupHomeSurface, StartupSignerConnectState, abbreviated_npub, 16250 about_conflict_action_specs, about_conflict_aggregate_text, about_conflict_detail_rows, 16251 about_conflict_review_body_key, about_manual_refresh_enabled, about_runtime_rows, 16252 about_status_rows, account_display_name, app_text, 16253 buyer_order_coordination_notice_forces_redraw, buyer_order_detail_focus_after_open, 16254 buyer_orders_retry_action_visible, farm_setup_onboarding_card_spec, farmer_home_farm_state, 16255 farmer_order_detail_focus_after_open, farmer_pack_day_available, home_auto_focus_target, 16256 home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, 16257 home_window_launch_size_px, home_window_minimum_size_px, 16258 pack_day_batch_print_action_presentation, pack_day_batch_print_status_presentation, 16259 pack_day_export_action_enabled, pack_day_export_action_label_key, 16260 pack_day_export_artifact_names, pack_day_export_detail_rows, 16261 pack_day_export_status_presentation, pack_day_host_handoff_action_presentations, 16262 pack_day_host_handoff_status_presentation, pack_day_print_action_presentations, 16263 pack_day_print_status_presentation, parse_optional_product_editor_stock_input, 16264 parse_product_editor_price_input, presented_farmer_reminder, product_display_title, 16265 reminder_action_target, reminder_deadline_text, reminder_delivery_state_key, 16266 reminder_urgency_color, reminder_urgency_key, settings_auto_focus_target, 16267 settings_preferences_general_row_state, shell_header_active_mode, startup_home_surface, 16268 startup_issue_summary_text, startup_notice_text, startup_signer_preview_summary, 16269 startup_signer_preview_summary_for_connect_state, startup_signer_source_input_is_editable, 16270 startup_signer_status_spec, startup_signer_transport_failure_requires_notice, 16271 trade_agreement_status_key, trade_inventory_status_key, trade_revision_status_key, 16272 trade_workflow_source_key, 16273 }; 16274 use crate::runtime::{ 16275 DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSdkDiagnosticsState, 16276 DesktopAppSdkDiagnosticsSummary, DesktopAppSdkIssueSummary, 16277 DesktopAppSdkReadyDiagnosticsSummary, DesktopAppSdkStatusSummary, 16278 DesktopAppSyncConflictSummary, DesktopAppSyncStatusSummary, 16279 }; 16280 use radroots_app_core::{ 16281 AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, 16282 AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSdkRelayUrlPolicy, 16283 }; 16284 use radroots_app_remote_signer::{ 16285 RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, 16286 RadrootsAppRemoteSignerSessionRecord, 16287 }; 16288 use radroots_app_state::{ 16289 AppShellProjection, BuyerOrdersScreenProjection, FarmWorkspaceReadinessProjection, 16290 FarmWorkspaceStatus, HomeRoute, PackDayBatchPrintProjection, PackDayBatchPrintRequest, 16291 PackDayExportProjection, PackDayHostHandoffProjection, PackDayHostHandoffRequest, 16292 PackDayPrintProjection, PackDayPrintRequest, 16293 }; 16294 use radroots_app_sync::{ 16295 AppSyncProjection, AppSyncRunStatus, SyncAggregateRef, SyncCheckpointStatus, SyncConflict, 16296 SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, 16297 }; 16298 use radroots_app_view::SettingsAccountProjection; 16299 use radroots_app_view::{ 16300 AccountCustody, AccountSummary, ActiveSurface, AppStartupGate, BuyerOrderDetailProjection, 16301 BuyerOrderStatus, BuyerOrdersListRow, FarmId, FarmOrderMethod, FarmReadiness, 16302 FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, FulfillmentWindowId, 16303 FulfillmentWindowSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, 16304 OrderDetailProjection, OrderId, OrderStatus, OrdersListRow, PackDayBatchPrintArtifact, 16305 PackDayBatchPrintFailureKind, PackDayExportArtifact, PackDayExportArtifactKind, 16306 PackDayExportBundle, PackDayHostHandoffKind, PackDayHostHandoffStatus, 16307 PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, 16308 PackDayProjection, PersonalSection, ProductId, ReminderDeadlineProjection, 16309 ReminderDeliveryState, ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, 16310 RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, 16311 TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TradeAgreementStatus, 16312 TradeEconomicsProjection, TradeInventoryStatus, TradeRevisionStatus, 16313 TradeWorkflowProjection, TradeWorkflowSource, 16314 }; 16315 use radroots_identity::RadrootsIdentity; 16316 use std::{ 16317 fs, 16318 path::PathBuf, 16319 time::{SystemTime, UNIX_EPOCH}, 16320 }; 16321 16322 struct TestDirectory { 16323 path: PathBuf, 16324 } 16325 16326 impl TestDirectory { 16327 fn new() -> Self { 16328 let path = std::env::temp_dir().join(FulfillmentWindowId::new().to_string()); 16329 fs::create_dir_all(&path).unwrap(); 16330 Self { path } 16331 } 16332 16333 fn path(&self) -> &PathBuf { 16334 &self.path 16335 } 16336 } 16337 16338 impl Drop for TestDirectory { 16339 fn drop(&mut self) { 16340 let _ = fs::remove_dir_all(&self.path); 16341 } 16342 } 16343 16344 fn write_artifact(bundle_directory: &PathBuf, file_name: &str) -> PathBuf { 16345 let path = bundle_directory.join(file_name); 16346 fs::write(&path, file_name).unwrap(); 16347 path 16348 } 16349 16350 fn test_home_view(label: &str) -> (HomeView, AppDesktopRuntimePaths, PathBuf) { 16351 let suffix = SystemTime::now() 16352 .duration_since(UNIX_EPOCH) 16353 .expect("clock") 16354 .as_nanos(); 16355 let home_dir = std::env::temp_dir().join(format!("radroots_home_view_{label}_{suffix}")); 16356 let paths = AppDesktopRuntimePaths::for_desktop( 16357 AppRuntimePlatform::Macos, 16358 AppRuntimeHostEnvironment { 16359 home_dir: Some(home_dir.clone()), 16360 ..AppRuntimeHostEnvironment::default() 16361 }, 16362 ) 16363 .expect("desktop runtime paths should resolve"); 16364 let runtime = crate::runtime::DesktopAppRuntime::bootstrap_with_paths( 16365 paths.clone(), 16366 vec!["wss://relay.example".to_owned()], 16367 ); 16368 16369 (HomeView::new(runtime), paths, home_dir) 16370 } 16371 16372 fn block_shared_local_events_database(paths: &AppDesktopRuntimePaths) { 16373 let database_path = paths.shared_local_events_database_path().unwrap(); 16374 if let Some(parent) = database_path.parent() { 16375 fs::create_dir_all(parent).unwrap(); 16376 } 16377 if database_path.is_file() { 16378 fs::remove_file(&database_path).unwrap(); 16379 } else if database_path.is_dir() { 16380 fs::remove_dir_all(&database_path).unwrap(); 16381 } 16382 fs::create_dir(&database_path).unwrap(); 16383 } 16384 16385 #[test] 16386 fn buyer_workspace_notice_tracks_visible_buyer_runtime_errors() { 16387 let (mut view, _, home_dir) = test_home_view("buyer_notice"); 16388 16389 assert!(view.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed)); 16390 assert_eq!( 16391 view.buyer_workspace_notice.as_deref(), 16392 Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str()) 16393 ); 16394 assert!(!view.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed)); 16395 assert!(view.set_buyer_workspace_notice(BuyerWorkspaceNotice::OrderPlaceFailed)); 16396 assert_eq!( 16397 view.buyer_workspace_notice.as_deref(), 16398 Some(app_text(AppTextKey::PersonalOrderPlaceFailedNotice).as_str()) 16399 ); 16400 assert!(view.set_buyer_workspace_notice(BuyerWorkspaceNotice::OrderCoordinationFailed)); 16401 assert_eq!( 16402 view.buyer_workspace_notice.as_deref(), 16403 Some(app_text(AppTextKey::PersonalOrderCoordinationFailedNotice).as_str()) 16404 ); 16405 assert!(view.clear_buyer_workspace_notice()); 16406 assert_eq!(view.buyer_workspace_notice, None); 16407 16408 let _ = fs::remove_dir_all(home_dir); 16409 } 16410 16411 #[test] 16412 fn buyer_order_place_failure_uses_typed_visible_notice() { 16413 let (mut view, _, home_dir) = test_home_view("buyer_notice"); 16414 16415 assert!(view.place_personal_order_update()); 16416 assert_eq!( 16417 view.buyer_workspace_notice.as_deref(), 16418 Some(app_text(AppTextKey::PersonalOrderPlaceFailedNotice).as_str()) 16419 ); 16420 16421 let _ = fs::remove_dir_all(home_dir); 16422 } 16423 16424 #[test] 16425 fn buyer_order_coordination_failure_forces_redraw_when_notice_is_unchanged() { 16426 assert!(buyer_order_coordination_notice_forces_redraw( 16427 BuyerWorkspaceNotice::OrderCoordinationFailed 16428 )); 16429 assert!(!buyer_order_coordination_notice_forces_redraw( 16430 BuyerWorkspaceNotice::OrderPlaceFailed 16431 )); 16432 } 16433 16434 #[test] 16435 fn buyer_orders_retry_action_tracks_recoverable_coordination() { 16436 let mut orders = BuyerOrdersScreenProjection::default(); 16437 assert!(!buyer_orders_retry_action_visible(&orders)); 16438 16439 orders.has_recoverable_coordination = true; 16440 assert!(buyer_orders_retry_action_visible(&orders)); 16441 } 16442 16443 #[test] 16444 fn buyer_order_detail_focus_reopens_same_selected_detail() { 16445 let order_id = OrderId::new(); 16446 let farm_id = FarmId::new(); 16447 let mut runtime = summary( 16448 HomeRoute::Personal, 16449 TodayAgendaProjection::default(), 16450 FarmSetupProjection::default(), 16451 ); 16452 16453 assert_eq!( 16454 buyer_order_detail_focus_after_open(false, &runtime, order_id), 16455 None 16456 ); 16457 16458 runtime.personal_projection.orders.detail = Some(BuyerOrderDetailProjection { 16459 order_id, 16460 farm_id, 16461 order_number: String::new(), 16462 farm_display_name: String::new(), 16463 fulfillment_summary: String::new(), 16464 status: BuyerOrderStatus::Placed, 16465 items: Vec::new(), 16466 economics: TradeEconomicsProjection::default(), 16467 workflow: TradeWorkflowProjection::from_buyer_order_status( 16468 order_id, 16469 BuyerOrderStatus::Placed, 16470 ), 16471 validation_receipts: Vec::new(), 16472 order_note: None, 16473 repeat_demand: None, 16474 }); 16475 16476 assert_eq!( 16477 buyer_order_detail_focus_after_open(false, &runtime, order_id), 16478 Some(HomeFocusedView::BuyerOrderDetail(order_id)) 16479 ); 16480 assert_eq!( 16481 buyer_order_detail_focus_after_open(false, &runtime, OrderId::new()), 16482 None 16483 ); 16484 } 16485 16486 #[test] 16487 fn farmer_order_detail_focus_reopens_same_selected_detail() { 16488 let order_id = OrderId::new(); 16489 let farm_id = FarmId::new(); 16490 let mut runtime = summary( 16491 HomeRoute::Today, 16492 TodayAgendaProjection::default(), 16493 FarmSetupProjection::default(), 16494 ); 16495 16496 assert_eq!( 16497 farmer_order_detail_focus_after_open(false, &runtime, order_id), 16498 None 16499 ); 16500 16501 runtime.orders_projection.detail = Some(OrderDetailProjection { 16502 order_id, 16503 farm_id, 16504 order_number: String::new(), 16505 customer_display_name: String::new(), 16506 status: OrderStatus::Scheduled, 16507 fulfillment_window_id: None, 16508 fulfillment_window_label: None, 16509 pickup_location_label: None, 16510 items: Vec::new(), 16511 economics: TradeEconomicsProjection::default(), 16512 workflow: TradeWorkflowProjection::from_order_status(order_id, OrderStatus::Scheduled), 16513 validation_receipts: Vec::new(), 16514 primary_action: None, 16515 }); 16516 16517 assert_eq!( 16518 farmer_order_detail_focus_after_open(false, &runtime, order_id), 16519 Some(HomeFocusedView::FarmerOrderDetail(order_id)) 16520 ); 16521 assert_eq!( 16522 farmer_order_detail_focus_after_open(false, &runtime, OrderId::new()), 16523 None 16524 ); 16525 } 16526 16527 #[test] 16528 fn buyer_browse_refresh_failure_uses_typed_visible_notice() { 16529 let (mut view, paths, home_dir) = test_home_view("buyer_notice"); 16530 block_shared_local_events_database(&paths); 16531 16532 assert!(view.select_personal_section_update(PersonalSection::Browse)); 16533 assert_eq!( 16534 view.buyer_workspace_notice.as_deref(), 16535 Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str()) 16536 ); 16537 16538 let _ = fs::remove_dir_all(home_dir); 16539 } 16540 16541 #[test] 16542 fn buyer_search_refresh_failure_uses_typed_visible_notice() { 16543 let (mut view, paths, home_dir) = test_home_view("buyer_notice"); 16544 block_shared_local_events_database(&paths); 16545 16546 assert!(view.set_personal_search_query_update("eggs")); 16547 assert_eq!( 16548 view.buyer_workspace_notice.as_deref(), 16549 Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str()) 16550 ); 16551 16552 let _ = fs::remove_dir_all(home_dir); 16553 } 16554 16555 #[test] 16556 fn buyer_detail_open_failure_uses_typed_visible_notice() { 16557 let (mut view, paths, home_dir) = test_home_view("buyer_notice"); 16558 block_shared_local_events_database(&paths); 16559 16560 assert!( 16561 view.open_personal_product_detail_update(PersonalSection::Browse, ProductId::new()) 16562 ); 16563 assert_eq!( 16564 view.buyer_workspace_notice.as_deref(), 16565 Some(app_text(AppTextKey::PersonalDetailOpenFailedNotice).as_str()) 16566 ); 16567 16568 let _ = fs::remove_dir_all(home_dir); 16569 } 16570 16571 fn sample_pack_day_bundle(bundle_directory: &PathBuf) -> PackDayExportBundle { 16572 PackDayExportBundle { 16573 fulfillment_window_id: FulfillmentWindowId::new(), 16574 export_instance_id: radroots_app_view::PackDayExportInstanceId::new(), 16575 generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), 16576 bundle_directory: bundle_directory.to_string_lossy().into_owned(), 16577 artifacts: vec![ 16578 PackDayExportArtifact { 16579 kind: PackDayExportArtifactKind::PackSheet, 16580 relative_path: "pack_sheet.txt".to_owned(), 16581 }, 16582 PackDayExportArtifact { 16583 kind: PackDayExportArtifactKind::PickupRoster, 16584 relative_path: "pickup_roster.txt".to_owned(), 16585 }, 16586 PackDayExportArtifact { 16587 kind: PackDayExportArtifactKind::CustomerLabels, 16588 relative_path: "customer_labels.txt".to_owned(), 16589 }, 16590 ], 16591 } 16592 } 16593 16594 #[test] 16595 fn farm_setup_onboarding_uses_frozen_copy_and_primary_action() { 16596 let spec = farm_setup_onboarding_card_spec(HomeRoute::FarmSetupOnboarding).unwrap(); 16597 16598 assert_eq!(spec.title_key, AppTextKey::HomeFarmSetupOnboardingTitle); 16599 assert_eq!(spec.body_key, AppTextKey::HomeFarmSetupOnboardingBody); 16600 assert_eq!( 16601 spec.action_key, 16602 Some(AppTextKey::HomeFarmSetupOnboardingAction) 16603 ); 16604 } 16605 16606 #[test] 16607 fn farm_setup_form_route_keeps_onboarding_copy_without_no_farm_empty_state() { 16608 let spec = farm_setup_onboarding_card_spec(HomeRoute::FarmSetupForm).unwrap(); 16609 16610 assert_eq!(spec.title_key, AppTextKey::HomeFarmSetupOnboardingTitle); 16611 assert_eq!(spec.body_key, AppTextKey::HomeFarmSetupOnboardingBody); 16612 assert_eq!(spec.action_key, None); 16613 } 16614 16615 #[test] 16616 fn settings_navigation_order_keeps_farm_between_account_and_settings() { 16617 assert_eq!( 16618 SETTINGS_NAVIGATION_ORDER, 16619 &[ 16620 SettingsPanelViewKey::Account, 16621 SettingsPanelViewKey::Farm, 16622 SettingsPanelViewKey::Settings, 16623 SettingsPanelViewKey::About, 16624 ] 16625 ); 16626 } 16627 16628 #[test] 16629 fn settings_account_display_uses_label_before_npub_fallback() { 16630 let labeled = AccountSummary { 16631 account_id: "account_1".to_owned(), 16632 npub: "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), 16633 label: Some(" Farm Profile ".to_owned()), 16634 custody: AccountCustody::LocalManaged, 16635 }; 16636 let unlabeled = AccountSummary { 16637 label: None, 16638 ..labeled.clone() 16639 }; 16640 16641 assert_eq!(account_display_name(&labeled), "Farm Profile"); 16642 assert_eq!(account_display_name(&unlabeled), "npub1qqqqq...qqqqqq"); 16643 } 16644 16645 #[test] 16646 fn settings_account_npub_fallback_stays_compact() { 16647 assert_eq!( 16648 abbreviated_npub("npub1sxczrq2dp4jtehcm8mtemj975u5ytf2d7mc6dpuuq3rzkjzr76ls5lkheq"), 16649 "npub1sxczr...5lkheq" 16650 ); 16651 } 16652 16653 #[test] 16654 fn settings_inventory_sections_follow_the_frozen_farm_rules_order() { 16655 assert_eq!( 16656 SETTINGS_FARM_PANEL_SECTIONS, 16657 &[ 16658 SettingsInventorySectionSpec { 16659 title_key: AppTextKey::HomeFarmSetupSectionFarm, 16660 field_keys: &[ 16661 AppTextKey::HomeFarmSetupFieldFarmName, 16662 AppTextKey::SettingsFarmFieldTimezone, 16663 AppTextKey::SettingsFarmFieldCurrency, 16664 ], 16665 }, 16666 SettingsInventorySectionSpec { 16667 title_key: AppTextKey::SettingsPickupLocationsSectionLabel, 16668 field_keys: &[ 16669 AppTextKey::SettingsPickupLocationsFieldLabel, 16670 AppTextKey::SettingsPickupLocationsFieldAddress, 16671 AppTextKey::SettingsPickupLocationsFieldDirections, 16672 AppTextKey::SettingsPickupLocationsFieldDefault, 16673 ], 16674 }, 16675 ] 16676 ); 16677 assert_eq!( 16678 SETTINGS_OPERATIONS_PANEL_SECTIONS, 16679 &[ 16680 SettingsInventorySectionSpec { 16681 title_key: AppTextKey::SettingsOperatingRulesSectionLabel, 16682 field_keys: &[ 16683 AppTextKey::SettingsOperatingRulesFieldPromiseLeadTime, 16684 AppTextKey::SettingsOperatingRulesFieldSubstitutionPolicy, 16685 ], 16686 }, 16687 SettingsInventorySectionSpec { 16688 title_key: AppTextKey::SettingsFulfillmentWindowsSectionLabel, 16689 field_keys: &[ 16690 AppTextKey::SettingsFulfillmentWindowsFieldLabel, 16691 AppTextKey::SettingsFulfillmentWindowsFieldPickupLocation, 16692 AppTextKey::SettingsFulfillmentWindowsFieldStartsAt, 16693 AppTextKey::SettingsFulfillmentWindowsFieldEndsAt, 16694 AppTextKey::SettingsFulfillmentWindowsFieldOrderCutoff, 16695 ], 16696 }, 16697 SettingsInventorySectionSpec { 16698 title_key: AppTextKey::SettingsBlackoutPeriodsSectionLabel, 16699 field_keys: &[ 16700 AppTextKey::SettingsBlackoutPeriodsFieldLabel, 16701 AppTextKey::SettingsBlackoutPeriodsFieldStartsAt, 16702 AppTextKey::SettingsBlackoutPeriodsFieldEndsAt, 16703 ], 16704 }, 16705 SettingsInventorySectionSpec { 16706 title_key: AppTextKey::SettingsReadinessSectionLabel, 16707 field_keys: &[ 16708 AppTextKey::SettingsReadinessFieldMissingProfileBasics, 16709 AppTextKey::SettingsReadinessFieldMissingPickupLocation, 16710 AppTextKey::SettingsReadinessFieldMissingFulfillmentWindow, 16711 AppTextKey::SettingsReadinessFieldMissingOperatingRules, 16712 AppTextKey::SettingsReadinessFieldInvalidTimingConflicts, 16713 ], 16714 }, 16715 ] 16716 ); 16717 } 16718 16719 #[test] 16720 fn trade_workflow_badge_keys_cover_refactored_status_axes() { 16721 for (status, key) in [ 16722 ( 16723 TradeAgreementStatus::Ordered, 16724 AppTextKey::TradeWorkflowAgreementOrdered, 16725 ), 16726 ( 16727 TradeAgreementStatus::Confirmed, 16728 AppTextKey::TradeWorkflowAgreementConfirmed, 16729 ), 16730 ( 16731 TradeAgreementStatus::Declined, 16732 AppTextKey::TradeWorkflowAgreementDeclined, 16733 ), 16734 ( 16735 TradeAgreementStatus::Cancelled, 16736 AppTextKey::TradeWorkflowAgreementCancelled, 16737 ), 16738 ( 16739 TradeAgreementStatus::NeedsReview, 16740 AppTextKey::TradeWorkflowAgreementNeedsReview, 16741 ), 16742 ] { 16743 assert_eq!(trade_agreement_status_key(status), key); 16744 assert!(!app_text(key).is_empty()); 16745 } 16746 16747 for (status, key) in [ 16748 ( 16749 TradeRevisionStatus::None, 16750 AppTextKey::TradeWorkflowRevisionNone, 16751 ), 16752 ( 16753 TradeRevisionStatus::ChangeProposed, 16754 AppTextKey::TradeWorkflowRevisionChangeProposed, 16755 ), 16756 ( 16757 TradeRevisionStatus::Updated, 16758 AppTextKey::TradeWorkflowRevisionUpdated, 16759 ), 16760 ( 16761 TradeRevisionStatus::KeptAsPlaced, 16762 AppTextKey::TradeWorkflowRevisionKeptAsPlaced, 16763 ), 16764 ] { 16765 assert_eq!(trade_revision_status_key(status), key); 16766 assert!(!app_text(key).is_empty()); 16767 } 16768 16769 for (status, key) in [ 16770 ( 16771 TradeInventoryStatus::Available, 16772 AppTextKey::TradeWorkflowInventoryAvailable, 16773 ), 16774 ( 16775 TradeInventoryStatus::Reserved, 16776 AppTextKey::TradeWorkflowInventoryReserved, 16777 ), 16778 ( 16779 TradeInventoryStatus::SoldOut, 16780 AppTextKey::TradeWorkflowInventorySoldOut, 16781 ), 16782 ( 16783 TradeInventoryStatus::NeedsReview, 16784 AppTextKey::TradeWorkflowInventoryNeedsReview, 16785 ), 16786 ] { 16787 assert_eq!(trade_inventory_status_key(status), key); 16788 assert!(!app_text(key).is_empty()); 16789 } 16790 16791 for (source, key) in [ 16792 ( 16793 TradeWorkflowSource::App, 16794 AppTextKey::TradeWorkflowProvenanceApp, 16795 ), 16796 ( 16797 TradeWorkflowSource::Cli, 16798 AppTextKey::TradeWorkflowProvenanceCli, 16799 ), 16800 ( 16801 TradeWorkflowSource::Relay, 16802 AppTextKey::TradeWorkflowProvenanceRelay, 16803 ), 16804 ( 16805 TradeWorkflowSource::LocalEvents, 16806 AppTextKey::TradeWorkflowProvenanceLocalEvents, 16807 ), 16808 ( 16809 TradeWorkflowSource::Unknown, 16810 AppTextKey::TradeWorkflowProvenanceUnknown, 16811 ), 16812 ] { 16813 assert_eq!(trade_workflow_source_key(source), key); 16814 assert!(!app_text(key).is_empty()); 16815 } 16816 } 16817 16818 #[test] 16819 fn today_route_has_no_setup_onboarding_card() { 16820 assert!(farm_setup_onboarding_card_spec(HomeRoute::Today).is_none()); 16821 } 16822 16823 #[test] 16824 fn home_window_launch_frame_and_minimum_size_are_split() { 16825 assert_eq!(home_window_launch_size_px(), (1284.0, 795.0)); 16826 assert_eq!(home_window_minimum_size_px(), (1080.0, 720.0)); 16827 } 16828 16829 #[test] 16830 fn startup_home_surface_tracks_the_shared_logged_out_phase_contract() { 16831 let continue_prompt = summary_with_logged_out_phase(LoggedOutStartupPhase::ContinuePrompt); 16832 let identity_choice = summary_with_logged_out_phase(LoggedOutStartupPhase::IdentityChoice); 16833 let generate_key_starting = 16834 summary_with_logged_out_phase(LoggedOutStartupPhase::GenerateKeyStarting); 16835 let signer_entry = summary_with_logged_out_phase(LoggedOutStartupPhase::SignerEntry); 16836 16837 assert_eq!( 16838 startup_home_surface(&continue_prompt), 16839 StartupHomeSurface::ContinuePrompt 16840 ); 16841 assert_eq!( 16842 startup_home_surface(&identity_choice), 16843 StartupHomeSurface::IdentityChoice 16844 ); 16845 assert_eq!( 16846 startup_home_surface(&generate_key_starting), 16847 StartupHomeSurface::GenerateKeyStarting 16848 ); 16849 assert_eq!( 16850 startup_home_surface(&signer_entry), 16851 StartupHomeSurface::SignerEntry 16852 ); 16853 } 16854 16855 #[test] 16856 fn startup_home_surface_uses_issue_card_when_setup_is_unavailable() { 16857 let blocked = DesktopAppRuntimeSummary { 16858 startup_gate: AppStartupGate::Blocked, 16859 startup_issue: Some("runtime unavailable".to_owned()), 16860 ..summary_with_logged_out_phase(LoggedOutStartupPhase::IdentityChoice) 16861 }; 16862 16863 assert_eq!( 16864 startup_home_surface(&blocked), 16865 StartupHomeSurface::IssueCard 16866 ); 16867 assert_eq!( 16868 startup_home_surface(&summary( 16869 HomeRoute::Personal, 16870 TodayAgendaProjection::default(), 16871 FarmSetupProjection::default(), 16872 )), 16873 StartupHomeSurface::IssueCard 16874 ); 16875 } 16876 16877 #[test] 16878 fn home_stage_uses_buyer_workspace_when_guest_enters_marketplace() { 16879 let mut guest_marketplace = summary( 16880 HomeRoute::SetupRequired, 16881 TodayAgendaProjection::default(), 16882 FarmSetupProjection::default(), 16883 ); 16884 guest_marketplace.startup_gate = AppStartupGate::SetupRequired; 16885 guest_marketplace.shell_projection = AppShellProjection::new( 16886 ActiveSurface::Personal, 16887 ShellSection::Personal(PersonalSection::Browse), 16888 ); 16889 16890 assert_eq!(home_stage(&guest_marketplace), HomeStage::BuyerWorkspace); 16891 } 16892 16893 #[test] 16894 fn shell_header_active_mode_tracks_account_as_a_peer_selector() { 16895 let mut runtime = summary( 16896 HomeRoute::Personal, 16897 TodayAgendaProjection::default(), 16898 FarmSetupProjection::default(), 16899 ); 16900 runtime.shell_projection = AppShellProjection::new( 16901 ActiveSurface::Personal, 16902 ShellSection::Personal(PersonalSection::Browse), 16903 ); 16904 assert_eq!( 16905 shell_header_active_mode(&runtime), 16906 ShellHeaderActiveMode::Marketplace 16907 ); 16908 16909 runtime.shell_projection = AppShellProjection::new( 16910 ActiveSurface::Farmer, 16911 ShellSection::Farmer(FarmerSection::Today), 16912 ); 16913 assert_eq!( 16914 shell_header_active_mode(&runtime), 16915 ShellHeaderActiveMode::Farm 16916 ); 16917 16918 runtime.shell_projection = 16919 AppShellProjection::new(ActiveSurface::Personal, ShellSection::Account); 16920 assert_eq!( 16921 shell_header_active_mode(&runtime), 16922 ShellHeaderActiveMode::Account 16923 ); 16924 16925 runtime.shell_projection = 16926 AppShellProjection::new(ActiveSurface::Farmer, ShellSection::Account); 16927 assert_eq!( 16928 shell_header_active_mode(&runtime), 16929 ShellHeaderActiveMode::Account 16930 ); 16931 } 16932 16933 #[test] 16934 fn home_auto_focus_target_tracks_startup_surface_contract() { 16935 assert_eq!( 16936 home_auto_focus_target( 16937 &summary_with_logged_out_phase(LoggedOutStartupPhase::ContinuePrompt), 16938 HomeAutoFocusState::default(), 16939 ), 16940 Some(HomeAutoFocusTarget::StartupContinue) 16941 ); 16942 assert_eq!( 16943 home_auto_focus_target( 16944 &summary_with_logged_out_phase(LoggedOutStartupPhase::IdentityChoice), 16945 HomeAutoFocusState::default(), 16946 ), 16947 Some(HomeAutoFocusTarget::StartupGenerateKey) 16948 ); 16949 assert_eq!( 16950 home_auto_focus_target( 16951 &summary_with_logged_out_phase(LoggedOutStartupPhase::SignerEntry), 16952 HomeAutoFocusState { 16953 has_startup_signer_input: true, 16954 startup_signer_input_is_editable: true, 16955 ..HomeAutoFocusState::default() 16956 }, 16957 ), 16958 Some(HomeAutoFocusTarget::StartupSignerInput) 16959 ); 16960 assert_eq!( 16961 home_auto_focus_target( 16962 &summary_with_logged_out_phase(LoggedOutStartupPhase::SignerEntry), 16963 HomeAutoFocusState { 16964 has_startup_signer_input: true, 16965 startup_signer_input_is_editable: false, 16966 ..HomeAutoFocusState::default() 16967 }, 16968 ), 16969 Some(HomeAutoFocusTarget::StartupSignerBack) 16970 ); 16971 } 16972 16973 #[test] 16974 fn home_auto_focus_target_tracks_buyer_surface_contract() { 16975 let mut buyer_search = summary( 16976 HomeRoute::Personal, 16977 TodayAgendaProjection::default(), 16978 FarmSetupProjection::default(), 16979 ); 16980 buyer_search.startup_gate = AppStartupGate::Personal; 16981 buyer_search.shell_projection = AppShellProjection::new( 16982 ActiveSurface::Personal, 16983 ShellSection::Personal(PersonalSection::Search), 16984 ); 16985 assert_eq!( 16986 home_auto_focus_target( 16987 &buyer_search, 16988 HomeAutoFocusState { 16989 has_personal_search_input: true, 16990 ..HomeAutoFocusState::default() 16991 }, 16992 ), 16993 Some(HomeAutoFocusTarget::BuyerSearchInput) 16994 ); 16995 16996 let mut buyer_cart_order_review = buyer_search.clone(); 16997 buyer_cart_order_review.shell_projection = AppShellProjection::new( 16998 ActiveSurface::Personal, 16999 ShellSection::Personal(PersonalSection::Cart), 17000 ); 17001 assert_eq!( 17002 home_auto_focus_target( 17003 &buyer_cart_order_review, 17004 HomeAutoFocusState { 17005 has_buyer_order_review_form: true, 17006 ..HomeAutoFocusState::default() 17007 }, 17008 ), 17009 Some(HomeAutoFocusTarget::BuyerOrderReviewNameInput) 17010 ); 17011 17012 let order_id = OrderId::new(); 17013 let farm_id = FarmId::new(); 17014 let mut buyer_orders = buyer_search.clone(); 17015 buyer_orders.shell_projection = AppShellProjection::new( 17016 ActiveSurface::Personal, 17017 ShellSection::Personal(PersonalSection::Orders), 17018 ); 17019 buyer_orders.personal_projection.orders.list.rows = vec![BuyerOrdersListRow { 17020 order_id, 17021 farm_id, 17022 order_number: String::new(), 17023 farm_display_name: String::new(), 17024 fulfillment_summary: String::new(), 17025 status: BuyerOrderStatus::Placed, 17026 workflow: TradeWorkflowProjection::from_buyer_order_status( 17027 order_id, 17028 BuyerOrderStatus::Placed, 17029 ), 17030 repeat_demand: None, 17031 }]; 17032 buyer_orders.personal_projection.orders.detail = Some(BuyerOrderDetailProjection { 17033 order_id, 17034 farm_id, 17035 order_number: String::new(), 17036 farm_display_name: String::new(), 17037 fulfillment_summary: String::new(), 17038 status: BuyerOrderStatus::Placed, 17039 items: Vec::new(), 17040 economics: TradeEconomicsProjection::default(), 17041 workflow: TradeWorkflowProjection::from_buyer_order_status( 17042 order_id, 17043 BuyerOrderStatus::Placed, 17044 ), 17045 validation_receipts: Vec::new(), 17046 order_note: None, 17047 repeat_demand: Some(RepeatDemandHandoffProjection { 17048 order_id, 17049 farm_id, 17050 eligibility: RepeatDemandEligibility::Eligible, 17051 available_item_count: 1, 17052 unavailable_item_count: 0, 17053 }), 17054 }); 17055 assert_eq!( 17056 home_auto_focus_target(&buyer_orders, HomeAutoFocusState::default()), 17057 Some(HomeAutoFocusTarget::BuyerOrderRepeatDemand) 17058 ); 17059 } 17060 17061 #[test] 17062 fn home_auto_focus_target_tracks_farmer_surface_contract() { 17063 let mut onboarding = summary( 17064 HomeRoute::FarmSetupOnboarding, 17065 TodayAgendaProjection::default(), 17066 FarmSetupProjection::default(), 17067 ); 17068 onboarding.startup_gate = AppStartupGate::Farmer; 17069 onboarding.shell_projection = AppShellProjection::new( 17070 ActiveSurface::Farmer, 17071 ShellSection::Farmer(FarmerSection::Today), 17072 ); 17073 assert_eq!( 17074 home_auto_focus_target(&onboarding, HomeAutoFocusState::default()), 17075 Some(HomeAutoFocusTarget::FarmerSetupStart) 17076 ); 17077 17078 let farm_id = FarmId::new(); 17079 let incomplete_farm = FarmSummary { 17080 farm_id, 17081 display_name: String::new(), 17082 readiness: FarmReadiness::Incomplete, 17083 }; 17084 let incomplete_today = summary( 17085 HomeRoute::Today, 17086 TodayAgendaProjection { 17087 farm: Some(incomplete_farm.clone()), 17088 setup_checklist: vec![TodaySetupTask { 17089 kind: TodaySetupTaskKind::AddFulfillmentWindow, 17090 is_complete: false, 17091 }], 17092 ..TodayAgendaProjection::default() 17093 }, 17094 FarmSetupProjection::new( 17095 FarmSetupDraft::new(String::new(), String::new(), [FarmOrderMethod::Pickup]), 17096 Some(incomplete_farm), 17097 ), 17098 ); 17099 assert_eq!( 17100 home_auto_focus_target(&incomplete_today, HomeAutoFocusState::default()), 17101 Some(HomeAutoFocusTarget::FarmerSetupContinue) 17102 ); 17103 17104 let saved_farm = FarmSummary { 17105 farm_id: FarmId::new(), 17106 display_name: String::new(), 17107 readiness: FarmReadiness::Ready, 17108 }; 17109 let mut products = summary( 17110 HomeRoute::Today, 17111 TodayAgendaProjection::default(), 17112 FarmSetupProjection::from_saved_farm(saved_farm.clone()), 17113 ); 17114 products.startup_gate = AppStartupGate::Farmer; 17115 products.shell_projection = AppShellProjection::new( 17116 ActiveSurface::Farmer, 17117 ShellSection::Farmer(FarmerSection::Products), 17118 ); 17119 assert_eq!( 17120 home_auto_focus_target( 17121 &products, 17122 HomeAutoFocusState { 17123 has_products_search_input: true, 17124 ..HomeAutoFocusState::default() 17125 }, 17126 ), 17127 Some(HomeAutoFocusTarget::ProductsSearchInput) 17128 ); 17129 assert_eq!( 17130 home_auto_focus_target( 17131 &products, 17132 HomeAutoFocusState { 17133 has_product_editor_form: true, 17134 ..HomeAutoFocusState::default() 17135 }, 17136 ), 17137 Some(HomeAutoFocusTarget::ProductEditorTitleInput) 17138 ); 17139 17140 let mut orders = summary( 17141 HomeRoute::Today, 17142 TodayAgendaProjection::default(), 17143 FarmSetupProjection::from_saved_farm(saved_farm), 17144 ); 17145 orders.startup_gate = AppStartupGate::Farmer; 17146 orders.shell_projection = AppShellProjection::new( 17147 ActiveSurface::Farmer, 17148 ShellSection::Farmer(FarmerSection::Orders), 17149 ); 17150 let farmer_order_id = OrderId::new(); 17151 let farmer_order_farm_id = FarmId::new(); 17152 orders.orders_projection.list.rows = vec![OrdersListRow { 17153 order_id: farmer_order_id, 17154 farm_id: farmer_order_farm_id, 17155 fulfillment_window_id: None, 17156 order_number: String::new(), 17157 customer_display_name: String::new(), 17158 fulfillment_window_label: None, 17159 pickup_location_label: None, 17160 status: OrderStatus::Scheduled, 17161 workflow: TradeWorkflowProjection::from_order_status( 17162 farmer_order_id, 17163 OrderStatus::Scheduled, 17164 ), 17165 primary_action: None, 17166 }]; 17167 orders.orders_projection.detail = Some(OrderDetailProjection { 17168 order_id: farmer_order_id, 17169 farm_id: farmer_order_farm_id, 17170 order_number: String::new(), 17171 customer_display_name: String::new(), 17172 status: OrderStatus::Scheduled, 17173 fulfillment_window_id: None, 17174 fulfillment_window_label: None, 17175 pickup_location_label: None, 17176 items: Vec::new(), 17177 economics: TradeEconomicsProjection::default(), 17178 workflow: TradeWorkflowProjection::from_order_status( 17179 farmer_order_id, 17180 OrderStatus::Scheduled, 17181 ), 17182 validation_receipts: Vec::new(), 17183 primary_action: None, 17184 }); 17185 assert_eq!( 17186 home_auto_focus_target(&orders, HomeAutoFocusState::default()), 17187 Some(HomeAutoFocusTarget::OrdersRowOpenFirst) 17188 ); 17189 } 17190 17191 #[test] 17192 fn settings_auto_focus_target_tracks_panel_contract() { 17193 let runtime = summary( 17194 HomeRoute::Today, 17195 TodayAgendaProjection::default(), 17196 FarmSetupProjection::default(), 17197 ); 17198 assert_eq!( 17199 settings_auto_focus_target(SettingsPanelViewKey::Account, None, &runtime), 17200 Some(SettingsAutoFocusTarget::AccountAdd) 17201 ); 17202 assert_eq!( 17203 settings_auto_focus_target(SettingsPanelViewKey::Farm, None, &runtime), 17204 Some(SettingsAutoFocusTarget::Navigation( 17205 SettingsPanelViewKey::Farm 17206 )) 17207 ); 17208 assert_eq!( 17209 settings_auto_focus_target(SettingsPanelViewKey::Settings, None, &runtime), 17210 Some(SettingsAutoFocusTarget::Navigation( 17211 SettingsPanelViewKey::Settings 17212 )) 17213 ); 17214 17215 let mut about_enabled = runtime.clone(); 17216 about_enabled.sync_status.account_id = Some("guest".to_owned()); 17217 assert_eq!( 17218 settings_auto_focus_target(SettingsPanelViewKey::About, None, &about_enabled), 17219 Some(SettingsAutoFocusTarget::AboutRefresh) 17220 ); 17221 assert_eq!( 17222 settings_auto_focus_target(SettingsPanelViewKey::About, None, &runtime), 17223 Some(SettingsAutoFocusTarget::Navigation( 17224 SettingsPanelViewKey::About 17225 )) 17226 ); 17227 } 17228 17229 #[test] 17230 fn settings_general_rows_read_runtime_projection_values() { 17231 let mut runtime = summary( 17232 HomeRoute::Today, 17233 TodayAgendaProjection::default(), 17234 FarmSetupProjection::default(), 17235 ); 17236 runtime 17237 .shell_projection 17238 .settings 17239 .general 17240 .allow_relay_connections = false; 17241 runtime.shell_projection.settings.general.use_media_servers = true; 17242 runtime.shell_projection.settings.general.use_nip05 = false; 17243 runtime.shell_projection.settings.general.launch_at_login = true; 17244 17245 let state = settings_preferences_general_row_state(&runtime); 17246 17247 assert!(!state.allow_relay_connections); 17248 assert!(state.use_media_servers); 17249 assert!(!state.use_nip05); 17250 assert!(state.launch_at_login); 17251 } 17252 17253 #[test] 17254 fn farmer_home_farm_state_distinguishes_no_farm_incomplete_and_configured() { 17255 let farm_id = FarmId::new(); 17256 let incomplete_farm = FarmSummary { 17257 farm_id, 17258 display_name: String::new(), 17259 readiness: FarmReadiness::Incomplete, 17260 }; 17261 let configured_farm = FarmSummary { 17262 farm_id: FarmId::new(), 17263 display_name: String::new(), 17264 readiness: FarmReadiness::Ready, 17265 }; 17266 17267 assert_eq!( 17268 farmer_home_farm_state(&summary( 17269 HomeRoute::FarmSetupOnboarding, 17270 TodayAgendaProjection::default(), 17271 FarmSetupProjection::default(), 17272 )), 17273 FarmerHomeFarmState::NoFarm 17274 ); 17275 assert_eq!( 17276 farmer_home_farm_state(&summary( 17277 HomeRoute::Today, 17278 TodayAgendaProjection { 17279 farm: Some(incomplete_farm.clone()), 17280 setup_checklist: vec![TodaySetupTask { 17281 kind: TodaySetupTaskKind::AddFulfillmentWindow, 17282 is_complete: false, 17283 }], 17284 ..TodayAgendaProjection::default() 17285 }, 17286 FarmSetupProjection::new( 17287 FarmSetupDraft::new(String::new(), String::new(), [FarmOrderMethod::Pickup]), 17288 Some(incomplete_farm), 17289 ), 17290 )), 17291 FarmerHomeFarmState::IncompleteFarm 17292 ); 17293 assert_eq!( 17294 farmer_home_farm_state(&summary( 17295 HomeRoute::Today, 17296 TodayAgendaProjection { 17297 farm: Some(configured_farm.clone()), 17298 ..TodayAgendaProjection::default() 17299 }, 17300 FarmSetupProjection::new( 17301 FarmSetupDraft::new( 17302 String::new(), 17303 String::new(), 17304 [FarmOrderMethod::Pickup, FarmOrderMethod::Delivery], 17305 ), 17306 Some(configured_farm), 17307 ), 17308 )), 17309 FarmerHomeFarmState::ConfiguredFarm 17310 ); 17311 } 17312 17313 #[test] 17314 fn pack_day_availability_tracks_the_contextual_window_projection() { 17315 let farm_id = FarmId::new(); 17316 let mut runtime = summary( 17317 HomeRoute::Today, 17318 TodayAgendaProjection::default(), 17319 FarmSetupProjection::from_saved_farm(FarmSummary { 17320 farm_id, 17321 display_name: String::new(), 17322 readiness: FarmReadiness::Ready, 17323 }), 17324 ); 17325 17326 assert!(!farmer_pack_day_available(&runtime)); 17327 assert_eq!( 17328 home_content_scroll_id(FarmerSection::PackDay), 17329 "home-pack-day-scroll" 17330 ); 17331 17332 runtime.pack_day_projection.projection = PackDayProjection { 17333 fulfillment_window: Some(FulfillmentWindowSummary { 17334 fulfillment_window_id: FulfillmentWindowId::new(), 17335 farm_id, 17336 starts_at: String::new(), 17337 ends_at: String::new(), 17338 }), 17339 reminders: Default::default(), 17340 totals_by_product: Vec::new(), 17341 pack_list: Vec::new(), 17342 pickup_roster: Vec::new(), 17343 }; 17344 17345 assert!(farmer_pack_day_available(&runtime)); 17346 } 17347 17348 #[test] 17349 fn pack_day_export_action_enabled_requires_a_window_and_exportable_rows() { 17350 let farm_id = FarmId::new(); 17351 let fulfillment_window_id = FulfillmentWindowId::new(); 17352 let mut runtime = summary( 17353 HomeRoute::Today, 17354 TodayAgendaProjection::default(), 17355 FarmSetupProjection::default(), 17356 ); 17357 17358 assert!(!pack_day_export_action_enabled(&runtime)); 17359 assert_eq!( 17360 pack_day_export_status_presentation(&runtime), 17361 PackDayExportStatusPresentation { 17362 indicator_color: APP_UI_THEME.components.app_status_indicator.offline, 17363 title_key: AppTextKey::PackDayExportUnavailableTitle, 17364 body_key: AppTextKey::PackDayExportUnavailableBody, 17365 } 17366 ); 17367 17368 runtime.pack_day_projection.projection = PackDayProjection { 17369 fulfillment_window: Some(FulfillmentWindowSummary { 17370 fulfillment_window_id, 17371 farm_id, 17372 starts_at: String::new(), 17373 ends_at: String::new(), 17374 }), 17375 reminders: Default::default(), 17376 totals_by_product: Vec::new(), 17377 pack_list: Vec::new(), 17378 pickup_roster: Vec::new(), 17379 }; 17380 17381 assert!(!pack_day_export_action_enabled(&runtime)); 17382 17383 runtime.pack_day_projection.projection.totals_by_product = vec![PackDayProductTotalRow { 17384 title: "Salad mix".to_owned(), 17385 quantity_display: "2 bags".to_owned(), 17386 }]; 17387 17388 assert!(pack_day_export_action_enabled(&runtime)); 17389 assert_eq!( 17390 pack_day_export_status_presentation(&runtime), 17391 PackDayExportStatusPresentation { 17392 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 17393 title_key: AppTextKey::PackDayExportReadyTitle, 17394 body_key: AppTextKey::PackDayExportReadyBody, 17395 } 17396 ); 17397 17398 runtime.pack_day_projection.export = PackDayExportProjection::running( 17399 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id), 17400 ); 17401 assert!(!pack_day_export_action_enabled(&runtime)); 17402 assert_eq!( 17403 pack_day_export_action_label_key(&runtime.pack_day_projection.export), 17404 AppTextKey::PackDayExportActionRunning 17405 ); 17406 assert_eq!( 17407 pack_day_export_status_presentation(&runtime), 17408 PackDayExportStatusPresentation { 17409 indicator_color: APP_UI_THEME.foundation.text.accent, 17410 title_key: AppTextKey::PackDayExportRunningTitle, 17411 body_key: AppTextKey::PackDayExportRunningBody, 17412 } 17413 ); 17414 } 17415 17416 #[test] 17417 fn pack_day_export_detail_rows_surface_bundle_and_failure_details() { 17418 let fulfillment_window_id = FulfillmentWindowId::new(); 17419 let bundle = PackDayExportBundle { 17420 fulfillment_window_id, 17421 export_instance_id: radroots_app_view::PackDayExportInstanceId::new(), 17422 generated_at_utc: "2026-04-23T15:00:00Z".to_owned(), 17423 bundle_directory: "exports/pack_day/window-1/20260423T150000Z".to_owned(), 17424 artifacts: vec![ 17425 PackDayExportArtifact { 17426 kind: PackDayExportArtifactKind::PackSheet, 17427 relative_path: "pack_sheet.txt".to_owned(), 17428 }, 17429 PackDayExportArtifact { 17430 kind: PackDayExportArtifactKind::PickupRoster, 17431 relative_path: "pickup_roster.txt".to_owned(), 17432 }, 17433 PackDayExportArtifact { 17434 kind: PackDayExportArtifactKind::CustomerLabels, 17435 relative_path: "customer_labels.txt".to_owned(), 17436 }, 17437 ], 17438 }; 17439 let request = 17440 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id); 17441 17442 let rows = pack_day_export_detail_rows(&PackDayExportProjection::succeeded( 17443 request.clone(), 17444 bundle.clone(), 17445 )); 17446 assert_eq!(rows.len(), 2); 17447 assert_eq!( 17448 rows[0], 17449 LabelValueRow::new( 17450 app_text(AppTextKey::PackDayExportFolderLabel), 17451 "exports/pack_day/window-1/20260423T150000Z" 17452 ) 17453 ); 17454 assert_eq!( 17455 rows[1], 17456 LabelValueRow::new( 17457 app_text(AppTextKey::PackDayExportFilesLabel), 17458 "pack_sheet.txt, pickup_roster.txt, customer_labels.txt" 17459 ) 17460 ); 17461 assert_eq!( 17462 pack_day_export_artifact_names(&bundle), 17463 "pack_sheet.txt, pickup_roster.txt, customer_labels.txt" 17464 ); 17465 17466 let failed = PackDayExportProjection::failed(request, "disk unavailable"); 17467 assert_eq!( 17468 pack_day_export_detail_rows(&failed), 17469 vec![LabelValueRow::new( 17470 app_text(AppTextKey::PackDayExportErrorLabel), 17471 "disk unavailable" 17472 )] 17473 ); 17474 assert_eq!( 17475 pack_day_export_status_presentation(&DesktopAppRuntimeSummary { 17476 pack_day_projection: radroots_app_state::PackDayScreenProjection { 17477 export: failed, 17478 ..Default::default() 17479 }, 17480 ..summary( 17481 HomeRoute::Today, 17482 TodayAgendaProjection::default(), 17483 FarmSetupProjection::default(), 17484 ) 17485 }), 17486 PackDayExportStatusPresentation { 17487 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17488 title_key: AppTextKey::PackDayExportFailedTitle, 17489 body_key: AppTextKey::PackDayExportFailedBody, 17490 } 17491 ); 17492 } 17493 17494 #[test] 17495 fn pack_day_host_handoff_actions_only_surface_after_a_successful_export() { 17496 let temp_dir = TestDirectory::new(); 17497 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17498 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17499 write_artifact(temp_dir.path(), "customer_labels.txt"); 17500 let bundle = sample_pack_day_bundle(temp_dir.path()); 17501 let fulfillment_window_id = bundle.fulfillment_window_id; 17502 let mut runtime = summary( 17503 HomeRoute::Today, 17504 TodayAgendaProjection::default(), 17505 FarmSetupProjection::default(), 17506 ); 17507 17508 assert!(pack_day_host_handoff_action_presentations(&runtime).is_empty()); 17509 17510 runtime.pack_day_projection.export = PackDayExportProjection::succeeded( 17511 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id), 17512 bundle, 17513 ); 17514 17515 assert_eq!( 17516 pack_day_host_handoff_action_presentations(&runtime), 17517 vec![ 17518 PackDayHostHandoffActionPresentation { 17519 kind: PackDayHostHandoffKind::RevealBundle, 17520 label_key: AppTextKey::PackDayHostHandoffRevealAction, 17521 enabled: true, 17522 }, 17523 PackDayHostHandoffActionPresentation { 17524 kind: PackDayHostHandoffKind::OpenPackSheet, 17525 label_key: AppTextKey::PackDayHostHandoffOpenPackSheetAction, 17526 enabled: true, 17527 }, 17528 PackDayHostHandoffActionPresentation { 17529 kind: PackDayHostHandoffKind::OpenPickupRoster, 17530 label_key: AppTextKey::PackDayHostHandoffOpenPickupRosterAction, 17531 enabled: true, 17532 }, 17533 PackDayHostHandoffActionPresentation { 17534 kind: PackDayHostHandoffKind::OpenCustomerLabels, 17535 label_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsAction, 17536 enabled: true, 17537 }, 17538 ] 17539 ); 17540 } 17541 17542 #[test] 17543 fn pack_day_host_handoff_running_and_failure_postures_track_the_active_request() { 17544 let temp_dir = TestDirectory::new(); 17545 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17546 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17547 write_artifact(temp_dir.path(), "customer_labels.txt"); 17548 let bundle = sample_pack_day_bundle(temp_dir.path()); 17549 let fulfillment_window_id = bundle.fulfillment_window_id; 17550 let export_request = 17551 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id); 17552 let reveal_request = 17553 PackDayHostHandoffRequest::for_bundle(PackDayHostHandoffKind::RevealBundle, &bundle); 17554 let open_request = PackDayHostHandoffRequest::for_bundle( 17555 PackDayHostHandoffKind::OpenCustomerLabels, 17556 &bundle, 17557 ); 17558 let mut runtime = summary( 17559 HomeRoute::Today, 17560 TodayAgendaProjection::default(), 17561 FarmSetupProjection::default(), 17562 ); 17563 runtime.pack_day_projection.export = 17564 PackDayExportProjection::succeeded(export_request, bundle); 17565 17566 runtime.pack_day_projection.host_handoff = 17567 PackDayHostHandoffProjection::running(reveal_request); 17568 assert_eq!( 17569 pack_day_host_handoff_action_presentations(&runtime), 17570 vec![ 17571 PackDayHostHandoffActionPresentation { 17572 kind: PackDayHostHandoffKind::RevealBundle, 17573 label_key: AppTextKey::PackDayHostHandoffRevealActionRunning, 17574 enabled: false, 17575 }, 17576 PackDayHostHandoffActionPresentation { 17577 kind: PackDayHostHandoffKind::OpenPackSheet, 17578 label_key: AppTextKey::PackDayHostHandoffOpenPackSheetAction, 17579 enabled: false, 17580 }, 17581 PackDayHostHandoffActionPresentation { 17582 kind: PackDayHostHandoffKind::OpenPickupRoster, 17583 label_key: AppTextKey::PackDayHostHandoffOpenPickupRosterAction, 17584 enabled: false, 17585 }, 17586 PackDayHostHandoffActionPresentation { 17587 kind: PackDayHostHandoffKind::OpenCustomerLabels, 17588 label_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsAction, 17589 enabled: false, 17590 }, 17591 ] 17592 ); 17593 assert_eq!( 17594 pack_day_host_handoff_status_presentation(&runtime), 17595 Some(PackDayHostHandoffStatusPresentation { 17596 indicator_color: APP_UI_THEME.foundation.text.accent, 17597 title_key: AppTextKey::PackDayHostHandoffRevealRunningTitle, 17598 }) 17599 ); 17600 17601 runtime.pack_day_projection.host_handoff = 17602 PackDayHostHandoffProjection::failed(open_request, "finder unavailable"); 17603 assert_eq!( 17604 runtime.pack_day_projection.host_handoff.status, 17605 PackDayHostHandoffStatus::Failed 17606 ); 17607 assert_eq!( 17608 pack_day_host_handoff_status_presentation(&runtime), 17609 Some(PackDayHostHandoffStatusPresentation { 17610 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17611 title_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsFailedTitle, 17612 }) 17613 ); 17614 } 17615 17616 #[test] 17617 fn pack_day_host_handoff_actions_disable_missing_artifacts_even_after_export_success() { 17618 let temp_dir = TestDirectory::new(); 17619 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17620 let bundle = sample_pack_day_bundle(temp_dir.path()); 17621 let fulfillment_window_id = bundle.fulfillment_window_id; 17622 let mut runtime = summary( 17623 HomeRoute::Today, 17624 TodayAgendaProjection::default(), 17625 FarmSetupProjection::default(), 17626 ); 17627 17628 runtime.pack_day_projection.export = PackDayExportProjection::succeeded( 17629 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id), 17630 bundle, 17631 ); 17632 17633 assert_eq!( 17634 pack_day_host_handoff_action_presentations(&runtime), 17635 vec![ 17636 PackDayHostHandoffActionPresentation { 17637 kind: PackDayHostHandoffKind::RevealBundle, 17638 label_key: AppTextKey::PackDayHostHandoffRevealAction, 17639 enabled: true, 17640 }, 17641 PackDayHostHandoffActionPresentation { 17642 kind: PackDayHostHandoffKind::OpenPackSheet, 17643 label_key: AppTextKey::PackDayHostHandoffOpenPackSheetAction, 17644 enabled: true, 17645 }, 17646 PackDayHostHandoffActionPresentation { 17647 kind: PackDayHostHandoffKind::OpenPickupRoster, 17648 label_key: AppTextKey::PackDayHostHandoffOpenPickupRosterAction, 17649 enabled: false, 17650 }, 17651 PackDayHostHandoffActionPresentation { 17652 kind: PackDayHostHandoffKind::OpenCustomerLabels, 17653 label_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsAction, 17654 enabled: false, 17655 }, 17656 ] 17657 ); 17658 } 17659 17660 #[test] 17661 fn pack_day_print_actions_only_surface_after_a_successful_export() { 17662 let temp_dir = TestDirectory::new(); 17663 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17664 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17665 write_artifact(temp_dir.path(), "customer_labels.txt"); 17666 let bundle = sample_pack_day_bundle(temp_dir.path()); 17667 let fulfillment_window_id = bundle.fulfillment_window_id; 17668 let mut runtime = summary( 17669 HomeRoute::Today, 17670 TodayAgendaProjection::default(), 17671 FarmSetupProjection::default(), 17672 ); 17673 17674 assert!(pack_day_print_action_presentations(&runtime).is_empty()); 17675 17676 runtime.pack_day_projection.export = PackDayExportProjection::succeeded( 17677 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id), 17678 bundle, 17679 ); 17680 17681 assert_eq!( 17682 pack_day_print_action_presentations(&runtime), 17683 vec![ 17684 PackDayPrintActionPresentation { 17685 kind: PackDayPrintKind::PrintPackSheet, 17686 label_key: AppTextKey::PackDayPrintPackSheetAction, 17687 enabled: true, 17688 }, 17689 PackDayPrintActionPresentation { 17690 kind: PackDayPrintKind::PrintPickupRoster, 17691 label_key: AppTextKey::PackDayPrintPickupRosterAction, 17692 enabled: true, 17693 }, 17694 PackDayPrintActionPresentation { 17695 kind: PackDayPrintKind::PrintCustomerLabels, 17696 label_key: AppTextKey::PackDayPrintCustomerLabelsAction, 17697 enabled: true, 17698 }, 17699 ] 17700 ); 17701 } 17702 17703 #[test] 17704 fn pack_day_batch_workflow_action_only_surfaces_after_a_successful_export() { 17705 let temp_dir = TestDirectory::new(); 17706 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17707 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17708 write_artifact(temp_dir.path(), "customer_labels.txt"); 17709 let bundle = sample_pack_day_bundle(temp_dir.path()); 17710 let fulfillment_window_id = bundle.fulfillment_window_id; 17711 let mut runtime = summary( 17712 HomeRoute::Today, 17713 TodayAgendaProjection::default(), 17714 FarmSetupProjection::default(), 17715 ); 17716 17717 assert_eq!(pack_day_batch_print_action_presentation(&runtime), None); 17718 17719 runtime.pack_day_projection.export = PackDayExportProjection::succeeded( 17720 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id), 17721 bundle, 17722 ); 17723 17724 assert_eq!( 17725 pack_day_batch_print_action_presentation(&runtime), 17726 Some(PackDayBatchPrintActionPresentation { 17727 label_key: AppTextKey::PackDayBatchPrintAction, 17728 enabled: true, 17729 }) 17730 ); 17731 } 17732 17733 #[test] 17734 fn pack_day_batch_print_running_disables_conflicting_pack_day_actions() { 17735 let temp_dir = TestDirectory::new(); 17736 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17737 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17738 write_artifact(temp_dir.path(), "customer_labels.txt"); 17739 let bundle = sample_pack_day_bundle(temp_dir.path()); 17740 let fulfillment_window_id = bundle.fulfillment_window_id; 17741 let export_request = 17742 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id); 17743 let batch_request = PackDayBatchPrintRequest::for_bundle(&bundle); 17744 let mut runtime = summary( 17745 HomeRoute::Today, 17746 TodayAgendaProjection::default(), 17747 FarmSetupProjection::default(), 17748 ); 17749 runtime.pack_day_projection.export = 17750 PackDayExportProjection::succeeded(export_request, bundle); 17751 runtime.pack_day_projection.batch_print = 17752 PackDayBatchPrintProjection::running(batch_request); 17753 17754 assert_eq!( 17755 pack_day_batch_print_action_presentation(&runtime), 17756 Some(PackDayBatchPrintActionPresentation { 17757 label_key: AppTextKey::PackDayBatchPrintActionRunning, 17758 enabled: false, 17759 }) 17760 ); 17761 assert!( 17762 pack_day_print_action_presentations(&runtime) 17763 .into_iter() 17764 .all(|action| !action.enabled) 17765 ); 17766 assert!( 17767 pack_day_host_handoff_action_presentations(&runtime) 17768 .into_iter() 17769 .all(|action| !action.enabled) 17770 ); 17771 } 17772 17773 #[test] 17774 fn pack_day_batch_print_status_tracks_outcomes_and_failed_artifacts() { 17775 let temp_dir = TestDirectory::new(); 17776 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17777 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17778 write_artifact(temp_dir.path(), "customer_labels.txt"); 17779 let bundle = sample_pack_day_bundle(temp_dir.path()); 17780 let fulfillment_window_id = bundle.fulfillment_window_id; 17781 let export_request = 17782 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id); 17783 let batch_request = PackDayBatchPrintRequest::for_bundle(&bundle); 17784 let mut runtime = summary( 17785 HomeRoute::Today, 17786 TodayAgendaProjection::default(), 17787 FarmSetupProjection::default(), 17788 ); 17789 runtime.pack_day_projection.export = 17790 PackDayExportProjection::succeeded(export_request, bundle); 17791 17792 runtime.pack_day_projection.batch_print = 17793 PackDayBatchPrintProjection::running(batch_request.clone()); 17794 assert_eq!( 17795 pack_day_batch_print_status_presentation(&runtime), 17796 Some(PackDayBatchPrintStatusPresentation { 17797 indicator_color: APP_UI_THEME.foundation.text.accent, 17798 title_key: AppTextKey::PackDayBatchPrintQueuedTitle, 17799 }) 17800 ); 17801 17802 runtime.pack_day_projection.batch_print = 17803 PackDayBatchPrintProjection::succeeded(batch_request.clone()); 17804 assert_eq!( 17805 pack_day_batch_print_status_presentation(&runtime), 17806 Some(PackDayBatchPrintStatusPresentation { 17807 indicator_color: APP_UI_THEME.components.app_status_indicator.online, 17808 title_key: AppTextKey::PackDayBatchPrintSucceededTitle, 17809 }) 17810 ); 17811 17812 runtime.pack_day_projection.batch_print = PackDayBatchPrintProjection::failed( 17813 batch_request.clone(), 17814 Some(PackDayBatchPrintArtifact::from_print_kind( 17815 PackDayPrintKind::PrintPickupRoster, 17816 )), 17817 PackDayBatchPrintFailureKind::QueueExit, 17818 ); 17819 assert_eq!( 17820 pack_day_batch_print_status_presentation(&runtime), 17821 Some(PackDayBatchPrintStatusPresentation { 17822 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17823 title_key: AppTextKey::PackDayPrintPickupRosterFailedTitle, 17824 }) 17825 ); 17826 17827 runtime.pack_day_projection.batch_print = PackDayBatchPrintProjection::failed( 17828 batch_request.clone(), 17829 None, 17830 PackDayBatchPrintFailureKind::Preflight, 17831 ); 17832 assert_eq!( 17833 pack_day_batch_print_status_presentation(&runtime), 17834 Some(PackDayBatchPrintStatusPresentation { 17835 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17836 title_key: AppTextKey::PackDayBatchPrintFailedPreflightTitle, 17837 }) 17838 ); 17839 17840 runtime.pack_day_projection.batch_print = PackDayBatchPrintProjection::failed( 17841 batch_request, 17842 Some(PackDayBatchPrintArtifact::from_print_kind( 17843 PackDayPrintKind::PrintCustomerLabels, 17844 )), 17845 PackDayBatchPrintFailureKind::CustomerLabelsAvery5160Overflow, 17846 ); 17847 assert_eq!( 17848 pack_day_batch_print_status_presentation(&runtime), 17849 Some(PackDayBatchPrintStatusPresentation { 17850 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17851 title_key: AppTextKey::PackDayBatchPrintCustomerLabelsAvery5160OverflowFailedTitle, 17852 }) 17853 ); 17854 } 17855 17856 #[test] 17857 fn pack_day_print_running_and_failure_postures_track_the_active_request() { 17858 let temp_dir = TestDirectory::new(); 17859 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17860 write_artifact(temp_dir.path(), "pickup_roster.txt"); 17861 write_artifact(temp_dir.path(), "customer_labels.txt"); 17862 let bundle = sample_pack_day_bundle(temp_dir.path()); 17863 let fulfillment_window_id = bundle.fulfillment_window_id; 17864 let export_request = 17865 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id); 17866 let print_request = 17867 PackDayPrintRequest::for_bundle(PackDayPrintKind::PrintPackSheet, &bundle); 17868 let failed_request = 17869 PackDayPrintRequest::for_bundle(PackDayPrintKind::PrintCustomerLabels, &bundle); 17870 let mut runtime = summary( 17871 HomeRoute::Today, 17872 TodayAgendaProjection::default(), 17873 FarmSetupProjection::default(), 17874 ); 17875 runtime.pack_day_projection.export = 17876 PackDayExportProjection::succeeded(export_request, bundle.clone()); 17877 17878 runtime.pack_day_projection.print = PackDayPrintProjection::running(print_request); 17879 assert_eq!( 17880 pack_day_print_action_presentations(&runtime), 17881 vec![ 17882 PackDayPrintActionPresentation { 17883 kind: PackDayPrintKind::PrintPackSheet, 17884 label_key: AppTextKey::PackDayPrintPackSheetActionRunning, 17885 enabled: false, 17886 }, 17887 PackDayPrintActionPresentation { 17888 kind: PackDayPrintKind::PrintPickupRoster, 17889 label_key: AppTextKey::PackDayPrintPickupRosterAction, 17890 enabled: false, 17891 }, 17892 PackDayPrintActionPresentation { 17893 kind: PackDayPrintKind::PrintCustomerLabels, 17894 label_key: AppTextKey::PackDayPrintCustomerLabelsAction, 17895 enabled: false, 17896 }, 17897 ] 17898 ); 17899 assert_eq!( 17900 pack_day_print_status_presentation(&runtime), 17901 Some(PackDayPrintStatusPresentation { 17902 indicator_color: APP_UI_THEME.foundation.text.accent, 17903 title_key: AppTextKey::PackDayPrintPackSheetQueuedTitle, 17904 }) 17905 ); 17906 assert_eq!( 17907 pack_day_host_handoff_action_presentations(&runtime), 17908 vec![ 17909 PackDayHostHandoffActionPresentation { 17910 kind: PackDayHostHandoffKind::RevealBundle, 17911 label_key: AppTextKey::PackDayHostHandoffRevealAction, 17912 enabled: false, 17913 }, 17914 PackDayHostHandoffActionPresentation { 17915 kind: PackDayHostHandoffKind::OpenPackSheet, 17916 label_key: AppTextKey::PackDayHostHandoffOpenPackSheetAction, 17917 enabled: false, 17918 }, 17919 PackDayHostHandoffActionPresentation { 17920 kind: PackDayHostHandoffKind::OpenPickupRoster, 17921 label_key: AppTextKey::PackDayHostHandoffOpenPickupRosterAction, 17922 enabled: false, 17923 }, 17924 PackDayHostHandoffActionPresentation { 17925 kind: PackDayHostHandoffKind::OpenCustomerLabels, 17926 label_key: AppTextKey::PackDayHostHandoffOpenCustomerLabelsAction, 17927 enabled: false, 17928 }, 17929 ] 17930 ); 17931 17932 runtime.pack_day_projection.print = PackDayPrintProjection::failed(failed_request); 17933 assert_eq!( 17934 runtime.pack_day_projection.print.status, 17935 PackDayPrintStatus::Failed 17936 ); 17937 assert_eq!( 17938 pack_day_print_status_presentation(&runtime), 17939 Some(PackDayPrintStatusPresentation { 17940 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17941 title_key: AppTextKey::PackDayPrintCustomerLabelsFailedTitle, 17942 }) 17943 ); 17944 17945 let overflow_request = 17946 PackDayPrintRequest::for_bundle(PackDayPrintKind::PrintCustomerLabels, &bundle); 17947 runtime.pack_day_projection.print = PackDayPrintProjection::failed_with_kind( 17948 overflow_request, 17949 PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow, 17950 ); 17951 assert_eq!( 17952 pack_day_print_status_presentation(&runtime), 17953 Some(PackDayPrintStatusPresentation { 17954 indicator_color: APP_UI_THEME.components.app_status_indicator.attention, 17955 title_key: AppTextKey::PackDayPrintCustomerLabelsAvery5160OverflowFailedTitle, 17956 }) 17957 ); 17958 } 17959 17960 #[test] 17961 fn pack_day_print_actions_disable_missing_artifacts_and_host_handoff_runs() { 17962 let temp_dir = TestDirectory::new(); 17963 write_artifact(temp_dir.path(), "pack_sheet.txt"); 17964 let bundle = sample_pack_day_bundle(temp_dir.path()); 17965 let fulfillment_window_id = bundle.fulfillment_window_id; 17966 let export_request = 17967 radroots_app_state::PackDayExportRequest::for_fulfillment_window(fulfillment_window_id); 17968 let host_handoff_request = 17969 PackDayHostHandoffRequest::for_bundle(PackDayHostHandoffKind::RevealBundle, &bundle); 17970 let mut runtime = summary( 17971 HomeRoute::Today, 17972 TodayAgendaProjection::default(), 17973 FarmSetupProjection::default(), 17974 ); 17975 17976 runtime.pack_day_projection.export = 17977 PackDayExportProjection::succeeded(export_request, bundle.clone()); 17978 assert_eq!( 17979 pack_day_print_action_presentations(&runtime), 17980 vec![ 17981 PackDayPrintActionPresentation { 17982 kind: PackDayPrintKind::PrintPackSheet, 17983 label_key: AppTextKey::PackDayPrintPackSheetAction, 17984 enabled: true, 17985 }, 17986 PackDayPrintActionPresentation { 17987 kind: PackDayPrintKind::PrintPickupRoster, 17988 label_key: AppTextKey::PackDayPrintPickupRosterAction, 17989 enabled: false, 17990 }, 17991 PackDayPrintActionPresentation { 17992 kind: PackDayPrintKind::PrintCustomerLabels, 17993 label_key: AppTextKey::PackDayPrintCustomerLabelsAction, 17994 enabled: false, 17995 }, 17996 ] 17997 ); 17998 17999 runtime.pack_day_projection.host_handoff = 18000 PackDayHostHandoffProjection::running(host_handoff_request); 18001 assert_eq!( 18002 pack_day_print_action_presentations(&runtime), 18003 vec![ 18004 PackDayPrintActionPresentation { 18005 kind: PackDayPrintKind::PrintPackSheet, 18006 label_key: AppTextKey::PackDayPrintPackSheetAction, 18007 enabled: false, 18008 }, 18009 PackDayPrintActionPresentation { 18010 kind: PackDayPrintKind::PrintPickupRoster, 18011 label_key: AppTextKey::PackDayPrintPickupRosterAction, 18012 enabled: false, 18013 }, 18014 PackDayPrintActionPresentation { 18015 kind: PackDayPrintKind::PrintCustomerLabels, 18016 label_key: AppTextKey::PackDayPrintCustomerLabelsAction, 18017 enabled: false, 18018 }, 18019 ] 18020 ); 18021 } 18022 18023 #[test] 18024 fn sidebar_navigation_keeps_destinations_stable() { 18025 assert_eq!( 18026 home_sidebar_navigation_sections(FarmerSection::Today, true, false), 18027 vec![ 18028 FarmerSection::Today, 18029 FarmerSection::Products, 18030 FarmerSection::Orders, 18031 ] 18032 ); 18033 assert_eq!( 18034 home_sidebar_navigation_sections(FarmerSection::Products, true, false), 18035 vec![ 18036 FarmerSection::Today, 18037 FarmerSection::Products, 18038 FarmerSection::Orders, 18039 ] 18040 ); 18041 assert_eq!( 18042 home_sidebar_navigation_sections(FarmerSection::Orders, true, false), 18043 vec![ 18044 FarmerSection::Today, 18045 FarmerSection::Products, 18046 FarmerSection::Orders, 18047 ] 18048 ); 18049 assert_eq!( 18050 home_sidebar_navigation_sections(FarmerSection::PackDay, true, true), 18051 vec![ 18052 FarmerSection::Today, 18053 FarmerSection::Products, 18054 FarmerSection::Orders, 18055 FarmerSection::PackDay, 18056 ] 18057 ); 18058 } 18059 18060 #[test] 18061 fn saved_farm_falls_back_to_local_projection_when_today_is_empty() { 18062 let saved_farm = FarmSummary { 18063 farm_id: FarmId::new(), 18064 display_name: String::new(), 18065 readiness: FarmReadiness::Ready, 18066 }; 18067 let runtime = summary( 18068 HomeRoute::Today, 18069 TodayAgendaProjection::default(), 18070 FarmSetupProjection::new( 18071 FarmSetupDraft::new(String::new(), String::new(), [FarmOrderMethod::Shipping]), 18072 Some(saved_farm.clone()), 18073 ), 18074 ); 18075 18076 assert_eq!(home_saved_farm(&runtime), Some(&saved_farm)); 18077 } 18078 18079 #[test] 18080 fn product_editor_price_parser_handles_blank_whole_and_decimal_inputs() { 18081 assert_eq!(parse_product_editor_price_input(""), Some(None)); 18082 assert_eq!(parse_product_editor_price_input("6"), Some(Some(600))); 18083 assert_eq!(parse_product_editor_price_input("6.5"), Some(Some(650))); 18084 assert_eq!(parse_product_editor_price_input("6.50"), Some(Some(650))); 18085 assert_eq!(parse_product_editor_price_input("6."), None); 18086 assert_eq!(parse_product_editor_price_input("6.500"), None); 18087 assert_eq!(parse_product_editor_price_input("abc"), None); 18088 } 18089 18090 #[test] 18091 fn product_editor_stock_parser_accepts_blank_or_whole_numbers_only() { 18092 assert_eq!(parse_optional_product_editor_stock_input(""), Some(None)); 18093 assert_eq!( 18094 parse_optional_product_editor_stock_input("14"), 18095 Some(Some(14)) 18096 ); 18097 assert_eq!(parse_optional_product_editor_stock_input("14.5"), None); 18098 assert_eq!(parse_optional_product_editor_stock_input("abc"), None); 18099 } 18100 18101 #[test] 18102 fn blank_product_titles_fall_back_to_the_untitled_copy() { 18103 assert_eq!( 18104 product_display_title(""), 18105 app_text(AppTextKey::ProductsUntitledDraft) 18106 ); 18107 assert_eq!( 18108 product_display_title(" "), 18109 app_text(AppTextKey::ProductsUntitledDraft) 18110 ); 18111 assert_eq!(product_display_title("Salad mix"), "Salad mix"); 18112 } 18113 18114 #[test] 18115 fn startup_signer_preview_summary_surfaces_parsed_signer_details() { 18116 let preview = startup_signer_preview_summary( 18117 "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", 18118 ) 18119 .expect("preview"); 18120 18121 assert_eq!( 18122 preview.source_label, 18123 app_text(AppTextKey::HomeSetupSignerSourceValueBunkerUri) 18124 ); 18125 assert!(preview.signer_npub.starts_with("npub1")); 18126 assert_eq!(preview.relays_label, "wss://relay.radroots.example"); 18127 assert_eq!( 18128 preview.permissions_label, 18129 format!( 18130 "{}, {}", 18131 app_text(AppTextKey::HomeSetupSignerPermissionSignEventKind1), 18132 app_text(AppTextKey::HomeSetupSignerPermissionSwitchRelays) 18133 ) 18134 ); 18135 } 18136 18137 #[test] 18138 fn startup_signer_status_prefers_auth_challenge_until_approval_is_complete() { 18139 let pending_session = fixture_pending_session(); 18140 18141 assert_eq!( 18142 startup_signer_status_spec(&StartupSignerConnectState::Connecting), 18143 Some((AppTextKey::HomeSetupSignerConnectingTitle, None)) 18144 ); 18145 assert_eq!( 18146 startup_signer_status_spec(&StartupSignerConnectState::PendingApproval { 18147 pending_session: pending_session.clone(), 18148 auth_challenge_url: None, 18149 }), 18150 Some((AppTextKey::HomeSetupSignerPendingTitle, None)) 18151 ); 18152 assert_eq!( 18153 startup_signer_status_spec(&StartupSignerConnectState::PendingApproval { 18154 pending_session: pending_session.clone(), 18155 auth_challenge_url: Some("https://auth.example/challenge".to_owned()), 18156 }), 18157 Some(( 18158 AppTextKey::HomeSetupSignerAuthChallengeTitle, 18159 Some("https://auth.example/challenge".to_owned()), 18160 )) 18161 ); 18162 assert_eq!( 18163 startup_signer_status_spec(&StartupSignerConnectState::Approved { 18164 pending_session, 18165 approved_session: RadrootsAppRemoteSignerApprovedSession { 18166 user_identity: fixture_identity( 18167 "2222222222222222222222222222222222222222222222222222222222222222", 18168 ) 18169 .to_public(), 18170 relays: vec!["wss://relay.radroots.example".to_owned()], 18171 approved_permissions: Default::default(), 18172 }, 18173 auth_challenge_url: None, 18174 }), 18175 Some((AppTextKey::HomeSetupSignerApprovedTitle, None)) 18176 ); 18177 } 18178 18179 #[test] 18180 fn startup_signer_source_input_is_editable_only_while_idle() { 18181 let pending_session = fixture_pending_session(); 18182 18183 assert!(startup_signer_source_input_is_editable( 18184 &StartupSignerConnectState::Idle 18185 )); 18186 assert!(!startup_signer_source_input_is_editable( 18187 &StartupSignerConnectState::Connecting 18188 )); 18189 assert!(!startup_signer_source_input_is_editable( 18190 &StartupSignerConnectState::PendingApproval { 18191 pending_session: pending_session.clone(), 18192 auth_challenge_url: None, 18193 } 18194 )); 18195 assert!(!startup_signer_source_input_is_editable( 18196 &StartupSignerConnectState::Approved { 18197 pending_session, 18198 approved_session: RadrootsAppRemoteSignerApprovedSession { 18199 user_identity: fixture_identity( 18200 "2222222222222222222222222222222222222222222222222222222222222222", 18201 ) 18202 .to_public(), 18203 relays: vec!["wss://relay.radroots.example".to_owned()], 18204 approved_permissions: Default::default(), 18205 }, 18206 auth_challenge_url: None, 18207 } 18208 )); 18209 } 18210 18211 #[test] 18212 fn startup_signer_preview_summary_prefers_pending_session_details_once_connect_starts() { 18213 let pending_session = fixture_pending_session(); 18214 let preview = startup_signer_preview_summary_for_connect_state( 18215 "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", 18216 &StartupSignerConnectState::PendingApproval { 18217 pending_session: pending_session.clone(), 18218 auth_challenge_url: None, 18219 }, 18220 ) 18221 .expect("preview"); 18222 18223 assert_eq!( 18224 preview.signer_npub, 18225 pending_session.record.signer_identity.public_key_npub 18226 ); 18227 assert_eq!(preview.relays_label, "wss://relay.radroots.example"); 18228 assert_eq!( 18229 preview.permissions_label, 18230 format!( 18231 "{}, {}", 18232 app_text(AppTextKey::HomeSetupSignerPermissionSignEventKind1), 18233 app_text(AppTextKey::HomeSetupSignerPermissionSwitchRelays) 18234 ) 18235 ); 18236 } 18237 18238 #[test] 18239 fn startup_signer_transport_failure_notice_ignores_the_waiting_timeout_copy() { 18240 assert!(!startup_signer_transport_failure_requires_notice( 18241 "remote signer did not respond yet" 18242 )); 18243 assert!(startup_signer_transport_failure_requires_notice( 18244 "remote signer connection failed: relay refused the request" 18245 )); 18246 } 18247 18248 #[test] 18249 fn startup_signer_notice_copy_maps_known_signer_failures() { 18250 assert_eq!( 18251 startup_notice_text("enter a bunker or discovery url to continue"), 18252 app_text(AppTextKey::HomeSetupSignerErrorEnterSource) 18253 ); 18254 assert_eq!( 18255 startup_notice_text( 18256 "enter a bunker or discovery url from the signer; raw nostrconnect client uris are signer-side only" 18257 ), 18258 app_text(AppTextKey::HomeSetupSignerErrorUseSignerUri) 18259 ); 18260 assert_eq!( 18261 startup_notice_text("discovery url does not contain a remote signer uri"), 18262 app_text(AppTextKey::HomeSetupSignerErrorMissingDiscoveryUri) 18263 ); 18264 assert_eq!( 18265 startup_notice_text("invalid discovery url: relative URL without a base"), 18266 app_text(AppTextKey::HomeSetupSignerErrorInvalidDiscoveryUrl) 18267 ); 18268 assert_eq!( 18269 startup_notice_text("invalid remote signer uri: invalid public key"), 18270 app_text(AppTextKey::HomeSetupSignerErrorInvalidRemoteSignerUri) 18271 ); 18272 assert_eq!( 18273 startup_notice_text("a remote signer connection is already pending approval"), 18274 app_text(AppTextKey::HomeSetupSignerErrorPendingApprovalExists) 18275 ); 18276 assert_eq!( 18277 startup_notice_text("remote signer connection failed: relay refused the request"), 18278 app_text(AppTextKey::HomeSetupSignerErrorConnectionFailed) 18279 ); 18280 assert_eq!( 18281 startup_notice_text("failed to add relay `{relay_url}`: {error}"), 18282 app_text(AppTextKey::HomeSetupErrorStartupFailed) 18283 ); 18284 } 18285 18286 #[test] 18287 fn startup_issue_copy_fails_closed_to_a_localized_summary() { 18288 assert_eq!( 18289 startup_issue_summary_text("runtime unavailable"), 18290 app_text(AppTextKey::HomeSetupIssueUnavailableBody) 18291 ); 18292 assert_eq!( 18293 startup_issue_summary_text("desktop runtime roots require HOME for macos"), 18294 app_text(AppTextKey::HomeSetupIssueUnavailableBody) 18295 ); 18296 } 18297 18298 #[test] 18299 fn reminder_action_target_prefers_order_detail_before_pack_day() { 18300 let order_id = radroots_app_view::OrderId::new(); 18301 let fulfillment_window_id = FulfillmentWindowId::new(); 18302 18303 assert_eq!( 18304 reminder_action_target(&fixture_reminder( 18305 Some(order_id), 18306 Some(fulfillment_window_id), 18307 ReminderKind::OrderAction, 18308 ReminderUrgency::DueSoon, 18309 )), 18310 Some(ReminderActionTarget::OrderDetail(order_id)) 18311 ); 18312 assert_eq!( 18313 reminder_action_target(&fixture_reminder( 18314 None, 18315 Some(fulfillment_window_id), 18316 ReminderKind::FulfillmentWindow, 18317 ReminderUrgency::Upcoming, 18318 )), 18319 Some(ReminderActionTarget::PackDay(fulfillment_window_id)) 18320 ); 18321 assert_eq!( 18322 reminder_action_target(&fixture_reminder( 18323 None, 18324 None, 18325 ReminderKind::SyncImpact, 18326 ReminderUrgency::Blocking, 18327 )), 18328 None 18329 ); 18330 } 18331 18332 #[test] 18333 fn reminder_urgency_helpers_follow_the_surface_contract() { 18334 assert_eq!( 18335 reminder_urgency_key(ReminderUrgency::Upcoming), 18336 AppTextKey::ReminderUrgencyUpcoming 18337 ); 18338 assert_eq!( 18339 reminder_urgency_key(ReminderUrgency::DueSoon), 18340 AppTextKey::ReminderUrgencyDueSoon 18341 ); 18342 assert_eq!( 18343 reminder_urgency_color(ReminderUrgency::Upcoming), 18344 APP_UI_THEME.components.app_status_indicator.offline 18345 ); 18346 assert_eq!( 18347 reminder_urgency_color(ReminderUrgency::DueSoon), 18348 APP_UI_THEME.foundation.text.accent 18349 ); 18350 assert_eq!( 18351 reminder_urgency_color(ReminderUrgency::Blocking), 18352 APP_UI_THEME.components.app_status_indicator.attention 18353 ); 18354 } 18355 18356 #[test] 18357 fn reminder_deadline_text_uses_the_typed_due_label() { 18358 let reminder = fixture_reminder( 18359 None, 18360 Some(FulfillmentWindowId::new()), 18361 ReminderKind::FulfillmentWindow, 18362 ReminderUrgency::Upcoming, 18363 ); 18364 18365 assert_eq!( 18366 reminder_deadline_text(&reminder), 18367 format!("{}: {}", app_text(AppTextKey::ReminderDeadlineLabel), "0") 18368 ); 18369 } 18370 18371 #[test] 18372 fn reminder_delivery_state_key_matches_the_local_presentation_contract() { 18373 assert_eq!( 18374 reminder_delivery_state_key(ReminderDeliveryState::Scheduled), 18375 AppTextKey::ReminderDeliveryStateScheduled 18376 ); 18377 assert_eq!( 18378 reminder_delivery_state_key(ReminderDeliveryState::Presented), 18379 AppTextKey::ReminderDeliveryStatePresented 18380 ); 18381 assert_eq!( 18382 reminder_delivery_state_key(ReminderDeliveryState::Acknowledged), 18383 AppTextKey::ReminderDeliveryStateAcknowledged 18384 ); 18385 assert_eq!( 18386 reminder_delivery_state_key(ReminderDeliveryState::Resolved), 18387 AppTextKey::ReminderDeliveryStateResolved 18388 ); 18389 } 18390 18391 #[test] 18392 fn presented_farmer_reminder_prefers_the_highest_priority_presented_item() { 18393 let mut runtime = summary( 18394 HomeRoute::Today, 18395 TodayAgendaProjection::default(), 18396 FarmSetupProjection::default(), 18397 ); 18398 let due_soon = fixture_reminder( 18399 None, 18400 Some(FulfillmentWindowId::new()), 18401 ReminderKind::FulfillmentWindow, 18402 ReminderUrgency::DueSoon, 18403 ); 18404 let blocking = fixture_reminder( 18405 None, 18406 None, 18407 ReminderKind::SyncImpact, 18408 ReminderUrgency::Blocking, 18409 ); 18410 18411 runtime 18412 .today_projection 18413 .reminders 18414 .items 18415 .push(ReminderDeadlineProjection { 18416 delivery_state: ReminderDeliveryState::Presented, 18417 ..due_soon 18418 }); 18419 runtime 18420 .orders_projection 18421 .reminders 18422 .items 18423 .push(ReminderDeadlineProjection { 18424 delivery_state: ReminderDeliveryState::Presented, 18425 ..blocking.clone() 18426 }); 18427 18428 assert_eq!( 18429 presented_farmer_reminder(&runtime) 18430 .expect("presented reminder") 18431 .reminder_id, 18432 blocking.reminder_id 18433 ); 18434 } 18435 18436 #[test] 18437 fn about_status_rows_disable_sync_without_a_selected_account() { 18438 let rows = about_status_rows( 18439 &summary( 18440 HomeRoute::SetupRequired, 18441 TodayAgendaProjection::default(), 18442 FarmSetupProjection::default(), 18443 ), 18444 None, 18445 ); 18446 18447 assert!(rows.iter().any(|row| { 18448 row.label == app_text(AppTextKey::MetadataSelectedAccount) 18449 && row.value == app_text(AppTextKey::ValueNone) 18450 })); 18451 assert!(rows.iter().any(|row| { 18452 row.label == app_text(AppTextKey::MetadataSyncRunStatus) 18453 && row.value == app_text(AppTextKey::ValueDisabled) 18454 })); 18455 assert!(rows.iter().any(|row| { 18456 row.label == app_text(AppTextKey::MetadataSyncCheckpointState) 18457 && row.value == app_text(AppTextKey::ValueNone) 18458 })); 18459 assert!(rows.iter().any(|row| { 18460 row.label == app_text(AppTextKey::MetadataStartupIssue) 18461 && row.value == app_text(AppTextKey::ValueNone) 18462 })); 18463 } 18464 18465 #[test] 18466 fn about_conflict_review_helpers_surface_actions_and_details_truthfully() { 18467 let blocking_conflict = DesktopAppSyncConflictSummary { 18468 conflict_id: String::new(), 18469 conflict: SyncConflict { 18470 aggregate: SyncAggregateRef::Farm(FarmId::new()), 18471 kind: SyncConflictKind::RevisionMismatch, 18472 severity: SyncConflictSeverity::Blocking, 18473 resolution: SyncConflictResolutionStatus::Unresolved, 18474 local_payload_json: String::new(), 18475 remote_payload_json: Some(String::new()), 18476 detected_at: "0".to_owned(), 18477 resolved_at: None, 18478 }, 18479 }; 18480 let review_conflict = DesktopAppSyncConflictSummary { 18481 conflict_id: String::new(), 18482 conflict: SyncConflict { 18483 aggregate: SyncAggregateRef::Order(radroots_app_view::OrderId::new()), 18484 kind: SyncConflictKind::RemoteValidationReject, 18485 severity: SyncConflictSeverity::ReviewRequired, 18486 resolution: SyncConflictResolutionStatus::Unresolved, 18487 local_payload_json: String::new(), 18488 remote_payload_json: Some(String::new()), 18489 detected_at: "0".to_owned(), 18490 resolved_at: None, 18491 }, 18492 }; 18493 let mut runtime = summary( 18494 HomeRoute::Today, 18495 TodayAgendaProjection::default(), 18496 FarmSetupProjection::default(), 18497 ); 18498 runtime.sync_status = DesktopAppSyncStatusSummary { 18499 account_id: Some(app_text(AppTextKey::AppName)), 18500 projection: AppSyncProjection { 18501 run_status: AppSyncRunStatus::Conflicted, 18502 checkpoint: SyncCheckpointStatus::never_synced(), 18503 conflict_status: SyncConflictStatus { 18504 unresolved_count: 2, 18505 blocking_count: 1, 18506 }, 18507 }, 18508 pending_write_count: 3, 18509 conflicts: vec![blocking_conflict.clone(), review_conflict.clone()], 18510 }; 18511 18512 assert_eq!( 18513 about_conflict_review_body_key(&runtime.sync_status), 18514 AppTextKey::SettingsAboutConflictReviewBlocking 18515 ); 18516 assert!(!about_manual_refresh_enabled(&runtime.sync_status)); 18517 18518 let blocking_actions = about_conflict_action_specs(&blocking_conflict.conflict); 18519 assert_eq!( 18520 blocking_actions, 18521 vec![ 18522 ( 18523 AppTextKey::SettingsAboutConflictAcceptLocalAction, 18524 SyncConflictResolutionStatus::AcceptedLocal, 18525 ), 18526 ( 18527 AppTextKey::SettingsAboutConflictAcceptRemoteAction, 18528 SyncConflictResolutionStatus::AcceptedRemote, 18529 ), 18530 ] 18531 ); 18532 18533 let review_actions = about_conflict_action_specs(&review_conflict.conflict); 18534 assert_eq!( 18535 review_actions, 18536 vec![ 18537 ( 18538 AppTextKey::SettingsAboutConflictAcceptLocalAction, 18539 SyncConflictResolutionStatus::AcceptedLocal, 18540 ), 18541 ( 18542 AppTextKey::SettingsAboutConflictAcceptRemoteAction, 18543 SyncConflictResolutionStatus::AcceptedRemote, 18544 ), 18545 ( 18546 AppTextKey::SettingsAboutConflictDismissAction, 18547 SyncConflictResolutionStatus::Dismissed, 18548 ), 18549 ] 18550 ); 18551 18552 let rows = about_conflict_detail_rows(&blocking_conflict); 18553 assert_eq!(rows.len(), 5); 18554 assert!(rows.iter().any(|row| { 18555 row.label == app_text(AppTextKey::MetadataSyncConflictAggregate) 18556 && row.value == about_conflict_aggregate_text(&blocking_conflict.conflict) 18557 })); 18558 assert!(rows.iter().any(|row| { 18559 row.label == app_text(AppTextKey::MetadataSyncConflictResolution) 18560 && row.value == app_text(AppTextKey::ValueSyncConflictResolutionUnresolved) 18561 })); 18562 } 18563 18564 #[test] 18565 fn about_status_rows_surface_ready_sdk_diagnostics() { 18566 let sdk_status = fixture_sdk_status(AppSdkLifecycleState::Ready); 18567 let sdk_diagnostics = DesktopAppSdkDiagnosticsSummary { 18568 status: sdk_status.clone(), 18569 state: DesktopAppSdkDiagnosticsState::Ready(DesktopAppSdkReadyDiagnosticsSummary { 18570 storage_kind: "directory".to_owned(), 18571 event_store_total_events: 7, 18572 outbox_total_events: 3, 18573 outbox_pending_events: 2, 18574 outbox_failed_terminal_events: 0, 18575 integrity_event_store_ok: true, 18576 integrity_outbox_ok: true, 18577 sync_source: "sdk_canonical_stores".to_owned(), 18578 sync_observed_at_ms: 42, 18579 sync_relay_target_count: 2, 18580 }), 18581 }; 18582 let mut runtime = summary( 18583 HomeRoute::Today, 18584 TodayAgendaProjection::default(), 18585 FarmSetupProjection::default(), 18586 ); 18587 runtime.sdk_status = Some(sdk_status); 18588 18589 let rows = about_status_rows(&runtime, Some(&sdk_diagnostics)); 18590 18591 assert!(rows.iter().any(|row| { 18592 row.label == app_text(AppTextKey::MetadataSdkLifecycleState) 18593 && row.value == app_text(AppTextKey::ValueSdkLifecycleReady) 18594 })); 18595 assert!(rows.iter().any(|row| { 18596 row.label == app_text(AppTextKey::MetadataSdkDiagnosticState) 18597 && row.value == app_text(AppTextKey::ValueSdkDiagnosticsReady) 18598 })); 18599 assert!(rows.iter().any(|row| { 18600 row.label == app_text(AppTextKey::MetadataSdkStorageKind) 18601 && row.value == app_text(AppTextKey::ValueSdkStorageKindDirectory) 18602 })); 18603 assert!(rows.iter().any(|row| { 18604 row.label == app_text(AppTextKey::MetadataSdkOutboxPendingCount) && row.value == "2" 18605 })); 18606 assert!(rows.iter().any(|row| { 18607 row.label == app_text(AppTextKey::MetadataSdkIntegrityStatus) 18608 && row.value == app_text(AppTextKey::ValueSdkIntegrityOk) 18609 })); 18610 assert!(rows.iter().any(|row| { 18611 row.label == app_text(AppTextKey::MetadataSdkLastIssueCode) 18612 && row.value == app_text(AppTextKey::ValueNone) 18613 })); 18614 } 18615 18616 #[test] 18617 fn about_status_rows_surface_blocked_sdk_issue_metadata() { 18618 let issue = DesktopAppSdkIssueSummary { 18619 code: "invalid_relay_url".to_owned(), 18620 class: "configuration".to_owned(), 18621 retryable: false, 18622 recovery_actions: vec!["configure_relay_targets".to_owned()], 18623 }; 18624 let mut sdk_status = fixture_sdk_status(AppSdkLifecycleState::Degraded); 18625 sdk_status.last_issue = Some(issue.clone()); 18626 let sdk_diagnostics = DesktopAppSdkDiagnosticsSummary { 18627 status: sdk_status.clone(), 18628 state: DesktopAppSdkDiagnosticsState::Blocked(issue), 18629 }; 18630 let mut runtime = summary( 18631 HomeRoute::Today, 18632 TodayAgendaProjection::default(), 18633 FarmSetupProjection::default(), 18634 ); 18635 runtime.sdk_status = Some(sdk_status); 18636 18637 let rows = about_status_rows(&runtime, Some(&sdk_diagnostics)); 18638 18639 assert!(rows.iter().any(|row| { 18640 row.label == app_text(AppTextKey::MetadataSdkLifecycleState) 18641 && row.value == app_text(AppTextKey::ValueSdkLifecycleDegraded) 18642 })); 18643 assert!(rows.iter().any(|row| { 18644 row.label == app_text(AppTextKey::MetadataSdkDiagnosticState) 18645 && row.value == app_text(AppTextKey::ValueSdkDiagnosticsBlocked) 18646 })); 18647 assert!(rows.iter().any(|row| { 18648 row.label == app_text(AppTextKey::MetadataSdkLastIssueCode) 18649 && row.value == "invalid_relay_url" 18650 })); 18651 assert!(rows.iter().any(|row| { 18652 row.label == app_text(AppTextKey::MetadataSdkIssueRetryable) 18653 && row.value == app_text(AppTextKey::ValueNo) 18654 })); 18655 assert!(rows.iter().any(|row| { 18656 row.label == app_text(AppTextKey::MetadataSdkRecoveryAction) 18657 && row.value == app_text(AppTextKey::ValueSdkRecoveryConfigureRelayTargets) 18658 })); 18659 } 18660 18661 #[test] 18662 fn about_runtime_rows_append_paths_schema_and_shell_section() { 18663 let mut runtime = summary( 18664 HomeRoute::Today, 18665 TodayAgendaProjection::default(), 18666 FarmSetupProjection::default(), 18667 ); 18668 let data_root = PathBuf::from("/tmp/radroots/data/apps/app"); 18669 let logs_root = PathBuf::from("/tmp/radroots/logs/apps/app"); 18670 let database_path = data_root.join("app.sqlite3"); 18671 runtime.shell_projection.selected_section = 18672 ShellSection::Settings(SettingsPanelViewKey::About); 18673 runtime.runtime_metadata = DesktopAppRuntimeMetadataSummary { 18674 data_root: Some(data_root.clone()), 18675 logs_root: Some(logs_root), 18676 database_path: Some(database_path), 18677 database_schema_version: Some(7), 18678 ..DesktopAppRuntimeMetadataSummary::default() 18679 }; 18680 runtime.sdk_status = Some(fixture_sdk_status(AppSdkLifecycleState::Ready)); 18681 18682 let rows = about_runtime_rows(&runtime); 18683 18684 assert!(rows.iter().any(|row| { 18685 row.label == app_text(AppTextKey::MetadataDataRoot) 18686 && row.value == data_root.display().to_string() 18687 })); 18688 assert!(rows.iter().any(|row| { 18689 row.label == app_text(AppTextKey::MetadataDatabaseSchemaVersion) 18690 && row.value == 7.to_string() 18691 })); 18692 assert!(rows.iter().any(|row| { 18693 row.label == app_text(AppTextKey::MetadataShellSection) 18694 && row.value == ShellSection::Settings(SettingsPanelViewKey::About).storage_key() 18695 })); 18696 assert!(rows.iter().any(|row| { 18697 row.label == app_text(AppTextKey::MetadataSdkStorageRoot) 18698 && row.value == "/tmp/radroots/data/apps/app/sdk" 18699 })); 18700 assert!(rows.iter().any(|row| { 18701 row.label == app_text(AppTextKey::MetadataSdkRelayUrlPolicy) 18702 && row.value == app_text(AppTextKey::ValueSdkRelayPolicyLocalhost) 18703 })); 18704 } 18705 18706 fn summary( 18707 home_route: HomeRoute, 18708 today_projection: TodayAgendaProjection, 18709 farm_setup_projection: FarmSetupProjection, 18710 ) -> DesktopAppRuntimeSummary { 18711 let farm_readiness_projection = match farm_setup_projection.saved_farm.as_ref() { 18712 Some(saved_farm) 18713 if saved_farm.readiness == FarmReadiness::Ready 18714 && !today_projection.needs_setup() => 18715 { 18716 FarmWorkspaceReadinessProjection { 18717 has_saved_farm: true, 18718 status: FarmWorkspaceStatus::Ready, 18719 ..FarmWorkspaceReadinessProjection::default() 18720 } 18721 } 18722 Some(_) => FarmWorkspaceReadinessProjection { 18723 has_saved_farm: true, 18724 status: FarmWorkspaceStatus::SetupRequired, 18725 ..FarmWorkspaceReadinessProjection::default() 18726 }, 18727 None => FarmWorkspaceReadinessProjection::default(), 18728 }; 18729 18730 DesktopAppRuntimeSummary { 18731 shell_projection: AppShellProjection::default(), 18732 settings_account_projection: SettingsAccountProjection::default(), 18733 startup_gate: AppStartupGate::Farmer, 18734 logged_out_startup: LoggedOutStartupProjection::default(), 18735 home_route, 18736 personal_projection: Default::default(), 18737 farm_rules_projection: Default::default(), 18738 farm_readiness_projection, 18739 farm_setup_projection, 18740 today_projection, 18741 products_projection: Default::default(), 18742 orders_projection: Default::default(), 18743 pack_day_projection: Default::default(), 18744 reminder_log: Default::default(), 18745 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 18746 sync_status: crate::runtime::DesktopAppSyncStatusSummary::default(), 18747 startup_issue: None, 18748 sdk_status: None, 18749 } 18750 } 18751 18752 fn fixture_sdk_status(lifecycle_state: AppSdkLifecycleState) -> DesktopAppSdkStatusSummary { 18753 let storage_root = PathBuf::from("/tmp/radroots/data/apps/app/sdk"); 18754 DesktopAppSdkStatusSummary { 18755 lifecycle_state, 18756 projection_lifecycle_state: AppSdkProjectionLifecycleState::Current, 18757 projection_lifecycle_reason: None, 18758 storage_root: storage_root.clone(), 18759 event_store_path: Some(storage_root.join("event_store.sqlite")), 18760 outbox_path: Some(storage_root.join("outbox.sqlite")), 18761 relay_target_count: 2, 18762 relay_url_policy: AppSdkRelayUrlPolicy::Localhost, 18763 last_issue: None, 18764 } 18765 } 18766 18767 fn summary_with_logged_out_phase(phase: LoggedOutStartupPhase) -> DesktopAppRuntimeSummary { 18768 DesktopAppRuntimeSummary { 18769 startup_gate: AppStartupGate::SetupRequired, 18770 home_route: HomeRoute::SetupRequired, 18771 logged_out_startup: LoggedOutStartupProjection { 18772 phase, 18773 ..LoggedOutStartupProjection::default() 18774 }, 18775 ..summary( 18776 HomeRoute::SetupRequired, 18777 TodayAgendaProjection::default(), 18778 FarmSetupProjection::default(), 18779 ) 18780 } 18781 } 18782 18783 fn fixture_identity(secret_key_hex: &str) -> RadrootsIdentity { 18784 RadrootsIdentity::from_secret_key_str(secret_key_hex).expect("identity") 18785 } 18786 18787 fn fixture_pending_session() -> RadrootsAppRemoteSignerPendingSession { 18788 let signer_identity = 18789 fixture_identity("1111111111111111111111111111111111111111111111111111111111111111"); 18790 let client_identity = 18791 fixture_identity("3333333333333333333333333333333333333333333333333333333333333333"); 18792 18793 RadrootsAppRemoteSignerPendingSession { 18794 record: RadrootsAppRemoteSignerSessionRecord::pending( 18795 client_identity.to_public(), 18796 signer_identity.to_public(), 18797 vec!["wss://relay.radroots.example".to_owned()], 18798 ), 18799 client_secret_key_hex: client_identity.secret_key_hex(), 18800 } 18801 } 18802 18803 fn fixture_reminder( 18804 order_id: Option<radroots_app_view::OrderId>, 18805 fulfillment_window_id: Option<FulfillmentWindowId>, 18806 kind: ReminderKind, 18807 urgency: ReminderUrgency, 18808 ) -> ReminderDeadlineProjection { 18809 ReminderDeadlineProjection { 18810 reminder_id: ReminderId::new(), 18811 farm_id: FarmId::new(), 18812 order_id, 18813 fulfillment_window_id, 18814 kind, 18815 surface: ReminderSurface::Orders, 18816 urgency, 18817 title: String::new(), 18818 detail: String::new(), 18819 deadline_at: "0".to_owned(), 18820 action_label: None, 18821 delivery_state: ReminderDeliveryState::Scheduled, 18822 } 18823 } 18824 }