Files
geo/app/docs/PLANNING-STRIPE-FLUTTER.md
Pierre 50f55d825d feat: Implémentation complète Stripe Connect V1 - Configuration des paiements pour amicales
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>
2025-09-01 18:11:28 +02:00

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*