Browse Source

page caisse

master
Stephane 4 months ago
parent
commit
be3d5264cf
  1. 3
      lib/layouts/main_layout.dart
  2. 11
      lib/main.dart
  3. 325
      lib/models/command_detail.dart
  4. 41
      lib/models/payment_method.dart
  5. 208
      lib/models/tables_order.dart
  6. 552
      lib/pages/caisse_screen.dart
  7. 200
      lib/pages/encaissement_screen.dart
  8. 285
      lib/services/restaurant_api_service.dart
  9. 43
      lib/widgets/bottom_navigation.dart
  10. 132
      lib/widgets/command_card.dart
  11. 184
      lib/widgets/command_directe_dialog.dart
  12. 6
      pubspec.yaml

3
lib/layouts/main_layout.dart

@ -61,6 +61,9 @@ class _MainLayoutState extends State<MainLayout> {
case 4:
route = '/plats';
break;
case 5:
route = '/encaissement';
break;
default:
route = '/tables';
}

11
lib/main.dart

@ -5,6 +5,7 @@ import 'pages/categorie.dart';
import 'pages/commandes_screen.dart';
import 'pages/login_screen.dart';
import 'pages/menus_screen.dart';
import 'pages/encaissement_screen.dart'; // NOUVEAU
void main() {
runApp(const MyApp());
@ -40,12 +41,16 @@ class MyApp extends StatelessWidget {
currentRoute: '/commandes',
child: OrdersManagementScreen(),
),
// MODIFICATION : Route simple pour le menu
'/plats':
(context) => const MainLayout(
currentRoute: '/plats',
child:
PlatsManagementScreen(), // Pas de paramètres requis maintenant
child: PlatsManagementScreen(),
),
// NOUVELLE ROUTE pour l'encaissement
'/encaissement':
(context) => const MainLayout(
currentRoute: '/encaissement',
child: EncaissementScreen(),
),
},
);

325
lib/models/command_detail.dart

@ -0,0 +1,325 @@
// models/commande_detail.dart
class CommandeDetail {
final int id;
final int clientId;
final int tableId;
final int? reservationId;
final String numeroCommande;
final String statut;
final double totalHt;
final double totalTva;
final double totalTtc;
final String? modePaiement;
final String? commentaires;
final String serveur;
final DateTime dateCommande;
final DateTime? dateService;
final DateTime createdAt;
final DateTime updatedAt;
final List<CommandeItem> items;
CommandeDetail({
required this.id,
required this.clientId,
required this.tableId,
this.reservationId,
required this.numeroCommande,
required this.statut,
required this.totalHt,
required this.totalTva,
required this.totalTtc,
this.modePaiement,
this.commentaires,
required this.serveur,
required this.dateCommande,
this.dateService,
required this.createdAt,
required this.updatedAt,
required this.items,
});
factory CommandeDetail.fromJson(Map<String, dynamic> json) {
// Gérer les cas les données sont dans "data" ou directement dans json
final data = json['data'] ?? json;
return CommandeDetail(
id: data['id'] ?? 0,
clientId: data['client_id'] ?? 0,
tableId: data['table_id'] ?? 0,
reservationId: data['reservation_id'],
numeroCommande: data['numero_commande'] ?? '',
statut: data['statut'] ?? 'en_cours',
totalHt: double.tryParse(data['total_ht']?.toString() ?? '0') ?? 0.0,
totalTva: double.tryParse(data['total_tva']?.toString() ?? '0') ?? 0.0,
totalTtc: double.tryParse(data['total_ttc']?.toString() ?? '0') ?? 0.0,
modePaiement: data['mode_paiement'],
commentaires: data['commentaires'],
serveur: data['serveur'] ?? 'Serveur par défaut',
dateCommande:
data['date_commande'] != null
? DateTime.parse(data['date_commande'])
: DateTime.now(),
dateService:
data['date_service'] != null
? DateTime.parse(data['date_service'])
: null,
createdAt:
data['created_at'] != null
? DateTime.parse(data['created_at'])
: DateTime.now(),
updatedAt:
data['updated_at'] != null
? DateTime.parse(data['updated_at'])
: DateTime.now(),
items:
(data['items'] as List<dynamic>?)
?.map((item) => CommandeItem.fromJson(item))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'client_id': clientId,
'table_id': tableId,
'reservation_id': reservationId,
'numero_commande': numeroCommande,
'statut': statut,
'total_ht': totalHt.toString(),
'total_tva': totalTva.toString(),
'total_ttc': totalTtc.toString(),
'mode_paiement': modePaiement,
'commentaires': commentaires,
'serveur': serveur,
'date_commande': dateCommande.toIso8601String(),
'date_service': dateService?.toIso8601String(),
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'items': items.map((item) => item.toJson()).toList(),
};
}
// Getters pour la compatibilité avec l'ancien code
String get commandeId => id.toString();
int get tableNumber => tableId;
double get total => totalTtc;
// Méthodes utilitaires
bool get isPaid => statut.toLowerCase() == 'payee';
bool get isInProgress => statut.toLowerCase() == 'en_cours';
bool get isReady => statut.toLowerCase() == 'pret';
bool get isCanceled => statut.toLowerCase() == 'annulee';
String get statutText {
switch (statut.toLowerCase()) {
case 'payee':
return 'Payée';
case 'en_cours':
return 'En cours';
case 'pret':
return 'Prête';
case 'annulee':
return 'Annulée';
case 'servie':
return 'Servie';
default:
return statut;
}
}
String get statutColor {
switch (statut.toLowerCase()) {
case 'payee':
return '#28A745'; // Vert
case 'en_cours':
return '#FFC107'; // Jaune
case 'pret':
return '#17A2B8'; // Bleu
case 'annulee':
return '#DC3545'; // Rouge
case 'servie':
return '#6F42C1'; // Violet
default:
return '#6C757D'; // Gris
}
}
// Calculs
double get totalItems => items.fold(0, (sum, item) => sum + item.totalItem);
int get totalQuantity => items.fold(0, (sum, item) => sum + item.quantite);
@override
String toString() {
return 'CommandeDetail{id: $id, numeroCommande: $numeroCommande, statut: $statut, totalTtc: $totalTtc}';
}
}
class CommandeItem {
final int id;
final int commandeId;
final int menuId;
final int quantite;
final double prixUnitaire;
final double totalItem;
final String? commentaires;
final String statut;
final DateTime createdAt;
final DateTime updatedAt;
final String menuNom;
final String? menuDescription;
final double menuPrixActuel;
CommandeItem({
required this.id,
required this.commandeId,
required this.menuId,
required this.quantite,
required this.prixUnitaire,
required this.totalItem,
this.commentaires,
required this.statut,
required this.createdAt,
required this.updatedAt,
required this.menuNom,
this.menuDescription,
required this.menuPrixActuel,
});
factory CommandeItem.fromJson(Map<String, dynamic> json) {
return CommandeItem(
id: json['id'] ?? 0,
commandeId: json['commande_id'] ?? 0,
menuId: json['menu_id'] ?? 0,
quantite: json['quantite'] ?? 1,
prixUnitaire:
double.tryParse(json['prix_unitaire']?.toString() ?? '0') ?? 0.0,
totalItem: double.tryParse(json['total_item']?.toString() ?? '0') ?? 0.0,
commentaires: json['commentaires'],
statut: json['statut'] ?? 'commande',
createdAt:
json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
updatedAt:
json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: DateTime.now(),
menuNom: json['menu_nom'] ?? json['name'] ?? 'Article inconnu',
menuDescription: json['menu_description'] ?? json['description'],
menuPrixActuel:
double.tryParse(json['menu_prix_actuel']?.toString() ?? '0') ?? 0.0,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'commande_id': commandeId,
'menu_id': menuId,
'quantite': quantite,
'prix_unitaire': prixUnitaire.toString(),
'total_item': totalItem.toString(),
'commentaires': commentaires,
'statut': statut,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'menu_nom': menuNom,
'menu_description': menuDescription,
'menu_prix_actuel': menuPrixActuel.toString(),
};
}
// Getters pour la compatibilité avec l'ancien code
String get name => menuNom;
int get quantity => quantite;
double get price => prixUnitaire;
// Méthodes utilitaires
String get statutText {
switch (statut.toLowerCase()) {
case 'commande':
return 'Commandé';
case 'preparation':
return 'En préparation';
case 'pret':
return 'Prêt';
case 'servi':
return 'Servi';
default:
return statut;
}
}
String get displayText => '$quantite× $menuNom';
bool get hasComments => commentaires != null && commentaires!.isNotEmpty;
@override
String toString() {
return 'CommandeItem{id: $id, menuNom: $menuNom, quantite: $quantite, totalItem: $totalItem}';
}
}
// Énumération pour les statuts de commande
enum CommandeStatut {
enCours('en_cours', 'En cours'),
pret('pret', 'Prête'),
servie('servie', 'Servie'),
payee('payee', 'Payée'),
annulee('annulee', 'Annulée');
const CommandeStatut(this.value, this.displayName);
final String value;
final String displayName;
static CommandeStatut fromString(String status) {
return CommandeStatut.values.firstWhere(
(e) => e.value == status.toLowerCase(),
orElse: () => CommandeStatut.enCours,
);
}
}
// Énumération pour les statuts d'items
enum ItemStatut {
commande('commande', 'Commandé'),
preparation('preparation', 'En préparation'),
pret('pret', 'Prêt'),
servi('servi', 'Servi');
const ItemStatut(this.value, this.displayName);
final String value;
final String displayName;
static ItemStatut fromString(String status) {
return ItemStatut.values.firstWhere(
(e) => e.value == status.toLowerCase(),
orElse: () => ItemStatut.commande,
);
}
}
// Classe de réponse API pour wrapper les données
class CommandeDetailResponse {
final bool success;
final CommandeDetail data;
final String? message;
CommandeDetailResponse({
required this.success,
required this.data,
this.message,
});
factory CommandeDetailResponse.fromJson(Map<String, dynamic> json) {
return CommandeDetailResponse(
success: json['success'] ?? false,
data: CommandeDetail.fromJson(json),
message: json['message'],
);
}
}

41
lib/models/payment_method.dart

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
class PaymentMethod {
final String id;
final String name;
final String description;
final IconData icon;
final Color color;
const PaymentMethod({
required this.id,
required this.name,
required this.description,
required this.icon,
required this.color,
});
}
final List<PaymentMethod> paymentMethods = [
PaymentMethod(
id: 'mvola',
name: 'MVola',
description: 'Paiement mobile MVola',
icon: Icons.phone,
color: const Color(0xFF4285F4),
),
PaymentMethod(
id: 'carte',
name: 'Carte Bancaire',
description: 'Paiement par carte',
icon: Icons.credit_card,
color: const Color(0xFF28A745),
),
PaymentMethod(
id: 'especes',
name: 'Espèces',
description: 'Paiement en liquide',
icon: Icons.attach_money,
color: const Color(0xFFFF9500),
),
];

208
lib/models/tables_order.dart

@ -0,0 +1,208 @@
// models/table_order.dart
class TableOrder {
final int id;
final String nom;
final int capacity;
final String status; // 'available', 'occupied', 'reserved', 'maintenance'
final String location;
final DateTime createdAt;
final DateTime updatedAt;
final double? total; // Optionnel pour les commandes en cours
final bool isEncashed;
final String? time; // Heure de la commande si applicable
final String? date; // Date de la commande si applicable
// final int? persons; // Nombre de personnes si applicable
TableOrder({
required this.id,
required this.nom,
required this.capacity,
required this.status,
required this.location,
required this.createdAt,
required this.updatedAt,
this.total,
this.isEncashed = false,
this.time,
this.date,
// this.persons,
});
factory TableOrder.fromJson(Map<String, dynamic> json) {
return TableOrder(
id: json['id'] ?? 0,
nom: json['nom'] ?? '',
capacity: json['capacity'] ?? 1,
status: json['status'] ?? 'available',
location: json['location'] ?? '',
createdAt:
json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
updatedAt:
json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: DateTime.now(),
total: json['total'] != null ? (json['total'] as num).toDouble() : null,
isEncashed: json['is_encashed'] ?? false,
time: json['time'],
date: json['date'],
// persons: json['persons'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'nom': nom,
'capacity': capacity,
'status': status,
'location': location,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
if (total != null) 'total': total,
'is_encashed': isEncashed,
if (time != null) 'time': time,
if (date != null) 'date': date,
// if (persons != null) 'persons': persons,
};
}
// Getters pour la compatibilité avec l'ancien code
int get tableNumber => id;
String get tableName => nom;
// Méthodes utilitaires
bool get isAvailable => status == 'available';
bool get isOccupied => status == 'occupied';
bool get isReserved => status == 'reserved';
bool get isInMaintenance => status == 'maintenance';
// Méthode pour obtenir la couleur selon le statut
String get statusColor {
switch (status.toLowerCase()) {
case 'available':
return '#28A745'; // Vert
case 'occupied':
return '#DC3545'; // Rouge
case 'reserved':
return '#FFC107'; // Jaune
case 'maintenance':
return '#6C757D'; // Gris
default:
return '#007BFF'; // Bleu par défaut
}
}
// Méthode pour obtenir le texte du statut en français
String get statusText {
switch (status.toLowerCase()) {
case 'available':
return 'Disponible';
case 'occupied':
return 'Occupée';
case 'reserved':
return 'Réservée';
case 'maintenance':
return 'Maintenance';
default:
return 'Inconnu';
}
}
// Méthode pour créer une copie avec des modifications
TableOrder copyWith({
int? id,
String? nom,
int? capacity,
String? status,
String? location,
DateTime? createdAt,
DateTime? updatedAt,
double? total,
bool? isEncashed,
String? time,
String? date,
int? persons,
}) {
return TableOrder(
id: id ?? this.id,
nom: nom ?? this.nom,
capacity: capacity ?? this.capacity,
status: status ?? this.status,
location: location ?? this.location,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
total: total ?? this.total,
isEncashed: isEncashed ?? this.isEncashed,
time: time ?? this.time,
date: date ?? this.date,
// persons: persons ?? this.persons,
);
}
@override
String toString() {
return 'TableOrder{id: $id, nom: $nom, capacity: $capacity, status: $status, location: $location}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TableOrder && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
// Énumération pour les statuts (optionnel, pour plus de type safety)
enum TableStatus {
available,
occupied,
reserved,
maintenance;
String get displayName {
switch (this) {
case TableStatus.available:
return 'Disponible';
case TableStatus.occupied:
return 'Occupée';
case TableStatus.reserved:
return 'Réservée';
case TableStatus.maintenance:
return 'Maintenance';
}
}
String get color {
switch (this) {
case TableStatus.available:
return '#28A745';
case TableStatus.occupied:
return '#DC3545';
case TableStatus.reserved:
return '#FFC107';
case TableStatus.maintenance:
return '#6C757D';
}
}
}
// Extension pour convertir string vers enum
extension TableStatusExtension on String {
TableStatus get toTableStatus {
switch (toLowerCase()) {
case 'available':
return TableStatus.available;
case 'occupied':
return TableStatus.occupied;
case 'reserved':
return TableStatus.reserved;
case 'maintenance':
return TableStatus.maintenance;
default:
return TableStatus.available;
}
}
}

552
lib/pages/caisse_screen.dart

@ -0,0 +1,552 @@
// pages/caisse_screen.dart
import 'package:flutter/material.dart';
import '../models/command_detail.dart';
import '../models/payment_method.dart';
import '../services/restaurant_api_service.dart';
class CaisseScreen extends StatefulWidget {
final String commandeId;
final int tableNumber;
const CaisseScreen({
Key? key,
required this.commandeId,
required this.tableNumber,
}) : super(key: key);
@override
_CaisseScreenState createState() => _CaisseScreenState();
}
class _CaisseScreenState extends State<CaisseScreen> {
CommandeDetail? commande;
PaymentMethod? selectedPaymentMethod;
bool isLoading = true;
bool isProcessingPayment = false;
final List<PaymentMethod> paymentMethods = [
PaymentMethod(
id: 'mvola',
name: 'MVola',
description: 'Paiement mobile MVola',
icon: Icons.phone,
color: const Color(0xFF4285F4),
),
PaymentMethod(
id: 'carte',
name: 'Carte Bancaire',
description: 'Paiement par carte',
icon: Icons.credit_card,
color: const Color(0xFF28A745),
),
PaymentMethod(
id: 'especes',
name: 'Espèces',
description: 'Paiement en liquide',
icon: Icons.attach_money,
color: const Color(0xFFFF9500),
),
];
@override
void initState() {
super.initState();
_loadCommandeDetails();
}
Future<void> _loadCommandeDetails() async {
setState(() => isLoading = true);
try {
final result = await RestaurantApiService.getCommandeDetails(
widget.commandeId,
);
setState(() {
commande = result;
isLoading = false;
});
} catch (e) {
setState(() => isLoading = false);
_showErrorDialog(
'Erreur lors du chargement des détails de la commande: $e',
);
}
}
Future<void> _processPayment() async {
if (selectedPaymentMethod == null) {
_showErrorDialog('Veuillez sélectionner une méthode de paiement');
return;
}
setState(() => isProcessingPayment = true);
try {
// await RestaurantApiService.processPayment(
// commandeId: widget.commandeId,
// paymentMethodId: selectedPaymentMethod!.id,
// );
_showSuccessDialog();
} catch (e) {
_showErrorDialog('Erreur lors du traitement du paiement: $e');
} finally {
setState(() => isProcessingPayment = false);
}
}
void _showErrorDialog(String message) {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: const Text('Erreur'),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
void _showSuccessDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder:
(context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.check_circle, color: Color(0xFF28A745), size: 28),
SizedBox(width: 12),
Text('Paiement réussi'),
],
),
content: Text(
'Le paiement de ${commande!.totalTtc.toStringAsFixed(2)} € a été traité avec succès via ${selectedPaymentMethod!.name}.',
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Fermer le dialog
Navigator.of(
context,
).pop(true); // Retourner à la page précédente avec succès
},
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildCommandeHeader() {
if (commande == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Commande #${commande!.numeroCommande}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.blue[200]!),
),
child: Text(
'Table ${widget.tableNumber}',
style: TextStyle(
color: Colors.blue[700],
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 16),
_buildCommandeItems(),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total:',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Text(
'${commande!.totalTtc.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF28A745),
),
),
],
),
],
),
);
}
Widget _buildCommandeItems() {
if (commande?.items.isEmpty ?? true) {
return const Text('Aucun article');
}
return Column(
children:
commande!.items.map((item) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item.quantite}× ${item.menuNom}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (item.menuDescription != null &&
item.menuDescription!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.menuDescription!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (item.hasComments)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'Note: ${item.commentaires}',
style: TextStyle(
fontSize: 12,
color: Colors.orange[700],
fontStyle: FontStyle.italic,
),
),
),
],
),
),
const SizedBox(width: 12),
Text(
'${item.totalItem.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
);
}).toList(),
);
}
Widget _buildPaymentMethods() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Méthode de paiement',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 16),
...paymentMethods.map((method) => _buildPaymentMethodCard(method)),
],
);
}
Widget _buildPaymentMethodCard(PaymentMethod method) {
final isSelected = selectedPaymentMethod?.id == method.id;
final amount = commande?.totalTtc ?? 0.0;
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
setState(() {
selectedPaymentMethod = method;
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: method.color,
borderRadius: BorderRadius.circular(12),
border:
isSelected ? Border.all(color: Colors.white, width: 3) : null,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(method.icon, color: Colors.white, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
method.name,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
method.description,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 13,
),
),
],
),
),
const SizedBox(width: 16),
Text(
'${amount.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
);
}
Widget _buildPaymentButton() {
final canPay = selectedPaymentMethod != null && !isProcessingPayment;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 20),
child: ElevatedButton(
onPressed: canPay ? _processPayment : null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF28A745),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child:
isProcessingPayment
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
SizedBox(width: 12),
Text(
'Traitement en cours...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.payment, size: 20),
const SizedBox(width: 8),
Text(
selectedPaymentMethod != null
? 'Payer ${commande?.totalTtc.toStringAsFixed(2)}'
: 'Sélectionnez une méthode de paiement',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black87),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text(
'Caisse',
style: TextStyle(
color: Colors.black87,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
centerTitle: true,
),
body:
isLoading
? const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF28A745),
),
),
SizedBox(height: 16),
Text(
'Chargement des détails...',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
)
: commande == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
const Text(
'Impossible de charger la commande',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Commande #${widget.commandeId}',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: _loadCommandeDetails,
child: const Text('Réessayer'),
),
],
),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCommandeHeader(),
const SizedBox(height: 32),
_buildPaymentMethods(),
_buildPaymentButton(),
const SizedBox(height: 20),
],
),
),
);
}
}

200
lib/pages/encaissement_screen.dart

@ -0,0 +1,200 @@
// pages/encaissement_screen.dart
import 'package:flutter/material.dart';
import 'package:itrimobe/pages/caisse_screen.dart';
import 'package:itrimobe/widgets/command_directe_dialog.dart';
import '../services/restaurant_api_service.dart';
import '../models/tables_order.dart';
import '../widgets/command_card.dart';
class EncaissementScreen extends StatefulWidget {
const EncaissementScreen({super.key});
@override
// ignore: library_private_types_in_public_api
_EncaissementScreenState createState() => _EncaissementScreenState();
}
class _EncaissementScreenState extends State<EncaissementScreen> {
List<TableOrder> commandes = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_loadCommandes();
}
Future<void> _loadCommandes() async {
setState(() => isLoading = true);
try {
final result = await RestaurantApiService.getCommandes();
setState(() {
commandes = result.where((c) => !c.isEncashed).toList();
isLoading = false;
});
} catch (e) {
setState(() => isLoading = false);
_showErrorSnackBar('Erreur lors du chargement: $e');
}
}
// Dans encaissement_screen.dart, modifier la méthode _allerAlaCaisse:
Future<void> _allerAlaCaisse(TableOrder commande) async {
// Navigation vers la page de caisse
final result = await Navigator.push(
context,
MaterialPageRoute(
builder:
(context) => CaisseScreen(
commandeId: commande.tableNumber.toString(),
tableNumber: commande.tableNumber,
),
),
);
// Recharger les données si le paiement a é effectué
if (result == true) {
_loadCommandes();
}
}
void _showCommandeDirecteDialog() {
showDialog(
context: context,
builder:
(context) => CommandeDirecteDialog(
onCommandeCreated: () {
Navigator.of(context).pop();
_loadCommandes();
},
),
);
}
void _showSuccessSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
body: Column(
children: [
// Header personnalisé
Container(
color: Colors.white,
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Icon(Icons.attach_money, color: Colors.black54, size: 28),
const SizedBox(width: 12),
Text(
'Prêt à encaisser (${commandes.length})',
style: const TextStyle(
color: Colors.black87,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
// Bouton Commande Directe
ElevatedButton.icon(
onPressed: _showCommandeDirecteDialog,
icon: const Icon(Icons.add_shopping_cart, size: 20),
label: const Text('Commande Directe'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF007BFF),
foregroundColor: Colors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
),
const Divider(height: 1, color: Color(0xFFE5E5E5)),
// Contenu principal
Expanded(
child: RefreshIndicator(
onRefresh: _loadCommandes,
child:
isLoading
? const Center(
child: CircularProgressIndicator(
color: Color(0xFF28A745),
),
)
: commandes.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.receipt_long,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune commande prête à encaisser',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Les commandes terminées apparaîtront ici',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: commandes.length,
itemBuilder: (context, index) {
return CommandeCard(
commande: commandes[index],
onAllerCaisse:
() => _allerAlaCaisse(commandes[index]),
);
},
),
),
),
],
),
);
}
}

285
lib/services/restaurant_api_service.dart

@ -0,0 +1,285 @@
// services/restaurant_api_service.dart (mise à jour)
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:itrimobe/models/command_detail.dart';
import 'package:itrimobe/models/payment_method.dart';
import 'package:itrimobe/models/tables_order.dart';
class RestaurantApiService {
static const String baseUrl = 'https://restaurant.careeracademy.mg';
static final Map<String, String> _headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
// Récupérer les commandes
static Future<List<TableOrder>> getCommandes() async {
try {
final response = await http
.get(Uri.parse('$baseUrl/api/commandes'), headers: _headers)
.timeout(
const Duration(seconds: 30),
onTimeout: () => throw TimeoutException('Délai d\'attente dépassé'),
);
if (response.statusCode == 200) {
final dynamic responseBody = json.decode(response.body);
print('Réponse getCommandes: ${responseBody['data']['commandes']}');
// Validation de la structure de réponse
if (responseBody == null) {
throw const FormatException('Réponse vide du serveur');
}
final List<dynamic> data =
responseBody is Map<String, dynamic>
? (responseBody['data']['commandes'] ??
responseBody['commandes'] ??
[])
: responseBody as List<dynamic>;
if (data.isEmpty) {
return []; // Retourner une liste vide si pas de données
}
return data.map((json) {
try {
return TableOrder.fromJson(json as Map<String, dynamic>);
} catch (e) {
if (kDebugMode) {
print('Erreur parsing commande: $json - $e');
}
rethrow;
}
}).toList();
} else if (response.statusCode >= 400 && response.statusCode < 500) {
throw Exception(
'Erreur client ${response.statusCode}: ${response.reasonPhrase}',
);
} else if (response.statusCode >= 500) {
throw Exception(
'Erreur serveur ${response.statusCode}: ${response.reasonPhrase}',
);
} else {
throw Exception(
'Réponse inattendue ${response.statusCode}: ${response.body}',
);
}
} on SocketException {
throw Exception('Pas de connexion internet. Vérifiez votre connexion.');
} on TimeoutException {
throw Exception(
'Délai d\'attente dépassé. Le serveur met trop de temps à répondre.',
);
} on FormatException catch (e) {
throw Exception('Réponse serveur invalide: ${e.message}');
} on http.ClientException catch (e) {
throw Exception('Erreur de connexion: ${e.message}');
} catch (e) {
print('Erreur inattendue getCommandes: $e');
throw Exception('Erreur lors de la récupération des commandes: $e');
}
}
// Créer une commande directe
static Future<bool> creerCommandeDirecte(
Map<String, Object> commandeData,
) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/commandes'),
headers: _headers,
body: json.encode(commandeData),
);
return response.statusCode == 201;
} catch (e) {
print('Erreur lors de la création de la commande directe: $e');
return false; // Pour la démo
}
}
//processPayment
static Future<bool> processPayment(
String commandeId,
PaymentMethod paymentMethod,
) async {
try {
final response = await http.post(
Uri.parse('$baseUrl/api/commandes/$commandeId'),
headers: _headers,
// body: json.encode({'payment_method': paymentMethod.toJson()}),
);
return response.statusCode == 200;
} catch (e) {
print('Erreur lors du paiement: $e');
return false; // Pour la démo
}
}
// Récupérer les détails d'une commande
// services/restaurant_api_service.dart (mise à jour de la méthode)
static Future<CommandeDetail> getCommandeDetails(String commandeId) async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/commandes/$commandeId'),
headers: _headers,
);
if (response.statusCode == 200) {
final Map<String, dynamic> jsonData = json.decode(response.body);
// Gestion de la réponse avec wrapper "success" et "data"
if (jsonData['success'] == true) {
return CommandeDetail.fromJson(jsonData);
} else {
throw Exception(
'Erreur API: ${jsonData['message'] ?? 'Erreur inconnue'}',
);
}
} else {
throw Exception('Erreur ${response.statusCode}: ${response.body}');
}
} catch (e) {
print('Erreur API getCommandeDetails: $e');
// Données de test basées sur votre JSON
return CommandeDetail(
id: int.tryParse(commandeId) ?? 31,
clientId: 1,
tableId: 2,
reservationId: 1,
numeroCommande: "CMD-1754147024077",
statut: "payee",
totalHt: 14.00,
totalTva: 0.00,
totalTtc: 14.00,
modePaiement: null,
commentaires: null,
serveur: "Serveur par défaut",
dateCommande: DateTime.parse("2025-08-02T15:03:44.000Z"),
dateService: null,
createdAt: DateTime.parse("2025-08-02T15:03:44.000Z"),
updatedAt: DateTime.parse("2025-08-02T15:05:21.000Z"),
items: [
CommandeItem(
id: 37,
commandeId: 31,
menuId: 3,
quantite: 1,
prixUnitaire: 14.00,
totalItem: 14.00,
commentaires: null,
statut: "commande",
createdAt: DateTime.parse("2025-08-02T15:03:44.000Z"),
updatedAt: DateTime.parse("2025-08-02T15:03:44.000Z"),
menuNom: "Pizza Margherita",
menuDescription: "Pizza traditionnelle tomate, mozzarella, basilic",
menuPrixActuel: 14.00,
),
],
);
}
}
// Récupérer toutes les tables
static Future<List<TableOrder>> getTables() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/tables'),
headers: _headers,
);
if (response.statusCode == 200) {
final List<dynamic> data = json.decode(response.body);
return data.map((json) => TableOrder.fromJson(json)).toList();
} else {
throw Exception('Erreur ${response.statusCode}: ${response.body}');
}
} catch (e) {
print('Erreur API getTables: $e');
// Données de test basées sur votre structure DB
return [
TableOrder(
id: 1,
nom: 'Table 1',
capacity: 4,
status: 'available',
location: 'Salle principale',
createdAt: DateTime.now().subtract(Duration(days: 1)),
updatedAt: DateTime.now(),
),
TableOrder(
id: 2,
nom: 'Table 2',
capacity: 2,
status: 'occupied',
location: 'Terrasse',
createdAt: DateTime.now().subtract(Duration(hours: 2)),
updatedAt: DateTime.now(),
total: 27.00,
time: '00:02',
date: '02/08/2025',
),
// Ajoutez d'autres tables de test...
];
}
}
// Récupérer les commandes prêtes à encaisser
static Future<List<TableOrder>> getCommandesPretesAEncaisser() async {
try {
final response = await http.get(
Uri.parse('$baseUrl/api/commandes'),
headers: _headers,
);
if (response.statusCode == 200) {
final dynamic responseBody = json.decode(response.body);
// Gérer les réponses avec wrapper "data"
final List<dynamic> data =
responseBody is Map<String, dynamic>
? (responseBody['data'] ?? responseBody['commandes'] ?? [])
: responseBody as List<dynamic>;
return data.map((json) => TableOrder.fromJson(json)).toList();
} else {
throw Exception(
'Erreur serveur ${response.statusCode}: ${response.body}',
);
}
} on SocketException {
throw Exception('Pas de connexion internet');
} on TimeoutException {
throw Exception('Délai d\'attente dépassé');
} on FormatException {
throw Exception('Réponse serveur invalide');
} catch (e) {
print('Erreur API getCommandesPretesAEncaisser: $e');
throw Exception('Erreur lors de la récupération des commandes: $e');
}
}
// Mettre à jour le statut d'une table
static Future<bool> updateTableStatus(int tableId, String newStatus) async {
try {
final response = await http.put(
Uri.parse('$baseUrl/api/tables/$tableId'),
headers: _headers,
body: json.encode({
'status': newStatus,
'updated_at': DateTime.now().toIso8601String(),
}),
);
return response.statusCode == 200;
} catch (e) {
print('Erreur lors de la mise à jour du statut: $e');
return true; // Pour la démo
}
}
}

43
lib/widgets/bottom_navigation.dart

@ -195,6 +195,49 @@ class AppBottomNavigation extends StatelessWidget {
),
),
const SizedBox(width: 20),
GestureDetector(
onTap: () => onItemTapped(5),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color:
selectedIndex == 5
? Colors.green.shade700
: Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.payment,
color:
selectedIndex == 5
? Colors.white
: Colors.grey.shade600,
size: 16,
),
const SizedBox(width: 6),
Text(
'Encaissement',
style: TextStyle(
color:
selectedIndex == 5
? Colors.white
: Colors.grey.shade600,
fontWeight:
selectedIndex == 5
? FontWeight.w500
: FontWeight.normal,
),
),
],
),
),
),
const Spacer(),
// User Profile Section

132
lib/widgets/command_card.dart

@ -0,0 +1,132 @@
// widgets/commande_card.dart
import 'package:flutter/material.dart';
import 'package:itrimobe/models/tables_order.dart';
class CommandeCard extends StatelessWidget {
final TableOrder commande;
final VoidCallback onAllerCaisse;
const CommandeCard({
super.key,
required this.commande,
required this.onAllerCaisse,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Color(0xFF28A745), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header avec numéro de table et badge
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Table ${commande.tableNumber}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Color(0xFF28A745),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.white, size: 16),
SizedBox(width: 4),
Text(
'À encaisser',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
SizedBox(height: 12),
// Informations détaillées
Row(
children: [
Icon(Icons.access_time, size: 16, color: Colors.grey[600]),
SizedBox(width: 6),
Text(
'${commande.time}${commande.date} ',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
],
),
SizedBox(height: 16),
// Total et bouton
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
// alignItems: MainAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Total',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
Text(
'${commande.total?.toStringAsFixed(2)} MGA',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF28A745),
),
),
],
),
ElevatedButton.icon(
onPressed: onAllerCaisse,
icon: Icon(Icons.point_of_sale, size: 18),
label: Text('Aller à la caisse'),
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF28A745),
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 2,
),
),
],
),
],
),
),
);
}
}

184
lib/widgets/command_directe_dialog.dart

@ -0,0 +1,184 @@
// widgets/commande_directe_dialog.dart
import 'package:flutter/material.dart';
import 'package:itrimobe/services/restaurant_api_service.dart';
class CommandeDirecteDialog extends StatefulWidget {
final VoidCallback onCommandeCreated;
const CommandeDirecteDialog({super.key, required this.onCommandeCreated});
@override
_CommandeDirecteDialogState createState() => _CommandeDirecteDialogState();
}
class _CommandeDirecteDialogState extends State<CommandeDirecteDialog> {
final _formKey = GlobalKey<FormState>();
final _tableController = TextEditingController();
final _personnesController = TextEditingController(text: '1');
final _totalController = TextEditingController();
bool _isLoading = false;
Future<void> _creerCommande() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final commandeData = {
'table_number': int.parse(_tableController.text),
'persons': int.parse(_personnesController.text),
'total': double.parse(_totalController.text),
'time': TimeOfDay.now().format(context),
'date': DateTime.now().toString().split(' ')[0],
'is_direct': true,
};
final success = await RestaurantApiService.creerCommandeDirecte(
commandeData,
);
setState(() => _isLoading = false);
if (success) {
widget.onCommandeCreated();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Commande directe créée avec succès'),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la création'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
padding: EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.add_shopping_cart, color: Color(0xFF007BFF)),
SizedBox(width: 12),
Text(
'Nouvelle Commande Directe',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
SizedBox(height: 24),
TextFormField(
controller: _tableController,
decoration: InputDecoration(
labelText: 'Numéro de table',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: Icon(Icons.table_restaurant),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value?.isEmpty ?? true) return 'Numéro de table requis';
if (int.tryParse(value!) == null) return 'Numéro invalide';
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _personnesController,
decoration: InputDecoration(
labelText: 'Nombre de personnes',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: Icon(Icons.people),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value?.isEmpty ?? true)
return 'Nombre de personnes requis';
if (int.tryParse(value!) == null) return 'Nombre invalide';
return null;
},
),
SizedBox(height: 16),
TextFormField(
controller: _totalController,
decoration: InputDecoration(
labelText: 'Total (MGA)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: Icon(Icons.euro),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value?.isEmpty ?? true) return 'Total requis';
if (double.tryParse(value!) == null)
return 'Montant invalide';
return null;
},
),
SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isLoading ? null : () => Navigator.pop(context),
child: Text('Annuler'),
),
SizedBox(width: 12),
ElevatedButton(
onPressed: _isLoading ? null : _creerCommande,
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF28A745),
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child:
_isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('Créer'),
),
],
),
],
),
),
),
);
}
}

6
pubspec.yaml

@ -35,3 +35,9 @@ flutter:
# Décommente ceci si tu veux ajouter des images par exemple :
assets:
- assets/logo_transparent.png
flutter_icons:
android: true
ios: true
image_path: "assets/logo_transparent.png"

Loading…
Cancel
Save