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

File diff suppressed because it is too large Load Diff

100
lib/models/album_photo.dart Normal file
View File

@@ -0,0 +1,100 @@
import 'dart:convert';
/// Represents a shared photo entry in the trip album.
///
/// Stores a remote [url], optional [caption], and the uploader identifier for
/// basic attribution. Persistence is local/offline via JSON helpers.
class AlbumPhoto {
/// Unique identifier for the photo entry.
final String id;
/// Public or signed URL of the photo.
final String url;
/// Optional caption provided by the user.
final String? caption;
/// Name or ID of the uploader for display.
final String? uploadedBy;
/// Creation timestamp.
final DateTime createdAt;
/// Creates an album photo.
const AlbumPhoto({
required this.id,
required this.url,
required this.createdAt,
this.caption,
this.uploadedBy,
});
/// Convenience builder for a new entry.
factory AlbumPhoto.newPhoto({
required String id,
required String url,
String? caption,
String? uploadedBy,
}) {
return AlbumPhoto(
id: id,
url: url,
caption: caption,
uploadedBy: uploadedBy,
createdAt: DateTime.now().toUtc(),
);
}
/// Copy with updates.
AlbumPhoto copyWith({
String? id,
String? url,
String? caption,
String? uploadedBy,
DateTime? createdAt,
}) {
return AlbumPhoto(
id: id ?? this.id,
url: url ?? this.url,
caption: caption ?? this.caption,
uploadedBy: uploadedBy ?? this.uploadedBy,
createdAt: createdAt ?? this.createdAt,
);
}
/// Serialize to JSON.
Map<String, dynamic> toJson() {
return {
'id': id,
'url': url,
'caption': caption,
'uploadedBy': uploadedBy,
'createdAt': createdAt.toIso8601String(),
};
}
/// Deserialize from JSON.
factory AlbumPhoto.fromJson(Map<String, dynamic> json) {
return AlbumPhoto(
id: json['id'] as String,
url: json['url'] as String,
caption: json['caption'] as String?,
uploadedBy: json['uploadedBy'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encode list to string.
static String encodeList(List<AlbumPhoto> photos) {
return json.encode(photos.map((p) => p.toJson()).toList());
}
/// Decode list from string.
static List<AlbumPhoto> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(AlbumPhoto.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,106 @@
import 'dart:convert';
/// Represents a budget envelope per category with currency awareness.
class BudgetCategory {
/// Unique identifier of the category entry.
final String id;
/// Category name (hébergement, transport, food, activités...).
final String name;
/// Planned amount.
final double planned;
/// Currency code (ISO 4217) used for the amount.
final String currency;
/// Amount actually spent (to be filled by expenses sync later).
final double spent;
/// Creation timestamp for ordering.
final DateTime createdAt;
/// Creates a budget category entry.
const BudgetCategory({
required this.id,
required this.name,
required this.planned,
required this.currency,
required this.spent,
required this.createdAt,
});
/// Convenience constructor for new envelope.
factory BudgetCategory.newCategory({
required String id,
required String name,
required double planned,
required String currency,
}) {
return BudgetCategory(
id: id,
name: name,
planned: planned,
currency: currency,
spent: 0,
createdAt: DateTime.now().toUtc(),
);
}
/// Returns a copy with updated fields.
BudgetCategory copyWith({
String? id,
String? name,
double? planned,
String? currency,
double? spent,
DateTime? createdAt,
}) {
return BudgetCategory(
id: id ?? this.id,
name: name ?? this.name,
planned: planned ?? this.planned,
currency: currency ?? this.currency,
spent: spent ?? this.spent,
createdAt: createdAt ?? this.createdAt,
);
}
/// JSON serialization.
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'planned': planned,
'currency': currency,
'spent': spent,
'createdAt': createdAt.toIso8601String(),
};
}
/// JSON deserialization.
factory BudgetCategory.fromJson(Map<String, dynamic> json) {
return BudgetCategory(
id: json['id'] as String,
name: json['name'] as String,
planned: (json['planned'] as num).toDouble(),
currency: json['currency'] as String,
spent: (json['spent'] as num).toDouble(),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encodes a list to JSON string.
static String encodeList(List<BudgetCategory> categories) {
return json.encode(categories.map((c) => c.toJson()).toList());
}
/// Decodes a list from JSON string.
static List<BudgetCategory> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(BudgetCategory.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:convert';
/// Data model representing a single checklist item for a trip.
///
/// Each item stores a unique [id], the [label] to display, its completion
/// status via [isDone], and optional timestamps to support ordering or
/// future reminders. The model includes JSON helpers to simplify
/// persistence with `SharedPreferences`.
class ChecklistItem {
/// Unique identifier of the checklist item.
final String id;
/// Humanreadable text describing the task to complete.
final String label;
/// Indicates whether the task has been completed.
final bool isDone;
/// Creation timestamp used to keep a stable order in the list UI.
final DateTime createdAt;
/// Optional due date for the task; can be leveraged by reminders later.
final DateTime? dueDate;
/// Creates a checklist item.
const ChecklistItem({
required this.id,
required this.label,
required this.isDone,
required this.createdAt,
this.dueDate,
});
/// Builds a new item in the pending state with the current timestamp.
factory ChecklistItem.newItem({
required String id,
required String label,
DateTime? dueDate,
}) {
return ChecklistItem(
id: id,
label: label,
isDone: false,
createdAt: DateTime.now().toUtc(),
dueDate: dueDate,
);
}
/// Creates a copy with updated fields while keeping immutability.
ChecklistItem copyWith({
String? id,
String? label,
bool? isDone,
DateTime? createdAt,
DateTime? dueDate,
}) {
return ChecklistItem(
id: id ?? this.id,
label: label ?? this.label,
isDone: isDone ?? this.isDone,
createdAt: createdAt ?? this.createdAt,
dueDate: dueDate ?? this.dueDate,
);
}
/// Serializes the item to JSON for storage.
Map<String, dynamic> toJson() {
return {
'id': id,
'label': label,
'isDone': isDone,
'createdAt': createdAt.toIso8601String(),
'dueDate': dueDate?.toIso8601String(),
};
}
/// Deserializes a checklist item from JSON.
factory ChecklistItem.fromJson(Map<String, dynamic> json) {
return ChecklistItem(
id: json['id'] as String,
label: json['label'] as String,
isDone: json['isDone'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
dueDate: json['dueDate'] != null
? DateTime.tryParse(json['dueDate'] as String)
: null,
);
}
/// Encodes a list of checklist items to a JSON string.
static String encodeList(List<ChecklistItem> items) {
final jsonList = items.map((item) => item.toJson()).toList();
return json.encode(jsonList);
}
/// Decodes a list of checklist items from a JSON string.
static List<ChecklistItem> decodeList(String jsonString) {
final decoded = json.decode(jsonString) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(ChecklistItem.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,100 @@
import 'dart:convert';
/// Represents an emergency contact for a trip (person or service).
///
/// Stores basic contact details and optional notes for quick access during
/// critical situations.
class EmergencyContact {
/// Unique identifier for the contact entry.
final String id;
/// Display name (ex: "Ambassade", "Marie", "Assurance Europ").
final String name;
/// Phone number in international format when possible.
final String phone;
/// Optional description or role (ex: "Assistance médicale", "Famille").
final String? note;
/// Creation timestamp for stable ordering.
final DateTime createdAt;
/// Creates an emergency contact entry.
const EmergencyContact({
required this.id,
required this.name,
required this.phone,
required this.createdAt,
this.note,
});
/// Builds a new contact with current timestamp.
factory EmergencyContact.newContact({
required String id,
required String name,
required String phone,
String? note,
}) {
return EmergencyContact(
id: id,
name: name,
phone: phone,
note: note,
createdAt: DateTime.now().toUtc(),
);
}
/// Returns a copy with updated fields.
EmergencyContact copyWith({
String? id,
String? name,
String? phone,
String? note,
DateTime? createdAt,
}) {
return EmergencyContact(
id: id ?? this.id,
name: name ?? this.name,
phone: phone ?? this.phone,
note: note ?? this.note,
createdAt: createdAt ?? this.createdAt,
);
}
/// Serializes contact to JSON.
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'phone': phone,
'note': note,
'createdAt': createdAt.toIso8601String(),
};
}
/// Deserializes contact from JSON.
factory EmergencyContact.fromJson(Map<String, dynamic> json) {
return EmergencyContact(
id: json['id'] as String,
name: json['name'] as String,
phone: json['phone'] as String,
note: json['note'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encodes list to JSON string.
static String encodeList(List<EmergencyContact> contacts) {
return json.encode(contacts.map((c) => c.toJson()).toList());
}
/// Decodes list from JSON string.
static List<EmergencyContact> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(EmergencyContact.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,99 @@
import 'dart:convert';
/// Represents an item in the shared packing list for a trip.
///
/// Each item stores a [label], completion flag [isPacked], and an optional
/// [assignee] to indicate who prend en charge l'item.
class PackingItem {
/// Unique identifier for the packing entry.
final String id;
/// Text displayed in the list (ex: "Adaptateur US", "Pharmacie").
final String label;
/// Whether the item is already packed.
final bool isPacked;
/// Optional assignee (user id or name) for accountability.
final String? assignee;
/// Creation timestamp for ordering.
final DateTime createdAt;
/// Creates a packing item.
const PackingItem({
required this.id,
required this.label,
required this.isPacked,
required this.createdAt,
this.assignee,
});
/// Factory to create a new item in not-packed state.
factory PackingItem.newItem({
required String id,
required String label,
String? assignee,
}) {
return PackingItem(
id: id,
label: label,
assignee: assignee,
isPacked: false,
createdAt: DateTime.now().toUtc(),
);
}
/// Returns a copy with modifications.
PackingItem copyWith({
String? id,
String? label,
bool? isPacked,
String? assignee,
DateTime? createdAt,
}) {
return PackingItem(
id: id ?? this.id,
label: label ?? this.label,
isPacked: isPacked ?? this.isPacked,
assignee: assignee ?? this.assignee,
createdAt: createdAt ?? this.createdAt,
);
}
/// JSON serialization helper.
Map<String, dynamic> toJson() {
return {
'id': id,
'label': label,
'isPacked': isPacked,
'assignee': assignee,
'createdAt': createdAt.toIso8601String(),
};
}
/// JSON deserialization helper.
factory PackingItem.fromJson(Map<String, dynamic> json) {
return PackingItem(
id: json['id'] as String,
label: json['label'] as String,
isPacked: json['isPacked'] as bool? ?? false,
assignee: json['assignee'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encodes list to JSON string.
static String encodeList(List<PackingItem> items) {
return json.encode(items.map((i) => i.toJson()).toList());
}
/// Decodes list from JSON string.
static List<PackingItem> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(PackingItem.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,106 @@
import 'dart:convert';
/// Represents a dated reminder or to-do for the trip.
class ReminderItem {
/// Unique identifier.
final String id;
/// Text to display.
final String title;
/// Optional detailed note.
final String? note;
/// Due date/time (UTC) for the reminder.
final DateTime dueAt;
/// Completion flag.
final bool isDone;
/// Creation timestamp.
final DateTime createdAt;
/// Creates a reminder item.
const ReminderItem({
required this.id,
required this.title,
required this.dueAt,
required this.isDone,
required this.createdAt,
this.note,
});
/// Convenience builder for new pending reminder.
factory ReminderItem.newItem({
required String id,
required String title,
required DateTime dueAt,
String? note,
}) {
return ReminderItem(
id: id,
title: title,
note: note,
dueAt: dueAt,
isDone: false,
createdAt: DateTime.now().toUtc(),
);
}
/// Copy with changes.
ReminderItem copyWith({
String? id,
String? title,
String? note,
DateTime? dueAt,
bool? isDone,
DateTime? createdAt,
}) {
return ReminderItem(
id: id ?? this.id,
title: title ?? this.title,
note: note ?? this.note,
dueAt: dueAt ?? this.dueAt,
isDone: isDone ?? this.isDone,
createdAt: createdAt ?? this.createdAt,
);
}
/// JSON serialization.
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'note': note,
'dueAt': dueAt.toIso8601String(),
'isDone': isDone,
'createdAt': createdAt.toIso8601String(),
};
}
/// JSON deserialization.
factory ReminderItem.fromJson(Map<String, dynamic> json) {
return ReminderItem(
id: json['id'] as String,
title: json['title'] as String,
note: json['note'] as String?,
dueAt: DateTime.parse(json['dueAt'] as String),
isDone: json['isDone'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encodes list.
static String encodeList(List<ReminderItem> reminders) {
return json.encode(reminders.map((r) => r.toJson()).toList());
}
/// Decodes list.
static List<ReminderItem> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(ReminderItem.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,179 @@
import 'dart:convert';
/// Represents a transport segment (vol/train/bus) tied to a trip.
///
/// Includes identifiers (PNR/train number), schedule times, status, carrier
/// and station/airport codes for display and potential real-time tracking.
class TransportSegment {
/// Unique identifier for this segment entry.
final String id;
/// Segment type: `flight`, `train`, `bus` (extendable).
final String type;
/// Carrier code (e.g., AF, SN, TGV, OUIGO).
final String carrier;
/// Public number (e.g., AF763, TGV 8401).
final String number;
/// Booking reference / PNR if available.
final String? pnr;
/// Departure code (IATA/CRS) or station name.
final String departureCode;
/// Arrival code (IATA/CRS) or station name.
final String arrivalCode;
/// Planned departure time (UTC).
final DateTime departureUtc;
/// Planned arrival time (UTC).
final DateTime arrivalUtc;
/// Current status string (scheduled/delayed/cancelled/boarding/in_air etc.).
final String status;
/// Gate/platform when known.
final String? gate;
/// Seat assignment if provided.
final String? seat;
/// Created-at timestamp for ordering.
final DateTime createdAt;
/// Creates a transport segment entry.
const TransportSegment({
required this.id,
required this.type,
required this.carrier,
required this.number,
required this.departureCode,
required this.arrivalCode,
required this.departureUtc,
required this.arrivalUtc,
required this.status,
required this.createdAt,
this.pnr,
this.gate,
this.seat,
});
/// Helper to instantiate a new scheduled segment with defaults.
factory TransportSegment.newSegment({
required String id,
required String type,
required String carrier,
required String number,
required String departureCode,
required String arrivalCode,
required DateTime departureUtc,
required DateTime arrivalUtc,
String? pnr,
String? gate,
String? seat,
}) {
return TransportSegment(
id: id,
type: type,
carrier: carrier,
number: number,
pnr: pnr,
departureCode: departureCode,
arrivalCode: arrivalCode,
departureUtc: departureUtc,
arrivalUtc: arrivalUtc,
gate: gate,
seat: seat,
status: 'scheduled',
createdAt: DateTime.now().toUtc(),
);
}
/// Returns a copy with updated fields.
TransportSegment copyWith({
String? id,
String? type,
String? carrier,
String? number,
String? pnr,
String? departureCode,
String? arrivalCode,
DateTime? departureUtc,
DateTime? arrivalUtc,
String? status,
String? gate,
String? seat,
DateTime? createdAt,
}) {
return TransportSegment(
id: id ?? this.id,
type: type ?? this.type,
carrier: carrier ?? this.carrier,
number: number ?? this.number,
pnr: pnr ?? this.pnr,
departureCode: departureCode ?? this.departureCode,
arrivalCode: arrivalCode ?? this.arrivalCode,
departureUtc: departureUtc ?? this.departureUtc,
arrivalUtc: arrivalUtc ?? this.arrivalUtc,
status: status ?? this.status,
gate: gate ?? this.gate,
seat: seat ?? this.seat,
createdAt: createdAt ?? this.createdAt,
);
}
/// Serializes the segment to JSON for persistence.
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'carrier': carrier,
'number': number,
'pnr': pnr,
'departureCode': departureCode,
'arrivalCode': arrivalCode,
'departureUtc': departureUtc.toIso8601String(),
'arrivalUtc': arrivalUtc.toIso8601String(),
'status': status,
'gate': gate,
'seat': seat,
'createdAt': createdAt.toIso8601String(),
};
}
/// Deserializes a segment from JSON.
factory TransportSegment.fromJson(Map<String, dynamic> json) {
return TransportSegment(
id: json['id'] as String,
type: json['type'] as String,
carrier: json['carrier'] as String,
number: json['number'] as String,
pnr: json['pnr'] as String?,
departureCode: json['departureCode'] as String,
arrivalCode: json['arrivalCode'] as String,
departureUtc: DateTime.parse(json['departureUtc'] as String),
arrivalUtc: DateTime.parse(json['arrivalUtc'] as String),
status: json['status'] as String,
gate: json['gate'] as String?,
seat: json['seat'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encodes a list of segments to JSON string.
static String encodeList(List<TransportSegment> segments) {
return json.encode(segments.map((s) => s.toJson()).toList());
}
/// Decodes a list of segments from JSON string.
static List<TransportSegment> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(TransportSegment.fromJson)
.toList();
}
}

View File

@@ -0,0 +1,123 @@
import 'dart:convert';
/// Represents a document attached to a trip (billet, passeport, assurance, etc.).
///
/// The model stores a human-friendly [title], a [category] to filter in the UI,
/// an optional [downloadUrl] when the file is hosted remotely, and an optional
/// [expiresAt] date for reminders (ex: passeport ou ESTA).
class TripDocument {
/// Unique identifier for the document entry.
final String id;
/// Display name chosen by the user (ex: « Billet retour AF763 »).
final String title;
/// Type/category (ex: `billet`, `passeport`, `assurance`, `hebergement`).
final String category;
/// Optional URL to open/download the document (cloud storage or external).
final String? downloadUrl;
/// Optional local file path when offline-only; kept for future sync.
final String? localPath;
/// Optional expiration date to trigger reminders.
final DateTime? expiresAt;
/// Creation timestamp used for stable ordering.
final DateTime createdAt;
/// Creates a trip document entry.
const TripDocument({
required this.id,
required this.title,
required this.category,
required this.createdAt,
this.downloadUrl,
this.localPath,
this.expiresAt,
});
/// Builds a new entry with defaults.
factory TripDocument.newEntry({
required String id,
required String title,
required String category,
String? downloadUrl,
String? localPath,
DateTime? expiresAt,
}) {
return TripDocument(
id: id,
title: title,
category: category,
downloadUrl: downloadUrl,
localPath: localPath,
expiresAt: expiresAt,
createdAt: DateTime.now().toUtc(),
);
}
/// Returns a copy with updated fields.
TripDocument copyWith({
String? id,
String? title,
String? category,
String? downloadUrl,
String? localPath,
DateTime? expiresAt,
DateTime? createdAt,
}) {
return TripDocument(
id: id ?? this.id,
title: title ?? this.title,
category: category ?? this.category,
downloadUrl: downloadUrl ?? this.downloadUrl,
localPath: localPath ?? this.localPath,
expiresAt: expiresAt ?? this.expiresAt,
createdAt: createdAt ?? this.createdAt,
);
}
/// Serializes the entry to JSON for persistence.
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'category': category,
'downloadUrl': downloadUrl,
'localPath': localPath,
'expiresAt': expiresAt?.toIso8601String(),
'createdAt': createdAt.toIso8601String(),
};
}
/// Deserializes a trip document from JSON.
factory TripDocument.fromJson(Map<String, dynamic> json) {
return TripDocument(
id: json['id'] as String,
title: json['title'] as String,
category: json['category'] as String,
downloadUrl: json['downloadUrl'] as String?,
localPath: json['localPath'] as String?,
expiresAt: json['expiresAt'] != null
? DateTime.tryParse(json['expiresAt'] as String)
: null,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
/// Encodes a list of documents to a JSON string.
static String encodeList(List<TripDocument> docs) {
return json.encode(docs.map((d) => d.toJson()).toList());
}
/// Decodes a list of documents from a JSON string.
static List<TripDocument> decodeList(String raw) {
final decoded = json.decode(raw) as List<dynamic>;
return decoded
.cast<Map<String, dynamic>>()
.map(TripDocument.fromJson)
.toList();
}
}

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