Files
geo/app/docs/PLANNING-STRIPE-FLUTTER.md
Pierre 0bebc58d1a 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

50 KiB

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)

# 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

cd app
flutter pub get
cd ios
pod install

Configuration iOS

<!-- 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

// 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é

// 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

// 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

// 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

// 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

// 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é

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

# 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:
    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