@pi-spi/app 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -5
- package/assets/icons/qr.png +0 -0
- package/assets/sounds/voice/search_prompt_en.mp3 +0 -0
- package/assets/sounds/voice/search_prompt_fr.mp3 +0 -0
- package/assets/sounds/voice/search_prompt_pt.mp3 +0 -0
- package/example/app.json +9 -1
- package/lib/module/core/assets.js +1 -0
- package/lib/module/core/assets.js.map +1 -1
- package/lib/module/core/router.js +3 -0
- package/lib/module/core/router.js.map +1 -1
- package/lib/module/core/routes.js +14 -0
- package/lib/module/core/routes.js.map +1 -1
- package/lib/module/core/storage.js +27 -29
- package/lib/module/core/storage.js.map +1 -1
- package/lib/module/l10n/app_localizations.js.map +1 -1
- package/lib/module/l10n/app_localizations_en.js +21 -1
- package/lib/module/l10n/app_localizations_en.js.map +1 -1
- package/lib/module/l10n/app_localizations_fr.js +21 -1
- package/lib/module/l10n/app_localizations_fr.js.map +1 -1
- package/lib/module/l10n/app_localizations_pt.js +21 -1
- package/lib/module/l10n/app_localizations_pt.js.map +1 -1
- package/lib/module/modules/alias/presentation/pages/alias_page_create_options.js +3 -3
- package/lib/module/modules/alias/presentation/pages/alias_page_create_options.js.map +1 -1
- package/lib/module/modules/alias/presentation/pages/alias_page_otp.js.map +1 -1
- package/lib/module/modules/categorie/presentation/pages/categorie_edit_icon_btn.js +2 -2
- package/lib/module/modules/categorie/presentation/pages/categorie_edit_icon_btn.js.map +1 -1
- package/lib/module/modules/categorie/presentation/pages/categorie_select_widget.js +12 -10
- package/lib/module/modules/categorie/presentation/pages/categorie_select_widget.js.map +1 -1
- package/lib/module/modules/compte/presentation/pages/solde_widget_with_qr.js +14 -9
- package/lib/module/modules/compte/presentation/pages/solde_widget_with_qr.js.map +1 -1
- package/lib/module/modules/home/presentation/pages/home_page.js +41 -2
- package/lib/module/modules/home/presentation/pages/home_page.js.map +1 -1
- package/lib/module/modules/home/presentation/pages/home_tab_compte_more_menu.js +2 -2
- package/lib/module/modules/home/presentation/pages/home_tab_compte_more_menu.js.map +1 -1
- package/lib/module/modules/notification/presentation/bloc/notification_bloc.js +11 -4
- package/lib/module/modules/notification/presentation/bloc/notification_bloc.js.map +1 -1
- package/lib/module/modules/notification/presentation/bloc/notification_state.js +7 -1
- package/lib/module/modules/notification/presentation/bloc/notification_state.js.map +1 -1
- package/lib/module/modules/notification/presentation/pages/notification_page_liste.js +6 -2
- package/lib/module/modules/notification/presentation/pages/notification_page_liste.js.map +1 -1
- package/lib/module/modules/profile/presentation/pages/parametre/profile_parametre_page_notifications.js +2 -2
- package/lib/module/modules/profile/presentation/pages/parametre/profile_parametre_page_notifications.js.map +1 -1
- package/lib/module/modules/profile/presentation/pages/web/profile_web_page.js.map +1 -1
- package/lib/module/modules/qrcode/presentation/pages/qrcode_alias_page.js.map +1 -1
- package/lib/module/modules/qrcode/presentation/pages/qrcode_alias_page_share.js +2 -2
- package/lib/module/modules/qrcode/presentation/pages/qrcode_alias_page_share.js.map +1 -1
- package/lib/module/modules/search/domain/models/fonctionnalite.js +199 -0
- package/lib/module/modules/search/domain/models/fonctionnalite.js.map +1 -0
- package/lib/module/modules/search/presentation/pages/search_fonctionnalites.js +119 -0
- package/lib/module/modules/search/presentation/pages/search_fonctionnalites.js.map +1 -0
- package/lib/module/modules/search/presentation/pages/search_page.js +359 -0
- package/lib/module/modules/search/presentation/pages/search_page.js.map +1 -0
- package/lib/module/modules/search/presentation/pages/search_page_voice_button.js +335 -0
- package/lib/module/modules/search/presentation/pages/search_page_voice_button.js.map +1 -0
- package/lib/module/modules/subscription/presentation/pages/subscription_menu_widget.js +2 -2
- package/lib/module/modules/subscription/presentation/pages/subscription_menu_widget.js.map +1 -1
- package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.js +13 -1
- package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.js.map +1 -1
- package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.js +7 -1
- package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.js.map +1 -1
- package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.js +216 -137
- package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.js.map +1 -1
- package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.js +6 -2
- package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.js.map +1 -1
- package/lib/module/modules/transactions/presentation/pages/transaction_send/transaction_send_page_options.js +3 -3
- package/lib/module/modules/transactions/presentation/pages/transaction_send/transaction_send_page_options.js.map +1 -1
- package/lib/module/shared/widgets/amount_widget.js +4 -1
- package/lib/module/shared/widgets/amount_widget.js.map +1 -1
- package/lib/module/shared/widgets/input_amount.js.map +1 -1
- package/lib/module/shared/widgets/input_text.js +2 -0
- package/lib/module/shared/widgets/input_text.js.map +1 -1
- package/lib/module/shared/widgets/menu_actions_widget.js +10 -1
- package/lib/module/shared/widgets/menu_actions_widget.js.map +1 -1
- package/lib/module/shared/widgets/pagination_loader_widget.js +29 -0
- package/lib/module/shared/widgets/pagination_loader_widget.js.map +1 -0
- package/lib/typescript/src/core/assets.d.ts +1 -0
- package/lib/typescript/src/core/assets.d.ts.map +1 -1
- package/lib/typescript/src/core/router.d.ts +1 -0
- package/lib/typescript/src/core/router.d.ts.map +1 -1
- package/lib/typescript/src/core/routes.d.ts.map +1 -1
- package/lib/typescript/src/core/storage.d.ts +11 -3
- package/lib/typescript/src/core/storage.d.ts.map +1 -1
- package/lib/typescript/src/l10n/app_localizations.d.ts +40 -0
- package/lib/typescript/src/l10n/app_localizations.d.ts.map +1 -1
- package/lib/typescript/src/l10n/app_localizations_en.d.ts.map +1 -1
- package/lib/typescript/src/l10n/app_localizations_fr.d.ts.map +1 -1
- package/lib/typescript/src/l10n/app_localizations_pt.d.ts.map +1 -1
- package/lib/typescript/src/modules/categorie/presentation/pages/categorie_select_widget.d.ts.map +1 -1
- package/lib/typescript/src/modules/compte/presentation/pages/solde_widget_with_qr.d.ts.map +1 -1
- package/lib/typescript/src/modules/home/presentation/pages/home_page.d.ts.map +1 -1
- package/lib/typescript/src/modules/notification/presentation/bloc/notification_bloc.d.ts.map +1 -1
- package/lib/typescript/src/modules/notification/presentation/bloc/notification_state.d.ts +5 -0
- package/lib/typescript/src/modules/notification/presentation/bloc/notification_state.d.ts.map +1 -1
- package/lib/typescript/src/modules/notification/presentation/pages/notification_page_liste.d.ts.map +1 -1
- package/lib/typescript/src/modules/profile/presentation/pages/web/profile_web_page.d.ts.map +1 -1
- package/lib/typescript/src/modules/search/domain/models/fonctionnalite.d.ts +70 -0
- package/lib/typescript/src/modules/search/domain/models/fonctionnalite.d.ts.map +1 -0
- package/lib/typescript/src/modules/search/presentation/pages/search_fonctionnalites.d.ts +21 -0
- package/lib/typescript/src/modules/search/presentation/pages/search_fonctionnalites.d.ts.map +1 -0
- package/lib/typescript/src/modules/search/presentation/pages/search_page.d.ts +21 -0
- package/lib/typescript/src/modules/search/presentation/pages/search_page.d.ts.map +1 -0
- package/lib/typescript/src/modules/search/presentation/pages/search_page_voice_button.d.ts +29 -0
- package/lib/typescript/src/modules/search/presentation/pages/search_page_voice_button.d.ts.map +1 -0
- package/lib/typescript/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.d.ts.map +1 -1
- package/lib/typescript/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.d.ts +5 -0
- package/lib/typescript/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.d.ts.map +1 -1
- package/lib/typescript/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.d.ts +5 -1
- package/lib/typescript/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.d.ts.map +1 -1
- package/lib/typescript/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.d.ts.map +1 -1
- package/lib/typescript/src/shared/widgets/amount_widget.d.ts +3 -1
- package/lib/typescript/src/shared/widgets/amount_widget.d.ts.map +1 -1
- package/lib/typescript/src/shared/widgets/input_text.d.ts +3 -3
- package/lib/typescript/src/shared/widgets/input_text.d.ts.map +1 -1
- package/lib/typescript/src/shared/widgets/menu_actions_widget.d.ts +5 -1
- package/lib/typescript/src/shared/widgets/menu_actions_widget.d.ts.map +1 -1
- package/lib/typescript/src/shared/widgets/pagination_loader_widget.d.ts +7 -0
- package/lib/typescript/src/shared/widgets/pagination_loader_widget.d.ts.map +1 -0
- package/package.json +49 -35
- package/src/core/assets.ts +1 -0
- package/src/core/router.tsx +3 -0
- package/src/core/routes.tsx +15 -0
- package/src/core/storage.ts +30 -22
- package/src/l10n/app_localizations.ts +40 -0
- package/src/l10n/app_localizations_en.ts +20 -0
- package/src/l10n/app_localizations_fr.ts +20 -0
- package/src/l10n/app_localizations_pt.ts +20 -0
- package/src/modules/alias/presentation/pages/alias_page_create_options.tsx +3 -3
- package/src/modules/alias/presentation/pages/alias_page_otp.tsx +2 -2
- package/src/modules/categorie/presentation/pages/categorie_edit_icon_btn.tsx +2 -2
- package/src/modules/categorie/presentation/pages/categorie_select_widget.tsx +13 -9
- package/src/modules/compte/presentation/pages/solde_widget_with_qr.tsx +10 -3
- package/src/modules/home/presentation/pages/home_page.tsx +31 -2
- package/src/modules/home/presentation/pages/home_tab_compte_more_menu.tsx +2 -2
- package/src/modules/notification/presentation/bloc/notification_bloc.ts +12 -3
- package/src/modules/notification/presentation/bloc/notification_state.ts +11 -1
- package/src/modules/notification/presentation/pages/notification_page_liste.tsx +4 -1
- package/src/modules/profile/presentation/pages/parametre/profile_parametre_page_notifications.tsx +2 -2
- package/src/modules/profile/presentation/pages/web/profile_web_page.tsx +3 -2
- package/src/modules/qrcode/presentation/pages/qrcode_alias_page.tsx +2 -2
- package/src/modules/qrcode/presentation/pages/qrcode_alias_page_share.tsx +2 -2
- package/src/modules/search/domain/models/fonctionnalite.ts +217 -0
- package/src/modules/search/presentation/pages/search_fonctionnalites.ts +135 -0
- package/src/modules/search/presentation/pages/search_page.tsx +314 -0
- package/src/modules/search/presentation/pages/search_page_voice_button.tsx +283 -0
- package/src/modules/subscription/presentation/pages/subscription_menu_widget.tsx +2 -2
- package/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.ts +15 -1
- package/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.ts +10 -1
- package/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.tsx +204 -101
- package/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.tsx +4 -1
- package/src/modules/transactions/presentation/pages/transaction_send/transaction_send_page_options.tsx +3 -3
- package/src/shared/widgets/amount_widget.tsx +5 -2
- package/src/shared/widgets/input_amount.tsx +2 -2
- package/src/shared/widgets/input_text.tsx +8 -3
- package/src/shared/widgets/menu_actions_widget.tsx +8 -2
- package/src/shared/widgets/pagination_loader_widget.tsx +24 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/** Lettres accentuées et ligatures ramenées à leur forme simple. */
|
|
2
|
+
const ACCENTS: Record<string, string> = {
|
|
3
|
+
à: 'a',
|
|
4
|
+
â: 'a',
|
|
5
|
+
ä: 'a',
|
|
6
|
+
á: 'a',
|
|
7
|
+
ã: 'a',
|
|
8
|
+
å: 'a',
|
|
9
|
+
ç: 'c',
|
|
10
|
+
é: 'e',
|
|
11
|
+
è: 'e',
|
|
12
|
+
ê: 'e',
|
|
13
|
+
ë: 'e',
|
|
14
|
+
î: 'i',
|
|
15
|
+
ï: 'i',
|
|
16
|
+
í: 'i',
|
|
17
|
+
ì: 'i',
|
|
18
|
+
ô: 'o',
|
|
19
|
+
ö: 'o',
|
|
20
|
+
ó: 'o',
|
|
21
|
+
ò: 'o',
|
|
22
|
+
õ: 'o',
|
|
23
|
+
ù: 'u',
|
|
24
|
+
û: 'u',
|
|
25
|
+
ü: 'u',
|
|
26
|
+
ú: 'u',
|
|
27
|
+
ÿ: 'y',
|
|
28
|
+
ñ: 'n',
|
|
29
|
+
œ: 'oe',
|
|
30
|
+
æ: 'ae',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Une fonctionnalité de l'application que l'utilisateur peut retrouver
|
|
35
|
+
* depuis la recherche.
|
|
36
|
+
*
|
|
37
|
+
* La recherche ignore les majuscules, les accents et la ponctuation :
|
|
38
|
+
* « Sécurité », « securite » et « SECURITÉ » donnent le même résultat.
|
|
39
|
+
*/
|
|
40
|
+
export class Fonctionnalite {
|
|
41
|
+
/** Icône (module Metro, voir `Images`). */
|
|
42
|
+
readonly icon: number;
|
|
43
|
+
|
|
44
|
+
/** Libellé affiché, déjà traduit. C'est le champ le plus pertinent. */
|
|
45
|
+
readonly titre: string;
|
|
46
|
+
|
|
47
|
+
/** Courte description affichée sous le titre, déjà traduite. */
|
|
48
|
+
readonly sousTitre?: string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Synonymes non affichés, dans toutes les langues de l'application.
|
|
52
|
+
* Ils doivent être écrits sans accents (voir {@link Fonctionnalite.normaliser}).
|
|
53
|
+
*/
|
|
54
|
+
readonly motsCles: readonly string[];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ouvre la fonctionnalité. Appelé par la page appelante une fois la
|
|
58
|
+
* recherche fermée, jamais par la page de recherche elle-même.
|
|
59
|
+
*/
|
|
60
|
+
readonly ouvrir: () => void;
|
|
61
|
+
|
|
62
|
+
constructor(options: {
|
|
63
|
+
icon: number;
|
|
64
|
+
titre: string;
|
|
65
|
+
sousTitre?: string;
|
|
66
|
+
motsCles?: readonly string[];
|
|
67
|
+
ouvrir: () => void;
|
|
68
|
+
}) {
|
|
69
|
+
this.icon = options.icon;
|
|
70
|
+
this.titre = options.titre;
|
|
71
|
+
this.sousTitre = options.sousTitre;
|
|
72
|
+
this.motsCles = options.motsCles ?? [];
|
|
73
|
+
this.ouvrir = options.ouvrir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Met [texte] en minuscules sans accents, et remplace tout ce qui
|
|
78
|
+
* n'est ni lettre ni chiffre par un seul espace.
|
|
79
|
+
* Exemple : « Code QR — Scanner ! » devient « code qr scanner ».
|
|
80
|
+
*/
|
|
81
|
+
static normaliser(texte: string): string {
|
|
82
|
+
let resultat = '';
|
|
83
|
+
for (const lettre of texte.toLowerCase()) {
|
|
84
|
+
resultat += ACCENTS[lettre] ?? lettre;
|
|
85
|
+
}
|
|
86
|
+
return resultat.replace(/[^a-z0-9]+/g, ' ').trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Mots normalisés de [texte], sans les vides. */
|
|
90
|
+
private static mots(texte: string): string[] {
|
|
91
|
+
return Fonctionnalite.normaliser(texte)
|
|
92
|
+
.split(' ')
|
|
93
|
+
.filter((mot) => mot.length > 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Mots utiles de la saisie. Si elle ne contient que des mots courants
|
|
98
|
+
* (« de » en cours de frappe de « demander »), ils sont tous gardés.
|
|
99
|
+
*/
|
|
100
|
+
private static motsUtiles(recherche: string): string[] {
|
|
101
|
+
const mots = Fonctionnalite.mots(recherche);
|
|
102
|
+
const utiles = mots.filter((mot) => !MOTS_VIDES.has(mot));
|
|
103
|
+
return utiles.length === 0 ? mots : utiles;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Qualité de la correspondance entre un mot [saisi] et un [mot] de la
|
|
108
|
+
* fonctionnalité, 0 s'ils ne correspondent pas :
|
|
109
|
+
* - 3 : mots identiques ;
|
|
110
|
+
* - 2 : [mot] commence par [saisi] (frappe en cours : « envo » trouve
|
|
111
|
+
* « envoi »), ou l'inverse pour un [mot] d'au moins 4 lettres
|
|
112
|
+
* (pluriel : « abonnements » trouve « abonnement ») ;
|
|
113
|
+
* - 1 : une seule faute de frappe, sur des mots d'au moins 5 lettres
|
|
114
|
+
* (« securiter », « abonement »).
|
|
115
|
+
*/
|
|
116
|
+
private static qualite(saisi: string, mot: string): number {
|
|
117
|
+
if (saisi === mot) return 3;
|
|
118
|
+
if (mot.startsWith(saisi)) return 2;
|
|
119
|
+
if (mot.length >= 4 && saisi.startsWith(mot)) return 2;
|
|
120
|
+
if (saisi.length >= 5 && mot.length >= 5 && uneFaute(saisi, mot)) return 1;
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Nombre de mots de la saisie trouvés dans la fonctionnalité, et points
|
|
126
|
+
* cumulés (qualité de la correspondance × poids du champ, la meilleure
|
|
127
|
+
* pour chaque mot saisi).
|
|
128
|
+
*/
|
|
129
|
+
private score(saisis: readonly string[]): { trouves: number; points: number } {
|
|
130
|
+
const champs: [string[], number][] = [
|
|
131
|
+
[Fonctionnalite.mots(this.titre), POIDS_TITRE],
|
|
132
|
+
[this.motsCles.flatMap((motCle) => Fonctionnalite.mots(motCle)), POIDS_MOTS_CLES],
|
|
133
|
+
[Fonctionnalite.mots(this.sousTitre ?? ''), POIDS_SOUS_TITRE],
|
|
134
|
+
];
|
|
135
|
+
let trouves = 0;
|
|
136
|
+
let points = 0;
|
|
137
|
+
for (const saisi of saisis) {
|
|
138
|
+
let meilleur = 0;
|
|
139
|
+
for (const [mots, poids] of champs) {
|
|
140
|
+
for (const mot of mots) {
|
|
141
|
+
meilleur = Math.max(meilleur, Fonctionnalite.qualite(saisi, mot) * poids);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (meilleur > 0) {
|
|
145
|
+
trouves++;
|
|
146
|
+
points += meilleur;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { trouves, points };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fonctionnalités de [catalogue] dont au moins un mot utile de
|
|
154
|
+
* [recherche] est trouvé, triées par nombre de mots trouvés puis par
|
|
155
|
+
* points. À égalité, l'ordre du catalogue est conservé.
|
|
156
|
+
* Une saisie vide renvoie tout le catalogue.
|
|
157
|
+
*/
|
|
158
|
+
static rechercher(catalogue: readonly Fonctionnalite[], recherche: string): Fonctionnalite[] {
|
|
159
|
+
const saisis = Fonctionnalite.motsUtiles(recherche);
|
|
160
|
+
if (saisis.length === 0) return [...catalogue];
|
|
161
|
+
return catalogue
|
|
162
|
+
.map((item, ordre) => ({ item, ordre, ...item.score(saisis) }))
|
|
163
|
+
.filter(({ trouves }) => trouves > 0)
|
|
164
|
+
.sort((a, b) => b.trouves - a.trouves || b.points - a.points || a.ordre - b.ordre)
|
|
165
|
+
.map(({ item }) => item);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Mots trop courants pour désigner une fonctionnalité, en français,
|
|
171
|
+
* anglais et portugais, sans accents. Ils sont ignorés dans la saisie,
|
|
172
|
+
* pour qu'une phrase dictée (« je veux envoyer de l'argent ») ne
|
|
173
|
+
* retienne que les mots utiles.
|
|
174
|
+
*/
|
|
175
|
+
const MOTS_VIDES: ReadonlySet<string> = new Set([
|
|
176
|
+
// Français
|
|
177
|
+
'je', 'j', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'me', 'm', 'te',
|
|
178
|
+
't', 'se', 's', 'le', 'la', 'les', 'l', 'un', 'une', 'des', 'du', 'de',
|
|
179
|
+
'd', 'au', 'aux', 'a', 'et', 'ou', 'en', 'y', 'pour', 'par', 'sur',
|
|
180
|
+
'avec', 'dans', 'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa',
|
|
181
|
+
'ses', 'notre', 'nos', 'votre', 'vos', 'ce', 'cet', 'cette', 'ces', 'c',
|
|
182
|
+
'qu', 'que', 'qui', 'quoi', 'comment', 'est', 'veux', 'voudrais',
|
|
183
|
+
'souhaite', 'aimerais', 'faire', 'peux', 'puis', 'voir', 'ne', 'n',
|
|
184
|
+
'pas', 'svp', 'stp',
|
|
185
|
+
// Anglais
|
|
186
|
+
'i', 'my', 'the', 'an', 'to', 'of', 'for', 'in', 'at', 'and', 'or',
|
|
187
|
+
'is', 'want', 'would', 'like', 'how', 'can', 'do', 'please', 'make',
|
|
188
|
+
// Portugais
|
|
189
|
+
'eu', 'o', 'os', 'as', 'um', 'uma', 'da', 'dos', 'das', 'no',
|
|
190
|
+
'na', 'em', 'para', 'por', 'com', 'meu', 'minha', 'meus', 'minhas',
|
|
191
|
+
'quero', 'queria', 'gostaria', 'como', 'fazer', 'ver', 'e',
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
/** Poids d'un mot trouvé selon le champ où il l'est. */
|
|
195
|
+
const POIDS_TITRE = 3;
|
|
196
|
+
const POIDS_MOTS_CLES = 2;
|
|
197
|
+
const POIDS_SOUS_TITRE = 1;
|
|
198
|
+
|
|
199
|
+
/** [a] et [b] ne diffèrent que d'une lettre remplacée, ajoutée ou retirée. */
|
|
200
|
+
function uneFaute(a: string, b: string): boolean {
|
|
201
|
+
if (Math.abs(a.length - b.length) > 1) return false;
|
|
202
|
+
const [court, long] = a.length <= b.length ? [a, b] : [b, a];
|
|
203
|
+
let i = 0;
|
|
204
|
+
let j = 0;
|
|
205
|
+
let fautes = 0;
|
|
206
|
+
while (i < court.length && j < long.length) {
|
|
207
|
+
if (court[i] === long[j]) {
|
|
208
|
+
i++;
|
|
209
|
+
j++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (++fautes > 1) return false;
|
|
213
|
+
if (court.length === long.length) i++;
|
|
214
|
+
j++;
|
|
215
|
+
}
|
|
216
|
+
return fautes + (long.length - j) <= 1;
|
|
217
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { Images } from '../../../../core/assets';
|
|
4
|
+
import { AppRouter, type SdkRouter } from '../../../../core/router';
|
|
5
|
+
import type { AppLocalizations } from '../../../../l10n/app_localizations';
|
|
6
|
+
import { Fonctionnalite } from '../../domain/models/fonctionnalite';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Catalogue des fonctionnalités accessibles depuis la recherche.
|
|
10
|
+
*
|
|
11
|
+
* [router] est celui de la page qui ouvre la recherche : la navigation
|
|
12
|
+
* part de là une fois la recherche fermée.
|
|
13
|
+
* [ouvrirOnglet] change d'onglet sur l'accueil (0 : compte, 1 : abonnements).
|
|
14
|
+
* Seules les pages présentes dans le SDK sont proposées (pas de
|
|
15
|
+
* sécurité ni d'à-propos : c'est l'app hôte qui les gère).
|
|
16
|
+
*
|
|
17
|
+
* L'ordre de la liste sert d'ordre d'affichage à pertinence égale :
|
|
18
|
+
* les fonctionnalités les plus utilisées sont en tête.
|
|
19
|
+
* Pour en ajouter une : titre et sous-titre traduits, mots-clés sans
|
|
20
|
+
* accents en français, anglais et portugais.
|
|
21
|
+
*/
|
|
22
|
+
export function fonctionnalitesRecherchables(
|
|
23
|
+
router: SdkRouter,
|
|
24
|
+
traductions: AppLocalizations,
|
|
25
|
+
{ ouvrirOnglet }: { ouvrirOnglet: (onglet: number) => void },
|
|
26
|
+
): Fonctionnalite[] {
|
|
27
|
+
const aller = (route: string, params?: unknown) => void AppRouter.push(router, route, { params });
|
|
28
|
+
const web = Platform.OS === 'web';
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
// Paiements
|
|
32
|
+
new Fonctionnalite({
|
|
33
|
+
icon: Images.arrowUp,
|
|
34
|
+
titre: traductions.homeActionSend,
|
|
35
|
+
sousTitre: traductions.searchSendSubTitle,
|
|
36
|
+
motsCles: ['envoi', 'transfert', 'payer', 'virement', 'send', 'pay', 'transfer', 'enviar', 'pagar'],
|
|
37
|
+
ouvrir: () => aller(AppRouter.transactionSend),
|
|
38
|
+
}),
|
|
39
|
+
new Fonctionnalite({
|
|
40
|
+
icon: Images.arrowDown,
|
|
41
|
+
titre: traductions.homeActionRequest,
|
|
42
|
+
sousTitre: traductions.searchReceiveSubTitle,
|
|
43
|
+
motsCles: ['demander', 'demande', 'rtp', 'request', 'receive', 'pedir', 'receber'],
|
|
44
|
+
ouvrir: () => aller(AppRouter.transactionReceive),
|
|
45
|
+
}),
|
|
46
|
+
new Fonctionnalite({
|
|
47
|
+
icon: Images.homeFooterQrcode,
|
|
48
|
+
titre: traductions.searchQrScanTitle,
|
|
49
|
+
sousTitre: traductions.searchQrScanSubTitle,
|
|
50
|
+
motsCles: ['qr', 'qrcode', 'scanner', 'scan', 'camera'],
|
|
51
|
+
// Le QR scanné ouvre directement l'envoi, comme depuis l'accueil.
|
|
52
|
+
ouvrir: () => aller(AppRouter.qrcodeScan, 'send_now'),
|
|
53
|
+
}),
|
|
54
|
+
new Fonctionnalite({
|
|
55
|
+
icon: Images.homeFooterQr,
|
|
56
|
+
titre: traductions.qrcodePagePartageQrCodeTitle,
|
|
57
|
+
sousTitre: traductions.searchQrShowSubTitle,
|
|
58
|
+
motsCles: ['qr', 'qrcode', 'partager', 'share', 'partilhar'],
|
|
59
|
+
ouvrir: () => aller(AppRouter.qrcodeShow),
|
|
60
|
+
}),
|
|
61
|
+
new Fonctionnalite({
|
|
62
|
+
icon: Images.iconSend,
|
|
63
|
+
titre: traductions.transactionSearchTitle,
|
|
64
|
+
sousTitre: traductions.searchTransactionsSubTitle,
|
|
65
|
+
motsCles: ['historique', 'paiement', 'operation', 'releve', 'history', 'payment', 'historico', 'pagamento'],
|
|
66
|
+
ouvrir: () => aller(AppRouter.transactionSearch),
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
// Abonnements
|
|
70
|
+
new Fonctionnalite({
|
|
71
|
+
icon: Images.transactionPlanifier,
|
|
72
|
+
titre: traductions.homeActionMoreSheetProgrammerTitle,
|
|
73
|
+
sousTitre: traductions.homeActionMoreSheetProgrammerSubTitle,
|
|
74
|
+
motsCles: ['programmer', 'planifier', 'recurrent', 'schedule', 'agendar'],
|
|
75
|
+
ouvrir: () => aller(AppRouter.subscriptionSchedule),
|
|
76
|
+
}),
|
|
77
|
+
new Fonctionnalite({
|
|
78
|
+
icon: Images.homeMoreFindSubscription,
|
|
79
|
+
titre: traductions.homePageToolbarTabbarAbonnement,
|
|
80
|
+
sousTitre: traductions.searchAbonnementsSubTitle,
|
|
81
|
+
motsCles: ['abonnement', 'echeance', 'programme', 'subscription', 'assinatura'],
|
|
82
|
+
ouvrir: () => ouvrirOnglet(1),
|
|
83
|
+
}),
|
|
84
|
+
new Fonctionnalite({
|
|
85
|
+
icon: Images.homeMoreFindSubscription,
|
|
86
|
+
titre: traductions.homeActionMoreSheetAbonnementTitle,
|
|
87
|
+
sousTitre: traductions.homeActionMoreSheetAbonnementSubTitle,
|
|
88
|
+
motsCles: ['abonnement', 'subscription', 'assinatura'],
|
|
89
|
+
ouvrir: () => aller(AppRouter.subscriptionSubscribe),
|
|
90
|
+
}),
|
|
91
|
+
new Fonctionnalite({
|
|
92
|
+
icon: Images.homeMoreSplitPayments,
|
|
93
|
+
titre: traductions.homeActionMoreSheetPartagerTitle,
|
|
94
|
+
sousTitre: traductions.homeActionMoreSheetPartagerSubTitle,
|
|
95
|
+
motsCles: ['partager', 'repartir', 'diviser', 'amis', 'split', 'share', 'dividir'],
|
|
96
|
+
ouvrir: () => aller(AppRouter.transactionSplitPaymentTx),
|
|
97
|
+
}),
|
|
98
|
+
|
|
99
|
+
// Profil
|
|
100
|
+
new Fonctionnalite({
|
|
101
|
+
icon: Images.iconsCircleUser,
|
|
102
|
+
titre: traductions.profilePageMenuCompteTitle,
|
|
103
|
+
sousTitre: traductions.searchCompteSubTitle,
|
|
104
|
+
motsCles: ['profil', 'compte', 'iban', 'rib', 'profile', 'account', 'perfil', 'conta'],
|
|
105
|
+
ouvrir: () => aller(AppRouter.profileCompte),
|
|
106
|
+
}),
|
|
107
|
+
new Fonctionnalite({
|
|
108
|
+
icon: Images.iconsSetting,
|
|
109
|
+
titre: traductions.profilePageMenuParametreTitle,
|
|
110
|
+
sousTitre: traductions.searchParametreSubTitle,
|
|
111
|
+
motsCles: ['parametre', 'reglage', 'theme', 'langue', 'notification', 'sombre', 'settings', 'language', 'dark', 'definicoes', 'idioma'],
|
|
112
|
+
ouvrir: () => aller(AppRouter.profileParametre),
|
|
113
|
+
}),
|
|
114
|
+
// Sur le web, le profil n'a pas ces pages : pas de contacts,
|
|
115
|
+
// et l'aide est un lien externe.
|
|
116
|
+
...(web
|
|
117
|
+
? []
|
|
118
|
+
: [
|
|
119
|
+
new Fonctionnalite({
|
|
120
|
+
icon: Images.iconTransactionSendByAlias,
|
|
121
|
+
titre: traductions.profilePageMenuAliasContactTitle,
|
|
122
|
+
sousTitre: traductions.searchContactsSubTitle,
|
|
123
|
+
motsCles: ['contact', 'alias', 'repertoire', 'contacts', 'contactos'],
|
|
124
|
+
ouvrir: () => aller(AppRouter.profileContacts),
|
|
125
|
+
}),
|
|
126
|
+
new Fonctionnalite({
|
|
127
|
+
icon: Images.iconsHelp,
|
|
128
|
+
titre: traductions.profilePageMenuHelpTitle,
|
|
129
|
+
sousTitre: traductions.searchAideSubTitle,
|
|
130
|
+
motsCles: ['aide', 'faq', 'assistance', 'support', 'help', 'ajuda'],
|
|
131
|
+
ouvrir: () => aller(AppRouter.profileHelpCenter),
|
|
132
|
+
}),
|
|
133
|
+
]),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
2
|
+
import { MaterialIcons } from '@expo/vector-icons';
|
|
3
|
+
import { createAudioPlayer, type AudioPlayer } from 'expo-audio';
|
|
4
|
+
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from 'expo-speech-recognition';
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { Keyboard, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
7
|
+
|
|
8
|
+
import { AppRouter, useAppRouter } from '../../../../core/router';
|
|
9
|
+
import { Themer, useTheme } from '../../../../core/theme';
|
|
10
|
+
import { ValueNotifier } from '../../../../core/value_notifier';
|
|
11
|
+
import { useAppLocalizations } from '../../../../l10n/localizations';
|
|
12
|
+
import { Card } from '../../../../shared/material/card';
|
|
13
|
+
import { AppBar, Scaffold } from '../../../../shared/material/scaffold';
|
|
14
|
+
import { CustomSnackBar } from '../../../../shared/widgets/custom_snack_bar';
|
|
15
|
+
import { MenuActionItem, MenuActionsWidget } from '../../../../shared/widgets/menu_actions_widget';
|
|
16
|
+
import { MyPageContainer } from '../../../../shared/widgets/my_page_container';
|
|
17
|
+
import { Fonctionnalite } from '../../domain/models/fonctionnalite';
|
|
18
|
+
import { SearchPageVoiceButton } from './search_page_voice_button';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Enregistrements de la phrase d'invitation, par langue.
|
|
22
|
+
* La première sert si celle de l'app n'en a pas.
|
|
23
|
+
*/
|
|
24
|
+
const VOIX_OFF: Readonly<Record<string, number>> = {
|
|
25
|
+
fr: require('../../../../../assets/sounds/voice/search_prompt_fr.mp3'),
|
|
26
|
+
en: require('../../../../../assets/sounds/voice/search_prompt_en.mp3'),
|
|
27
|
+
pt: require('../../../../../assets/sounds/voice/search_prompt_pt.mp3'),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recherche dans les fonctionnalités de l'application.
|
|
32
|
+
*
|
|
33
|
+
* La recherche se fait au clavier ou à la voix (bouton micro du champ).
|
|
34
|
+
* La page ne navigue pas elle-même : elle se ferme en renvoyant la
|
|
35
|
+
* [Fonctionnalite] choisie à la page appelante, qui l'ouvre. Ainsi le
|
|
36
|
+
* retour depuis la fonctionnalité ramène à l'accueil, pas à la recherche.
|
|
37
|
+
*
|
|
38
|
+
* Recherche vocale, dans l'ordre :
|
|
39
|
+
* 1. premier appui : demande de la permission micro ;
|
|
40
|
+
* 2. voix off « Dites-moi ce que vous souhaitez faire » dans la langue
|
|
41
|
+
* de l'app ; un nouvel appui la coupe et passe directement à l'écoute ;
|
|
42
|
+
* 3. écoute : le texte reconnu remplit le champ au fil de la parole ;
|
|
43
|
+
* un appui l'arrête, sinon elle s'arrête seule après un silence.
|
|
44
|
+
*/
|
|
45
|
+
export function SearchPage({
|
|
46
|
+
fonctionnalites,
|
|
47
|
+
}: {
|
|
48
|
+
/** Catalogue dans lequel chercher, construit par la page appelante (voir `fonctionnalitesRecherchables`). */
|
|
49
|
+
fonctionnalites: Fonctionnalite[];
|
|
50
|
+
}) {
|
|
51
|
+
const traductions = useAppLocalizations();
|
|
52
|
+
const theme = useTheme();
|
|
53
|
+
const router = useAppRouter();
|
|
54
|
+
|
|
55
|
+
/** Texte du champ, qu'il soit tapé ou dicté. */
|
|
56
|
+
const [recherche, setRecherche] = useState('');
|
|
57
|
+
/** La voix off est en train d'être jouée. */
|
|
58
|
+
const [annonce, setAnnonce] = useState(false);
|
|
59
|
+
/** Le micro est ouvert et la reconnaissance vocale écoute. */
|
|
60
|
+
const [ecoute, setEcoute] = useState(false);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Volume de la voix entre 0 et 1, lu par le bouton micro pour ses ondes.
|
|
64
|
+
* Un [ValueNotifier] évite de re-rendre toute la page à chaque mesure.
|
|
65
|
+
*/
|
|
66
|
+
const niveau = useMemo(() => new ValueNotifier(0), []);
|
|
67
|
+
/** Plus bas et plus haut volumes mesurés pendant l'écoute en cours. */
|
|
68
|
+
const decibels = useRef<{ min?: number; max?: number }>({});
|
|
69
|
+
/** Lecteur de la phrase d'invitation jouée avant l'écoute. */
|
|
70
|
+
const voixOff = useRef<AudioPlayer | null>(null);
|
|
71
|
+
/** Termine l'attente de la voix off (fin de lecture ou coupure). */
|
|
72
|
+
const finAnnonce = useRef<(() => void) | null>(null);
|
|
73
|
+
const monte = useRef(true);
|
|
74
|
+
|
|
75
|
+
useEffect(
|
|
76
|
+
() => () => {
|
|
77
|
+
monte.current = false;
|
|
78
|
+
finAnnonce.current?.();
|
|
79
|
+
voixOff.current?.remove();
|
|
80
|
+
ExpoSpeechRecognitionModule.abort();
|
|
81
|
+
},
|
|
82
|
+
[],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/** Remet le bouton micro au repos. Appelé aussi quand la reconnaissance s'arrête seule (silence, erreur, durée maximale). */
|
|
86
|
+
const arreterEcoute = () => {
|
|
87
|
+
niveau.value = 0;
|
|
88
|
+
if (monte.current) setEcoute(false);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
useSpeechRecognitionEvent('result', (event) => {
|
|
92
|
+
if (monte.current) setRecherche(event.results[0]?.transcript ?? '');
|
|
93
|
+
});
|
|
94
|
+
useSpeechRecognitionEvent('end', arreterEcoute);
|
|
95
|
+
useSpeechRecognitionEvent('error', arreterEcoute);
|
|
96
|
+
// Le volume varie selon le téléphone : il est ramené entre 0 et 1
|
|
97
|
+
// par rapport au plus bas et au plus haut de l'écoute.
|
|
98
|
+
useSpeechRecognitionEvent('volumechange', ({ value }) => {
|
|
99
|
+
const bas = Math.min(decibels.current.min ?? value, value);
|
|
100
|
+
const haut = Math.max(decibels.current.max ?? value, value);
|
|
101
|
+
decibels.current = { min: bas, max: haut };
|
|
102
|
+
const ecart = haut - bas;
|
|
103
|
+
niveau.value = ecart < 1 ? 0 : Math.min(Math.max((value - bas) / ecart, 0), 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* La permission micro est demandée au premier appui, pas à l'ouverture.
|
|
108
|
+
* Renvoie `false` si elle est refusée ou si le téléphone n'a pas de
|
|
109
|
+
* reconnaissance vocale.
|
|
110
|
+
*/
|
|
111
|
+
const preparerVoix = async (): Promise<boolean> => {
|
|
112
|
+
try {
|
|
113
|
+
if (!ExpoSpeechRecognitionModule.isRecognitionAvailable()) return false;
|
|
114
|
+
const permission = await ExpoSpeechRecognitionModule.requestPermissionsAsync();
|
|
115
|
+
return permission.granted;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** Langue de l'application si le téléphone la reconnaît, sinon celle du système. */
|
|
122
|
+
const langueEcoute = async (langue: string): Promise<string | undefined> => {
|
|
123
|
+
const systeme = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
124
|
+
if (systeme?.startsWith(langue)) return systeme;
|
|
125
|
+
try {
|
|
126
|
+
const { locales } = await ExpoSpeechRecognitionModule.getSupportedLocales({});
|
|
127
|
+
const trouvee = locales.find((item) => item.startsWith(langue));
|
|
128
|
+
if (trouvee) return trouvee;
|
|
129
|
+
} catch {
|
|
130
|
+
// Liste indisponible (Android 12 et moins) : langue du système.
|
|
131
|
+
}
|
|
132
|
+
return systeme;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Joue la phrase d'invitation et rend la main quand elle est finie
|
|
137
|
+
* ou coupée. Le micro ne s'ouvre qu'après, sinon il l'entendrait
|
|
138
|
+
* et l'écrirait dans le champ.
|
|
139
|
+
*/
|
|
140
|
+
const annoncer = async (langue: string): Promise<void> => {
|
|
141
|
+
const source = VOIX_OFF[langue] ?? VOIX_OFF.fr;
|
|
142
|
+
try {
|
|
143
|
+
const lecteur = voixOff.current ?? createAudioPlayer(source);
|
|
144
|
+
if (voixOff.current) lecteur.replace(source);
|
|
145
|
+
voixOff.current = lecteur;
|
|
146
|
+
await lecteur.seekTo(0);
|
|
147
|
+
setAnnonce(true);
|
|
148
|
+
await new Promise<void>((resolve) => {
|
|
149
|
+
// Abonnement avant `play` pour ne pas manquer la fin d'un son court.
|
|
150
|
+
const abonnement = lecteur.addListener('playbackStatusUpdate', (statut) => {
|
|
151
|
+
if (statut.didJustFinish) finAnnonce.current?.();
|
|
152
|
+
});
|
|
153
|
+
finAnnonce.current = () => {
|
|
154
|
+
finAnnonce.current = null;
|
|
155
|
+
abonnement.remove();
|
|
156
|
+
lecteur.pause();
|
|
157
|
+
resolve();
|
|
158
|
+
};
|
|
159
|
+
lecteur.play();
|
|
160
|
+
});
|
|
161
|
+
} catch {
|
|
162
|
+
// La voix off est un plus : sans elle, l'écoute démarre quand même.
|
|
163
|
+
} finally {
|
|
164
|
+
if (monte.current) setAnnonce(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Appui sur le micro, selon l'état en cours :
|
|
170
|
+
* - voix off en cours : la coupe, l'écoute démarre aussitôt ;
|
|
171
|
+
* - écoute en cours : l'arrête ;
|
|
172
|
+
* - au repos : lance la voix off puis l'écoute.
|
|
173
|
+
*/
|
|
174
|
+
const basculerEcoute = async () => {
|
|
175
|
+
if (annonce) {
|
|
176
|
+
// Termine `annoncer` : l'appel en attente plus bas poursuit vers l'écoute.
|
|
177
|
+
finAnnonce.current?.();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (ecoute) {
|
|
181
|
+
ExpoSpeechRecognitionModule.stop();
|
|
182
|
+
arreterEcoute();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const langue = traductions.localeName;
|
|
186
|
+
Keyboard.dismiss();
|
|
187
|
+
|
|
188
|
+
if (!(await preparerVoix())) {
|
|
189
|
+
CustomSnackBar.show(traductions.searchVoiceUnavailable);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const lang = await langueEcoute(langue);
|
|
193
|
+
if (!monte.current) return;
|
|
194
|
+
await annoncer(langue);
|
|
195
|
+
if (!monte.current) return;
|
|
196
|
+
|
|
197
|
+
decibels.current = {};
|
|
198
|
+
setEcoute(true);
|
|
199
|
+
ExpoSpeechRecognitionModule.start({
|
|
200
|
+
lang,
|
|
201
|
+
// Le champ se remplit pendant que l'utilisateur parle.
|
|
202
|
+
interimResults: true,
|
|
203
|
+
continuous: false,
|
|
204
|
+
// Optimisé pour des requêtes courtes plutôt qu'une dictée.
|
|
205
|
+
iosTaskHint: 'search',
|
|
206
|
+
androidIntentOptions: {
|
|
207
|
+
EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS: 3000,
|
|
208
|
+
EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS: 3000,
|
|
209
|
+
},
|
|
210
|
+
volumeChangeEventOptions: { enabled: true, intervalMillis: 100 },
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const resultats = Fonctionnalite.rechercher(fonctionnalites, recherche);
|
|
215
|
+
const voixActive = ecoute || annonce;
|
|
216
|
+
|
|
217
|
+
/** Ligne de résultat : ferme la recherche en renvoyant [item]. */
|
|
218
|
+
const ligne = (item: Fonctionnalite) =>
|
|
219
|
+
new MenuActionItem(item.icon, item.titre, item.sousTitre, () => AppRouter.pop(router, { value: item }), {
|
|
220
|
+
iconSize: 20,
|
|
221
|
+
trailing: (
|
|
222
|
+
<MaterialIcons
|
|
223
|
+
name={Platform.OS === 'ios' ? 'arrow-forward-ios' : 'arrow-forward'}
|
|
224
|
+
size={16}
|
|
225
|
+
color={Themer.neural03Color}
|
|
226
|
+
/>
|
|
227
|
+
),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<Scaffold
|
|
232
|
+
appBar={
|
|
233
|
+
<AppBar
|
|
234
|
+
title={<Text style={theme.textTheme.headlineMedium}>{traductions.searchTitle}</Text>}
|
|
235
|
+
onBack={() => AppRouter.pop(router)}
|
|
236
|
+
/>
|
|
237
|
+
}
|
|
238
|
+
>
|
|
239
|
+
<MyPageContainer>
|
|
240
|
+
<Pressable accessible={false} style={styles.flex} onPress={Keyboard.dismiss}>
|
|
241
|
+
<View style={styles.gap10} />
|
|
242
|
+
{/* Champ de recherche : loupe à gauche ; à droite, la croix pour effacer (hors voix) et le bouton micro */}
|
|
243
|
+
<Card>
|
|
244
|
+
<View style={[styles.champ, { backgroundColor: theme.inputDecorationTheme.fillColor }]}>
|
|
245
|
+
<MaterialIcons name="search" size={18} color={Themer.neural03Color} />
|
|
246
|
+
<TextInput
|
|
247
|
+
value={recherche}
|
|
248
|
+
onChangeText={setRecherche}
|
|
249
|
+
autoFocus
|
|
250
|
+
style={[theme.textTheme.displayLarge, styles.input]}
|
|
251
|
+
// Le texte d'aide suit l'état de la voix.
|
|
252
|
+
placeholder={
|
|
253
|
+
annonce
|
|
254
|
+
? traductions.searchVoicePrompt
|
|
255
|
+
: ecoute
|
|
256
|
+
? traductions.searchVoiceListening
|
|
257
|
+
: traductions.searchHint
|
|
258
|
+
}
|
|
259
|
+
placeholderTextColor={Themer.neural03Color}
|
|
260
|
+
returnKeyType="search"
|
|
261
|
+
// « Entrée » ouvre le résultat le plus pertinent.
|
|
262
|
+
onSubmitEditing={() => {
|
|
263
|
+
if (recherche.trim().length > 0 && resultats.length > 0) {
|
|
264
|
+
AppRouter.pop(router, { value: resultats[0] });
|
|
265
|
+
}
|
|
266
|
+
}}
|
|
267
|
+
/>
|
|
268
|
+
{recherche.length > 0 && !voixActive && (
|
|
269
|
+
<Pressable accessibilityRole="button" hitSlop={8} style={styles.effacer} onPress={() => setRecherche('')}>
|
|
270
|
+
<MaterialIcons name="close" size={18} color={Themer.neural03Color} />
|
|
271
|
+
</Pressable>
|
|
272
|
+
)}
|
|
273
|
+
<SearchPageVoiceButton
|
|
274
|
+
ecoute={voixActive}
|
|
275
|
+
niveau={niveau}
|
|
276
|
+
tooltip={voixActive ? traductions.searchVoiceStop : traductions.searchVoiceStart}
|
|
277
|
+
onPress={() => void basculerEcoute()}
|
|
278
|
+
/>
|
|
279
|
+
</View>
|
|
280
|
+
</Card>
|
|
281
|
+
<View style={styles.gap16} />
|
|
282
|
+
<View style={styles.flex}>
|
|
283
|
+
{resultats.length === 0 ? (
|
|
284
|
+
<View style={styles.vide}>
|
|
285
|
+
<MaterialIcons name="search-off" size={40} color={Themer.neural03Color} />
|
|
286
|
+
<View style={styles.gap12} />
|
|
287
|
+
<Text style={[theme.textTheme.displayMedium, styles.centre, { color: Themer.neural04Color }]}>
|
|
288
|
+
{traductions.searchEmpty(recherche.trim())}
|
|
289
|
+
</Text>
|
|
290
|
+
</View>
|
|
291
|
+
) : (
|
|
292
|
+
<ScrollView keyboardDismissMode="on-drag" keyboardShouldPersistTaps="handled" contentContainerStyle={styles.liste}>
|
|
293
|
+
<MenuActionsWidget items={resultats.map(ligne)} />
|
|
294
|
+
</ScrollView>
|
|
295
|
+
)}
|
|
296
|
+
</View>
|
|
297
|
+
</Pressable>
|
|
298
|
+
</MyPageContainer>
|
|
299
|
+
</Scaffold>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const styles = StyleSheet.create({
|
|
304
|
+
flex: { flex: 1 },
|
|
305
|
+
gap10: { height: 10 },
|
|
306
|
+
gap12: { height: 12 },
|
|
307
|
+
gap16: { height: 16 },
|
|
308
|
+
champ: { height: 40, flexDirection: 'row', alignItems: 'center', paddingLeft: 12, paddingRight: 4 },
|
|
309
|
+
input: { flex: 1, marginLeft: 8, paddingVertical: 0 },
|
|
310
|
+
effacer: { paddingHorizontal: 6 },
|
|
311
|
+
vide: { paddingTop: 32, alignItems: 'center' },
|
|
312
|
+
centre: { textAlign: 'center' },
|
|
313
|
+
liste: { paddingBottom: 20 },
|
|
314
|
+
});
|