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:
Van Leemput Dayron
2026-03-13 15:02:23 +01:00
parent 3215a990d1
commit 9b08b2896c
36 changed files with 4731 additions and 2 deletions

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

View 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 [];
}
}
}

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

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

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

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

View File

@@ -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);
}
}

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

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

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

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

View File

@@ -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,

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

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

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