@pi-spi/app 1.3.0 → 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.
Files changed (157) hide show
  1. package/README.md +28 -5
  2. package/assets/icons/qr.png +0 -0
  3. package/assets/sounds/voice/search_prompt_en.mp3 +0 -0
  4. package/assets/sounds/voice/search_prompt_fr.mp3 +0 -0
  5. package/assets/sounds/voice/search_prompt_pt.mp3 +0 -0
  6. package/example/app.json +9 -1
  7. package/lib/module/core/assets.js +1 -0
  8. package/lib/module/core/assets.js.map +1 -1
  9. package/lib/module/core/router.js +3 -0
  10. package/lib/module/core/router.js.map +1 -1
  11. package/lib/module/core/routes.js +14 -0
  12. package/lib/module/core/routes.js.map +1 -1
  13. package/lib/module/core/storage.js +27 -29
  14. package/lib/module/core/storage.js.map +1 -1
  15. package/lib/module/l10n/app_localizations.js.map +1 -1
  16. package/lib/module/l10n/app_localizations_en.js +21 -1
  17. package/lib/module/l10n/app_localizations_en.js.map +1 -1
  18. package/lib/module/l10n/app_localizations_fr.js +21 -1
  19. package/lib/module/l10n/app_localizations_fr.js.map +1 -1
  20. package/lib/module/l10n/app_localizations_pt.js +21 -1
  21. package/lib/module/l10n/app_localizations_pt.js.map +1 -1
  22. package/lib/module/modules/alias/presentation/pages/alias_page_create_options.js +3 -3
  23. package/lib/module/modules/alias/presentation/pages/alias_page_create_options.js.map +1 -1
  24. package/lib/module/modules/alias/presentation/pages/alias_page_otp.js.map +1 -1
  25. package/lib/module/modules/categorie/presentation/pages/categorie_edit_icon_btn.js +2 -2
  26. package/lib/module/modules/categorie/presentation/pages/categorie_edit_icon_btn.js.map +1 -1
  27. package/lib/module/modules/categorie/presentation/pages/categorie_select_widget.js +12 -10
  28. package/lib/module/modules/categorie/presentation/pages/categorie_select_widget.js.map +1 -1
  29. package/lib/module/modules/compte/presentation/pages/solde_widget_with_qr.js +14 -9
  30. package/lib/module/modules/compte/presentation/pages/solde_widget_with_qr.js.map +1 -1
  31. package/lib/module/modules/home/presentation/pages/home_page.js +41 -2
  32. package/lib/module/modules/home/presentation/pages/home_page.js.map +1 -1
  33. package/lib/module/modules/home/presentation/pages/home_tab_compte_more_menu.js +10 -5
  34. package/lib/module/modules/home/presentation/pages/home_tab_compte_more_menu.js.map +1 -1
  35. package/lib/module/modules/notification/presentation/bloc/notification_bloc.js +11 -4
  36. package/lib/module/modules/notification/presentation/bloc/notification_bloc.js.map +1 -1
  37. package/lib/module/modules/notification/presentation/bloc/notification_state.js +7 -1
  38. package/lib/module/modules/notification/presentation/bloc/notification_state.js.map +1 -1
  39. package/lib/module/modules/notification/presentation/pages/notification_page_liste.js +6 -2
  40. package/lib/module/modules/notification/presentation/pages/notification_page_liste.js.map +1 -1
  41. package/lib/module/modules/profile/presentation/pages/parametre/profile_parametre_page_notifications.js +2 -2
  42. package/lib/module/modules/profile/presentation/pages/parametre/profile_parametre_page_notifications.js.map +1 -1
  43. package/lib/module/modules/profile/presentation/pages/web/profile_web_page.js.map +1 -1
  44. package/lib/module/modules/qrcode/presentation/pages/qrcode_alias_page.js.map +1 -1
  45. package/lib/module/modules/qrcode/presentation/pages/qrcode_alias_page_share.js +2 -2
  46. package/lib/module/modules/qrcode/presentation/pages/qrcode_alias_page_share.js.map +1 -1
  47. package/lib/module/modules/search/domain/models/fonctionnalite.js +199 -0
  48. package/lib/module/modules/search/domain/models/fonctionnalite.js.map +1 -0
  49. package/lib/module/modules/search/presentation/pages/search_fonctionnalites.js +119 -0
  50. package/lib/module/modules/search/presentation/pages/search_fonctionnalites.js.map +1 -0
  51. package/lib/module/modules/search/presentation/pages/search_page.js +359 -0
  52. package/lib/module/modules/search/presentation/pages/search_page.js.map +1 -0
  53. package/lib/module/modules/search/presentation/pages/search_page_voice_button.js +335 -0
  54. package/lib/module/modules/search/presentation/pages/search_page_voice_button.js.map +1 -0
  55. package/lib/module/modules/subscription/presentation/pages/subscription_menu_widget.js +9 -4
  56. package/lib/module/modules/subscription/presentation/pages/subscription_menu_widget.js.map +1 -1
  57. package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.js +13 -1
  58. package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.js.map +1 -1
  59. package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.js +7 -1
  60. package/lib/module/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.js.map +1 -1
  61. package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.js +216 -137
  62. package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.js.map +1 -1
  63. package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.js +6 -2
  64. package/lib/module/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.js.map +1 -1
  65. package/lib/module/modules/transactions/presentation/pages/transaction_send/transaction_send_page_options.js +3 -3
  66. package/lib/module/modules/transactions/presentation/pages/transaction_send/transaction_send_page_options.js.map +1 -1
  67. package/lib/module/shared/widgets/amount_widget.js +4 -1
  68. package/lib/module/shared/widgets/amount_widget.js.map +1 -1
  69. package/lib/module/shared/widgets/input_amount.js.map +1 -1
  70. package/lib/module/shared/widgets/input_text.js +2 -0
  71. package/lib/module/shared/widgets/input_text.js.map +1 -1
  72. package/lib/module/shared/widgets/menu_actions_widget.js +10 -1
  73. package/lib/module/shared/widgets/menu_actions_widget.js.map +1 -1
  74. package/lib/module/shared/widgets/pagination_loader_widget.js +29 -0
  75. package/lib/module/shared/widgets/pagination_loader_widget.js.map +1 -0
  76. package/lib/typescript/src/core/assets.d.ts +1 -0
  77. package/lib/typescript/src/core/assets.d.ts.map +1 -1
  78. package/lib/typescript/src/core/router.d.ts +1 -0
  79. package/lib/typescript/src/core/router.d.ts.map +1 -1
  80. package/lib/typescript/src/core/routes.d.ts.map +1 -1
  81. package/lib/typescript/src/core/storage.d.ts +11 -3
  82. package/lib/typescript/src/core/storage.d.ts.map +1 -1
  83. package/lib/typescript/src/l10n/app_localizations.d.ts +40 -0
  84. package/lib/typescript/src/l10n/app_localizations.d.ts.map +1 -1
  85. package/lib/typescript/src/l10n/app_localizations_en.d.ts.map +1 -1
  86. package/lib/typescript/src/l10n/app_localizations_fr.d.ts.map +1 -1
  87. package/lib/typescript/src/l10n/app_localizations_pt.d.ts.map +1 -1
  88. package/lib/typescript/src/modules/categorie/presentation/pages/categorie_select_widget.d.ts.map +1 -1
  89. package/lib/typescript/src/modules/compte/presentation/pages/solde_widget_with_qr.d.ts.map +1 -1
  90. package/lib/typescript/src/modules/home/presentation/pages/home_page.d.ts.map +1 -1
  91. package/lib/typescript/src/modules/home/presentation/pages/home_tab_compte_more_menu.d.ts.map +1 -1
  92. package/lib/typescript/src/modules/notification/presentation/bloc/notification_bloc.d.ts.map +1 -1
  93. package/lib/typescript/src/modules/notification/presentation/bloc/notification_state.d.ts +5 -0
  94. package/lib/typescript/src/modules/notification/presentation/bloc/notification_state.d.ts.map +1 -1
  95. package/lib/typescript/src/modules/notification/presentation/pages/notification_page_liste.d.ts.map +1 -1
  96. package/lib/typescript/src/modules/profile/presentation/pages/web/profile_web_page.d.ts.map +1 -1
  97. package/lib/typescript/src/modules/search/domain/models/fonctionnalite.d.ts +70 -0
  98. package/lib/typescript/src/modules/search/domain/models/fonctionnalite.d.ts.map +1 -0
  99. package/lib/typescript/src/modules/search/presentation/pages/search_fonctionnalites.d.ts +21 -0
  100. package/lib/typescript/src/modules/search/presentation/pages/search_fonctionnalites.d.ts.map +1 -0
  101. package/lib/typescript/src/modules/search/presentation/pages/search_page.d.ts +21 -0
  102. package/lib/typescript/src/modules/search/presentation/pages/search_page.d.ts.map +1 -0
  103. package/lib/typescript/src/modules/search/presentation/pages/search_page_voice_button.d.ts +29 -0
  104. package/lib/typescript/src/modules/search/presentation/pages/search_page_voice_button.d.ts.map +1 -0
  105. package/lib/typescript/src/modules/subscription/presentation/pages/subscription_menu_widget.d.ts.map +1 -1
  106. package/lib/typescript/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.d.ts.map +1 -1
  107. package/lib/typescript/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.d.ts +5 -0
  108. package/lib/typescript/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.d.ts.map +1 -1
  109. package/lib/typescript/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.d.ts +5 -1
  110. package/lib/typescript/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.d.ts.map +1 -1
  111. package/lib/typescript/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.d.ts.map +1 -1
  112. package/lib/typescript/src/shared/widgets/amount_widget.d.ts +3 -1
  113. package/lib/typescript/src/shared/widgets/amount_widget.d.ts.map +1 -1
  114. package/lib/typescript/src/shared/widgets/input_text.d.ts +3 -3
  115. package/lib/typescript/src/shared/widgets/input_text.d.ts.map +1 -1
  116. package/lib/typescript/src/shared/widgets/menu_actions_widget.d.ts +5 -1
  117. package/lib/typescript/src/shared/widgets/menu_actions_widget.d.ts.map +1 -1
  118. package/lib/typescript/src/shared/widgets/pagination_loader_widget.d.ts +7 -0
  119. package/lib/typescript/src/shared/widgets/pagination_loader_widget.d.ts.map +1 -0
  120. package/package.json +49 -35
  121. package/src/core/assets.ts +1 -0
  122. package/src/core/router.tsx +3 -0
  123. package/src/core/routes.tsx +15 -0
  124. package/src/core/storage.ts +30 -22
  125. package/src/l10n/app_localizations.ts +40 -0
  126. package/src/l10n/app_localizations_en.ts +20 -0
  127. package/src/l10n/app_localizations_fr.ts +20 -0
  128. package/src/l10n/app_localizations_pt.ts +20 -0
  129. package/src/modules/alias/presentation/pages/alias_page_create_options.tsx +3 -3
  130. package/src/modules/alias/presentation/pages/alias_page_otp.tsx +2 -2
  131. package/src/modules/categorie/presentation/pages/categorie_edit_icon_btn.tsx +2 -2
  132. package/src/modules/categorie/presentation/pages/categorie_select_widget.tsx +13 -9
  133. package/src/modules/compte/presentation/pages/solde_widget_with_qr.tsx +10 -3
  134. package/src/modules/home/presentation/pages/home_page.tsx +31 -2
  135. package/src/modules/home/presentation/pages/home_tab_compte_more_menu.tsx +10 -5
  136. package/src/modules/notification/presentation/bloc/notification_bloc.ts +12 -3
  137. package/src/modules/notification/presentation/bloc/notification_state.ts +11 -1
  138. package/src/modules/notification/presentation/pages/notification_page_liste.tsx +4 -1
  139. package/src/modules/profile/presentation/pages/parametre/profile_parametre_page_notifications.tsx +2 -2
  140. package/src/modules/profile/presentation/pages/web/profile_web_page.tsx +3 -2
  141. package/src/modules/qrcode/presentation/pages/qrcode_alias_page.tsx +2 -2
  142. package/src/modules/qrcode/presentation/pages/qrcode_alias_page_share.tsx +2 -2
  143. package/src/modules/search/domain/models/fonctionnalite.ts +217 -0
  144. package/src/modules/search/presentation/pages/search_fonctionnalites.ts +135 -0
  145. package/src/modules/search/presentation/pages/search_page.tsx +314 -0
  146. package/src/modules/search/presentation/pages/search_page_voice_button.tsx +283 -0
  147. package/src/modules/subscription/presentation/pages/subscription_menu_widget.tsx +9 -4
  148. package/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_bloc.ts +15 -1
  149. package/src/modules/transactions/presentation/bloc/transaction_search/transaction_search_state.ts +10 -1
  150. package/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_filters.tsx +204 -101
  151. package/src/modules/transactions/presentation/pages/transaction_search/transaction_search_page_liste.tsx +4 -1
  152. package/src/modules/transactions/presentation/pages/transaction_send/transaction_send_page_options.tsx +3 -3
  153. package/src/shared/widgets/amount_widget.tsx +5 -2
  154. package/src/shared/widgets/input_amount.tsx +2 -2
  155. package/src/shared/widgets/input_text.tsx +8 -3
  156. package/src/shared/widgets/menu_actions_widget.tsx +8 -2
  157. package/src/shared/widgets/pagination_loader_widget.tsx +24 -0
@@ -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
+ });
@@ -0,0 +1,283 @@
1
+ import { MaterialIcons } from '@expo/vector-icons';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { AccessibilityInfo, Animated, Easing, Platform, Pressable, StyleSheet, Vibration } from 'react-native';
4
+
5
+ import { useTheme } from '../../../../core/theme';
6
+ import type { ValueNotifier } from '../../../../core/value_notifier';
7
+
8
+ /** Zone tactile, plus grande que le disque pour laisser place aux ondes. */
9
+ const TAILLE = 40;
10
+
11
+ /** Diamètre du disque une fois actif. */
12
+ const DISQUE = 30;
13
+
14
+ /** Taille du disque au repos, par rapport à sa taille active. */
15
+ const ECHELLE_REPOS = 0.85;
16
+
17
+ /** Durée d'un cycle d'onde pendant l'écoute. */
18
+ const CYCLE_ONDES = 1600;
19
+
20
+ /** Nombre d'ondes, décalées d'un tiers de cycle. */
21
+ const NOMBRE_ONDES = 3;
22
+
23
+ /**
24
+ * Bouton micro de la recherche.
25
+ *
26
+ * Trois états visuels :
27
+ * - repos : micro coloré sur un disque teinté, pour se distinguer des
28
+ * autres icônes du champ. À l'ouverture, deux pulsations douces
29
+ * signalent qu'il est utilisable ;
30
+ * - actif ([ecoute]) : disque plein qui grossit avec un léger rebond,
31
+ * et trois ondes qui en partent en continu ;
32
+ * - appui : le bouton s'enfonce légèrement, avec une vibration (Android).
33
+ *
34
+ * Les ondes et l'invitation sont coupées si le téléphone demande de
35
+ * réduire les animations (accessibilité) ; seul le changement d'état
36
+ * du disque reste visible.
37
+ */
38
+ export function SearchPageVoiceButton({
39
+ ecoute,
40
+ niveau,
41
+ onPress,
42
+ tooltip,
43
+ }: {
44
+ /** La voix est active (voix off ou écoute) : le bouton est « allumé ». */
45
+ ecoute: boolean;
46
+ /**
47
+ * Volume de la voix de 0 (silence) à 1 (voix forte) : plus il est
48
+ * haut, plus le disque grossit et plus les ondes s'éloignent.
49
+ */
50
+ niveau: ValueNotifier<number>;
51
+ onPress: () => void;
52
+ /** Libellé du lecteur d'écran. */
53
+ tooltip?: string;
54
+ }) {
55
+ const theme = useTheme();
56
+ const couleur = theme.colorScheme.primary;
57
+ const surCouleur = theme.colorScheme.onPrimary;
58
+ const animationsReduites = useAnimationsReduites();
59
+
60
+ /** Passage repos → actif (0 → 1). */
61
+ const entree = useRef(new Animated.Value(ecoute ? 1 : 0)).current;
62
+ /** Enfoncement au toucher. */
63
+ const appui = useRef(new Animated.Value(0)).current;
64
+ /** Une pulsation d'invitation au repos, de 0 à 1. */
65
+ const invitation = useRef(new Animated.Value(0)).current;
66
+ /** [niveau] adouci, pour que le disque ne tremble pas à chaque mesure du micro. */
67
+ const niveauLisse = useRef(new Animated.Value(0)).current;
68
+ /** Avancement de chaque onde d'écoute, de 0 à 1. */
69
+ const ondes = useRef(Array.from({ length: NOMBRE_ONDES }, () => new Animated.Value(0))).current;
70
+
71
+ // Allume ou éteint le bouton : rebond à l'arrivée, retour plus rapide.
72
+ useEffect(() => {
73
+ if (ecoute) invitation.setValue(0);
74
+ const animation = ecoute
75
+ ? Animated.spring(entree, { toValue: 1, friction: 5, tension: 120, useNativeDriver: true })
76
+ : Animated.timing(entree, { toValue: 0, duration: 240, easing: Easing.in(Easing.cubic), useNativeDriver: true });
77
+ animation.start();
78
+ return () => animation.stop();
79
+ }, [ecoute, entree, invitation]);
80
+
81
+ // Ondes en boucle pendant l'écoute, décalées d'un tiers de cycle.
82
+ useEffect(() => {
83
+ if (!ecoute || animationsReduites) return;
84
+ const boucles = ondes.map((onde, i) => {
85
+ onde.setValue(0);
86
+ return Animated.sequence([
87
+ Animated.delay((CYCLE_ONDES / NOMBRE_ONDES) * i),
88
+ Animated.loop(
89
+ Animated.timing(onde, { toValue: 1, duration: CYCLE_ONDES, easing: Easing.linear, useNativeDriver: true }),
90
+ ),
91
+ ]);
92
+ });
93
+ boucles.forEach((boucle) => boucle.start());
94
+ return () => boucles.forEach((boucle) => boucle.stop());
95
+ }, [ecoute, animationsReduites, ondes]);
96
+
97
+ // Le volume suit la voix en douceur, et retombe à 0 hors écoute.
98
+ useEffect(() => {
99
+ const suivre = () =>
100
+ Animated.timing(niveauLisse, {
101
+ toValue: ecoute ? Math.min(Math.max(niveau.value, 0), 1) : 0,
102
+ duration: 120,
103
+ useNativeDriver: true,
104
+ }).start();
105
+ suivre();
106
+ return niveau.addListener(suivre);
107
+ }, [ecoute, niveau, niveauLisse]);
108
+
109
+ // Invitation : deux pulsations, une seule fois par ouverture de la page,
110
+ // après l'entrée de la page pour que l'œil la voie.
111
+ const invitationLancee = useRef(false);
112
+ useEffect(() => {
113
+ if (invitationLancee.current || animationsReduites !== false) return;
114
+ invitationLancee.current = true;
115
+ if (ecoute) return;
116
+ const pulsation = () => Animated.timing(invitation, { toValue: 1, duration: 1100, useNativeDriver: true });
117
+ invitation.setValue(0);
118
+ const animation = Animated.sequence([
119
+ Animated.delay(500),
120
+ pulsation(),
121
+ Animated.timing(invitation, { toValue: 0, duration: 0, useNativeDriver: true }),
122
+ pulsation(),
123
+ ]);
124
+ animation.start();
125
+ return () => animation.stop();
126
+ }, [animationsReduites, ecoute, invitation]);
127
+
128
+ const toucher = () => {
129
+ // Sur iOS, `Vibration` ne sait faire qu'une longue vibration : pas de retour haptique.
130
+ if (Platform.OS === 'android') Vibration.vibrate(10);
131
+ onPress();
132
+ };
133
+
134
+ const presence = entree.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' });
135
+ // 0 → 1 → 0 sur une pulsation d'invitation.
136
+ const souffle = invitation.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0, 1, 0] });
137
+ const echelle = Animated.multiply(
138
+ Animated.multiply(
139
+ entree.interpolate({ inputRange: [0, 1, 1.2], outputRange: [ECHELLE_REPOS, 1, 1.03], extrapolate: 'clamp' }),
140
+ niveauLisse.interpolate({ inputRange: [0, 1], outputRange: [1, 1.16] }),
141
+ ),
142
+ souffle.interpolate({ inputRange: [0, 1], outputRange: [1, 1.1] }),
143
+ );
144
+ const enfoncement = appui.interpolate({ inputRange: [0, 1], outputRange: [1, 0.86] });
145
+ const rayonBase = DISQUE / 2;
146
+ const rayonRepos = (DISQUE / 2) * ECHELLE_REPOS;
147
+
148
+ return (
149
+ <Pressable
150
+ accessibilityRole="button"
151
+ accessibilityLabel={tooltip}
152
+ accessibilityState={{ selected: ecoute }}
153
+ onPressIn={() => Animated.timing(appui, { toValue: 1, duration: 90, useNativeDriver: true }).start()}
154
+ onPressOut={() => Animated.timing(appui, { toValue: 0, duration: 160, useNativeDriver: true }).start()}
155
+ onPress={toucher}
156
+ style={styles.zone}
157
+ >
158
+ {!animationsReduites && (
159
+ <>
160
+ {/* Onde d'invitation, au repos */}
161
+ <Animated.View
162
+ pointerEvents="none"
163
+ style={[
164
+ styles.onde,
165
+ {
166
+ width: rayonRepos * 2,
167
+ height: rayonRepos * 2,
168
+ borderRadius: rayonRepos,
169
+ backgroundColor: couleur,
170
+ opacity: Animated.multiply(
171
+ invitation.interpolate({ inputRange: [0, 0.01, 0.5, 1], outputRange: [0, 0.4, 0.1, 0] }),
172
+ presence.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }),
173
+ ),
174
+ transform: [
175
+ {
176
+ scale: easeOutQuad(invitation).interpolate({
177
+ inputRange: [0, 1],
178
+ outputRange: [1, (rayonRepos + 10) / rayonRepos],
179
+ }),
180
+ },
181
+ ],
182
+ },
183
+ ]}
184
+ />
185
+ {/* Ondes d'écoute : plus amples et plus visibles quand la voix est forte */}
186
+ {ondes.map((onde, i) => (
187
+ <Animated.View
188
+ key={i}
189
+ pointerEvents="none"
190
+ style={[
191
+ styles.onde,
192
+ {
193
+ width: DISQUE,
194
+ height: DISQUE,
195
+ borderRadius: rayonBase,
196
+ backgroundColor: couleur,
197
+ opacity: Animated.multiply(
198
+ Animated.multiply(
199
+ onde.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0.5, 0.125, 0] }),
200
+ presence,
201
+ ),
202
+ niveauLisse.interpolate({ inputRange: [0, 1], outputRange: [0.45, 1] }),
203
+ ),
204
+ transform: [
205
+ {
206
+ scale: Animated.add(
207
+ 1,
208
+ Animated.multiply(
209
+ easeOutQuad(onde),
210
+ niveauLisse.interpolate({ inputRange: [0, 1], outputRange: [6 / rayonBase, 16 / rayonBase] }),
211
+ ),
212
+ ),
213
+ },
214
+ ],
215
+ },
216
+ ]}
217
+ />
218
+ ))}
219
+ </>
220
+ )}
221
+ <Animated.View style={[styles.centre, { transform: [{ scale: enfoncement }] }]}>
222
+ {/* Disque : teinté au repos, plein une fois actif */}
223
+ <Animated.View style={[styles.disque, { transform: [{ scale: echelle }] }]}>
224
+ <Animated.View
225
+ style={[
226
+ StyleSheet.absoluteFill,
227
+ styles.rond,
228
+ { backgroundColor: couleur, opacity: souffle.interpolate({ inputRange: [0, 1], outputRange: [0.12, 0.2] }) },
229
+ ]}
230
+ />
231
+ <Animated.View style={[StyleSheet.absoluteFill, styles.rond, { backgroundColor: couleur, opacity: presence }]} />
232
+ </Animated.View>
233
+ {/* Micro : contour au repos, plein pendant l'écoute */}
234
+ <Animated.View style={[styles.icone, { opacity: presence.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }) }]}>
235
+ <MaterialIcons name="mic-none" size={20} color={couleur} />
236
+ </Animated.View>
237
+ <Animated.View style={[styles.icone, { opacity: presence, transform: [{ scale: presence }] }]}>
238
+ <MaterialIcons name="mic" size={20} color={surCouleur} />
239
+ </Animated.View>
240
+ </Animated.View>
241
+ </Pressable>
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Courbe `Easing.out(Easing.quad)` (1 - (1 - t)²) appliquée à une valeur
247
+ * de 0 à 1, décrite point par point : `interpolate` n'accepte pas d'option
248
+ * `easing`, et le pilote natif rejette l'animation (erreur « Interpolation
249
+ * property 'easing' is not supported by native animated module » à chaque
250
+ * image).
251
+ */
252
+ function easeOutQuad(valeur: Animated.Value): Animated.AnimatedInterpolation<number> {
253
+ return valeur.interpolate({
254
+ inputRange: [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1],
255
+ outputRange: [0, 0.234375, 0.4375, 0.609375, 0.75, 0.859375, 0.9375, 0.984375, 1],
256
+ });
257
+ }
258
+
259
+ /** Réglage « réduire les animations » du téléphone ; `undefined` tant qu'il n'est pas lu. */
260
+ function useAnimationsReduites(): boolean | undefined {
261
+ const [reduites, setReduites] = useState<boolean | undefined>(undefined);
262
+ useEffect(() => {
263
+ let actif = true;
264
+ void AccessibilityInfo.isReduceMotionEnabled().then((valeur) => {
265
+ if (actif) setReduites(valeur);
266
+ });
267
+ const abonnement = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduites);
268
+ return () => {
269
+ actif = false;
270
+ abonnement.remove();
271
+ };
272
+ }, []);
273
+ return reduites;
274
+ }
275
+
276
+ const styles = StyleSheet.create({
277
+ zone: { width: TAILLE, height: TAILLE, alignItems: 'center', justifyContent: 'center' },
278
+ onde: { position: 'absolute' },
279
+ centre: { width: DISQUE, height: DISQUE, alignItems: 'center', justifyContent: 'center' },
280
+ disque: { position: 'absolute', width: DISQUE, height: DISQUE },
281
+ rond: { borderRadius: DISQUE / 2 },
282
+ icone: { position: 'absolute' },
283
+ });
@@ -6,7 +6,7 @@ import { AppRouter, useAppRouter } from '../../../../core/router';
6
6
  import { useTheme } from '../../../../core/theme';
7
7
  import { useAppLocalizations } from '../../../../l10n/localizations';
8
8
  import { Card } from '../../../../shared/material/card';
9
- import { MenuActionItem, MenuActionsWiget } from '../../../../shared/widgets/menu_actions_widget';
9
+ import { MenuActionItem, MenuActionsWidget } from '../../../../shared/widgets/menu_actions_widget';
10
10
 
11
11
  /** Menu de création : programmer un paiement ou souscrire à un abonnement (feuille du bas) */
12
12
  export function SubscriptionMenuWidget() {
@@ -15,6 +15,11 @@ export function SubscriptionMenuWidget() {
15
15
  const router = useAppRouter();
16
16
  const { height } = useWindowDimensions();
17
17
  const insets = useSafeAreaInsets();
18
+ // Fermer le menu avant d'ouvrir la page : sinon il reste affiché par-dessus
19
+ const open = (location: string) => {
20
+ AppRouter.pop(router);
21
+ void AppRouter.push(router, location);
22
+ };
18
23
  return (
19
24
  <View style={[styles.box, { maxHeight: height * 0.4, backgroundColor: theme.colorScheme.surface }]}>
20
25
  <ScrollView contentContainerStyle={[styles.content, { paddingBottom: 20 + insets.bottom }]}>
@@ -22,14 +27,14 @@ export function SubscriptionMenuWidget() {
22
27
  <Text style={theme.textTheme.headlineMedium}>{traductions.subscriptionEmptyBtnCreate}</Text>
23
28
  <View style={styles.gap20} />
24
29
  <Card>
25
- <MenuActionsWiget
30
+ <MenuActionsWidget
26
31
  items={[
27
32
  // Programmer
28
33
  new MenuActionItem(
29
34
  Images.homeFooterTransfer,
30
35
  traductions.subscriptionMenuScheduleTitle,
31
36
  traductions.subscriptionMenuScheduleSubtitle,
32
- () => void AppRouter.push(router, AppRouter.subscriptionSchedule),
37
+ () => open(AppRouter.subscriptionSchedule),
33
38
  { iconSize: 20 },
34
39
  ),
35
40
  // Souscrire à un abonnement
@@ -37,7 +42,7 @@ export function SubscriptionMenuWidget() {
37
42
  Images.iconRepeat,
38
43
  traductions.subscriptionMenuSubscribeTitle,
39
44
  traductions.subscriptionMenuSubscribeSubtitle,
40
- () => void AppRouter.push(router, AppRouter.subscriptionSubscribe),
45
+ () => open(AppRouter.subscriptionSubscribe),
41
46
  { iconSize: 20 },
42
47
  ),
43
48
  ]}
@@ -52,8 +52,13 @@ export class TransactionSearchBloc extends Bloc<TransactionSearchEvent, Transact
52
52
  /** Quand on demande la liste des transactions */
53
53
  private async onList(event: TransactionSearchListEvent, emit: Emitter<TransactionSearchState>): Promise<void> {
54
54
  emit(new TransactionSearchLoadingState(this.state.transactions, event.command));
55
+
56
+ // Repartir de la première page
57
+ const command = event.command;
58
+ command.index = null;
59
+ command.total = 0;
60
+
55
61
  try {
56
- const command = event.command;
57
62
  // Rechercher d'abord en local et afficher
58
63
  const donneesLocales = await this.transactionsInputPort.list(this.params(command));
59
64
  if (command.filters.categories.length > 0) {
@@ -76,16 +81,25 @@ export class TransactionSearchBloc extends Bloc<TransactionSearchEvent, Transact
76
81
 
77
82
  /** Pagination */
78
83
  private async onPaginate(event: TransactionSearchPaginateEvent, emit: Emitter<TransactionSearchState>): Promise<void> {
84
+ // Une page est déjà en cours de chargement
85
+ const current = this.state;
86
+ if (!(current instanceof TransactionSearchListState) || current.isPaginating) return;
87
+
79
88
  const command = event.command;
89
+ // Affiche le loader en bas de la liste
90
+ emit(new TransactionSearchListState(current.transactions, command, true));
80
91
  try {
81
92
  const liste = await this.transactionsInputPort.search(this.params(command, command.index));
82
93
  command.total = liste.meta.total;
83
94
  command.index = liste.meta.next as number | string | null | undefined;
84
95
  if (liste.isNotEmpty) {
85
96
  emit(new TransactionSearchListState(new TransactionListe([...this.state.transactions.data, ...liste.data], liste.meta), command));
97
+ return;
86
98
  }
87
99
  } catch (e) {
88
100
  this.logger.e('Recuperer liste transaction pagination error =>', { error: e });
89
101
  }
102
+ // Page vide ou erreur : on retire le loader
103
+ emit(new TransactionSearchListState(this.state.transactions, command));
90
104
  }
91
105
  }