Compare commits

...

2 Commits

Author SHA1 Message Date
Van Leemput Dayron
e665dea82a feat(activities): add autocomplete & what's new popup
All checks were successful
Deploy TravelMate (Full Mobile) / deploy-android (push) Successful in 2m6s
Deploy TravelMate (Full Mobile) / deploy-ios (push) Successful in 4m3s
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.
2026-01-13 17:36:51 +01:00
Van Leemput Dayron
b511ec5df0 Update map autocompletion
All checks were successful
Deploy TravelMate (Full Mobile) / deploy-android (push) Successful in 2m5s
Deploy TravelMate (Full Mobile) / deploy-ios (push) Successful in 3m53s
2026-01-13 17:15:22 +01:00
9 changed files with 526 additions and 73 deletions

View File

@@ -7,6 +7,8 @@ import '../../blocs/activity/activity_state.dart';
import '../../models/trip.dart'; import '../../models/trip.dart';
import '../../models/activity.dart'; import '../../models/activity.dart';
import '../../services/activity_cache_service.dart'; import '../../services/activity_cache_service.dart';
import '../../services/activity_places_service.dart';
import 'dart:async';
import '../loading/laoding_content.dart'; import '../loading/laoding_content.dart';
import '../../blocs/user/user_bloc.dart'; import '../../blocs/user/user_bloc.dart';
@@ -39,6 +41,14 @@ class _ActivitiesPageState extends State<ActivitiesPage>
List<Activity> _approvedActivities = []; List<Activity> _approvedActivities = [];
bool _isLoadingTripActivities = false; 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 = bool _autoReloadInProgress =
false; // Protection contre les rechargements en boucle false; // Protection contre les rechargements en boucle
int _lastAutoReloadTriggerCount = int _lastAutoReloadTriggerCount =
@@ -105,6 +115,9 @@ class _ActivitiesPageState extends State<ActivitiesPage>
void dispose() { void dispose() {
_tabController.dispose(); _tabController.dispose();
_searchController.dispose(); _searchController.dispose();
_searchFocusNode.dispose();
_debounceTimer?.cancel();
_hideSuggestions();
super.dispose(); super.dispose();
} }
@@ -278,9 +291,13 @@ class _ActivitiesPageState extends State<ActivitiesPage>
} }
Widget _buildSearchBar(ThemeData theme) { Widget _buildSearchBar(ThemeData theme) {
return Container( return CompositedTransformTarget(
padding: const EdgeInsets.all(16), link: _layerLink,
child: Container( child: Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues( color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3, alpha: 0.3,
@@ -289,6 +306,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
), ),
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Rechercher restaurants, musées...', hintText: 'Rechercher restaurants, musées...',
hintStyle: TextStyle( hintStyle: TextStyle(
@@ -303,17 +321,174 @@ class _ActivitiesPageState extends State<ActivitiesPage>
horizontal: 16, horizontal: 16,
vertical: 12, 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) { onSubmitted: (value) {
_hideSuggestions();
if (value.isNotEmpty) { if (value.isNotEmpty) {
_performSearch(value); _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) { Widget _buildCategoryTabs(ThemeData theme) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),

View File

@@ -24,6 +24,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../services/place_image_service.dart'; import '../../services/place_image_service.dart';
import '../../services/trip_geocoding_service.dart'; import '../../services/trip_geocoding_service.dart';
import '../../services/logger_service.dart'; import '../../services/logger_service.dart';
import '../../firebase_options.dart';
/// Create trip content widget for trip creation and editing functionality. /// Create trip content widget for trip creation and editing functionality.
/// ///
@@ -86,16 +87,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
/// Google Maps API key for location services /// Google Maps API key for location services
static String get _apiKey { static String get _apiKey {
if (Platform.isAndroid) { return DefaultFirebaseOptions.currentPlatform.apiKey;
return dotenv.env['GOOGLE_MAPS_API_KEY_ANDROID'] ??
dotenv.env['GOOGLE_MAPS_API_KEY'] ??
'';
} else if (Platform.isIOS) {
return dotenv.env['GOOGLE_MAPS_API_KEY_IOS'] ??
dotenv.env['GOOGLE_MAPS_API_KEY'] ??
'';
}
return dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
} }
/// Participant management /// Participant management

View File

@@ -6,6 +6,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../firebase_options.dart';
import '../../services/error_service.dart'; import '../../services/error_service.dart';
import '../../services/map_navigation_service.dart'; import '../../services/map_navigation_service.dart';
import '../../services/logger_service.dart'; import '../../services/logger_service.dart';
@@ -34,16 +35,7 @@ class _MapContentState extends State<MapContent> {
List<PlaceSuggestion> _suggestions = []; List<PlaceSuggestion> _suggestions = [];
static String get _apiKey { static String get _apiKey {
if (Platform.isAndroid) { return DefaultFirebaseOptions.currentPlatform.apiKey;
return dotenv.env['GOOGLE_MAPS_API_KEY_ANDROID'] ??
dotenv.env['GOOGLE_MAPS_API_KEY'] ??
'';
} else if (Platform.isIOS) {
return dotenv.env['GOOGLE_MAPS_API_KEY_IOS'] ??
dotenv.env['GOOGLE_MAPS_API_KEY'] ??
'';
}
return dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
} }
@override @override

View 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,
});
}

View File

@@ -27,18 +27,6 @@ class DefaultFirebaseOptions {
return android; return android;
case TargetPlatform.iOS: case TargetPlatform.iOS:
return ios; return ios;
case TargetPlatform.macOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for macos - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default: default:
throw UnsupportedError( throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.', 'DefaultFirebaseOptions are not supported for this platform.',
@@ -63,15 +51,4 @@ class DefaultFirebaseOptions {
iosClientId: '521527250907-3i1qe2656eojs8k9hjdi573j09i9p41m.apps.googleusercontent.com', iosClientId: '521527250907-3i1qe2656eojs8k9hjdi573j09i9p41m.apps.googleusercontent.com',
iosBundleId: 'com.example.travelMate', iosBundleId: 'com.example.travelMate',
); );
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyC4t-WOvp22zns9b9t58urznsNAhSHRAag',
appId: '1:521527250907:web:53ff98bcdb8c218f7da1fe',
messagingSenderId: '521527250907',
projectId: 'travelmate-a47f5',
authDomain: 'travelmate-a47f5.firebaseapp.com',
storageBucket: 'travelmate-a47f5.firebasestorage.app',
measurementId: 'G-J246Y7J61M',
);
} }

View File

@@ -14,6 +14,8 @@ import '../blocs/auth/auth_event.dart';
import '../services/error_service.dart'; import '../services/error_service.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../services/map_navigation_service.dart'; import '../services/map_navigation_service.dart';
import '../services/whats_new_service.dart';
import '../components/whats_new_dialog.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); 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) { Widget _buildPage(int index) {

View File

@@ -654,4 +654,72 @@ class ActivityPlacesService {
throw Exception('Erreur HTTP ${response.statusCode}'); 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;
}
}
} }

View 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');
}
}
}

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.9.2 sdk: ^3.9.2