From e665dea82a557b20dd128944214aede2cd28865b Mon Sep 17 00:00:00 2001 From: Van Leemput Dayron Date: Tue, 13 Jan 2026 17:36:51 +0100 Subject: [PATCH] feat(activities): add autocomplete & what's new popup Features: - Add autocomplete support for Activity search with Google Places API. - Add "What's New" popup system to showcase new features on app update. - Implement logic to detect fresh installs vs updates. Fixes: - Switch API key handling to use Firebase config for Release mode support. - Refactor map pins to be consistent (red pins). - UI fixes on Create Trip page (overflow issues). Refactor: - Make WhatsNewDialog reusable by accepting features list as parameter. --- .../activities/activities_page.dart | 233 +++++++++++++++--- lib/components/whats_new_dialog.dart | 140 +++++++++++ lib/pages/home.dart | 41 +++ lib/services/activity_places_service.dart | 68 +++++ lib/services/whats_new_service.dart | 68 +++++ pubspec.yaml | 2 +- 6 files changed, 522 insertions(+), 30 deletions(-) create mode 100644 lib/components/whats_new_dialog.dart create mode 100644 lib/services/whats_new_service.dart diff --git a/lib/components/activities/activities_page.dart b/lib/components/activities/activities_page.dart index 9afe665..3ba2f4b 100644 --- a/lib/components/activities/activities_page.dart +++ b/lib/components/activities/activities_page.dart @@ -7,6 +7,8 @@ import '../../blocs/activity/activity_state.dart'; import '../../models/trip.dart'; import '../../models/activity.dart'; import '../../services/activity_cache_service.dart'; +import '../../services/activity_places_service.dart'; +import 'dart:async'; import '../loading/laoding_content.dart'; import '../../blocs/user/user_bloc.dart'; @@ -39,6 +41,14 @@ class _ActivitiesPageState extends State List _approvedActivities = []; bool _isLoadingTripActivities = false; + // Autocomplete variables + List> _suggestions = []; + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + final FocusNode _searchFocusNode = FocusNode(); + Timer? _debounceTimer; + bool _isLoadingSuggestions = false; + bool _autoReloadInProgress = false; // Protection contre les rechargements en boucle int _lastAutoReloadTriggerCount = @@ -105,6 +115,9 @@ class _ActivitiesPageState extends State void dispose() { _tabController.dispose(); _searchController.dispose(); + _searchFocusNode.dispose(); + _debounceTimer?.cancel(); + _hideSuggestions(); super.dispose(); } @@ -278,42 +291,204 @@ class _ActivitiesPageState extends State } Widget _buildSearchBar(ThemeData theme) { - return Container( - padding: const EdgeInsets.all(16), + return CompositedTransformTarget( + link: _layerLink, child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), - borderRadius: BorderRadius.circular(12), - ), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher restaurants, musées...', - hintStyle: TextStyle( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: 'Rechercher restaurants, musées...', + hintStyle: TextStyle( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + prefixIcon: Icon( + Icons.search, + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + suffixIcon: _isLoadingSuggestions + ? const SizedBox( + width: 20, + height: 20, + child: Padding( + padding: EdgeInsets.all(10.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : null, + ), + onChanged: _onSearchChanged, + onSubmitted: (value) { + _hideSuggestions(); + if (value.isNotEmpty) { + _performSearch(value); + } + }, + ), ), - prefixIcon: Icon( - Icons.search, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - onSubmitted: (value) { - if (value.isNotEmpty) { - _performSearch(value); - } - }, + ], ), ), ); } + void _onSearchChanged(String value) { + if (_debounceTimer?.isActive ?? false) _debounceTimer!.cancel(); + + if (value.isEmpty) { + _hideSuggestions(); + return; + } + + _debounceTimer = Timer(const Duration(milliseconds: 300), () async { + setState(() { + _isLoadingSuggestions = true; + }); + + try { + final suggestions = await ActivityPlacesService().fetchSuggestions( + query: value, + lat: widget.trip.latitude, + lng: widget.trip.longitude, + ); + + if (mounted) { + setState(() { + _suggestions = suggestions; + _isLoadingSuggestions = false; + }); + + if (_suggestions.isNotEmpty) { + _showSuggestions(); + } else { + _hideSuggestions(); + } + } + } catch (e) { + if (mounted) { + setState(() { + _isLoadingSuggestions = false; + }); + } + } + }); + } + + void _showSuggestions() { + _overlayEntry?.remove(); + _overlayEntry = _createOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + } + + void _hideSuggestions() { + _overlayEntry?.remove(); + _overlayEntry = null; + } + + OverlayEntry _createOverlayEntry() { + final renderBox = context.findRenderObject() as RenderBox; + final theme = Theme.of(context); + final size = renderBox.size; + final width = size.width - 32; // padding 16 * 2 + + return OverlayEntry( + builder: (context) => Positioned( + width: width, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: const Offset(16, 80), // Adjust vertical offset + child: Material( + elevation: 4, + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.surface, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 250), + child: ListView.separated( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: _suggestions.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final suggestion = _suggestions[index]; + return ListTile( + leading: const Icon(Icons.location_on_outlined), + title: Text( + suggestion['description'] ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + _selectSuggestion(suggestion); + }, + ); + }, + ), + ), + ), + ), + ), + ); + } + + Future _selectSuggestion(Map suggestion) async { + _hideSuggestions(); + _searchController.text = suggestion['description']!; + _searchFocusNode.unfocus(); + + // Passer à l'onglet "Suggestion" si ce n'est pas déjà fait + if (_tabController.index != 2) { + _tabController.animateTo(2); + } + + // Charger l'activité spécifique via le Bloc ou Service + // Ici on va utiliser le Bloc pour ajouter l'activité aux résultats de recherche + // Pour ça, il faudrait idéalement un événement "LoadSingleActivity" dans le Bloc + // Mais pour faire simple et rapide, on peut faire une recherche "exacte" ou hack: + // On charge l'activité manuellement et on l'ajoute comme si c'était un résultat de recherche. + + setState(() { + _isLoadingSuggestions = true; + }); + + try { + final activity = await ActivityPlacesService().getActivityByPlaceId( + placeId: suggestion['placeId']!, + tripId: widget.trip.id!, + ); + + if (mounted && activity != null) { + // Injecter ce résultat unique dans le Bloc + context.read().add( + RestoreCachedSearchResults(searchResults: [activity]), + ); + } + } catch (e) { + ErrorService().showError(message: "Impossible de charger l'activité"); + } finally { + if (mounted) { + setState(() { + _isLoadingSuggestions = false; + }); + } + } + } + Widget _buildCategoryTabs(ThemeData theme) { return Container( padding: const EdgeInsets.all(16), diff --git a/lib/components/whats_new_dialog.dart b/lib/components/whats_new_dialog.dart new file mode 100644 index 0000000..7650167 --- /dev/null +++ b/lib/components/whats_new_dialog.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import '../../services/whats_new_service.dart'; + +class WhatsNewDialog extends StatelessWidget { + final VoidCallback onDismiss; + final List features; + + const WhatsNewDialog({ + super.key, + required this.onDismiss, + required this.features, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + backgroundColor: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon( + Icons.auto_awesome, + color: theme.colorScheme.primary, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + 'Quoi de neuf ?', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Features List + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: features.length, + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final feature = features[index]; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + feature.icon, + color: theme.colorScheme.primary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + feature.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + feature.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + + const SizedBox(height: 32), + + // Button + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () { + // Marquer comme vu via le service + WhatsNewService().markCurrentVersionAsSeen(); + onDismiss(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'C\'est parti !', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ); + } +} + +class WhatsNewItem { + final IconData icon; + final String title; + final String description; + + const WhatsNewItem({ + required this.icon, + required this.title, + required this.description, + }); +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 9592c27..0d5d71b 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -14,6 +14,8 @@ import '../blocs/auth/auth_event.dart'; import '../services/error_service.dart'; import '../services/notification_service.dart'; import '../services/map_navigation_service.dart'; +import '../services/whats_new_service.dart'; +import '../components/whats_new_dialog.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -57,6 +59,45 @@ class _HomePageState extends State { }); } }); + + // Vérifier les nouveautés + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _checkAndShowWhatsNew(); + }); + } + + Future _checkAndShowWhatsNew() async { + final service = WhatsNewService(); + if (await service.shouldShowWhatsNew()) { + if (!mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => WhatsNewDialog( + onDismiss: () => Navigator.pop(context), + features: const [ + WhatsNewItem( + icon: Icons.map_outlined, + title: 'Recherche globale', + description: + 'Recherchez des restaurants, musées et plus encore directement depuis la carte.', + ), + WhatsNewItem( + icon: Icons.search, + title: 'Autocomplétion améliorée', + description: + 'Découvrez des suggestions intelligentes lors de la recherche de lieux et d\'activités.', + ), + WhatsNewItem( + icon: Icons.warning_amber_rounded, + title: 'Alertes de distance', + description: + 'Soyez averti si une activité est trop éloignée de votre lieu de séjour.', + ), + ], + ), + ); + } } Widget _buildPage(int index) { diff --git a/lib/services/activity_places_service.dart b/lib/services/activity_places_service.dart index 28435bf..a2d713d 100644 --- a/lib/services/activity_places_service.dart +++ b/lib/services/activity_places_service.dart @@ -654,4 +654,72 @@ class ActivityPlacesService { throw Exception('Erreur HTTP ${response.statusCode}'); } } + + /// Récupère des suggestions d'autocomplétion + Future>> fetchSuggestions({ + required String query, + double? lat, + double? lng, + }) async { + if (query.isEmpty) return []; + + try { + String url = + 'https://maps.googleapis.com/maps/api/place/autocomplete/json' + '?input=${Uri.encodeComponent(query)}' + '&key=$_apiKey' + '&language=fr'; + + if (lat != null && lng != null) { + url += '&location=$lat,$lng&radius=50000'; // 50km bias + } + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['status'] == 'OK') { + return (data['predictions'] as List).map>((p) { + return { + 'description': p['description'] as String, + 'placeId': p['place_id'] as String, + }; + }).toList(); + } + } + return []; + } catch (e) { + LoggerService.error('ActivityPlacesService: Erreur autocomplete: $e'); + return []; + } + } + + /// Récupère une activité via son Place ID + Future getActivityByPlaceId({ + required String placeId, + required String tripId, + }) async { + try { + final details = await _getPlaceDetails(placeId); + if (details == null) return null; + + // Créer une map simulant la structure "place" attendue par _convertPlaceToActivity + // Note: _getPlaceDetails retourne "result", qui est déjà ce qu'on veut, + // mais _convertPlaceToActivity attend le format "search result" qui a geometry au premier niveau. + // Heureusement _getPlaceDetails retourne une structure compatible pour geometry/photos etc. + + // On doit s'assurer d'avoir les types pour déterminer la catégorie + final types = List.from(details['types'] ?? []); + final category = _determineCategoryFromTypes(types); + + return await _convertPlaceToActivity( + details, // details a la structure nécessaire (geometry, name, etc) + tripId, + category, + ); + } catch (e) { + LoggerService.error('ActivityPlacesService: Erreur get details: $e'); + return null; + } + } } diff --git a/lib/services/whats_new_service.dart b/lib/services/whats_new_service.dart new file mode 100644 index 0000000..99baa44 --- /dev/null +++ b/lib/services/whats_new_service.dart @@ -0,0 +1,68 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../services/logger_service.dart'; + +class WhatsNewService { + static const String _lastVersionKey = 'last_known_version'; + + /// Vérifie si le popup "Nouveautés" doit être affiché. + /// + /// Retourne true si: + /// - Ce n'est PAS une nouvelle installation + /// - ET la version actuelle est plus récente que la version stockée + Future shouldShowWhatsNew() async { + try { + final prefs = await SharedPreferences.getInstance(); + final packageInfo = await PackageInfo.fromPlatform(); + + final currentVersion = packageInfo.version; + final lastVersion = prefs.getString(_lastVersionKey); + + LoggerService.info( + 'WhatsNewService: Current=$currentVersion, Last=$lastVersion', + ); + + // Cas 1: Première installation (lastVersion est null) + if (lastVersion == null) { + // On sauvegarde la version actuelle pour ne pas afficher le popup + // la prochaine fois, et on retourne false maintenant. + await prefs.setString(_lastVersionKey, currentVersion); + LoggerService.info( + 'WhatsNewService: Fresh install detected. Marking version $currentVersion as read.', + ); + return false; + } + + // Cas 2: Mise à jour (lastVersion != currentVersion) + if (lastVersion != currentVersion) { + // C'est une mise à jour, on doit afficher le popup. + // On NE met PAS à jour la version ici, on attend que l'utilisateur ait vu le popup. + LoggerService.info( + 'WhatsNewService: Update detected ($lastVersion -> $currentVersion). Showing popup.', + ); + return true; + } + + // Cas 3: Même version + return false; + } catch (e) { + LoggerService.error('WhatsNewService: Error checking version: $e'); + return false; + } + } + + /// Marque la version actuelle comme "Vue". + /// À appeler quand l'utilisateur ferme le popup. + Future markCurrentVersionAsSeen() async { + try { + final prefs = await SharedPreferences.getInstance(); + final packageInfo = await PackageInfo.fromPlatform(); + await prefs.setString(_lastVersionKey, packageInfo.version); + LoggerService.info( + 'WhatsNewService: Version ${packageInfo.version} marked as seen.', + ); + } catch (e) { + LoggerService.error('WhatsNewService: Error marking seen: $e'); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9c7ce0a..f7885c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.1.2+1 +version: 2026.1.3+1 environment: sdk: ^3.9.2