Développement d'une Application de Suivi Alimentaire sur HarmonyOS : Persistance des Données et Interaction Utilisateur

Visualisation des Fonctionnalités

L'application permet aux utilisateurs d'enregistrer leurs apports alimentaires et leurs dépenses énergétiques. En entrant dans la page d'enregistrement, l'utilisateur voit un résumé des données du jour. Un bouton d'ajout vert déclenche l'affichage d'une interface de sélection d'article, suivie d'une interface de saisie de quantité. Une fois la quantité saisie via un clavier numérique virtuel, la soumission met à jour instantanément le résumé des statistiques nutritionnelles et la liste des enregistrements du jour.

Développement de la Couche Métier de Suivi Alimentaire

Objectifs

  1. Définir un modèle de données nommé RecapNutritionnel pour centraliser les statistiques nutritionnelles quotidiennes (calories ingérées, dépensées, glucides, protéines, lipides).
  2. Créer une structure InfoRegroupement pour organiser les données d'enreigstrements récupérées de la base de données par catégorie, facilitant ainsi leur affichage groupé dans l'interface utilisateur (par exemple, "Petit-déjeuner", "Déjeuner", "Dîner", "Activité physique"). Les composants utilisant cette structure, comme ListeArticles, seront adaptés en conséquence.
  3. Encapsuler toute la logique métier de manipulation des enregistrements (ajout, suppression, récupération, calcul de statistiques et regroupement) dans un service dédié nommé ServiceSuiviAlimentaire.

Implémentation

RecapNutritionnel.ets:

export default class RecapNutritionnel {
  // Calories consommées ce jour
  caloriesIngerees: number;
  // Énergie dépensée par l'activité physique
  caloriesDepensees: number;
  // Quantité totale de glucides consommés
  totalGlucides: number;
  // Quantité totale de protéines consommées
  totalProteines: number;
  // Quantité totale de lipides consommés
  totalLipides: number;

  constructor(ingerees: number = 0, depensees: number = 0, glucides: number = 0, proteines: number = 0, lipides: number = 0) {
    this.caloriesIngerees = ingerees;
    this.caloriesDepensees = depensees;
    this.totalGlucides = glucides;
    this.totalProteines = proteines;
    this.totalLipides = lipides;
  }
}

InfoRegroupement.ets:

export default class InfoRegroupement<T_TYPE, T_ELEMENT> {
  // Catégorie du regroupement (ex: Repas, Exercice)
  categorie: T_TYPE;
  // Liste des éléments appartenant à ce regroupement
  elements: T_ELEMENT[];
  // Somme des calories pour les éléments de ce groupe
  sommeCalories: number;

  constructor(typeGroupe: T_TYPE, listeElements: T_ELEMENT[]) {
    this.categorie = typeGroupe;
    this.elements = listeElements;
    this.sommeCalories = 0; // Initialisé à 0
  }
}

ListeArticles.ets

La boucle ForEach dans le composant ListeArticles doit être mise à jour pour utiliser le nouveau type InfoRegroupement<CategorieArticle, ArticleEnregistre> pour ses groupes :


// ... dans le composant ListeArticles
ForEach(this.groupes, (groupe: InfoRegroupement<CategorieArticle, ArticleEnregistre>) => {
  // ...
})
// ...

ServiceSuiviAlimentaire.ets

import UtilitaireDate from '../common/utils/DateUtil';
import ModeleArticle from '../model/ItemModel';
import ModeleEnregistrement from '../model/RecordModel';
import { TypeEnregistrementEnum, TypesEnregistrement } from '../model/RecordTypeModel';
import InfoRegroupement from '../viewmodel/GroupInfo'; // Assurez-vous que l'import est correct
import CategorieEnregistrement from '../viewmodel/RecordType';
import ValeurObjetEnregistrement from '../viewmodel/RecordVO';
import RecapNutritionnel from '../viewmodel/StatsInfo'; // Assurez-vous que l'import est correct

class ServiceSuiviAlimentaire {
  /**
   * Ajoute un nouvel enregistrement alimentaire ou d'activité physique.
   * @param idType ID de la catégorie d'enregistrement.
   * @param idArticle ID de l'article enregistré (aliment ou exercice).
   * @param quantite Quantité (poids, durée, etc.).
   * @returns Promesse du nombre d'enregistrements ajoutés.
   */
  async ajouterEnregistrement(idType: number, idArticle: number, quantite: number): Promise<number> {
    // 1. Détermine le moment de la création
    const tempsCreation = (AppStorage.Get('dateSelectionnee') || UtilitaireDate.debutJournee(new Date())) as number;
    // 2. Insère le nouvel enregistrement
    return ModeleEnregistrement.insert({ idType, idArticle, quantite, tempsCreation });
  }

  /**
   * Supprime un enregistrement par son identifiant.
   * @param id Identifiant de l'enregistrement à supprimer.
   * @returns Promesse du nombre d'enregistrements supprimés.
   */
  async supprimerParId(id: number): Promise<number> {
    return ModeleEnregistrement.deleteById(id);
  }

  /**
   * Récupère la liste des enregistrements pour une date donnée.
   * @param date Timestamp de la date à rechercher.
   * @returns Promesse d'une liste d'objets ValeurObjetEnregistrement.
   */
  async obtenirEnregistrementsParDate(date: number): Promise<ValeurObjetEnregistrement[]> {
    // 1. Récupère les PO (Plain Object) de la base de données
    const enregistrementsPO = await ModeleEnregistrement.listByDate(date);
    const listeVO: ValeurObjetEnregistrement[] = [];

    // 2. Convertit chaque RecordPO en ValeurObjetEnregistrement
    for (const po of enregistrementsPO) {
      const vo: ValeurObjetEnregistrement = {
        id: po.id,
        idType: po.idType,
        quantite: po.quantite
      } as ValeurObjetEnregistrement; // Conversion de type pour initialisation

      // 2.2. Récupère les détails de l'article enregistré
      vo.articleEnregistre = ModeleArticle.getById(po.idArticle, po.idType !== TypeEnregistrementEnum.ENTRAINEMENT);
      // 2.3. Calcule les calories pour cet enregistrement
      vo.calories = po.quantite * vo.articleEnregistre.calorie;
      listeVO.push(vo);
    }
    return listeVO;
  }

  /**
   * Calcule les informations statistiques (calories, nutriments) à partir d'une liste d'enregistrements.
   * @param enregistrements Liste des enregistrements alimentaires/d'activité.
   * @returns Objet RecapNutritionnel contenant les totaux.
   */
  calculerRecapNutritionnel(enregistrements: ValeurObjetEnregistrement[]): RecapNutritionnel {
    const recap = new RecapNutritionnel();
    if (!enregistrements || enregistrements.length === 0) {
      return recap;
    }

    for (const record of enregistrements) {
      if (record.idType === TypeEnregistrementEnum.ENTRAINEMENT) {
        // Activité physique: accumule les calories dépensées
        recap.caloriesDepensees += record.calories;
      } else {
        // Aliment: accumule les calories ingérées, protéines, glucides, lipides
        recap.caloriesIngerees += record.calories;
        recap.totalGlucides += record.articleEnregistre.carbon;
        recap.totalProteines += record.articleEnregistre.protein;
        recap.totalLipides += record.articleEnregistre.fat;
      }
    }
    return recap;
  }

  /**
   * Regroupe une liste d'enregistrements par type d'enregistrement.
   * @param enregistrements Liste des enregistrements.
   * @returns Liste d'objets InfoRegroupement.
   */
  regrouperEnregistrements(enregistrements: ValeurObjetEnregistrement[]): InfoRegroupement<CategorieEnregistrement, ValeurObjetEnregistrement>[] {
    // 1. Crée des groupes vides pour chaque type d'enregistrement connu
    const groupesInitialises = TypesEnregistrement.map(typeRec => new InfoRegroupement(typeRec, []));

    if (!enregistrements || enregistrements.length === 0) {
      return groupesInitialises;
    }

    // 2. Parcourt tous les enregistrements et les assigne à leur groupe respectif
    for (const record of enregistrements) {
      const groupeCorrespondant = groupesInitialises[record.idType];
      if (groupeCorrespondant) {
        groupeCorrespondant.elements.push(record);
        groupeCorrespondant.sommeCalories += record.calories;
      }
    }
    return groupesInitialises;
  }
}

const serviceSuivi = new ServiceSuiviAlimentaire();
export default serviceSuivi;

Persistance des Données et Interaction Utilisateur

Objectifs

  1. Pour synchroniser les données entre les composants enfants CarteStatistiques et ListeEnregistrements, la liste des enregistrements sera fournie par leur parent commun, IndexEnregistrements, via la décorateur @Provide.
  2. Les composants d'affichage des statistiques (StatistiquesCalories et StatistiquesNutriments) ainsi que la ListeEnregistrements seront modifiés pour recevoir leurs données via des propriétés réactives (@Prop) afin de réagir aux changements.
  3. Implémenter la navigation depuis les titres de catégories d'enregistrement vers une page de saisie d'article (IndexArticle) où la logique de persistance des nouvelles données sera gérée.
  4. Ajouter un état réactif (@State) au composant racine (PagePrincipale) pour informer IndexEnregistrements de l'état d'affichage de la page, déclenchant ainsi un rechargement des données.
  5. Les fonctions @Builder utilisées dans CarteArticle.ets, StatistiquesCalories.ets et StatistiquesNutriments.ets seront ajustées pour utiliser des liaisons bidirectionnelles ($$:{}) pour les valeurs qui doivent être modifiées ou réactualisées par le parent ou d'autres logiques.

Implémentation

CarteStatistiques.ets:

import ConstantesPointRupture from '../../common/constants/BreakpointConstants';
import { ConstantesCommunes } from '../../common/constants/CommonConstants';
import UtilitaireDate from '../../common/utils/DateUtil';
import ServiceSuiviAlimentaire from '../../service/RecordService'; // Mise à jour de l'import
import ValeurObjetEnregistrement from '../../viewmodel/RecordVO';
import RecapNutritionnel from '../../viewmodel/StatsInfo'; // Mise à jour de l'import
import StatistiquesCalories from './CalorieState'; // Mise à jour de l'import
import DialogueChoixDate from './DatePickDialog';
import StatistiquesNutriments from './NutrientState'; // Mise à jour de l'import


@Component
export default struct CarteStatistiques {

  @StorageProp('dateSelectionnee') dateSelectionnee: number = UtilitaireDate.debutJournee(new Date());
  @StorageProp('pointRuptureActuel') pointRuptureActuel: string = ConstantesPointRupture.BREAKPOINT_SM;
  // Surveillance des changements dans les enregistrements
  @Consume @Watch('gererChangementEnregistrements') enregistrements: ValeurObjetEnregistrement[];
  @State informationsRecap: RecapNutritionnel = new RecapNutritionnel();

  gererChangementEnregistrements() {
    this.informationsRecap = ServiceSuiviAlimentaire.calculerRecapNutritionnel(this.enregistrements);
  }

  controleurDialogue: CustomDialogController = new CustomDialogController({
    builder: DialogueChoixDate({ dateSelectionnee: new Date(this.dateSelectionnee) })
  });

  build() {
    Column() {
      // 1. Informations sur la date
      Row() {
        Text(UtilitaireDate.formatDate(this.dateSelectionnee))
          .fontColor($r('app.color.secondary_color'));
        Image($r('app.media.ic_public_spinner'))
          .width(20)
          .fillColor($r('app.color.secondary_color'));
      }
      .padding(ConstantesCommunes.ESPACE_8)
      .onClick(() => this.controleurDialogue.open());

      // 2. Informations statistiques
      Swiper() {
        // 2.1. Statistiques caloriques
        StatistiquesCalories({
          caloriesIngerees: this.informationsRecap.caloriesIngerees,
          caloriesDepensees: this.informationsRecap.caloriesDepensees
        });
        // 2.2. Statistiques nutritionnelles
        StatistiquesNutriments({
          glucides: this.informationsRecap.totalGlucides,
          proteines: this.informationsRecap.totalProteines,
          lipides: this.informationsRecap.totalLipides
        });
      }
    }
    .width(ConstantesCommunes.MILLIEME_940)
    .backgroundColor($r('app.color.stats_title_bgc'))
    .borderRadius(ConstantesCommunes.RAYON_DEFAUT_18);
  }
}

ListeEnregistrements.ets:

import router from '@ohos.router';
import { ConstantesCommunes } from '../../common/constants/CommonConstants';
import ServiceSuiviAlimentaire from '../../service/RecordService'; // Mise à jour de l'import
import InfoRegroupement from '../../viewmodel/GroupInfo'; // Mise à jour de l'import
import CategorieEnregistrement from '../../viewmodel/RecordType';
import ValeurObjetEnregistrement from '../../viewmodel/RecordVO';

// Fonction d'extension pour le style de texte gris
@Extend(Text) function texteGris() {
  .fontSize(14)
  .fontColor($r('app.color.light_gray'));
}

@Component
export default struct ListeEnregistrements {

  // Surveillance des changements dans les enregistrements
  @Consume @Watch('gererChangementEnregistrements') enregistrements: ValeurObjetEnregistrement[];
  @State groupes: InfoRegroupement<CategorieEnregistrement, ValeurObjetEnregistrement> [] = [];

  gererChangementEnregistrements() {
    // Regroupe et calcule les statistiques des enregistrements alimentaires
    this.groupes = ServiceSuiviAlimentaire.regrouperEnregistrements(this.enregistrements);
  }

  build() {
    List({ space: ConstantesCommunes.ESPACE_10 }) {
      ForEach(this.groupes, (groupe: InfoRegroupement<CategorieEnregistrement, ValeurObjetEnregistrement>) => {
        ListItem() {
          Column({ space: ConstantesCommunes.ESPACE_8 }) {
            // 1. Titre du groupe
            Row({ space: ConstantesCommunes.ESPACE_4 }) {
              Image(groupe.categorie.icon).width(24);
              Text(groupe.categorie.name).fontSize(18).fontWeight(ConstantesCommunes.POIDS_POLICE_700);
              Text(`Recommandé ${groupe.categorie.min}~${groupe.categorie.max} kcal`).texteGris();
              // Espacement
              Blank();
              Text(groupe.sommeCalories.toFixed(0)).fontSize(14).fontColor($r('app.color.primary_color'));
              Text('kcal').texteGris();
              Image($r('app.media.ic_public_add_norm_filled'))
                .width(20)
                .fillColor($r('app.color.primary_color'));
            }
            .width('100%')
            .onClick(() => {
              router.pushUrl({
                url: 'pages/ItemIndex',
                params: { type: groupe.categorie }
              });
            });
            // 2. Liste des articles dans le groupe
            List() {
              ForEach(groupe.elements, (article: ValeurObjetEnregistrement) => {
                ListItem() {
                  Row({ space: ConstantesCommunes.ESPACE_6 }) {
                    Image(article.articleEnregistre.image).width(50);
                    Column({ space: ConstantesCommunes.ESPACE_4 }) {
                      Text(article.articleEnregistre.name).fontWeight(ConstantesCommunes.POIDS_POLICE_500);
                      Text(`${article.quantite}${article.articleEnregistre.unit}`).texteGris();
                    }
                    Blank();
                    Text(`${article.calories.toFixed(0)} kcal`).texteGris();
                  }
                  .width('100%')
                  .padding(ConstantesCommunes.ESPACE_6);
                }.swipeAction({ end: this.boutonSupprimer.bind(this) }); // Utilisation de bind
              });
            }
            .width('100%');
          }
          .width('100%')
          .backgroundColor(Color.White)
          .borderRadius(ConstantesCommunes.RAYON_DEFAUT_18)
          .padding(ConstantesCommunes.ESPACE_12);
        }
      });
    }
    .width(ConstantesCommunes.MILLIEME_940)
    .height('100%')
    .margin({ top: 10 });
  }

  @Builder boutonSupprimer() {
    Image($r('app.media.ic_public_delete_filled'))
      .width(20)
      .fillColor(Color.Red)
      .margin(5);
  }
}

IndexEnregistrements.ets:

import UtilitaireDate from '../../common/utils/DateUtil';
import ServiceSuiviAlimentaire from '../../service/RecordService'; // Mise à jour de l'import
import ValeurObjetEnregistrement from '../../viewmodel/RecordVO';
import ListeEnregistrements from './RecordList'; // Mise à jour de l'import
import EnTeteRecherche from './SearchHeader';
import CarteStatistiques from './StatsCard'; // Mise à jour de l'import

@Component
export default struct IndexEnregistrements {
  @Prop @Watch('gererAffichagePage') estPageVisible: boolean;
  @Provide enregistrementsFournis: ValeurObjetEnregistrement[] = [];
  // Surveillance des changements de date
  @Watch('initialiserDonnees')
  @StorageProp('dateSelectionnee') dateSelectionnee: number = UtilitaireDate.debutJournee(new Date());

  gererAffichagePage() {
    if (this.estPageVisible) {
      this.initialiserDonnees();
    }
  }

  // Fournit les données lors de l'apparition du composant ou d'un changement de date
  async initialiserDonnees() {
    this.enregistrementsFournis = await ServiceSuiviAlimentaire.obtenirEnregistrementsParDate(this.dateSelectionnee);
  }

  build() {
    Column() {
      // 1. Barre de recherche en haut
      EnTeteRecherche();
      // 2. Carte des statistiques
      CarteStatistiques();
      // 3. Liste des enregistrements
      ListeEnregistrements()
        .layoutWeight(1);
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.index_page_background'));
  }
}

StatistiquesCalories.ets:

import { ConstantesCommunes } from '../../common/constants/CommonConstants';
@Component
export default struct StatistiquesCalories {
  @Prop caloriesIngerees: number;
  @Prop caloriesDepensees: number;
  recommandationCalorique: number = ConstantesCommunes.CALORIES_RECOMMANDEES;

  calculerCaloriesRestantes(): number {
    return this.recommandationCalorique - this.caloriesIngerees + this.caloriesDepensees;
  }

  // Fonction d'aide pour l'affichage des statistiques
  @Builder VueStatistique($$: { label: string, valeur: number, indice?: string }) {
    Column({ space: ConstantesCommunes.ESPACE_6 }) {
      Text($$.label)
        .fontColor($r('app.color.gray'))
        .fontWeight(ConstantesCommunes.POIDS_POLICE_600);
      Text($$.valeur.toFixed(0))
        .fontSize(20)
        .fontWeight(ConstantesCommunes.POIDS_POLICE_700);
      if ($$.indice) {
        Text($$.indice)
          .fontSize(12)
          .fontColor($r('app.color.light_gray'));
      }
    }
  }

  build() {
    Row({ space: ConstantesCommunes.ESPACE_6 }) {
      // 1. Apport calorique
      this.VueStatistique({ label: 'Apport Alimentaire', valeur: this.caloriesIngerees });
      // 2. Calories restantes
      Stack() {
        // 2.1. Barre de progression
        Progress({
          value: this.caloriesIngerees,
          total: this.recommandationCalorique,
          type: ProgressType.Ring
        })
          .width(120)
          .style({ strokeWidth: ConstantesCommunes.LARGEUR_DEFAUT_10 })
          .color($r('app.color.primary_color'));
        // 2.2. Données statistiques
        this.VueStatistique({
          label: 'Calories Restantes',
          valeur: this.calculerCaloriesRestantes(),
          indice: `Recommandé ${this.recommandationCalorique}`
        });
      }
      // 3. Dépenses énergétiques
      this.VueStatistique({ label: 'Dépense Physique', valeur: this.caloriesDepensees });
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceEvenly)
    // Définition des marges
    .padding({ top: 30, bottom: 35 });
  }
}

StatistiquesNutriments.ets:

import { ConstantesCommunes } from '../../common/constants/CommonConstants';

@Component
export default struct StatistiquesNutriments {
  @Prop glucides: number;
  @Prop proteines: number;
  @Prop lipides: number;
  recommandationGlucides: number = ConstantesCommunes.GLUCIDES_RECOMMANDEES;
  recommandationProteines: number = ConstantesCommunes.PROTEINES_RECOMMANDEES;
  recommandationLipides: number = ConstantesCommunes.LIPIDES_RECOMMANDEES;

  // Fonction d'aide pour l'affichage des nutriments
  @Builder VueNutriment($$: {
    libelle: string,
    valeurActuelle: number,
    valeurRecommandee: number,
    couleurProgress: ResourceStr
  }) {
    Column({ space: ConstantesCommunes.ESPACE_6 }) {
      Stack() {
        Progress({
          value: $$.valeurActuelle,
          total: $$.valeurRecommandee,
          type: ProgressType.Ring
        })
          .width(95)
          .style({ strokeWidth: ConstantesCommunes.LARGEUR_DEFAUT_6 })
          .color($$.couleurProgress);
        Column({ space: ConstantesCommunes.ESPACE_6 }) {
          Text('Apport/Recommandation')
            .fontSize(12)
            .fontColor($r('app.color.gray'));
          Text(`${$$.valeurActuelle.toFixed(0)}/${$$.valeurRecommandee.toFixed(0)}`)
            .fontSize(18)
            .fontWeight(ConstantesCommunes.POIDS_POLICE_600);
        }
      }
      Text(' (grammes)')
        .fontSize(12)
        .fontColor($r('app.color.light_gray'));
    }
  }

  build() {
    Row({ space: ConstantesCommunes.ESPACE_6 }) {
      this.VueNutriment({
        libelle: 'Glucides',
        valeurActuelle: this.glucides,
        valeurRecommandee: this.recommandationGlucides,
        couleurProgress: $r('app.color.carbon_color')
      });
      this.VueNutriment({
        libelle: 'Protéines',
        valeurActuelle: this.proteines,
        valeurRecommandee: this.recommandationProteines,
        couleurProgress: $r('app.color.protein_color')
      });
      this.VueNutriment({
        libelle: 'Lipides',
        valeurActuelle: this.lipides,
        valeurRecommandee: this.recommandationLipides,
        couleurProgress: $r('app.color.fat_color')
      });
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceEvenly)
    .padding({ top: 30, bottom: 35 });
  }
}

IndexArticle.ets:

import router from '@ohos.router';
import { ConstantesCommunes } from '../common/constants/CommonConstants';
import { TypeEnregistrementEnum, TypesEnregistrement } from '../model/RecordTypeModel';
import ServiceSuiviAlimentaire from '../service/RecordService'; // Mise à jour de l'import
import CarteArticle from '../view/item/ItemCard'; // Mise à jour de l'import
import ListeArticles from '../view/item/ItemList'; // Mise à jour de l'import
import EnTetePanneauArticle from '../view/item/ItemPanelHeader';
import ClavierNumerique from '../view/item/NumberKeyboard';
import ArticleEnregistre from '../viewmodel/RecordItem';
import CategorieEnregistrement from '../viewmodel/RecordType';

@Entry
@Component
struct IndexArticle {
  @State afficherPanneau: boolean = false;
  @State quantiteSaisie: number = 1;
  @State valeurClavier: string = '';
  // item utilisé pour recevoir les données de l'article cliqué
  @State articleSelectionne: ArticleEnregistre = null;
  @State typeEnregistrement: CategorieEnregistrement = TypesEnregistrement[0];
  @State estAliment: boolean = true;

  onAfficherPanneau(article: ArticleEnregistre) {
    // Contrôle l'affichage du panneau
    this.quantiteSaisie = 1;
    this.valeurClavier = '';
    this.articleSelectionne = article;
    this.afficherPanneau = true;
  }

  onPageShow() {
    // 1. Récupère les paramètres de navigation
    const params: any = router.getParams();
    // 2. Obtient le type d'enregistrement cliqué
    this.typeEnregistrement = params.type;
    // 3. Détermine si c'est un aliment (différent d'entraînement)
    this.estAliment = this.typeEnregistrement.id !== TypeEnregistrementEnum.ENTRAINEMENT;
  }

  build() {
    Column() {
      // 1. En-tête de navigation
      this.EnTetePage();
      // 2. Liste des articles
      ListeArticles({ onAfficherPanneau: this.onAfficherPanneau.bind(this), estAliment: this.estAliment })
        // Définir la hauteur de la liste pour que le panneau s'affiche correctement
        .layoutWeight(1);
      // 3. Panneau inférieur
      Panel(this.afficherPanneau) {
        // 3.1. En-tête du panneau (date)
        EnTetePanneauArticle();
        // 3.2. Carte de l'article enregistré
        if (this.articleSelectionne) {
          CarteArticle({ quantite: this.quantiteSaisie, article: $articleSelectionne }); // Utilisation de $
        }
        // 3.3. Clavier numérique
        ClavierNumerique({ quantite: $quantiteSaisie, valeur: $valeurClavier }); // Utilisation de $
        // 3.4. Boutons d'action
        Row({ space: ConstantesCommunes.ESPACE_6 }) {
          Button('Annuler')
            .width(120)
            .backgroundColor($r('app.color.light_gray'))
            .type(ButtonType.Normal)
            .borderRadius(6)
            .onClick(() => this.afficherPanneau = false);
          Button('Soumettre')
            .width(120)
            .type(ButtonType.Normal)
            .borderRadius(6)
            .backgroundColor($r('app.color.primary_color'))
            .onClick(() => {
              // 1. Enregistre les données
              ServiceSuiviAlimentaire.ajouterEnregistrement(
                this.typeEnregistrement.id,
                this.articleSelectionne.id,
                this.quantiteSaisie
              )
                .then(() => {
                  // 2. Ferme le panneau après succès
                  this.afficherPanneau = false;
                });
            });
        }
        .margin({ top: 10 });
      }
      .mode(PanelMode.Full)
      .dragBar(false)
      .backgroundMask($r('app.color.light_gray'))
      .backgroundColor(Color.White);
    }
    .height('100%');
  }

  // En-tête de la page
  @Builder EnTetePage() {
    Row() {
      Image($r('app.media.ic_public_back'))
        .width(30)
        .onClick(() => router.back());
      Blank();
      Text(this.typeEnregistrement.name).fontSize(18).fontWeight(ConstantesCommunes.POIDS_POLICE_600);
    }
    .width(ConstantesCommunes.MILLIEME_940)
    .height(32);
  }
}

PagePrincipale.ets:

import TypePointRupture from '../common/bean/BreanpointType';
import ConstantesPointRupture from '../common/constants/BreakpointConstants';
import { ConstantesCommunes } from '../common/constants/CommonConstants';
import SystemePointsRupture from '../common/utils/BreakpointSystem';
import IndexEnregistrements from '../view/record/RecordIndex'; // Mise à jour de l'import

@Entry
@Component
struct PagePrincipale {
  @State indexOngletActuel: number = 0;
  @State pageEstVisible: boolean = false;
  private systemePointsRupture: SystemePointsRupture = new SystemePointsRupture();
  @StorageProp('pointRuptureActuel') pointRuptureActuel: string = ConstantesPointRupture.BREAKPOINT_SM;

  // Appelé lors de l'affichage de la page
  onPageShow() {
    this.pageEstVisible = true;
  }
  // Appelé lors de la masquage de la page
  onPageHide() {
    this.pageEstVisible = false;
  }

  aboutToAppear() {
    this.systemePointsRupture.register();
  }

  aboutToDisappear() {
    this.systemePointsRupture.unregister();
  }

  @Builder ConstructeurBarreOnglets(titre: ResourceStr, image: ResourceStr, index: number) {
    Column({ space: ConstantesCommunes.ESPACE_8 }) {
      Image(image)
        .width(22)
        .fillColor(this.selectionnerCouleur(index));
      Text(titre)
        .fontSize(14)
        .fontColor(this.selectionnerCouleur(index));
    }
  }

  // Logique de sélection de couleur pour les onglets
  selectionnerCouleur(index: number): ResourceColor {
    return this.indexOngletActuel === index ? $r('app.color.primary_color') : $r('app.color.gray');
  }

  build() {
    Tabs({ barPosition: ConstantesPointRupture.BAR_POSITION.getValue(this.pointRuptureActuel) }) {
      TabContent() {
        IndexEnregistrements({ estPageVisible: this.pageEstVisible });
      }
      .tabBar(this.ConstructeurBarreOnglets($r('app.string.tab_record'), $r('app.media.ic_calendar'), 0));

      TabContent() {
        Text('Page Découverte');
      }
      .tabBar(this.ConstructeurBarreOnglets($r('app.string.tab_discover'), $r('app.media.discover'), 1));

      TabContent() {
        Text('Page Mon Profil');
      }
      .tabBar(this.ConstructeurBarreOnglets($r('app.string.tab_user'), $r('app.media.ic_user_portrait'), 2));

    }
    .width('100%')
    .height('100%')
    .vertical(false)
    .onChange(index => this.indexOngletActuel = index)
    .vertical(new TypePointRupture({
      sm: false,
      md: true,
      lg: true
    }).getValue(this.pointRuptureActuel));
  }
}

CarteArticle.ets

import { ConstantesCommunes } from '../../common/constants/CommonConstants';
import ArticleEnregistre from '../../viewmodel/RecordItem'; // Mise à jour de l'import

@Component
export default struct CarteArticle {
  @Prop quantite: number;
  // Réception de l'article avec lien bidirectionnel
  @Link article: ArticleEnregistre;

  build() {
    Column({ space: ConstantesCommunes.ESPACE_8 }) {
      // 1. Image de l'article
      Image(this.article.image)
        .width(150);
      // 2. Nom de l'article
      Row() {
        Text(this.article.name).fontWeight(ConstantesCommunes.POIDS_POLICE_700);
      }
      .backgroundColor($r('app.color.lightest_primary_color'))
      .padding({ top: 5, bottom: 5, left: 15, right: 12 });
      // Ligne de séparation
      Divider().width(ConstantesCommunes.MILLIEME_940).opacity(0.6);
      // 3. Informations nutritionnelles
      Row({ space: ConstantesCommunes.ESPACE_8 }) {
        this.InfoNutriment({ libelle: 'Calories (kcal)', valeur: this.article.calorie });
        // Vérifie si l'ID est inférieur à 10000, les activités n'ayant pas de nutriments
        if (this.article.id < 10000) {
          this.InfoNutriment({ libelle: 'Glucides (g)', valeur: this.article.carbon });
          this.InfoNutriment({ libelle: 'Protéines (g)', valeur: this.article.protein });
          this.InfoNutriment({ libelle: 'Lipides (g)', valeur: this.article.fat });
        }
      }
      Divider().width(ConstantesCommunes.MILLIEME_940).opacity(0.6);
      // 4. Quantité
      Row() {
        Column({ space: ConstantesCommunes.ESPACE_4 }) {
          Text(this.quantite.toFixed(1))
            .fontSize(50).fontColor($r('app.color.primary_color'))
            .fontWeight(ConstantesCommunes.POIDS_POLICE_600);
          Divider().color($r('app.color.primary_color'));
        }
        Text(this.article.unit)
          .fontColor($r('app.color.light_gray'))
          .fontWeight(ConstantesCommunes.POIDS_POLICE_600);
      }.width(150);
    }
  }

  @Builder InfoNutriment($$: { libelle: string, valeur: number }) {
    Column({ space: ConstantesCommunes.ESPACE_8 }) {
      Text($$.libelle).fontSize(14).fontColor($r('app.color.light_gray'));
      Text(($$.valeur * this.quantite).toFixed(1)).fontSize(18).fontWeight(ConstantesCommunes.POIDS_POLICE_700);
    }
  }
}

Étiquettes: HarmonyOS ArkUI TypeScript Gestion d'état Persistance des Données

Publié le 20 juillet à 23h40