feat: Add services for managing trip-related data
- Implement EmergencyService for handling emergency contacts per trip. - Create GuestFlagService to manage guest mode flags for trips. - Introduce NotificationService with local notification capabilities. - Add OfflineFlagService for managing offline caching flags. - Develop PackingService for shared packing lists per trip. - Implement ReminderService for managing reminders/to-dos per trip. - Create SosService for dispatching SOS events to a backend. - Add StorageService with album image upload functionality. - Implement TransportService for managing transport segments per trip. - Create TripChecklistService for storing and retrieving trip checklists. - Add TripDocumentService for persisting trip documents metadata. test: Add unit tests for new services - Implement tests for AlbumService, BudgetService, EmergencyService, GuestFlagService, PackingService, ReminderService, SosService, TransportService, TripChecklistService, and TripDocumentService. - Ensure tests cover adding, loading, deleting, and handling corrupted payloads for each service.
This commit is contained in:
39
lib/services/activity_suggestion_service.dart
Normal file
39
lib/services/activity_suggestion_service.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Provides lightweight, offline activity suggestions using heuristics.
|
||||
class ActivitySuggestionService {
|
||||
/// Returns a list of suggestion strings based on [city] and [weatherCode].
|
||||
///
|
||||
/// [weatherCode] is a simple tag: `sunny`, `rain`, `cold`, `default`.
|
||||
List<String> suggestions({
|
||||
required String city,
|
||||
String weatherCode = 'default',
|
||||
}) {
|
||||
final base = <String>[
|
||||
'Free walking tour de $city',
|
||||
'Spot photo au coucher du soleil',
|
||||
'Café local pour travailler/charger',
|
||||
'Parc ou rooftop tranquille',
|
||||
];
|
||||
|
||||
if (weatherCode == 'rain') {
|
||||
base.addAll([
|
||||
'Musée immanquable de $city',
|
||||
'Escape game ou activité indoor',
|
||||
'Food court couvert pour goûter local',
|
||||
]);
|
||||
} else if (weatherCode == 'cold') {
|
||||
base.addAll(['Spa / bains chauds', 'Visite guidée en intérieur']);
|
||||
} else {
|
||||
base.addAll([
|
||||
'Balade vélo ou trottinette',
|
||||
'Pique-nique au parc central',
|
||||
'Vue panoramique / rooftop',
|
||||
]);
|
||||
}
|
||||
|
||||
// Shuffle slightly for variation.
|
||||
base.shuffle(Random(city.hashCode));
|
||||
return base.take(6).toList();
|
||||
}
|
||||
}
|
||||
59
lib/services/ai_activity_service.dart
Normal file
59
lib/services/ai_activity_service.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Provides AI-powered activity suggestions using an external endpoint.
|
||||
///
|
||||
/// The endpoint is expected to accept POST JSON:
|
||||
/// { "city": "...", "interests": ["food","art"], "budget": "low|mid|high" }
|
||||
/// and return { "suggestions": [ { "title": "...", "detail": "..." }, ... ] }
|
||||
///
|
||||
/// This class is a thin client and can be wired to a custom backend that
|
||||
/// proxies LLM calls (to avoid shipping secrets in the app).
|
||||
class AiActivityService {
|
||||
/// Base URL of the AI suggestion endpoint.
|
||||
final String baseUrl;
|
||||
|
||||
/// Optional API key if your backend requires it.
|
||||
final String? apiKey;
|
||||
|
||||
/// Creates an AI activity service client.
|
||||
const AiActivityService({required this.baseUrl, this.apiKey});
|
||||
|
||||
/// Fetches suggestions for the given [city] with optional [interests] and [budget].
|
||||
///
|
||||
/// Returns a list of string suggestions. In case of error, returns an empty list
|
||||
/// to keep the UI responsive.
|
||||
Future<List<String>> fetchSuggestions({
|
||||
required String city,
|
||||
List<String> interests = const [],
|
||||
String budget = 'mid',
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/ai/suggestions');
|
||||
try {
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (apiKey != null) 'Authorization': 'Bearer $apiKey',
|
||||
},
|
||||
body: json.encode({
|
||||
'city': city,
|
||||
'interests': interests,
|
||||
'budget': budget,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final list = (data['suggestions'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
return list
|
||||
.map((item) => item['title'] as String? ?? 'Suggestion')
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/services/album_service.dart
Normal file
42
lib/services/album_service.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/album_photo.dart';
|
||||
|
||||
/// Stores shared album photos per trip locally for offline access.
|
||||
class AlbumService {
|
||||
/// Loads photos for the given trip.
|
||||
Future<List<AlbumPhoto>> loadPhotos(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return AlbumPhoto.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves photo list.
|
||||
Future<void> savePhotos(String tripId, List<AlbumPhoto> photos) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), AlbumPhoto.encodeList(photos));
|
||||
}
|
||||
|
||||
/// Adds one photo.
|
||||
Future<List<AlbumPhoto>> addPhoto(String tripId, AlbumPhoto photo) async {
|
||||
final current = await loadPhotos(tripId);
|
||||
final updated = [...current, photo];
|
||||
await savePhotos(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a photo.
|
||||
Future<List<AlbumPhoto>> deletePhoto(String tripId, String photoId) async {
|
||||
final current = await loadPhotos(tripId);
|
||||
final updated = current.where((p) => p.id != photoId).toList();
|
||||
await savePhotos(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'album_$tripId';
|
||||
}
|
||||
72
lib/services/budget_service.dart
Normal file
72
lib/services/budget_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/budget_category.dart';
|
||||
|
||||
/// Service to manage per-trip budget envelopes (multi-devise) locally.
|
||||
///
|
||||
/// Stores envelopes in `SharedPreferences` under `budget_<tripId>` so they
|
||||
/// remain available offline. Integration with expense data can later update
|
||||
/// the [spent] field.
|
||||
class BudgetService {
|
||||
/// Loads budget categories for the trip.
|
||||
Future<List<BudgetCategory>> loadBudgets(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return BudgetCategory.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists full list.
|
||||
Future<void> saveBudgets(
|
||||
String tripId,
|
||||
List<BudgetCategory> categories,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), BudgetCategory.encodeList(categories));
|
||||
}
|
||||
|
||||
/// Adds an envelope.
|
||||
Future<List<BudgetCategory>> addBudget(
|
||||
String tripId,
|
||||
BudgetCategory category,
|
||||
) async {
|
||||
final current = await loadBudgets(tripId);
|
||||
final updated = [...current, category];
|
||||
await saveBudgets(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes an envelope.
|
||||
Future<List<BudgetCategory>> deleteBudget(
|
||||
String tripId,
|
||||
String categoryId,
|
||||
) async {
|
||||
final current = await loadBudgets(tripId);
|
||||
final updated = current.where((c) => c.id != categoryId).toList();
|
||||
await saveBudgets(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Updates spent amount for a category (used later by expense sync).
|
||||
Future<List<BudgetCategory>> updateSpent(
|
||||
String tripId,
|
||||
String categoryId,
|
||||
double spent,
|
||||
) async {
|
||||
final current = await loadBudgets(tripId);
|
||||
final updated = current
|
||||
.map((c) {
|
||||
if (c.id != categoryId) return c;
|
||||
return c.copyWith(spent: spent);
|
||||
})
|
||||
.toList(growable: false);
|
||||
await saveBudgets(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'budget_$tripId';
|
||||
}
|
||||
55
lib/services/emergency_service.dart
Normal file
55
lib/services/emergency_service.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/emergency_contact.dart';
|
||||
|
||||
/// Stores emergency contacts per trip for offline access.
|
||||
///
|
||||
/// Data is persisted locally in `SharedPreferences` under the key
|
||||
/// `emergency_<tripId>`. Corrupted payloads are cleaned up automatically to
|
||||
/// avoid crashing the UI during critical usage.
|
||||
class EmergencyService {
|
||||
/// Loads contacts for [tripId]. Returns an empty list if none or corrupted.
|
||||
Future<List<EmergencyContact>> loadContacts(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return EmergencyContact.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the complete contact list.
|
||||
Future<void> saveContacts(
|
||||
String tripId,
|
||||
List<EmergencyContact> contacts,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), EmergencyContact.encodeList(contacts));
|
||||
}
|
||||
|
||||
/// Adds a contact and returns updated list.
|
||||
Future<List<EmergencyContact>> addContact(
|
||||
String tripId,
|
||||
EmergencyContact contact,
|
||||
) async {
|
||||
final current = await loadContacts(tripId);
|
||||
final updated = [...current, contact];
|
||||
await saveContacts(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a contact.
|
||||
Future<List<EmergencyContact>> deleteContact(
|
||||
String tripId,
|
||||
String contactId,
|
||||
) async {
|
||||
final current = await loadContacts(tripId);
|
||||
final updated = current.where((c) => c.id != contactId).toList();
|
||||
await saveContacts(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'emergency_$tripId';
|
||||
}
|
||||
18
lib/services/guest_flag_service.dart
Normal file
18
lib/services/guest_flag_service.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores a simple read-only guest mode flag per trip.
|
||||
class GuestFlagService {
|
||||
/// Returns whether guest mode is enabled for [tripId].
|
||||
Future<bool> isGuestEnabled(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_key(tripId)) ?? false;
|
||||
}
|
||||
|
||||
/// Sets guest mode flag for [tripId].
|
||||
Future<void> setGuestEnabled(String tripId, bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_key(tripId), enabled);
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'guest_$tripId';
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:travel_mate/components/account/group_expenses_page.dart';
|
||||
import 'package:travel_mate/components/activities/activities_page.dart';
|
||||
import 'package:travel_mate/components/group/chat_group_content.dart';
|
||||
@@ -50,6 +52,8 @@ class NotificationService {
|
||||
if (_isInitialized) return;
|
||||
|
||||
await _requestPermissions();
|
||||
tz.initializeTimeZones();
|
||||
tz.setLocalLocation(tz.getLocation('UTC'));
|
||||
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
@@ -411,4 +415,75 @@ class NotificationService {
|
||||
payload: json.encode(message.data),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a local push notification with a custom [title] and [body].
|
||||
///
|
||||
/// Used for recap/reminder notifications when on-device scheduling is desired.
|
||||
Future<void> showLocalRecap({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'recap_channel',
|
||||
'Recap quotidien',
|
||||
channelDescription: 'Notifications de récap voyage',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedules a local reminder notification at [dueAt] with [title]/[body].
|
||||
Future<void> scheduleReminder({
|
||||
required String id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime dueAt,
|
||||
}) async {
|
||||
try {
|
||||
final int notifId = id.hashCode & 0x7fffffff;
|
||||
final scheduled = tz.TZDateTime.from(dueAt, tz.local);
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'reminder_channel',
|
||||
'Rappels voyage',
|
||||
channelDescription: 'Notifications des rappels/to-dos',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.zonedSchedule(
|
||||
notifId,
|
||||
title,
|
||||
body,
|
||||
scheduled,
|
||||
details,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
} catch (e) {
|
||||
LoggerService.error('Failed to schedule reminder', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a scheduled reminder notification by [id].
|
||||
Future<void> cancelReminder(String id) async {
|
||||
final notifId = id.hashCode & 0x7fffffff;
|
||||
await _localNotifications.cancel(notifId);
|
||||
}
|
||||
}
|
||||
|
||||
18
lib/services/offline_flag_service.dart
Normal file
18
lib/services/offline_flag_service.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores a per-trip offline toggle to trigger background caching later.
|
||||
class OfflineFlagService {
|
||||
/// Returns whether offline caching is enabled for [tripId].
|
||||
Future<bool> isOfflineEnabled(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_key(tripId)) ?? false;
|
||||
}
|
||||
|
||||
/// Persists the offline toggle.
|
||||
Future<void> setOfflineEnabled(String tripId, bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_key(tripId), enabled);
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'offline_trip_$tripId';
|
||||
}
|
||||
78
lib/services/packing_service.dart
Normal file
78
lib/services/packing_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/packing_item.dart';
|
||||
|
||||
/// Service handling shared packing lists per trip.
|
||||
///
|
||||
/// Uses local `SharedPreferences` for fast offline access. The list can later
|
||||
/// be synced remotely without changing the calling code.
|
||||
class PackingService {
|
||||
/// Loads packing items for a trip. Returns empty list if none/corrupted.
|
||||
Future<List<PackingItem>> loadItems(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return PackingItem.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves complete list.
|
||||
Future<void> saveItems(String tripId, List<PackingItem> items) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), PackingItem.encodeList(items));
|
||||
}
|
||||
|
||||
/// Adds one item.
|
||||
Future<List<PackingItem>> addItem(String tripId, PackingItem item) async {
|
||||
final current = await loadItems(tripId);
|
||||
final updated = [...current, item];
|
||||
await saveItems(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Toggles packed flag.
|
||||
Future<List<PackingItem>> toggleItem(String tripId, String itemId) async {
|
||||
final current = await loadItems(tripId);
|
||||
final updated = current
|
||||
.map((i) {
|
||||
if (i.id != itemId) return i;
|
||||
return i.copyWith(isPacked: !i.isPacked);
|
||||
})
|
||||
.toList(growable: false);
|
||||
await saveItems(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes an item.
|
||||
Future<List<PackingItem>> deleteItem(String tripId, String itemId) async {
|
||||
final current = await loadItems(tripId);
|
||||
final updated = current.where((i) => i.id != itemId).toList();
|
||||
await saveItems(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Suggests a starter template based on duration/weather (simplified here).
|
||||
List<String> suggestedItems({required int nights, required bool cold}) {
|
||||
final base = [
|
||||
'Passeport/ID',
|
||||
'Billets / PNR',
|
||||
'Chargeurs et adaptateurs',
|
||||
'Trousse de secours',
|
||||
'Assurance voyage',
|
||||
];
|
||||
if (cold) {
|
||||
base.addAll(['Veste chaude', 'Gants', 'Bonnet', 'Chaussettes chaudes']);
|
||||
} else {
|
||||
base.addAll(['Crème solaire', 'Lunettes de soleil', 'Maillot de bain']);
|
||||
}
|
||||
if (nights > 4) {
|
||||
base.add('Lessive/ziplock');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'packing_$tripId';
|
||||
}
|
||||
64
lib/services/reminder_service.dart
Normal file
64
lib/services/reminder_service.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/reminder_item.dart';
|
||||
|
||||
/// Persists dated reminders/to-dos per trip locally for offline use.
|
||||
class ReminderService {
|
||||
/// Loads reminders for [tripId].
|
||||
Future<List<ReminderItem>> loadReminders(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return ReminderItem.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves full list.
|
||||
Future<void> saveReminders(
|
||||
String tripId,
|
||||
List<ReminderItem> reminders,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), ReminderItem.encodeList(reminders));
|
||||
}
|
||||
|
||||
/// Adds a reminder.
|
||||
Future<List<ReminderItem>> addReminder(
|
||||
String tripId,
|
||||
ReminderItem reminder,
|
||||
) async {
|
||||
final current = await loadReminders(tripId);
|
||||
final updated = [...current, reminder];
|
||||
await saveReminders(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Toggles done state.
|
||||
Future<List<ReminderItem>> toggleReminder(
|
||||
String tripId,
|
||||
String reminderId,
|
||||
) async {
|
||||
final current = await loadReminders(tripId);
|
||||
final updated = current
|
||||
.map((r) => r.id == reminderId ? r.copyWith(isDone: !r.isDone) : r)
|
||||
.toList(growable: false);
|
||||
await saveReminders(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a reminder.
|
||||
Future<List<ReminderItem>> deleteReminder(
|
||||
String tripId,
|
||||
String reminderId,
|
||||
) async {
|
||||
final current = await loadReminders(tripId);
|
||||
final updated = current.where((r) => r.id != reminderId).toList();
|
||||
await saveReminders(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'reminders_$tripId';
|
||||
}
|
||||
54
lib/services/sos_service.dart
Normal file
54
lib/services/sos_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Service in charge of dispatching SOS events to a backend endpoint.
|
||||
///
|
||||
/// The backend is expected to accept POST JSON payloads like:
|
||||
/// {
|
||||
/// "tripId": "...",
|
||||
/// "lat": 0.0,
|
||||
/// "lng": 0.0,
|
||||
/// "message": "...",
|
||||
/// }
|
||||
class SosService {
|
||||
/// Base URL of the backend (e.g. https://api.example.com/sos).
|
||||
final String baseUrl;
|
||||
|
||||
/// Optional API key header.
|
||||
final String? apiKey;
|
||||
|
||||
/// Optional injected HTTP client (useful for testing).
|
||||
final http.Client _client;
|
||||
|
||||
/// Creates a new SOS service.
|
||||
SosService({required this.baseUrl, this.apiKey, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
/// Sends an SOS event. Returns true on HTTP 200.
|
||||
Future<bool> sendSos({
|
||||
required String tripId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
String message = 'SOS déclenché',
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/sos');
|
||||
try {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (apiKey != null) 'Authorization': 'Bearer $apiKey',
|
||||
},
|
||||
body: json.encode({
|
||||
'tripId': tripId,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'message': message,
|
||||
}),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,43 @@ class StorageService {
|
||||
: _storage = storage ?? FirebaseStorage.instance,
|
||||
_errorService = errorService ?? ErrorService();
|
||||
|
||||
/// Uploads an album image for a trip with compression.
|
||||
///
|
||||
/// Saves the file under `album/<tripId>/` with a unique name, and returns
|
||||
/// the download URL. Uses the same compression pipeline as receipts.
|
||||
Future<String> uploadAlbumImage(String tripId, File imageFile) async {
|
||||
try {
|
||||
_validateImageFile(imageFile);
|
||||
final compressedImage = await _compressImage(imageFile);
|
||||
final fileName =
|
||||
'album_${DateTime.now().millisecondsSinceEpoch}_${path.basename(imageFile.path)}';
|
||||
final storageRef = _storage.ref().child('album/$tripId/$fileName');
|
||||
|
||||
final metadata = SettableMetadata(
|
||||
contentType: 'image/jpeg',
|
||||
customMetadata: {
|
||||
'tripId': tripId,
|
||||
'uploadedAt': DateTime.now().toIso8601String(),
|
||||
'compressed': 'true',
|
||||
},
|
||||
);
|
||||
|
||||
final uploadTask = storageRef.putData(compressedImage, metadata);
|
||||
|
||||
final snapshot = await uploadTask;
|
||||
final downloadUrl = await snapshot.ref.getDownloadURL();
|
||||
|
||||
_errorService.logSuccess(
|
||||
'StorageService',
|
||||
'Album image uploaded: $fileName',
|
||||
);
|
||||
return downloadUrl;
|
||||
} catch (e) {
|
||||
_errorService.logError('StorageService', 'Error uploading album image: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads a receipt image for an expense with automatic compression.
|
||||
///
|
||||
/// Validates the image file, compresses it to JPEG format with 85% quality,
|
||||
|
||||
72
lib/services/transport_service.dart
Normal file
72
lib/services/transport_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/transport_segment.dart';
|
||||
|
||||
/// Service that stores per-trip transport segments locally for offline access.
|
||||
///
|
||||
/// Uses `SharedPreferences` keyed by `trip_transport_<tripId>` to keep
|
||||
/// creation/edit quick without round-trips. Real-time status can later be
|
||||
/// updated by a background job hitting external APIs.
|
||||
class TransportService {
|
||||
/// Loads stored transport segments for [tripId].
|
||||
Future<List<TransportSegment>> loadSegments(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return TransportSegment.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the full list of segments for [tripId].
|
||||
Future<void> saveSegments(
|
||||
String tripId,
|
||||
List<TransportSegment> segments,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), TransportSegment.encodeList(segments));
|
||||
}
|
||||
|
||||
/// Adds a segment entry.
|
||||
Future<List<TransportSegment>> addSegment(
|
||||
String tripId,
|
||||
TransportSegment segment,
|
||||
) async {
|
||||
final current = await loadSegments(tripId);
|
||||
final updated = [...current, segment];
|
||||
await saveSegments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a segment by [segmentId].
|
||||
Future<List<TransportSegment>> deleteSegment(
|
||||
String tripId,
|
||||
String segmentId,
|
||||
) async {
|
||||
final current = await loadSegments(tripId);
|
||||
final updated = current.where((s) => s.id != segmentId).toList();
|
||||
await saveSegments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Updates the status of a segment (e.g., delayed/boarding/in_air).
|
||||
Future<List<TransportSegment>> updateStatus(
|
||||
String tripId,
|
||||
String segmentId,
|
||||
String status,
|
||||
) async {
|
||||
final current = await loadSegments(tripId);
|
||||
final updated = current
|
||||
.map((s) {
|
||||
if (s.id != segmentId) return s;
|
||||
return s.copyWith(status: status);
|
||||
})
|
||||
.toList(growable: false);
|
||||
await saveSegments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'trip_transport_$tripId';
|
||||
}
|
||||
77
lib/services/trip_checklist_service.dart
Normal file
77
lib/services/trip_checklist_service.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/checklist_item.dart';
|
||||
|
||||
/// Service responsible for storing and retrieving per-trip checklists.
|
||||
///
|
||||
/// Persistence relies on `SharedPreferences` with one JSON string per trip
|
||||
/// key. All methods are resilient to corrupted payloads and return empty
|
||||
/// lists rather than throwing to keep the UI responsive.
|
||||
class TripChecklistService {
|
||||
/// Loads the checklist items for the given [tripId].
|
||||
///
|
||||
/// Returns an empty list if no data exists or if deserialization fails.
|
||||
Future<List<ChecklistItem>> loadChecklist(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
|
||||
if (raw == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
try {
|
||||
return ChecklistItem.decodeList(raw);
|
||||
} catch (_) {
|
||||
// Corrupted payload: clear it to avoid persisting errors.
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the provided [items] list for [tripId].
|
||||
///
|
||||
/// This method overrides the previously stored list; use helpers like
|
||||
/// [addItem], [toggleItem], or [deleteItem] for incremental updates.
|
||||
Future<void> saveChecklist(String tripId, List<ChecklistItem> items) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), ChecklistItem.encodeList(items));
|
||||
}
|
||||
|
||||
/// Adds a new [item] to the checklist for [tripId].
|
||||
///
|
||||
/// Items are appended in creation order; the updated list is persisted.
|
||||
Future<List<ChecklistItem>> addItem(String tripId, ChecklistItem item) async {
|
||||
final current = await loadChecklist(tripId);
|
||||
final updated = [...current, item];
|
||||
await saveChecklist(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Toggles the completion state of the item matching [itemId].
|
||||
///
|
||||
/// Returns the updated list. If the item is not found, the list is
|
||||
/// returned unchanged to keep UI state consistent.
|
||||
Future<List<ChecklistItem>> toggleItem(String tripId, String itemId) async {
|
||||
final current = await loadChecklist(tripId);
|
||||
final updated = current
|
||||
.map((item) {
|
||||
if (item.id != itemId) {
|
||||
return item;
|
||||
}
|
||||
return item.copyWith(isDone: !item.isDone);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
await saveChecklist(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes the item matching [itemId] and persists the change.
|
||||
Future<List<ChecklistItem>> deleteItem(String tripId, String itemId) async {
|
||||
final current = await loadChecklist(tripId);
|
||||
final updated = current.where((item) => item.id != itemId).toList();
|
||||
await saveChecklist(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'checklist_$tripId';
|
||||
}
|
||||
49
lib/services/trip_document_service.dart
Normal file
49
lib/services/trip_document_service.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/trip_document.dart';
|
||||
|
||||
/// Service that persists per-trip documents metadata locally.
|
||||
///
|
||||
/// Documents are stored as JSON in `SharedPreferences` to keep the UI
|
||||
/// responsive offline. Each trip key is `trip_docs_<tripId>`. The service is
|
||||
/// tolerant to corrupted payloads and resets gracefully to an empty list.
|
||||
class TripDocumentService {
|
||||
/// Loads documents for the given [tripId]. Returns an empty list when none.
|
||||
Future<List<TripDocument>> loadDocuments(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return TripDocument.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the full document list for [tripId].
|
||||
Future<void> saveDocuments(String tripId, List<TripDocument> docs) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), TripDocument.encodeList(docs));
|
||||
}
|
||||
|
||||
/// Adds a new document entry and persists the updated list.
|
||||
Future<List<TripDocument>> addDocument(
|
||||
String tripId,
|
||||
TripDocument doc,
|
||||
) async {
|
||||
final current = await loadDocuments(tripId);
|
||||
final updated = [...current, doc];
|
||||
await saveDocuments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a document by [docId] and persists the change.
|
||||
Future<List<TripDocument>> deleteDocument(String tripId, String docId) async {
|
||||
final current = await loadDocuments(tripId);
|
||||
final updated = current.where((d) => d.id != docId).toList();
|
||||
await saveDocuments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'trip_docs_$tripId';
|
||||
}
|
||||
Reference in New Issue
Block a user