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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user