feat(tahuantinsuyu): modal "Guardar como…" real para cartas libres (F3)

Reemplaza el `save_free_chart_quick` MVP de la fase A por un
modal completo que el usuario controla:

Tree:
- Nuevo `Modal::SaveFreeChart { source_id, name, new_contact_name,
  selected_contact, all_contacts, error }`.
- `open_save_free_chart_modal` abre el modal pre-poblando `name`
  con el label de la carta libre y `selected_contact` con el
  primer contacto existente (o `None` = nuevo contacto si no
  hay ninguno).
- `gather_all_contacts` recorre la jerarquía recursivamente
  devolviendo `(ContactId, "Grupo / Subgrupo / Contacto")` —
  el usuario ve la ruta completa, no solo el nombre.
- `render_save_free_chart` pinta:
  * Input "Nombre" pre-cargado
  * Lista de contactos como botones radio (● / ○) + opción
    "Nuevo contacto…" al final
  * Si "Nuevo contacto…" seleccionado, aparece input
    "Nombre del contacto nuevo"
  * Botones Cancelar / Guardar
- `set_save_modal_contact` alterna el radio sin recrear inputs.
- Validaciones: nombre de carta no vacío; si `selected_contact`
  es `None`, exigir `new_contact_name` no vacío. Errores se
  muestran en una pill destructiva dentro del modal.
- Submit emite nuevo evento `TreeEvent::FreeChartSaveConfirmed
  { source_id, chart_name, contact, new_contact_name }`.

Shell:
- `persist_free_chart` resuelve el contacto destino (existente
  o crea uno nuevo), llama `store.create_chart`, y al éxito
  remueve la carta libre del mapa (salvo `sky-now`, que es
  persistente). Si la carta libre estaba seleccionada, vuelve
  al Cielo. Refresca opciones del picker para que el dropdown
  ChartPicker incluya la carta recién guardada.
- El handler `SaveFreeChartRequested` queda como hook vacío;
  el menú del tree abre el modal directamente con `window`.

10 tests verdes (no se afectaron los paths probados).

Próximo: F2 (editor inline de fecha/lugar/hora de la carta
libre) y F4 (botón "Guardar como…" en cada módulo overlay
con sufijo automático).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 21:36:30 +00:00
parent 72da2934e8
commit a83b0396ce
2 changed files with 448 additions and 32 deletions
+72 -29
View File
@@ -481,12 +481,25 @@ impl Shell {
self.apply_selection(TreeSelection::FreeChart(id), cx);
return;
}
TreeEvent::SaveFreeChartRequested(id) => {
// Por ahora: log + persistencia simple en "General"
// (contact nuevo con el label de la carta). En la
// fase B se reemplaza por un modal con dropdown de
// contacto y custom name.
self.save_free_chart_quick(id.clone());
TreeEvent::SaveFreeChartRequested(_id) => {
// El menú del tree abre el modal directamente; este
// evento queda como hook por si una tecla u otra
// UI quiere disparar el flujo sin pasar por el menú.
return;
}
TreeEvent::FreeChartSaveConfirmed {
source_id,
chart_name,
contact,
new_contact_name,
} => {
self.persist_free_chart(
source_id.clone(),
chart_name.clone(),
contact.clone(),
new_contact_name.clone(),
cx,
);
return;
}
TreeEvent::DeleteFreeChartRequested(id) => {
@@ -510,40 +523,70 @@ impl Shell {
self.apply_selection(selection.clone(), cx);
}
/// Guardado rápido de una carta libre como carta natal bajo un
/// contacto nuevo (auto-nombrado con el label de la carta libre).
/// Es la versión MVP — en la fase B se reemplaza por un modal
/// con dropdown de contacto + input de nombre custom.
fn save_free_chart_quick(&mut self, id: FreeChartId) {
let Some(chart) = self.free_charts.get(&id).cloned() else {
/// Persiste una carta libre como `Chart` en la store. El usuario
/// eligió en el modal: nombre + contacto destino (existente o
/// uno nuevo creado al vuelo). La carta libre se REMUEVE del
/// mapa tras el persist exitoso — si quedaba seleccionada,
/// volvemos a "Cielo ahora". Si falla la persistencia, la carta
/// libre se conserva y logueamos.
fn persist_free_chart(
&mut self,
source_id: FreeChartId,
chart_name: String,
contact: Option<ContactId>,
new_contact_name: Option<String>,
cx: &mut Context<Self>,
) {
let Some(chart) = self.free_charts.get(&source_id).cloned() else {
return;
};
// 1) Crear o reusar un contacto. Por simplicidad, creamos uno
// nuevo con el label de la carta como nombre. La fase B le
// dará al usuario el dropdown.
let contact = match self.store.create_contact(None, &chart.label, None) {
Ok(c) => c,
Err(e) => {
eprintln!("[shell] create_contact al guardar libre: {}", e);
// 1) Resolver el contact destino (existente o crear nuevo).
let contact_id = match (contact, new_contact_name) {
(Some(cid), _) => cid,
(None, Some(name)) => match self.store.create_contact(None, &name, None) {
Ok(c) => c.id,
Err(e) => {
eprintln!("[shell] create_contact al guardar libre: {}", e);
return;
}
},
(None, None) => {
eprintln!("[shell] persist_free_chart sin contacto ni nombre nuevo");
return;
}
};
// 2) Crear la carta bajo ese contacto.
if let Err(e) = self.store.create_chart(
contact.id,
// 2) Crear la carta.
match self.store.create_chart(
contact_id,
chart.kind,
&chart.label,
&chart_name,
&chart.birth_data,
&chart.config,
chart.related_chart_id,
) {
eprintln!("[shell] create_chart al guardar libre: {}", e);
return;
Ok(_) => {
eprintln!(
"[shell] carta libre {:?} guardada como '{}' bajo contacto {}",
source_id, chart_name, contact_id
);
}
Err(e) => {
eprintln!("[shell] create_chart al guardar libre: {}", e);
return;
}
}
eprintln!(
"[shell] carta libre '{}' guardada bajo contacto '{}' (id {})",
chart.label, contact.name, contact.id
);
// 3) Sky-now se conserva (siempre es); las demás se quitan
// del mapa libre. Si era la activa, volver al Cielo.
if !source_id.is_sky_now() {
self.free_charts.remove(&source_id);
self.push_free_charts_to_tree(cx);
// Si la activa era esta libre, regresar al Cielo.
self.apply_selection(
TreeSelection::FreeChart(FreeChartId::sky_now()),
cx,
);
}
self.refresh_chart_options(cx);
}
fn apply_selection(&mut self, sel: TreeSelection, cx: &mut Context<Self>) {