Component: CardText
Work Item: WI-2127
Figma Key: card-md
Type: Domain control — Menu Text: composes the generic CardTextViewModel scaffold via MenuTextFactory (maps MenuItem → scaffold; does not subclass it — see Decisions D11)
Last Updated: 2026-06-05 (migrated to the domain-control pattern)
Favorite heart — rendered (design decision D31, 2026-06-26): The
i-heart(18×18) IS drawn on the card.IsFavoritepersists as client state (Settings.FavoriteItemIdsJSON).
Shows how CardTextViewModel properties map to the rendered control regions.
┌──────────────────────────────────────────────────────────┐
│ │
│ [ img — 250×200px — fill:#000000 ] │ ◄── ImageSource
│ │
├──────────────────────────────────────────────────────────┤
│ content (250×122px) │
│ │
Label ──────────────► MEC Group │ ◄── Label (optional — null → Title variant)
│ │
Title ────────────► United Airlines │ ◄── Title (string)
│ │
Description ──────► Lorem ipsum dolor sit amet, consectetur │ ◄── Description (body text, string)
│ adipiscing elit, sed do eiusmod tempor. │
│ │
Link ─────────────► (tap navigates to destination) │ ◄── Link (TBD — navigation path/URL)
│ │
├──────────────────────────────────────────────────────────┤
│ status (250×26px) │
│ │
NewCount ─────────► [ 11 ] (red badge, white text) │ ◄── NewCount (int — persisted to Settings, hidden when 0)
IsFavorite ───────► (favorite state — heart rendered) │ ◄── IsFavorite (bool — persisted to Settings.FavoriteItemIdsJSON)
│ │
└──────────────────────────────────────────────────────────┘
IsLabelVisible ───► Controls visibility of label row ◄── !string.IsNullOrEmpty(Label)
IsLinkVisible ────► Controls visibility of link row ◄── ⚠️ TBD
IsLoading ────────► Default strategy — no skeleton/overlay
✅ All properties resolved — Description + NewCount via MenuItem API expansion; IsFavorite via local storage
Note: The CardText has three layout variants, named by their leading element and driven by property presence (simplest-first). These map to a
TextCardVariantenum: - Description (TextCardVariant.Description): Description + Link only (Label and Title not set) - Title (TextCardVariant.Title): Title + Description + Link (Label not set) - Label (TextCardVariant.Label): Label + Title + Description + Link
| Property | Status | Source | Notes |
|---|---|---|---|
| Label | ✅ Available | MenuItem.Title of parent category (MEC group, airline, section) |
Optional — null/empty collapses label row |
| Title | ✅ Available | MenuItem.Title |
Primary content heading |
| Description | ✅ Resolved | MenuItem.Description (new field — backend API expansion) |
Add Description string to MenuItem model + API response |
| ImageSource | ✅ Available | MenuItem.ImageSource |
Falls back to glyph-derived FontImageSource when empty |
| Link | ✅ Available | MenuItem.Path |
Navigation path/URL — opened via SharedActionsService or Shell navigation |
| NewCount | ✅ Resolved | Settings.NewCountsJSON (local Microsoft.Maui.Storage.Preferences) |
Client-computed only — not backend-provided. Persisted as JSON dict { itemId: count } |
| IsFavorite | ✅ Resolved | Settings.FavoriteItemIdsJSON (local Microsoft.Maui.Storage.Preferences) |
Persisted as JSON array of MenuItem.Id — same pattern as FlightSearchNotifyMeList |
| IsLabelVisible | ✅ Available | Derived: !string.IsNullOrEmpty(Label) |
Drives Label vs. Title/Description layout |
| IsLinkVisible | ⚠️ TBD | ViewModel state | Depends on whether Link is populated |
| IsLoading | ✅ Available | ViewModel state | Default strategy (no skeleton) |
Follow the existing Settings.cs pattern (ALPAMobile/Helpers/Settings.cs). Add two new keys:
// In Settings.cs
private const string FavoriteItemIdsJSONKey = "text_card_favorite_item_ids_json_key";
private const string NewCountsJSONKey = "text_card_new_counts_json_key";
/// Persisted list of favorited MenuItem.Id values
public static string FavoriteItemIdsJSON
{
get => Microsoft.Maui.Storage.Preferences.Get(FavoriteItemIdsJSONKey, "[]");
set => Microsoft.Maui.Storage.Preferences.Set(FavoriteItemIdsJSONKey, value);
}
/// Persisted dict of { menuItemId: newCount } for badge display
public static string NewCountsJSON
{
get => Microsoft.Maui.Storage.Preferences.Get(NewCountsJSONKey, "{}");
set => Microsoft.Maui.Storage.Preferences.Set(NewCountsJSONKey, value);
}
Helper methods on the domain service (MenuTextService) — persistence lives here, not on the scaffold VM:
public void ToggleFavorite()
{
var favorites = JsonConvert.DeserializeObject<List<int>>(Settings.FavoriteItemIdsJSON) ?? new();
if (IsFavorite) favorites.Remove(Id);
else favorites.Add(Id);
IsFavorite = !IsFavorite;
Settings.FavoriteItemIdsJSON = JsonConvert.SerializeObject(favorites);
}
public static bool GetIsFavorite(int itemId)
{
var favorites = JsonConvert.DeserializeObject<List<int>>(Settings.FavoriteItemIdsJSON) ?? new();
return favorites.Contains(itemId);
}
public static int GetNewCount(int itemId)
{
var counts = JsonConvert.DeserializeObject<Dictionary<int, int>>(Settings.NewCountsJSON) ?? new();
return counts.TryGetValue(itemId, out var count) ? count : 0;
}
NewCountwrite path (incrementing/clearing the badge) is a separate concern — likely driven by push notification receipt or document sync. TheSettings.NewCountsJSONkey provides the read path for display.
Architecture decision: Expand MenuItem rather than DocumentItem. CardText is a navigation tile with richer display — MenuItem is the source of truth for what appears on the home screen. Adding Description and NewCount to the MenuItem response keeps the data flow simple and avoids a second async query per card.
DocumentCardViewModel remains the correct base for CardHero and future pure-content cards. CardTextViewModel stays MenuItem-backed.
Proposed MenuItem model addition:
// In ALPADocs.Data.Models.MenuItem
public string Description { get; set; } = string.Empty; // card body text
Backend work required: Add description to the MenuItem API response. Coordinate with backend team.
NewCount is client-computed only — not backend-provided. Persisted entirely in Settings.NewCountsJSON. Write path driven by local events (push notification receipt, document sync, etc.).
Read path resolved via Settings.NewCountsJSON. The write path (who increments/clears the count) still needs a decision:
Current recommendation: Wire read path to Settings.NewCountsJSON now. Write path TBD with backend/product team.
Persist favorited MenuItem.Id values as a JSON array in Settings.FavoriteItemIdsJSON (same pattern as FlightSearchNotifyMeList). No backend required for MVP. See helper methods above.
The Menu Text domain control keeps the generic CardTextViewModel scaffold domain-free; a pure factory maps a MenuItem (plus resolved client state) onto it; a domain service reads the local favorite / new-count state and owns the toggles, then calls the factory (decision D11).
// Scaffold — generic text card, domain-free
public class CardTextViewModel : CardViewModel
{
public string? Label { get; set; } // null/empty → Title variant
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public ImageSource? ImageSource { get; set; }
public string? Link { get; set; }
public int NewCount { get; set; }
public bool IsFavorite { get; set; }
public bool IsLabelVisible => !string.IsNullOrEmpty(Label);
public bool IsLinkVisible => !string.IsNullOrEmpty(Link);
public bool IsNewCountVisible => NewCount > 0;
}
// Domain control — PURE MAPPER: MenuItem (+ resolved client state) in, scaffold VM out
public sealed class MenuTextFactory : RawRepresentationFactory<CardTextViewModel>
{
public CardTextViewModel Create(MenuItem item, MenuItem? parent, int newCount, bool isFavorite) => new()
{
Label = parent?.Title, // parent category title (e.g. MEC Group)
Title = item.Title,
Description = item.Description, // new MenuItem field — empty until backend adds it
Link = item.Path,
NewCount = newCount,
IsFavorite = isFavorite,
ImageSource = ResolveImage(item),
};
// Prefer FontImageSource from glyph; fall back to string asset
private static ImageSource? ResolveImage(MenuItem item) =>
!string.IsNullOrEmpty(item.Glyph) && !string.IsNullOrEmpty(item.GlyphFontFamily)
? MenuItemViewModel.GetImageFromGlyph(item.Glyph, item.GlyphFontFamily)
: string.IsNullOrEmpty(item.ImageSource) ? null : item.ImageSource;
}
// Domain service — resolves client state (Settings) + owns toggles, then maps
public sealed class MenuTextService(MenuTextFactory factory)
{
public CardTextViewModel Build(MenuItem item, MenuItem? parent = null) =>
factory.Create(item, parent,
newCount: GetNewCount(item.Id), // read from Settings.NewCountsJSON
isFavorite: GetIsFavorite(item.Id)); // read from Settings.FavoriteItemIdsJSON
// GetNewCount / GetIsFavorite / ToggleFavorite live here (persistence concern), not on the scaffold VM.
}
MenuItem.Title of parent category (e.g., MEC group name, airline name, or section header)null or empty string collapses the eyebrow row → falls back to the Title variant"MEC Group"MenuItem.Title"United Airlines"MenuItem.Description — new field, requires backend API expansionnull/empty → collapses to the Title or Description variantdescription to MenuItem API responseMenuItemViewModel.GetImageFromGlyph(item.Glyph, item.GlyphFontFamily) (preferred) OR MenuItem.ImageSource (string path/asset)MenuItemViewModelMenuItem.Path — navigation URI resolved by Shell or SharedActionsService"https://alpa.org/contracts/united"Settings.NewCountsJSON — client-computed, never backend-providedGetNewCount(item.Id) helperNewCount == 0Settings.FavoriteItemIdsJSON — persisted List<int> of favorited MenuItem.Id valuesGetIsFavorite(item.Id) helperToggleFavorite() method updates state + persists immediatelyFlightSearchNotifyMeList in Settings.cs!string.IsNullOrEmpty(Label)true → Label variant; false → Title / Description!string.IsNullOrEmpty(Link)ComponentViewModel| Service / Model | Interface / Class | Notes |
|---|---|---|
| Menu Data | MenuItem (Data.Models) |
All card properties: Title, Description (new), NewCount (new), Path, ImageSource, Glyph |
| Menu ViewModel | MenuItemViewModel |
Glyph → FontImageSource helper reused |
| Navigation | Shell / SharedActionsService |
Resolves MenuItem.Path to navigation action |
| Push Notifications | PushNotificationsListPageViewModel |
Write path candidate for NewCount increment — TBD |
| Local Settings | Settings.cs (Microsoft.Maui.Storage.Preferences) |
FavoriteItemIdsJSON + NewCountsJSON — same pattern as JumpseatSavedFlightsJSONSettings |
docs/component-specifications/card-text/card-text-component.htmldocs/component-specifications/card/pilot-card-property-mapping.mdALPAMobile/Data/Models/MenuItem.csALPAMobile/ViewModels/MenuItemViewModel.csALPAMobile/ViewModels/HomePageViewModel.csDocument Owner: ALPA Mobile Team
Work Item: WI-2127
Purpose: Data source analysis for CardText component — presentation-layer implementation reference