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/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,42 +291,204 @@ 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(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.all(16),
|
||||||
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
child: Column(
|
||||||
alpha: 0.3,
|
children: [
|
||||||
),
|
Container(
|
||||||
borderRadius: BorderRadius.circular(12),
|
decoration: BoxDecoration(
|
||||||
),
|
color: theme.colorScheme.surfaceContainerHighest.withValues(
|
||||||
child: TextField(
|
alpha: 0.3,
|
||||||
controller: _searchController,
|
),
|
||||||
decoration: InputDecoration(
|
borderRadius: BorderRadius.circular(12),
|
||||||
hintText: 'Rechercher restaurants, musées...',
|
),
|
||||||
hintStyle: TextStyle(
|
child: TextField(
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
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) {
|
Widget _buildCategoryTabs(ThemeData theme) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
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/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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user