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.
This commit is contained in:
@@ -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<ActivitiesPage>
|
||||
List<Activity> _approvedActivities = [];
|
||||
bool _isLoadingTripActivities = false;
|
||||
|
||||
// Autocomplete variables
|
||||
List<Map<String, String>> _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<ActivitiesPage>
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
_debounceTimer?.cancel();
|
||||
_hideSuggestions();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -278,42 +291,204 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
}
|
||||
|
||||
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<void> _selectSuggestion(Map<String, String> 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<ActivityBloc>().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),
|
||||
|
||||
140
lib/components/whats_new_dialog.dart
Normal file
140
lib/components/whats_new_dialog.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../services/whats_new_service.dart';
|
||||
|
||||
class WhatsNewDialog extends StatelessWidget {
|
||||
final VoidCallback onDismiss;
|
||||
final List<WhatsNewItem> 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,
|
||||
});
|
||||
}
|
||||
@@ -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<HomePage> {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Vérifier les nouveautés
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
await _checkAndShowWhatsNew();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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) {
|
||||
|
||||
@@ -654,4 +654,72 @@ class ActivityPlacesService {
|
||||
throw Exception('Erreur HTTP ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère des suggestions d'autocomplétion
|
||||
Future<List<Map<String, String>>> 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<Map<String, String>>((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<Activity?> 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<String>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
lib/services/whats_new_service.dart
Normal file
68
lib/services/whats_new_service.dart
Normal file
@@ -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<bool> 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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user