Cette intégration permet aux amicales de configurer leurs comptes Stripe Express pour accepter les paiements par carte bancaire avec 0% de commission plateforme. ## 🎯 Fonctionnalités implémentées ### API PHP (Backend) - **POST /api/stripe/accounts**: Création comptes Stripe Express - **GET /api/stripe/accounts/:id/status**: Vérification statut compte - **POST /api/stripe/accounts/:id/onboarding-link**: Liens onboarding - **POST /api/stripe/locations**: Création locations Terminal - **POST /api/stripe/terminal/connection-token**: Tokens connexion - **POST /api/stripe/webhook**: Réception événements Stripe ### Interface Flutter (Frontend) - Widget configuration Stripe dans amicale_form.dart - Service StripeConnectService pour communication API - États visuels dynamiques avec codes couleur - Messages utilisateur "100% des paiements pour votre amicale" ## 🔧 Corrections techniques ### StripeController.php - Fix Database::getInstance() → $this->db - Fix $db->prepare() → $this->db->prepare() - Suppression colonne details_submitted inexistante - Ajout exit après réponses JSON (évite 502) ### StripeService.php - Ajout imports Stripe SDK (use Stripe\Account) - Fix Account::retrieve() → $this->stripe->accounts->retrieve() - **CRUCIAL**: Déchiffrement données encrypted_email/encrypted_name - Suppression calcul commission (0% plateforme) ### Router.php - Suppression logs debug excessifs (fix nginx 502 "header too big") ### AppConfig.php - application_fee_percent: 0 (était 2.5) - application_fee_minimum: 0 (était 50) - **POLITIQUE**: 100% des paiements vers amicales ## ✅ Tests validés - Compte pilote créé: acct_1S2YfNP63A07c33Y - Location Terminal: tml_GLJ21w7KCYX4Wj - Onboarding Stripe complété avec succès - Toutes les APIs retournent 200 OK ## 📚 Documentation - Plannings mis à jour avec accomplissements - Architecture technique documentée - Erreurs résolues listées avec solutions ## 🚀 Prêt pour production V1 Stripe Connect opérationnelle - Prochaine étape: Terminal Payments V2 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1711 lines
50 KiB
Markdown
1711 lines
50 KiB
Markdown
# PLANNING STRIPE - DÉVELOPPEUR FLUTTER
|
|
## App Flutter - Intégration Stripe Tap to Pay (iOS uniquement V1)
|
|
### Période : 25/08/2024 - 05/09/2024
|
|
|
|
---
|
|
|
|
## 📅 LUNDI 25/08 - Setup et architecture (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Installation packages (EN COURS D'IMPLÉMENTATION)
|
|
```yaml
|
|
# pubspec.yaml - PLANIFIÉ
|
|
dependencies:
|
|
stripe_terminal: ^3.2.0 # Pour Tap to Pay (iOS uniquement)
|
|
stripe_ios: ^10.0.0 # SDK iOS Stripe
|
|
dio: ^5.4.0 # Déjà présent
|
|
device_info_plus: ^10.1.0 # Info appareils
|
|
shared_preferences: ^2.2.2 # Déjà présent
|
|
connectivity_plus: ^5.0.2 # Connectivité réseau
|
|
```
|
|
|
|
**STATUS**: Configuration Stripe Connect intégrée dans l'interface existante
|
|
|
|
```bash
|
|
cd app
|
|
flutter pub get
|
|
cd ios
|
|
pod install
|
|
```
|
|
|
|
#### ✅ Configuration iOS
|
|
```xml
|
|
<!-- ios/Runner/Info.plist -->
|
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
<string>L'app utilise Bluetooth pour Tap to Pay</string>
|
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
<string>L'app utilise Bluetooth pour accepter les paiements</string>
|
|
<key>NSLocationWhenInUseUsageDescription</key>
|
|
<string>Localisation nécessaire pour les paiements</string>
|
|
<key>UIBackgroundModes</key>
|
|
<array>
|
|
<string>bluetooth-central</string>
|
|
<string>bluetooth-peripheral</string>
|
|
<string>external-accessory</string>
|
|
</array>
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Service de base StripeTerminalService
|
|
```dart
|
|
// lib/services/stripe_terminal_service.dart
|
|
import 'package:stripe_terminal/stripe_terminal.dart';
|
|
|
|
class StripeTerminalService {
|
|
static final StripeTerminalService _instance = StripeTerminalService._internal();
|
|
factory StripeTerminalService() => _instance;
|
|
StripeTerminalService._internal();
|
|
|
|
Terminal? _terminal;
|
|
bool _isInitialized = false;
|
|
|
|
Future<void> initialize() async {
|
|
if (_isInitialized) return;
|
|
|
|
_terminal = await Terminal.getInstance(
|
|
fetchConnectionToken: _fetchConnectionToken,
|
|
);
|
|
|
|
_isInitialized = true;
|
|
}
|
|
|
|
Future<String> _fetchConnectionToken() async {
|
|
final response = await ApiService().post('/terminal/connection-token');
|
|
return response['secret'];
|
|
}
|
|
|
|
Future<bool> checkTapToPayCapability() async {
|
|
if (!Platform.isIOS) return false;
|
|
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
final iosInfo = await deviceInfo.iosInfo;
|
|
|
|
// iPhone XS et ultérieurs
|
|
final supportedModels = [
|
|
'iPhone11,', // XS, XS Max
|
|
'iPhone12,', // 11, 11 Pro
|
|
'iPhone13,', // 12 series
|
|
'iPhone14,', // 13 series
|
|
'iPhone15,', // 14 series
|
|
'iPhone16,', // 15 series
|
|
];
|
|
|
|
final modelIdentifier = iosInfo.utsname.machine;
|
|
final isSupported = supportedModels.any((model) =>
|
|
modelIdentifier.startsWith(model)
|
|
);
|
|
|
|
// iOS 15.4 minimum
|
|
final osVersion = iosInfo.systemVersion.split('.').map(int.parse).toList();
|
|
final isOSSupported = osVersion[0] > 15 ||
|
|
(osVersion[0] == 15 && osVersion.length > 1 && osVersion[1] >= 4);
|
|
|
|
return isSupported && isOSSupported;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 MARDI 26/08 - UI Paiement principal (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Écran de vérification compatibilité
|
|
```dart
|
|
// lib/screens/payment/compatibility_check_screen.dart
|
|
class CompatibilityCheckScreen extends StatefulWidget {
|
|
@override
|
|
_CompatibilityCheckScreenState createState() => _CompatibilityCheckScreenState();
|
|
}
|
|
|
|
class _CompatibilityCheckScreenState extends State<CompatibilityCheckScreen> {
|
|
bool? _isCompatible;
|
|
String _deviceInfo = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_checkCompatibility();
|
|
}
|
|
|
|
Future<void> _checkCompatibility() async {
|
|
final service = StripeTerminalService();
|
|
final compatible = await service.checkTapToPayCapability();
|
|
|
|
final deviceInfo = DeviceInfoPlugin();
|
|
final iosInfo = await deviceInfo.iosInfo;
|
|
|
|
setState(() {
|
|
_isCompatible = compatible;
|
|
_deviceInfo = '${iosInfo.name} - iOS ${iosInfo.systemVersion}';
|
|
});
|
|
|
|
if (compatible) {
|
|
await service.initialize();
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => PaymentScreen())
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_isCompatible == null) {
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
CircularProgressIndicator(),
|
|
SizedBox(height: 20),
|
|
Text('Vérification de compatibilité...'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!_isCompatible!) {
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.error_outline, size: 80, color: Colors.orange),
|
|
SizedBox(height: 20),
|
|
Text(
|
|
'Tap to Pay non disponible',
|
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 10),
|
|
Text(
|
|
'Votre appareil: $_deviceInfo',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
SizedBox(height: 20),
|
|
Text(
|
|
'Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 15.4+',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 16),
|
|
),
|
|
SizedBox(height: 30),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Retour'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(); // Ne devrait jamais arriver ici
|
|
}
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Écran de paiement principal
|
|
```dart
|
|
// lib/screens/payment/payment_screen.dart
|
|
class PaymentScreen extends StatefulWidget {
|
|
final Amicale amicale;
|
|
|
|
PaymentScreen({required this.amicale});
|
|
|
|
@override
|
|
_PaymentScreenState createState() => _PaymentScreenState();
|
|
}
|
|
|
|
class _PaymentScreenState extends State<PaymentScreen> {
|
|
final _amounts = [10.0, 20.0, 30.0, 50.0]; // en euros
|
|
double _selectedAmount = 20.0;
|
|
bool _isProcessing = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Paiement Calendrier'),
|
|
backgroundColor: Colors.red.shade700,
|
|
),
|
|
body: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Info amicale
|
|
Card(
|
|
child: ListTile(
|
|
leading: Icon(Icons.group, color: Colors.red),
|
|
title: Text(widget.amicale.name),
|
|
subtitle: Text('Calendrier ${DateTime.now().year}'),
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
// Sélection montant
|
|
Text(
|
|
'Sélectionnez le montant',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 15),
|
|
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: _amounts.map((amount) {
|
|
final isSelected = _selectedAmount == amount;
|
|
return ChoiceChip(
|
|
label: Text(
|
|
'${amount.toStringAsFixed(0)}€',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: isSelected ? Colors.white : Colors.black,
|
|
),
|
|
),
|
|
selected: isSelected,
|
|
selectedColor: Colors.red.shade700,
|
|
onSelected: (selected) {
|
|
if (selected && !_isProcessing) {
|
|
setState(() => _selectedAmount = amount);
|
|
}
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
|
|
// Montant personnalisé
|
|
SizedBox(height: 20),
|
|
TextField(
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: 'Ou entrez un montant personnalisé',
|
|
prefixText: '€ ',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (value) {
|
|
final amount = double.tryParse(value);
|
|
if (amount != null && amount >= 1) {
|
|
setState(() => _selectedAmount = amount);
|
|
}
|
|
},
|
|
),
|
|
|
|
Spacer(),
|
|
|
|
// Bouton paiement
|
|
ElevatedButton(
|
|
onPressed: _isProcessing ? null : _startPayment,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red.shade700,
|
|
padding: EdgeInsets.symmetric(vertical: 20),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
child: _isProcessing
|
|
? Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
color: Colors.white,
|
|
strokeWidth: 2,
|
|
),
|
|
),
|
|
SizedBox(width: 10),
|
|
Text('Traitement...', style: TextStyle(fontSize: 18)),
|
|
],
|
|
)
|
|
: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.contactless, size: 30),
|
|
SizedBox(width: 10),
|
|
Text(
|
|
'Payer ${_selectedAmount.toStringAsFixed(2)}€',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 10),
|
|
|
|
Text(
|
|
'Paiement sécurisé par Stripe',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _startPayment() async {
|
|
setState(() => _isProcessing = true);
|
|
|
|
try {
|
|
// Naviguer vers l'écran Tap to Pay
|
|
final result = await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => TapToPayScreen(
|
|
amount: _selectedAmount,
|
|
amicale: widget.amicale,
|
|
),
|
|
),
|
|
);
|
|
|
|
if (result != null && result['success']) {
|
|
_showSuccessDialog(result['paymentIntentId']);
|
|
}
|
|
} catch (e) {
|
|
_showErrorDialog(e.toString());
|
|
} finally {
|
|
setState(() => _isProcessing = false);
|
|
}
|
|
}
|
|
|
|
void _showSuccessDialog(String paymentIntentId) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: Colors.green, size: 30),
|
|
SizedBox(width: 10),
|
|
Text('Paiement réussi !'),
|
|
],
|
|
),
|
|
content: Text('Le paiement a été effectué avec succès.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
Navigator.of(context).pop(); // Retour écran principal
|
|
},
|
|
child: Text('OK'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showErrorDialog(String error) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('Erreur de paiement'),
|
|
content: Text(error),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text('Réessayer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 MERCREDI 27/08 - Flux Tap to Pay (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Écran Tap to Pay
|
|
```dart
|
|
// lib/screens/payment/tap_to_pay_screen.dart
|
|
class TapToPayScreen extends StatefulWidget {
|
|
final double amount;
|
|
final Amicale amicale;
|
|
|
|
TapToPayScreen({required this.amount, required this.amicale});
|
|
|
|
@override
|
|
_TapToPayScreenState createState() => _TapToPayScreenState();
|
|
}
|
|
|
|
class _TapToPayScreenState extends State<TapToPayScreen> {
|
|
final _terminalService = StripeTerminalService();
|
|
PaymentIntent? _paymentIntent;
|
|
String _status = 'Initialisation...';
|
|
bool _isProcessing = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializePayment();
|
|
}
|
|
|
|
Future<void> _initializePayment() async {
|
|
try {
|
|
setState(() => _status = 'Création du paiement...');
|
|
|
|
// 1. Créer PaymentIntent via API
|
|
final response = await ApiService().post('/payments/create-intent', {
|
|
'amount': (widget.amount * 100).round(), // en centimes
|
|
'amicale_id': widget.amicale.id,
|
|
});
|
|
|
|
setState(() => _status = 'Connexion au lecteur...');
|
|
|
|
// 2. Découvrir et connecter le lecteur Tap to Pay
|
|
await _terminalService.discoverReaders(
|
|
config: LocalMobileDiscoveryConfiguration(),
|
|
);
|
|
|
|
final readers = await _terminalService.getDiscoveredReaders();
|
|
if (readers.isEmpty) {
|
|
throw Exception('Aucun lecteur Tap to Pay disponible');
|
|
}
|
|
|
|
await _terminalService.connectToReader(readers.first);
|
|
|
|
setState(() => _status = 'Prêt pour le paiement');
|
|
|
|
// 3. Collecter le paiement
|
|
_paymentIntent = await _terminalService.collectPaymentMethod(
|
|
response['client_secret'],
|
|
);
|
|
|
|
setState(() => _status = 'Traitement du paiement...');
|
|
|
|
// 4. Confirmer le paiement
|
|
final confirmedIntent = await _terminalService.confirmPaymentIntent(
|
|
_paymentIntent!,
|
|
);
|
|
|
|
if (confirmedIntent.status == PaymentIntentStatus.succeeded) {
|
|
_onPaymentSuccess(confirmedIntent.id);
|
|
} else {
|
|
throw Exception('Paiement non confirmé');
|
|
}
|
|
|
|
} catch (e) {
|
|
_onPaymentError(e.toString());
|
|
}
|
|
}
|
|
|
|
void _onPaymentSuccess(String paymentIntentId) {
|
|
Navigator.pop(context, {
|
|
'success': true,
|
|
'paymentIntentId': paymentIntentId,
|
|
});
|
|
}
|
|
|
|
void _onPaymentError(String error) {
|
|
setState(() {
|
|
_isProcessing = false;
|
|
_status = 'Erreur: $error';
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.grey.shade100,
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
// Header
|
|
Container(
|
|
padding: EdgeInsets.all(20),
|
|
color: Colors.white,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: Icon(Icons.close),
|
|
onPressed: _isProcessing ? null : () => Navigator.pop(context),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
'Paiement sans contact',
|
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
SizedBox(width: 48), // Pour équilibrer avec IconButton
|
|
],
|
|
),
|
|
),
|
|
|
|
// Montant
|
|
Container(
|
|
margin: EdgeInsets.all(20),
|
|
padding: EdgeInsets.all(30),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(15),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.grey.shade300,
|
|
blurRadius: 10,
|
|
offset: Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'${widget.amount.toStringAsFixed(2)} €',
|
|
style: TextStyle(
|
|
fontSize: 48,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.red.shade700,
|
|
),
|
|
),
|
|
SizedBox(height: 10),
|
|
Text(
|
|
widget.amicale.name,
|
|
style: TextStyle(fontSize: 16, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Animation Tap to Pay
|
|
Expanded(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (_status.contains('Prêt'))
|
|
TapToPayAnimation()
|
|
else if (_isProcessing)
|
|
CircularProgressIndicator(
|
|
size: 80,
|
|
strokeWidth: 8,
|
|
valueColor: AlwaysStoppedAnimation(Colors.red.shade700),
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
Text(
|
|
_status,
|
|
style: TextStyle(fontSize: 18),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
|
|
if (_status.contains('Prêt'))
|
|
Padding(
|
|
padding: EdgeInsets.only(top: 20),
|
|
child: Text(
|
|
'Approchez la carte du dos de l\'iPhone',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Footer
|
|
if (!_isProcessing && _status.contains('Erreur'))
|
|
Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: ElevatedButton(
|
|
onPressed: _initializePayment,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red.shade700,
|
|
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 15),
|
|
),
|
|
child: Text('Réessayer', style: TextStyle(fontSize: 16)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Animation Tap to Pay
|
|
```dart
|
|
// lib/widgets/tap_to_pay_animation.dart
|
|
class TapToPayAnimation extends StatefulWidget {
|
|
@override
|
|
_TapToPayAnimationState createState() => _TapToPayAnimationState();
|
|
}
|
|
|
|
class _TapToPayAnimationState extends State<TapToPayAnimation>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _scaleAnimation;
|
|
late Animation<double> _opacityAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
duration: Duration(seconds: 2),
|
|
vsync: this,
|
|
)..repeat();
|
|
|
|
_scaleAnimation = Tween<double>(
|
|
begin: 0.8,
|
|
end: 1.5,
|
|
).animate(CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.easeOut,
|
|
));
|
|
|
|
_opacityAnimation = Tween<double>(
|
|
begin: 1.0,
|
|
end: 0.0,
|
|
).animate(CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.easeOut,
|
|
));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 200,
|
|
height: 200,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
// Ondes animées
|
|
AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return Transform.scale(
|
|
scale: _scaleAnimation.value,
|
|
child: Opacity(
|
|
opacity: _opacityAnimation.value,
|
|
child: Container(
|
|
width: 150,
|
|
height: 150,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(
|
|
color: Colors.red.shade700,
|
|
width: 3,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// Icône centrale
|
|
Container(
|
|
width: 100,
|
|
height: 100,
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade700,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.contactless,
|
|
size: 60,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 JEUDI 28/08 - Gestion offline et sync (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Service de queue offline
|
|
```dart
|
|
// lib/services/offline_queue_service.dart
|
|
class OfflineQueueService {
|
|
static const String _queueKey = 'offline_payment_queue';
|
|
|
|
Future<void> addToQueue(PaymentData payment) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final queueJson = prefs.getString(_queueKey) ?? '[]';
|
|
final queue = List<Map<String, dynamic>>.from(json.decode(queueJson));
|
|
|
|
queue.add({
|
|
'local_id': DateTime.now().millisecondsSinceEpoch.toString(),
|
|
'amount': payment.amount,
|
|
'amicale_id': payment.amicaleId,
|
|
'pompier_id': payment.pompierId,
|
|
'created_at': DateTime.now().toIso8601String(),
|
|
'payment_method': 'card',
|
|
'status': 'pending_sync',
|
|
});
|
|
|
|
await prefs.setString(_queueKey, json.encode(queue));
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> getQueue() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final queueJson = prefs.getString(_queueKey) ?? '[]';
|
|
return List<Map<String, dynamic>>.from(json.decode(queueJson));
|
|
}
|
|
|
|
Future<void> syncQueue() async {
|
|
final queue = await getQueue();
|
|
if (queue.isEmpty) return;
|
|
|
|
final connectivity = await Connectivity().checkConnectivity();
|
|
if (connectivity == ConnectivityResult.none) return;
|
|
|
|
try {
|
|
final response = await ApiService().post('/payments/batch-sync', {
|
|
'transactions': queue,
|
|
});
|
|
|
|
// Clear queue after successful sync
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_queueKey, '[]');
|
|
|
|
} catch (e) {
|
|
print('Sync failed: $e');
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Moniteur de connectivité
|
|
```dart
|
|
// lib/services/connectivity_monitor.dart
|
|
class ConnectivityMonitor {
|
|
final _connectivity = Connectivity();
|
|
StreamSubscription<ConnectivityResult>? _subscription;
|
|
final _offlineQueue = OfflineQueueService();
|
|
|
|
void startMonitoring() {
|
|
_subscription = _connectivity.onConnectivityChanged.listen((result) {
|
|
if (result != ConnectivityResult.none) {
|
|
// Back online, try to sync
|
|
_offlineQueue.syncQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
void stopMonitoring() {
|
|
_subscription?.cancel();
|
|
}
|
|
|
|
Future<bool> isOnline() async {
|
|
final result = await _connectivity.checkConnectivity();
|
|
return result != ConnectivityResult.none;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 VENDREDI 29/08 - Écran récap et reçu (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Écran de récapitulatif post-paiement
|
|
```dart
|
|
// lib/screens/payment/payment_receipt_screen.dart
|
|
class PaymentReceiptScreen extends StatelessWidget {
|
|
final String paymentIntentId;
|
|
final double amount;
|
|
final Amicale amicale;
|
|
|
|
PaymentReceiptScreen({
|
|
required this.paymentIntentId,
|
|
required this.amount,
|
|
required this.amicale,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Colors.green.shade50,
|
|
appBar: AppBar(
|
|
title: Text('Paiement confirmé'),
|
|
backgroundColor: Colors.green.shade700,
|
|
automaticallyImplyLeading: false,
|
|
),
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Column(
|
|
children: [
|
|
// Success icon
|
|
Container(
|
|
padding: EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.check_circle,
|
|
size: 80,
|
|
color: Colors.green.shade700,
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
Text(
|
|
'Merci pour votre soutien !',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 20),
|
|
|
|
// Détails de la transaction
|
|
Container(
|
|
padding: EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(15),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.grey.shade200,
|
|
blurRadius: 10,
|
|
offset: Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildReceiptRow('Montant', '${amount.toStringAsFixed(2)} €'),
|
|
Divider(),
|
|
_buildReceiptRow('Amicale', amicale.name),
|
|
Divider(),
|
|
_buildReceiptRow('Date', DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now())),
|
|
Divider(),
|
|
_buildReceiptRow('Référence', paymentIntentId.substring(0, 10).toUpperCase()),
|
|
],
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
// Options de reçu
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () => _sendReceiptByEmail(context),
|
|
icon: Icon(Icons.email),
|
|
label: Text('Email'),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _sendReceiptBySMS(context),
|
|
icon: Icon(Icons.sms),
|
|
label: Text('SMS'),
|
|
style: OutlinedButton.styleFrom(
|
|
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
Spacer(),
|
|
|
|
// Bouton terminer
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.green.shade700,
|
|
padding: EdgeInsets.symmetric(horizontal: 50, vertical: 15),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
child: Text(
|
|
'Terminer',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildReceiptRow(String label, String value) {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label, style: TextStyle(color: Colors.grey)),
|
|
Text(value, style: TextStyle(fontWeight: FontWeight.bold)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _sendReceiptByEmail(BuildContext context) async {
|
|
// Implémenter l'envoi par email via API
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Reçu envoyé par email')),
|
|
);
|
|
}
|
|
|
|
Future<void> _sendReceiptBySMS(BuildContext context) async {
|
|
// Implémenter l'envoi par SMS via API
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Reçu envoyé par SMS')),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Dashboard pompier
|
|
```dart
|
|
// lib/screens/dashboard/pompier_dashboard_screen.dart
|
|
class PompierDashboardScreen extends StatefulWidget {
|
|
@override
|
|
_PompierDashboardScreenState createState() => _PompierDashboardScreenState();
|
|
}
|
|
|
|
class _PompierDashboardScreenState extends State<PompierDashboardScreen> {
|
|
Map<String, dynamic>? _stats;
|
|
bool _isLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadStats();
|
|
}
|
|
|
|
Future<void> _loadStats() async {
|
|
try {
|
|
final response = await ApiService().get('/pompiers/me/stats');
|
|
setState(() {
|
|
_stats = response;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Mes ventes'),
|
|
backgroundColor: Colors.red.shade700,
|
|
),
|
|
body: _isLoading
|
|
? Center(child: CircularProgressIndicator())
|
|
: RefreshIndicator(
|
|
onRefresh: _loadStats,
|
|
child: ListView(
|
|
padding: EdgeInsets.all(20),
|
|
children: [
|
|
// Stats du jour
|
|
_buildStatCard(
|
|
'Aujourd\'hui',
|
|
_stats?['today_count'] ?? 0,
|
|
_stats?['today_amount'] ?? 0,
|
|
Colors.blue,
|
|
),
|
|
|
|
SizedBox(height: 15),
|
|
|
|
// Stats de la semaine
|
|
_buildStatCard(
|
|
'Cette semaine',
|
|
_stats?['week_count'] ?? 0,
|
|
_stats?['week_amount'] ?? 0,
|
|
Colors.orange,
|
|
),
|
|
|
|
SizedBox(height: 15),
|
|
|
|
// Stats totales
|
|
_buildStatCard(
|
|
'Total',
|
|
_stats?['total_count'] ?? 0,
|
|
_stats?['total_amount'] ?? 0,
|
|
Colors.green,
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
// Dernières transactions
|
|
Text(
|
|
'Dernières ventes',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
|
|
SizedBox(height: 10),
|
|
|
|
...(_stats?['recent_transactions'] ?? []).map((transaction) {
|
|
return Card(
|
|
child: ListTile(
|
|
leading: Icon(Icons.check_circle, color: Colors.green),
|
|
title: Text('${(transaction['amount'] / 100).toStringAsFixed(2)} €'),
|
|
subtitle: Text(DateFormat('dd/MM HH:mm').format(
|
|
DateTime.parse(transaction['created_at'])
|
|
)),
|
|
trailing: Icon(Icons.arrow_forward_ios, size: 16),
|
|
),
|
|
);
|
|
}).toList(),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => CompatibilityCheckScreen()),
|
|
);
|
|
},
|
|
backgroundColor: Colors.red.shade700,
|
|
icon: Icon(Icons.contactless),
|
|
label: Text('Nouveau paiement'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard(String title, int count, int amount, Color color) {
|
|
return Container(
|
|
padding: EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [color.withOpacity(0.8), color],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(15),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withOpacity(0.3),
|
|
blurRadius: 10,
|
|
offset: Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(color: Colors.white, fontSize: 16),
|
|
),
|
|
SizedBox(height: 5),
|
|
Text(
|
|
'$count ventes',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
'${(amount / 100).toStringAsFixed(2)} €',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 LUNDI 01/09 - Intégration avec login (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Vérification statut Stripe au login
|
|
```dart
|
|
// lib/screens/auth/login_screen.dart
|
|
class LoginScreen extends StatefulWidget {
|
|
@override
|
|
_LoginScreenState createState() => _LoginScreenState();
|
|
}
|
|
|
|
class _LoginScreenState extends State<LoginScreen> {
|
|
final _emailController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
bool _isLoading = false;
|
|
|
|
Future<void> _login() async {
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
// 1. Authentification
|
|
final authResponse = await ApiService().post('/auth/login', {
|
|
'email': _emailController.text,
|
|
'password': _passwordController.text,
|
|
});
|
|
|
|
// 2. Stocker token
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('auth_token', authResponse['token']);
|
|
await prefs.setString('user_id', authResponse['user']['id'].toString());
|
|
|
|
// 3. Vérifier statut Stripe de l'amicale
|
|
final amicaleResponse = await ApiService().get(
|
|
'/amicales/${authResponse['user']['amicale_id']}/stripe-status'
|
|
);
|
|
|
|
if (!amicaleResponse['charges_enabled']) {
|
|
// Amicale pas encore configurée pour Stripe
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => StripeSetupPendingScreen()),
|
|
);
|
|
} else {
|
|
// Tout est OK, aller au dashboard
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => PompierDashboardScreen()),
|
|
);
|
|
}
|
|
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Erreur de connexion: ${e.toString()}')),
|
|
);
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: SafeArea(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Logo
|
|
Image.asset('assets/logo_pompiers.png', height: 100),
|
|
|
|
SizedBox(height: 40),
|
|
|
|
Text(
|
|
'Calendriers des Pompiers',
|
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
),
|
|
|
|
SizedBox(height: 40),
|
|
|
|
// Email field
|
|
TextField(
|
|
controller: _emailController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Email',
|
|
prefixIcon: Icon(Icons.email),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
|
|
SizedBox(height: 15),
|
|
|
|
// Password field
|
|
TextField(
|
|
controller: _passwordController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Mot de passe',
|
|
prefixIcon: Icon(Icons.lock),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
obscureText: true,
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
// Login button
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _login,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red.shade700,
|
|
padding: EdgeInsets.symmetric(horizontal: 50, vertical: 15),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
),
|
|
child: _isLoading
|
|
? CircularProgressIndicator(color: Colors.white)
|
|
: Text(
|
|
'Se connecter',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Écran attente configuration Stripe
|
|
```dart
|
|
// lib/screens/stripe/stripe_setup_pending_screen.dart
|
|
class StripeSetupPendingScreen extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
body: Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(30),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.hourglass_empty,
|
|
size: 80,
|
|
color: Colors.orange,
|
|
),
|
|
|
|
SizedBox(height: 30),
|
|
|
|
Text(
|
|
'Configuration en attente',
|
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
|
),
|
|
|
|
SizedBox(height: 20),
|
|
|
|
Text(
|
|
'Votre amicale n\'a pas encore configuré son compte de paiement Stripe.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 16, color: Colors.grey),
|
|
),
|
|
|
|
SizedBox(height: 10),
|
|
|
|
Text(
|
|
'Contactez votre responsable pour finaliser la configuration.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
|
),
|
|
|
|
SizedBox(height: 40),
|
|
|
|
ElevatedButton.icon(
|
|
onPressed: () async {
|
|
// Vérifier à nouveau le statut
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final amicaleId = prefs.getString('amicale_id');
|
|
|
|
final response = await ApiService().get(
|
|
'/amicales/$amicaleId/stripe-status'
|
|
);
|
|
|
|
if (response['charges_enabled']) {
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => PompierDashboardScreen()),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Configuration toujours en attente')),
|
|
);
|
|
}
|
|
},
|
|
icon: Icon(Icons.refresh),
|
|
label: Text('Vérifier à nouveau'),
|
|
style: ElevatedButton.styleFrom(
|
|
padding: EdgeInsets.symmetric(horizontal: 30, vertical: 12),
|
|
),
|
|
),
|
|
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => LoginScreen()),
|
|
);
|
|
},
|
|
child: Text('Se déconnecter'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 MARDI 02/09 - Tests unitaires (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Tests du service Terminal
|
|
```dart
|
|
// test/services/stripe_terminal_service_test.dart
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mockito/mockito.dart';
|
|
|
|
void main() {
|
|
group('StripeTerminalService', () {
|
|
test('should detect Tap to Pay capability on compatible iPhone', () async {
|
|
// Test avec iPhone 14
|
|
final service = StripeTerminalService();
|
|
// Mock device info
|
|
final isCompatible = await service.checkTapToPayCapability();
|
|
expect(isCompatible, true);
|
|
});
|
|
|
|
test('should reject old iPhone models', () async {
|
|
// Test avec iPhone X
|
|
final service = StripeTerminalService();
|
|
// Mock device info pour iPhone X
|
|
final isCompatible = await service.checkTapToPayCapability();
|
|
expect(isCompatible, false);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Tests d'intégration
|
|
```dart
|
|
// test/integration/payment_flow_test.dart
|
|
void main() {
|
|
testWidgets('Complete payment flow', (WidgetTester tester) async {
|
|
// Test du flux complet de paiement
|
|
await tester.pumpWidget(MyApp());
|
|
|
|
// Login
|
|
await tester.enterText(find.byType(TextField).first, 'test@pompiers.fr');
|
|
await tester.enterText(find.byType(TextField).last, 'password');
|
|
await tester.tap(find.text('Se connecter'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Navigate to payment
|
|
await tester.tap(find.text('Nouveau paiement'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Select amount
|
|
await tester.tap(find.text('20€'));
|
|
await tester.tap(find.text('Payer 20.00€'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify Tap to Pay screen
|
|
expect(find.text('Paiement sans contact'), findsOneWidget);
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 MERCREDI 03/09 - Optimisations et polish (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Gestion des erreurs améliorée
|
|
```dart
|
|
// lib/utils/error_handler.dart
|
|
class ErrorHandler {
|
|
static String getReadableError(dynamic error) {
|
|
if (error is StripeException) {
|
|
switch (error.code) {
|
|
case 'card_declined':
|
|
return 'Carte refusée';
|
|
case 'insufficient_funds':
|
|
return 'Solde insuffisant';
|
|
case 'lost_card':
|
|
return 'Carte perdue';
|
|
case 'stolen_card':
|
|
return 'Carte volée';
|
|
default:
|
|
return 'Erreur de paiement: ${error.message}';
|
|
}
|
|
}
|
|
|
|
if (error.toString().contains('SocketException')) {
|
|
return 'Pas de connexion internet';
|
|
}
|
|
|
|
return 'Une erreur est survenue. Veuillez réessayer.';
|
|
}
|
|
}
|
|
```
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Performance et cache
|
|
```dart
|
|
// lib/services/cache_service.dart
|
|
class CacheService {
|
|
static const Duration _cacheValidity = Duration(minutes: 5);
|
|
final Map<String, CacheEntry> _cache = {};
|
|
|
|
T? get<T>(String key) {
|
|
final entry = _cache[key];
|
|
if (entry == null) return null;
|
|
|
|
if (DateTime.now().difference(entry.timestamp) > _cacheValidity) {
|
|
_cache.remove(key);
|
|
return null;
|
|
}
|
|
|
|
return entry.data as T;
|
|
}
|
|
|
|
void set(String key, dynamic data) {
|
|
_cache[key] = CacheEntry(data: data, timestamp: DateTime.now());
|
|
}
|
|
}
|
|
|
|
class CacheEntry {
|
|
final dynamic data;
|
|
final DateTime timestamp;
|
|
|
|
CacheEntry({required this.data, required this.timestamp});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 📅 JEUDI 04/09 - Finalisation et déploiement (8h)
|
|
|
|
---
|
|
|
|
## 📅 VENDREDI 05/09 - Livraison et support (8h)
|
|
|
|
### 🌅 Matin (4h)
|
|
|
|
#### ✅ Build et livraison finale
|
|
```bash
|
|
# iOS - Build final
|
|
flutter build ios --release
|
|
# Upload vers TestFlight pour distribution beta
|
|
```
|
|
|
|
#### ✅ Tests finaux sur appareils réels
|
|
- [ ] Test complet sur iPhone XS
|
|
- [ ] Test complet sur iPhone 14/15
|
|
- [ ] Validation avec vraies cartes test Stripe
|
|
- [ ] Vérification mode offline/online
|
|
|
|
### 🌆 Après-midi (4h)
|
|
|
|
#### ✅ Support et formation
|
|
- [ ] Formation équipe support client
|
|
- [ ] Création guide utilisateur pompier
|
|
- [ ] Vidéo démo Tap to Pay
|
|
- [ ] Support hotline pour premiers utilisateurs
|
|
- [ ] Monitoring des premiers paiements en production
|
|
|
|
---
|
|
|
|
## 📊 RÉCAPITULATIF
|
|
|
|
- **Total heures** : 72h sur 10 jours
|
|
- **Écrans créés** : 8
|
|
- **Services** : 5
|
|
- **Tests** : 15+
|
|
- **Compatibilité** : iOS uniquement (iPhone XS+, iOS 15.4+)
|
|
|
|
## 🎯 LIVRABLES
|
|
|
|
1. **App Flutter iOS & Android** avec Tap to Pay
|
|
2. **Détection automatique** compatibilité appareil (iOS et Android)
|
|
3. **Vérification API** pour appareils Android certifiés
|
|
4. **Mode offline** avec synchronisation
|
|
5. **Dashboard** des ventes
|
|
6. **Reçus** par email/SMS
|
|
7. **Liste dynamique** des appareils Android compatibles
|
|
|
|
## ⚠️ POINTS D'ATTENTION
|
|
|
|
- [ ] Tester sur vrais iPhone XS, 11, 12, 13, 14, 15
|
|
- [ ] Valider avec cartes test Stripe
|
|
- [ ] Former utilisateurs pilotes
|
|
- [ ] Prévoir support jour J
|
|
|
|
---
|
|
|
|
## 🎯 BILAN DÉVELOPPEMENT FLUTTER (01/09/2024)
|
|
|
|
### ✅ INTÉGRATION STRIPE CONNECT RÉALISÉE
|
|
|
|
#### **Interface de Configuration Stripe**
|
|
- **Widget**: `amicale_form.dart` mis à jour
|
|
- **Fonctionnalités implémentées**:
|
|
- Vérification statut compte Stripe automatique
|
|
- Bouton "Configurer Stripe" pour amicales non configurées
|
|
- Affichage statut : "✅ Compte Stripe configuré - 100% des paiements pour votre amicale"
|
|
- Gestion des erreurs et états de chargement
|
|
|
|
#### **Service Stripe Connect**
|
|
- **Fichier**: `stripe_connect_service.dart` ✅
|
|
- **Méthodes implémentées**:
|
|
```dart
|
|
Future<Map<String, dynamic>> createStripeAccount() // Créer compte
|
|
Future<Map<String, dynamic>> getAccountStatus(int id) // Statut compte
|
|
Future<String> createOnboardingLink(String accountId) // Lien onboarding
|
|
Future<Map<String, dynamic>> createLocation() // Location Terminal
|
|
```
|
|
- **Intégration API**: Communication avec backend PHP via `ApiService`
|
|
|
|
#### **États et Interface Utilisateur**
|
|
- **Statut Stripe dynamique** avec codes couleur :
|
|
- 🟠 Orange : Configuration en cours / Non configuré
|
|
- 🟢 Vert : Compte configuré et opérationnel
|
|
- **Messages utilisateur** :
|
|
- "💳 Activez les paiements par carte bancaire pour vos membres"
|
|
- "⏳ Configuration Stripe en cours. Veuillez compléter le processus d'onboarding."
|
|
- "✅ Compte Stripe configuré - 100% des paiements pour votre amicale"
|
|
|
|
### 🔧 ARCHITECTURE TECHNIQUE FLUTTER
|
|
|
|
#### **Pattern Repository**
|
|
- `AmicaleRepository`: Gestion des données amicale + intégration Stripe
|
|
- `ApiService`: Communication HTTP avec backend
|
|
- `HiveService`: Stockage local (pas utilisé pour Stripe - données sensibles)
|
|
|
|
#### **Gestion d'État**
|
|
- `ValueListenableBuilder`: Réactivité automatique UI
|
|
- `ChangeNotifier`: États de chargement Stripe
|
|
- Pas de stockage local des données Stripe (sécurité)
|
|
|
|
#### **Flow Utilisateur Implémenté**
|
|
1. **Amicale non configurée** → Bouton "Configurer Stripe" visible
|
|
2. **Clic configuration** → Appel API création compte
|
|
3. **Compte créé** → Redirection vers lien onboarding Stripe
|
|
4. **Onboarding complété** → Statut mise à jour automatiquement
|
|
5. **Compte opérationnel** → Message "100% des paiements"
|
|
|
|
### 📱 INTERFACE UTILISATEUR
|
|
|
|
#### **Responsive Design**
|
|
- Adaptation mobile/desktop
|
|
- Cards Material Design
|
|
- Indicateurs visuels clairs (icônes, couleurs)
|
|
- Gestion des états d'erreur
|
|
|
|
#### **Messages Utilisateur**
|
|
- **Français uniquement** (conforme CLAUDE.md)
|
|
- Termes métier appropriés ("amicale", "membres")
|
|
- Messages d'erreur explicites
|
|
- Feedback temps réel
|
|
|
|
### 🚀 FONCTIONNALITÉS OPÉRATIONNELLES
|
|
|
|
#### **Stripe Connect V1** ✅
|
|
- Création comptes Stripe Express ✅
|
|
- Génération liens onboarding ✅
|
|
- Vérification statut en temps réel ✅
|
|
- Affichage information "0% commission plateforme" ✅
|
|
|
|
#### **Prêt pour V2 - Terminal Payments** 🔄
|
|
- Architecture préparée pour Tap to Pay
|
|
- Services Stripe prêts pour extension
|
|
- Interface utilisateur extensible
|
|
|
|
### ⚠️ LIMITATIONS ACTUELLES
|
|
|
|
#### **V1 - Connect Onboarding Uniquement**
|
|
- Pas encore de paiements Terminal implémentés
|
|
- Interface de configuration uniquement
|
|
- Tap to Pay prévu en V2
|
|
|
|
#### **Sécurité**
|
|
- Aucune donnée sensible stockée localement
|
|
- Clés Stripe uniquement côté backend
|
|
- Communication HTTPS obligatoire
|
|
|
|
### 🎯 PROCHAINES ÉTAPES FLUTTER
|
|
|
|
#### **V2 - Terminal Payments (À venir)**
|
|
1. **Packages Stripe Terminal**
|
|
- `stripe_terminal: ^3.2.0`
|
|
- `stripe_ios: ^10.0.0` (iOS uniquement initialement)
|
|
|
|
2. **Écrans de Paiement**
|
|
- Sélection montant
|
|
- Interface Tap to Pay
|
|
- Confirmation et reçu
|
|
|
|
3. **Compatibilité Appareils**
|
|
- Vérification iPhone XS+ / iOS 15.4+
|
|
- Liste appareils Android certifiés (via API)
|
|
|
|
4. **Mode Offline**
|
|
- Queue de synchronisation
|
|
- Gestion connectivité réseau
|
|
|
|
#### **Tests et Validation**
|
|
- Tests widgets Stripe
|
|
- Tests d'intégration API
|
|
- Validation UX/UI sur vrais appareils
|
|
|
|
### 📊 MÉTRIQUES DÉVELOPPEMENT
|
|
|
|
- **Fichiers modifiés** : 2 (`amicale_form.dart`, `stripe_connect_service.dart`)
|
|
- **Lignes de code** : ~200 lignes ajoutées
|
|
- **APIs intégrées** : 4 endpoints Stripe
|
|
- **Tests** : Interface testée manuellement ✅
|
|
- **Statut** : V1 Connect opérationnelle ✅
|
|
|
|
---
|
|
|
|
*Document créé le 24/08/2024 - Dernière mise à jour : 01/09/2024* |