Files
geo/app/docs/PLANNING-STRIPE-FLUTTER.md

45 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

# pubspec.yaml
dependencies:
  stripe_terminal: ^3.2.0
  stripe_ios: ^10.0.0
  dio: ^5.4.0
  device_info_plus: ^10.1.0
  shared_preferences: ^2.2.2
  connectivity_plus: ^5.0.2
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

Document créé le 24/08/2024 - À mettre à jour quotidiennement