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:
File diff suppressed because it is too large
Load Diff
100
lib/models/album_photo.dart
Normal file
100
lib/models/album_photo.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/models/budget_category.dart
Normal file
106
lib/models/budget_category.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
104
lib/models/checklist_item.dart
Normal file
104
lib/models/checklist_item.dart
Normal 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;
|
||||||
|
|
||||||
|
/// Human‑readable 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
100
lib/models/emergency_contact.dart
Normal file
100
lib/models/emergency_contact.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/models/packing_item.dart
Normal file
99
lib/models/packing_item.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/models/reminder_item.dart
Normal file
106
lib/models/reminder_item.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
179
lib/models/transport_segment.dart
Normal file
179
lib/models/transport_segment.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
lib/models/trip_document.dart
Normal file
123
lib/models/trip_document.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
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:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.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/account/group_expenses_page.dart';
|
||||||
import 'package:travel_mate/components/activities/activities_page.dart';
|
import 'package:travel_mate/components/activities/activities_page.dart';
|
||||||
import 'package:travel_mate/components/group/chat_group_content.dart';
|
import 'package:travel_mate/components/group/chat_group_content.dart';
|
||||||
@@ -50,6 +52,8 @@ class NotificationService {
|
|||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
await _requestPermissions();
|
await _requestPermissions();
|
||||||
|
tz.initializeTimeZones();
|
||||||
|
tz.setLocalLocation(tz.getLocation('UTC'));
|
||||||
|
|
||||||
const androidSettings = AndroidInitializationSettings(
|
const androidSettings = AndroidInitializationSettings(
|
||||||
'@mipmap/ic_launcher',
|
'@mipmap/ic_launcher',
|
||||||
@@ -411,4 +415,75 @@ class NotificationService {
|
|||||||
payload: json.encode(message.data),
|
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,
|
: _storage = storage ?? FirebaseStorage.instance,
|
||||||
_errorService = errorService ?? ErrorService();
|
_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.
|
/// Uploads a receipt image for an expense with automatic compression.
|
||||||
///
|
///
|
||||||
/// Validates the image file, compresses it to JPEG format with 85% quality,
|
/// 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';
|
||||||
|
}
|
||||||
@@ -1558,7 +1558,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.11"
|
version: "0.6.11"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: timezone
|
name: timezone
|
||||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.3.2+3
|
version: 2026.3.14
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -30,6 +30,7 @@ environment:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
timezone: ^0.10.1
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
path_provider: ^2.1.1
|
path_provider: ^2.1.1
|
||||||
|
|||||||
39
test/services/album_service_test.dart
Normal file
39
test/services/album_service_test.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/album_photo.dart';
|
||||||
|
import 'package:travel_mate/services/album_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-album-1';
|
||||||
|
late AlbumService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = AlbumService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and loads photos', () async {
|
||||||
|
final photo = AlbumPhoto.newPhoto(
|
||||||
|
id: 'p1',
|
||||||
|
url: 'https://example.com/img.jpg',
|
||||||
|
caption: 'Coucher de soleil',
|
||||||
|
uploadedBy: 'Alice',
|
||||||
|
);
|
||||||
|
await service.addPhoto(tripId, photo);
|
||||||
|
final loaded = await service.loadPhotos(tripId);
|
||||||
|
expect(loaded.single.url, contains('example.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes photo and clears corrupted payload', () async {
|
||||||
|
final p = AlbumPhoto.newPhoto(id: 'p1', url: 'u', caption: null);
|
||||||
|
await service.addPhoto(tripId, p);
|
||||||
|
var updated = await service.deletePhoto(tripId, 'p1');
|
||||||
|
expect(updated, isEmpty);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('album_$tripId', 'oops');
|
||||||
|
updated = await service.loadPhotos(tripId);
|
||||||
|
expect(updated, isEmpty);
|
||||||
|
expect(prefs.getString('album_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
51
test/services/budget_service_test.dart
Normal file
51
test/services/budget_service_test.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/budget_category.dart';
|
||||||
|
import 'package:travel_mate/services/budget_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-budget-1';
|
||||||
|
late BudgetService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = BudgetService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and deletes budget envelopes', () async {
|
||||||
|
final cat = BudgetCategory.newCategory(
|
||||||
|
id: 'food',
|
||||||
|
name: 'Food',
|
||||||
|
planned: 300,
|
||||||
|
currency: 'EUR',
|
||||||
|
);
|
||||||
|
await service.addBudget(tripId, cat);
|
||||||
|
|
||||||
|
var loaded = await service.loadBudgets(tripId);
|
||||||
|
expect(loaded.single.name, 'Food');
|
||||||
|
|
||||||
|
loaded = await service.deleteBudget(tripId, 'food');
|
||||||
|
expect(loaded, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates spent amount', () async {
|
||||||
|
final cat = BudgetCategory.newCategory(
|
||||||
|
id: 'transport',
|
||||||
|
name: 'Transport',
|
||||||
|
planned: 200,
|
||||||
|
currency: 'USD',
|
||||||
|
);
|
||||||
|
await service.addBudget(tripId, cat);
|
||||||
|
|
||||||
|
final updated = await service.updateSpent(tripId, 'transport', 55.5);
|
||||||
|
expect(updated.first.spent, 55.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupted payload cleared', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('budget_$tripId', 'oops');
|
||||||
|
final loaded = await service.loadBudgets(tripId);
|
||||||
|
expect(loaded, isEmpty);
|
||||||
|
expect(prefs.getString('budget_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
54
test/services/emergency_service_test.dart
Normal file
54
test/services/emergency_service_test.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/emergency_contact.dart';
|
||||||
|
import 'package:travel_mate/services/emergency_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-emergency-1';
|
||||||
|
late EmergencyService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = EmergencyService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and loads contacts', () async {
|
||||||
|
final contact = EmergencyContact.newContact(
|
||||||
|
id: 'c1',
|
||||||
|
name: 'Assistance',
|
||||||
|
phone: '+33123456789',
|
||||||
|
note: 'Assurance',
|
||||||
|
);
|
||||||
|
await service.addContact(tripId, contact);
|
||||||
|
|
||||||
|
final loaded = await service.loadContacts(tripId);
|
||||||
|
expect(loaded.single.phone, '+33123456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes contact', () async {
|
||||||
|
final a = EmergencyContact.newContact(
|
||||||
|
id: 'a',
|
||||||
|
name: 'Ambassade',
|
||||||
|
phone: '+321234',
|
||||||
|
);
|
||||||
|
final b = EmergencyContact.newContact(
|
||||||
|
id: 'b',
|
||||||
|
name: 'Marie',
|
||||||
|
phone: '+33999',
|
||||||
|
);
|
||||||
|
await service.addContact(tripId, a);
|
||||||
|
await service.addContact(tripId, b);
|
||||||
|
|
||||||
|
final updated = await service.deleteContact(tripId, 'a');
|
||||||
|
expect(updated.map((c) => c.id), contains('b'));
|
||||||
|
expect(updated.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupted payload cleared', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('emergency_$tripId', 'oops');
|
||||||
|
final loaded = await service.loadContacts(tripId);
|
||||||
|
expect(loaded, isEmpty);
|
||||||
|
expect(prefs.getString('emergency_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
19
test/services/guest_flag_service_test.dart
Normal file
19
test/services/guest_flag_service_test.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/services/guest_flag_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-guest-1';
|
||||||
|
late GuestFlagService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = GuestFlagService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets and reads guest mode flag', () async {
|
||||||
|
expect(await service.isGuestEnabled(tripId), isFalse);
|
||||||
|
await service.setGuestEnabled(tripId, true);
|
||||||
|
expect(await service.isGuestEnabled(tripId), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
46
test/services/packing_service_test.dart
Normal file
46
test/services/packing_service_test.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/packing_item.dart';
|
||||||
|
import 'package:travel_mate/services/packing_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-pack-1';
|
||||||
|
late PackingService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = PackingService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds, toggles and deletes packing items', () async {
|
||||||
|
final item = PackingItem.newItem(id: '1', label: 'Adaptateur US');
|
||||||
|
await service.addItem(tripId, item);
|
||||||
|
|
||||||
|
var loaded = await service.loadItems(tripId);
|
||||||
|
expect(loaded.first.isPacked, isFalse);
|
||||||
|
|
||||||
|
loaded = await service.toggleItem(tripId, '1');
|
||||||
|
expect(loaded.first.isPacked, isTrue);
|
||||||
|
|
||||||
|
loaded = await service.deleteItem(tripId, '1');
|
||||||
|
expect(loaded, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('suggested items react to cold and duration', () {
|
||||||
|
final cold = service.suggestedItems(nights: 5, cold: true);
|
||||||
|
expect(cold, contains('Veste chaude'));
|
||||||
|
expect(cold, contains('Lessive/ziplock'));
|
||||||
|
|
||||||
|
final warm = service.suggestedItems(nights: 2, cold: false);
|
||||||
|
expect(warm, contains('Crème solaire'));
|
||||||
|
expect(warm, isNot(contains('Lessive/ziplock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles corrupted payload', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('packing_$tripId', 'oops');
|
||||||
|
final items = await service.loadItems(tripId);
|
||||||
|
expect(items, isEmpty);
|
||||||
|
expect(prefs.getString('packing_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
45
test/services/reminder_service_test.dart
Normal file
45
test/services/reminder_service_test.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/reminder_item.dart';
|
||||||
|
import 'package:travel_mate/services/reminder_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-reminders-1';
|
||||||
|
late ReminderService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = ReminderService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and toggles reminders', () async {
|
||||||
|
final r = ReminderItem.newItem(
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Check-in en ligne',
|
||||||
|
dueAt: DateTime.utc(2026, 4, 10, 7),
|
||||||
|
);
|
||||||
|
await service.addReminder(tripId, r);
|
||||||
|
var list = await service.loadReminders(tripId);
|
||||||
|
expect(list.single.isDone, isFalse);
|
||||||
|
|
||||||
|
list = await service.toggleReminder(tripId, 'r1');
|
||||||
|
expect(list.single.isDone, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes and clears corrupted payload', () async {
|
||||||
|
final r = ReminderItem.newItem(
|
||||||
|
id: 'r1',
|
||||||
|
title: 'Acheter métro pass',
|
||||||
|
dueAt: DateTime.utc(2026, 4, 1),
|
||||||
|
);
|
||||||
|
await service.addReminder(tripId, r);
|
||||||
|
var list = await service.deleteReminder(tripId, 'r1');
|
||||||
|
expect(list, isEmpty);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('reminders_$tripId', 'oops');
|
||||||
|
list = await service.loadReminders(tripId);
|
||||||
|
expect(list, isEmpty);
|
||||||
|
expect(prefs.getString('reminders_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
37
test/services/sos_service_test.dart
Normal file
37
test/services/sos_service_test.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:travel_mate/services/sos_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('sendSos returns true on 200', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
expect(request.url.toString(), 'https://api.example.com/sos');
|
||||||
|
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||||
|
expect(body['tripId'], 't1');
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final service = SosService(
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
client: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.sendSos(tripId: 't1', lat: 1, lng: 2);
|
||||||
|
expect(result, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendSos returns false on error', () async {
|
||||||
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response('fail', 500);
|
||||||
|
});
|
||||||
|
final service = SosService(
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
client: mockClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await service.sendSos(tripId: 't1', lat: 0, lng: 0);
|
||||||
|
expect(result, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
75
test/services/transport_service_test.dart
Normal file
75
test/services/transport_service_test.dart
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/transport_segment.dart';
|
||||||
|
import 'package:travel_mate/services/transport_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-transport-1';
|
||||||
|
late TransportService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = TransportService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and loads a segment', () async {
|
||||||
|
final seg = TransportSegment.newSegment(
|
||||||
|
id: 'seg1',
|
||||||
|
type: 'flight',
|
||||||
|
carrier: 'AF',
|
||||||
|
number: '763',
|
||||||
|
departureCode: 'CDG',
|
||||||
|
arrivalCode: 'JFK',
|
||||||
|
departureUtc: DateTime.utc(2026, 4, 10, 7, 0),
|
||||||
|
arrivalUtc: DateTime.utc(2026, 4, 10, 11, 0),
|
||||||
|
pnr: 'ABC123',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.addSegment(tripId, seg);
|
||||||
|
final loaded = await service.loadSegments(tripId);
|
||||||
|
|
||||||
|
expect(loaded, hasLength(1));
|
||||||
|
expect(loaded.first.number, '763');
|
||||||
|
expect(loaded.first.status, 'scheduled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates status', () async {
|
||||||
|
final seg = TransportSegment.newSegment(
|
||||||
|
id: 'seg1',
|
||||||
|
type: 'train',
|
||||||
|
carrier: 'TGV',
|
||||||
|
number: '8401',
|
||||||
|
departureCode: 'PAR',
|
||||||
|
arrivalCode: 'BRU',
|
||||||
|
departureUtc: DateTime.utc(2026, 5, 1, 8, 30),
|
||||||
|
arrivalUtc: DateTime.utc(2026, 5, 1, 10, 30),
|
||||||
|
);
|
||||||
|
await service.addSegment(tripId, seg);
|
||||||
|
|
||||||
|
final updated = await service.updateStatus(tripId, 'seg1', 'delayed');
|
||||||
|
|
||||||
|
expect(updated.first.status, 'delayed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes segment and handles corrupted payload', () async {
|
||||||
|
final seg = TransportSegment.newSegment(
|
||||||
|
id: 'seg1',
|
||||||
|
type: 'bus',
|
||||||
|
carrier: 'FLX',
|
||||||
|
number: '12',
|
||||||
|
departureCode: 'AMS',
|
||||||
|
arrivalCode: 'BRU',
|
||||||
|
departureUtc: DateTime.utc(2026, 6, 1, 9, 0),
|
||||||
|
arrivalUtc: DateTime.utc(2026, 6, 1, 11, 30),
|
||||||
|
);
|
||||||
|
await service.addSegment(tripId, seg);
|
||||||
|
await service.deleteSegment(tripId, 'seg1');
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('trip_transport_$tripId', 'bad-json');
|
||||||
|
|
||||||
|
final loaded = await service.loadSegments(tripId);
|
||||||
|
expect(loaded, isEmpty);
|
||||||
|
expect(prefs.getString('trip_transport_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
63
test/services/trip_checklist_service_test.dart
Normal file
63
test/services/trip_checklist_service_test.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/checklist_item.dart';
|
||||||
|
import 'package:travel_mate/services/trip_checklist_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late TripChecklistService service;
|
||||||
|
const tripId = 'trip-123';
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = TripChecklistService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and loads checklist items', () async {
|
||||||
|
final created = ChecklistItem.newItem(id: '1', label: 'Préparer passeport');
|
||||||
|
await service.addItem(tripId, created);
|
||||||
|
|
||||||
|
final result = await service.loadChecklist(tripId);
|
||||||
|
|
||||||
|
expect(result, hasLength(1));
|
||||||
|
expect(result.first.label, 'Préparer passeport');
|
||||||
|
expect(result.first.isDone, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggles completion state', () async {
|
||||||
|
final item = ChecklistItem.newItem(id: '1', label: 'Acheter billets');
|
||||||
|
await service.addItem(tripId, item);
|
||||||
|
|
||||||
|
final toggled = await service.toggleItem(tripId, '1');
|
||||||
|
|
||||||
|
expect(toggled.first.isDone, isTrue);
|
||||||
|
|
||||||
|
final toggledBack = await service.toggleItem(tripId, '1');
|
||||||
|
expect(toggledBack.first.isDone, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes items and keeps list consistent', () async {
|
||||||
|
final itemA = ChecklistItem.newItem(id: '1', label: 'Adapter prise');
|
||||||
|
final itemB = ChecklistItem.newItem(id: '2', label: 'Chargeur');
|
||||||
|
await service.addItem(tripId, itemA);
|
||||||
|
await service.addItem(tripId, itemB);
|
||||||
|
|
||||||
|
final afterDelete = await service.deleteItem(tripId, '1');
|
||||||
|
|
||||||
|
expect(afterDelete, hasLength(1));
|
||||||
|
expect(afterDelete.first.id, '2');
|
||||||
|
|
||||||
|
final persisted = await service.loadChecklist(tripId);
|
||||||
|
expect(persisted, hasLength(1));
|
||||||
|
expect(persisted.first.id, '2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles corrupted payload gracefully', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('checklist_$tripId', 'not-json');
|
||||||
|
|
||||||
|
final items = await service.loadChecklist(tripId);
|
||||||
|
|
||||||
|
expect(items, isEmpty);
|
||||||
|
expect(prefs.getString('checklist_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
60
test/services/trip_document_service_test.dart
Normal file
60
test/services/trip_document_service_test.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:travel_mate/models/trip_document.dart';
|
||||||
|
import 'package:travel_mate/services/trip_document_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const tripId = 'trip-docs-1';
|
||||||
|
late TripDocumentService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = TripDocumentService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and loads documents', () async {
|
||||||
|
final doc = TripDocument.newEntry(
|
||||||
|
id: 'doc1',
|
||||||
|
title: 'Billet Aller',
|
||||||
|
category: 'billet',
|
||||||
|
downloadUrl: 'https://example.com/billet.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.addDocument(tripId, doc);
|
||||||
|
final loaded = await service.loadDocuments(tripId);
|
||||||
|
|
||||||
|
expect(loaded, hasLength(1));
|
||||||
|
expect(loaded.first.title, 'Billet Aller');
|
||||||
|
expect(loaded.first.category, 'billet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes a document', () async {
|
||||||
|
final a = TripDocument.newEntry(
|
||||||
|
id: 'a',
|
||||||
|
title: 'Passeport',
|
||||||
|
category: 'passeport',
|
||||||
|
);
|
||||||
|
final b = TripDocument.newEntry(
|
||||||
|
id: 'b',
|
||||||
|
title: 'Assurance',
|
||||||
|
category: 'assurance',
|
||||||
|
);
|
||||||
|
await service.addDocument(tripId, a);
|
||||||
|
await service.addDocument(tripId, b);
|
||||||
|
|
||||||
|
final afterDelete = await service.deleteDocument(tripId, 'a');
|
||||||
|
|
||||||
|
expect(afterDelete, hasLength(1));
|
||||||
|
expect(afterDelete.first.id, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles corrupted payload gracefully', () async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('trip_docs_$tripId', 'oops');
|
||||||
|
|
||||||
|
final docs = await service.loadDocuments(tripId);
|
||||||
|
|
||||||
|
expect(docs, isEmpty);
|
||||||
|
expect(prefs.getString('trip_docs_$tripId'), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user