# 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 ```yaml # 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 ``` ```bash cd app flutter pub get cd ios pod install ``` #### ✅ Configuration iOS ```xml NSBluetoothAlwaysUsageDescription L'app utilise Bluetooth pour Tap to Pay NSBluetoothPeripheralUsageDescription L'app utilise Bluetooth pour accepter les paiements NSLocationWhenInUseUsageDescription Localisation nécessaire pour les paiements UIBackgroundModes bluetooth-central bluetooth-peripheral external-accessory ``` ### 🌆 Après-midi (4h) #### ✅ Service de base StripeTerminalService ```dart // lib/services/stripe_terminal_service.dart import 'package:stripe_terminal/stripe_terminal.dart'; class StripeTerminalService { static final StripeTerminalService _instance = StripeTerminalService._internal(); factory StripeTerminalService() => _instance; StripeTerminalService._internal(); Terminal? _terminal; bool _isInitialized = false; Future initialize() async { if (_isInitialized) return; _terminal = await Terminal.getInstance( fetchConnectionToken: _fetchConnectionToken, ); _isInitialized = true; } Future _fetchConnectionToken() async { final response = await ApiService().post('/terminal/connection-token'); return response['secret']; } Future checkTapToPayCapability() async { if (!Platform.isIOS) return false; final deviceInfo = DeviceInfoPlugin(); final iosInfo = await deviceInfo.iosInfo; // iPhone XS et ultérieurs final supportedModels = [ 'iPhone11,', // XS, XS Max 'iPhone12,', // 11, 11 Pro 'iPhone13,', // 12 series 'iPhone14,', // 13 series 'iPhone15,', // 14 series 'iPhone16,', // 15 series ]; final modelIdentifier = iosInfo.utsname.machine; final isSupported = supportedModels.any((model) => modelIdentifier.startsWith(model) ); // iOS 15.4 minimum final osVersion = iosInfo.systemVersion.split('.').map(int.parse).toList(); final isOSSupported = osVersion[0] > 15 || (osVersion[0] == 15 && osVersion.length > 1 && osVersion[1] >= 4); return isSupported && isOSSupported; } } ``` --- ## 📅 MARDI 26/08 - UI Paiement principal (8h) ### 🌅 Matin (4h) #### ✅ Écran de vérification compatibilité ```dart // lib/screens/payment/compatibility_check_screen.dart class CompatibilityCheckScreen extends StatefulWidget { @override _CompatibilityCheckScreenState createState() => _CompatibilityCheckScreenState(); } class _CompatibilityCheckScreenState extends State { bool? _isCompatible; String _deviceInfo = ''; @override void initState() { super.initState(); _checkCompatibility(); } Future _checkCompatibility() async { final service = StripeTerminalService(); final compatible = await service.checkTapToPayCapability(); final deviceInfo = DeviceInfoPlugin(); final iosInfo = await deviceInfo.iosInfo; setState(() { _isCompatible = compatible; _deviceInfo = '${iosInfo.name} - iOS ${iosInfo.systemVersion}'; }); if (compatible) { await service.initialize(); Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => PaymentScreen()) ); } } @override Widget build(BuildContext context) { if (_isCompatible == null) { return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 20), Text('Vérification de compatibilité...'), ], ), ), ); } if (!_isCompatible!) { return Scaffold( body: Center( child: Padding( padding: EdgeInsets.all(20), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 80, color: Colors.orange), SizedBox(height: 20), Text( 'Tap to Pay non disponible', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), SizedBox(height: 10), Text( 'Votre appareil: $_deviceInfo', style: TextStyle(color: Colors.grey), ), SizedBox(height: 20), Text( 'Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 15.4+', textAlign: TextAlign.center, style: TextStyle(fontSize: 16), ), SizedBox(height: 30), ElevatedButton( onPressed: () => Navigator.pop(context), child: Text('Retour'), ), ], ), ), ), ); } return Container(); // Ne devrait jamais arriver ici } } ``` ### 🌆 Après-midi (4h) #### ✅ Écran de paiement principal ```dart // lib/screens/payment/payment_screen.dart class PaymentScreen extends StatefulWidget { final Amicale amicale; PaymentScreen({required this.amicale}); @override _PaymentScreenState createState() => _PaymentScreenState(); } class _PaymentScreenState extends State { 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 _startPayment() async { setState(() => _isProcessing = true); try { // Naviguer vers l'écran Tap to Pay final result = await Navigator.push( context, MaterialPageRoute( builder: (_) => TapToPayScreen( amount: _selectedAmount, amicale: widget.amicale, ), ), ); if (result != null && result['success']) { _showSuccessDialog(result['paymentIntentId']); } } catch (e) { _showErrorDialog(e.toString()); } finally { setState(() => _isProcessing = false); } } void _showSuccessDialog(String paymentIntentId) { showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: Row( children: [ Icon(Icons.check_circle, color: Colors.green, size: 30), SizedBox(width: 10), Text('Paiement réussi !'), ], ), content: Text('Le paiement a été effectué avec succès.'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); Navigator.of(context).pop(); // Retour écran principal }, child: Text('OK'), ), ], ), ); } void _showErrorDialog(String error) { showDialog( context: context, builder: (context) => AlertDialog( title: Text('Erreur de paiement'), content: Text(error), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('Réessayer'), ), ], ), ); } } ``` --- ## 📅 MERCREDI 27/08 - Flux Tap to Pay (8h) ### 🌅 Matin (4h) #### ✅ Écran Tap to Pay ```dart // lib/screens/payment/tap_to_pay_screen.dart class TapToPayScreen extends StatefulWidget { final double amount; final Amicale amicale; TapToPayScreen({required this.amount, required this.amicale}); @override _TapToPayScreenState createState() => _TapToPayScreenState(); } class _TapToPayScreenState extends State { final _terminalService = StripeTerminalService(); PaymentIntent? _paymentIntent; String _status = 'Initialisation...'; bool _isProcessing = true; @override void initState() { super.initState(); _initializePayment(); } Future _initializePayment() async { try { setState(() => _status = 'Création du paiement...'); // 1. Créer PaymentIntent via API final response = await ApiService().post('/payments/create-intent', { 'amount': (widget.amount * 100).round(), // en centimes 'amicale_id': widget.amicale.id, }); setState(() => _status = 'Connexion au lecteur...'); // 2. Découvrir et connecter le lecteur Tap to Pay await _terminalService.discoverReaders( config: LocalMobileDiscoveryConfiguration(), ); final readers = await _terminalService.getDiscoveredReaders(); if (readers.isEmpty) { throw Exception('Aucun lecteur Tap to Pay disponible'); } await _terminalService.connectToReader(readers.first); setState(() => _status = 'Prêt pour le paiement'); // 3. Collecter le paiement _paymentIntent = await _terminalService.collectPaymentMethod( response['client_secret'], ); setState(() => _status = 'Traitement du paiement...'); // 4. Confirmer le paiement final confirmedIntent = await _terminalService.confirmPaymentIntent( _paymentIntent!, ); if (confirmedIntent.status == PaymentIntentStatus.succeeded) { _onPaymentSuccess(confirmedIntent.id); } else { throw Exception('Paiement non confirmé'); } } catch (e) { _onPaymentError(e.toString()); } } void _onPaymentSuccess(String paymentIntentId) { Navigator.pop(context, { 'success': true, 'paymentIntentId': paymentIntentId, }); } void _onPaymentError(String error) { setState(() { _isProcessing = false; _status = 'Erreur: $error'; }); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey.shade100, body: SafeArea( child: Column( children: [ // Header Container( padding: EdgeInsets.all(20), color: Colors.white, child: Row( children: [ IconButton( icon: Icon(Icons.close), onPressed: _isProcessing ? null : () => Navigator.pop(context), ), Expanded( child: Text( 'Paiement sans contact', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), ), SizedBox(width: 48), // Pour équilibrer avec IconButton ], ), ), // Montant Container( margin: EdgeInsets.all(20), padding: EdgeInsets.all(30), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(15), boxShadow: [ BoxShadow( color: Colors.grey.shade300, blurRadius: 10, offset: Offset(0, 5), ), ], ), child: Column( children: [ Text( '${widget.amount.toStringAsFixed(2)} €', style: TextStyle( fontSize: 48, fontWeight: FontWeight.bold, color: Colors.red.shade700, ), ), SizedBox(height: 10), Text( widget.amicale.name, style: TextStyle(fontSize: 16, color: Colors.grey), ), ], ), ), // Animation Tap to Pay Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (_status.contains('Prêt')) TapToPayAnimation() else if (_isProcessing) CircularProgressIndicator( size: 80, strokeWidth: 8, valueColor: AlwaysStoppedAnimation(Colors.red.shade700), ), SizedBox(height: 30), Text( _status, style: TextStyle(fontSize: 18), textAlign: TextAlign.center, ), if (_status.contains('Prêt')) Padding( padding: EdgeInsets.only(top: 20), child: Text( 'Approchez la carte du dos de l\'iPhone', style: TextStyle(fontSize: 16, color: Colors.grey), ), ), ], ), ), ), // Footer if (!_isProcessing && _status.contains('Erreur')) Padding( padding: EdgeInsets.all(20), child: ElevatedButton( onPressed: _initializePayment, style: ElevatedButton.styleFrom( backgroundColor: Colors.red.shade700, padding: EdgeInsets.symmetric(horizontal: 40, vertical: 15), ), child: Text('Réessayer', style: TextStyle(fontSize: 16)), ), ), ], ), ), ); } } ``` ### 🌆 Après-midi (4h) #### ✅ Animation Tap to Pay ```dart // lib/widgets/tap_to_pay_animation.dart class TapToPayAnimation extends StatefulWidget { @override _TapToPayAnimationState createState() => _TapToPayAnimationState(); } class _TapToPayAnimationState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _scaleAnimation; late Animation _opacityAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: Duration(seconds: 2), vsync: this, )..repeat(); _scaleAnimation = Tween( begin: 0.8, end: 1.5, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOut, )); _opacityAnimation = Tween( begin: 1.0, end: 0.0, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeOut, )); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( width: 200, height: 200, child: Stack( alignment: Alignment.center, children: [ // Ondes animées AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.scale( scale: _scaleAnimation.value, child: Opacity( opacity: _opacityAnimation.value, child: Container( width: 150, height: 150, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: Colors.red.shade700, width: 3, ), ), ), ), ); }, ), // Icône centrale Container( width: 100, height: 100, decoration: BoxDecoration( color: Colors.red.shade700, shape: BoxShape.circle, ), child: Icon( Icons.contactless, size: 60, color: Colors.white, ), ), ], ), ); } } ``` --- ## 📅 JEUDI 28/08 - Gestion offline et sync (8h) ### 🌅 Matin (4h) #### ✅ Service de queue offline ```dart // lib/services/offline_queue_service.dart class OfflineQueueService { static const String _queueKey = 'offline_payment_queue'; Future addToQueue(PaymentData payment) async { final prefs = await SharedPreferences.getInstance(); final queueJson = prefs.getString(_queueKey) ?? '[]'; final queue = List>.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>> getQueue() async { final prefs = await SharedPreferences.getInstance(); final queueJson = prefs.getString(_queueKey) ?? '[]'; return List>.from(json.decode(queueJson)); } Future syncQueue() async { final queue = await getQueue(); if (queue.isEmpty) return; final connectivity = await Connectivity().checkConnectivity(); if (connectivity == ConnectivityResult.none) return; try { final response = await ApiService().post('/payments/batch-sync', { 'transactions': queue, }); // Clear queue after successful sync final prefs = await SharedPreferences.getInstance(); await prefs.setString(_queueKey, '[]'); } catch (e) { print('Sync failed: $e'); } } } ``` ### 🌆 Après-midi (4h) #### ✅ Moniteur de connectivité ```dart // lib/services/connectivity_monitor.dart class ConnectivityMonitor { final _connectivity = Connectivity(); StreamSubscription? _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 isOnline() async { final result = await _connectivity.checkConnectivity(); return result != ConnectivityResult.none; } } ``` --- ## 📅 VENDREDI 29/08 - Écran récap et reçu (8h) ### 🌅 Matin (4h) #### ✅ Écran de récapitulatif post-paiement ```dart // lib/screens/payment/payment_receipt_screen.dart class PaymentReceiptScreen extends StatelessWidget { final String paymentIntentId; final double amount; final Amicale amicale; PaymentReceiptScreen({ required this.paymentIntentId, required this.amount, required this.amicale, }); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.green.shade50, appBar: AppBar( title: Text('Paiement confirmé'), backgroundColor: Colors.green.shade700, automaticallyImplyLeading: false, ), body: SafeArea( child: Padding( padding: EdgeInsets.all(20), child: Column( children: [ // Success icon Container( padding: EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, ), child: Icon( Icons.check_circle, size: 80, color: Colors.green.shade700, ), ), SizedBox(height: 30), Text( 'Merci pour votre soutien !', style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, ), ), SizedBox(height: 20), // Détails de la transaction Container( padding: EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(15), boxShadow: [ BoxShadow( color: Colors.grey.shade200, blurRadius: 10, offset: Offset(0, 5), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildReceiptRow('Montant', '${amount.toStringAsFixed(2)} €'), Divider(), _buildReceiptRow('Amicale', amicale.name), Divider(), _buildReceiptRow('Date', DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now())), Divider(), _buildReceiptRow('Référence', paymentIntentId.substring(0, 10).toUpperCase()), ], ), ), SizedBox(height: 30), // Options de reçu Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ OutlinedButton.icon( onPressed: () => _sendReceiptByEmail(context), icon: Icon(Icons.email), label: Text('Email'), style: OutlinedButton.styleFrom( padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), ), ), OutlinedButton.icon( onPressed: () => _sendReceiptBySMS(context), icon: Icon(Icons.sms), label: Text('SMS'), style: OutlinedButton.styleFrom( padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), ), ), ], ), Spacer(), // Bouton terminer ElevatedButton( onPressed: () { Navigator.of(context).popUntil((route) => route.isFirst); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.green.shade700, padding: EdgeInsets.symmetric(horizontal: 50, vertical: 15), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: Text( 'Terminer', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ], ), ), ), ); } Widget _buildReceiptRow(String label, String value) { return Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(color: Colors.grey)), Text(value, style: TextStyle(fontWeight: FontWeight.bold)), ], ), ); } Future _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 _sendReceiptBySMS(BuildContext context) async { // Implémenter l'envoi par SMS via API ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Reçu envoyé par SMS')), ); } } ``` ### 🌆 Après-midi (4h) #### ✅ Dashboard pompier ```dart // lib/screens/dashboard/pompier_dashboard_screen.dart class PompierDashboardScreen extends StatefulWidget { @override _PompierDashboardScreenState createState() => _PompierDashboardScreenState(); } class _PompierDashboardScreenState extends State { Map? _stats; bool _isLoading = true; @override void initState() { super.initState(); _loadStats(); } Future _loadStats() async { try { final response = await ApiService().get('/pompiers/me/stats'); setState(() { _stats = response; _isLoading = false; }); } catch (e) { setState(() => _isLoading = false); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Mes ventes'), backgroundColor: Colors.red.shade700, ), body: _isLoading ? Center(child: CircularProgressIndicator()) : RefreshIndicator( onRefresh: _loadStats, child: ListView( padding: EdgeInsets.all(20), children: [ // Stats du jour _buildStatCard( 'Aujourd\'hui', _stats?['today_count'] ?? 0, _stats?['today_amount'] ?? 0, Colors.blue, ), SizedBox(height: 15), // Stats de la semaine _buildStatCard( 'Cette semaine', _stats?['week_count'] ?? 0, _stats?['week_amount'] ?? 0, Colors.orange, ), SizedBox(height: 15), // Stats totales _buildStatCard( 'Total', _stats?['total_count'] ?? 0, _stats?['total_amount'] ?? 0, Colors.green, ), SizedBox(height: 30), // Dernières transactions Text( 'Dernières ventes', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), SizedBox(height: 10), ...(_stats?['recent_transactions'] ?? []).map((transaction) { return Card( child: ListTile( leading: Icon(Icons.check_circle, color: Colors.green), title: Text('${(transaction['amount'] / 100).toStringAsFixed(2)} €'), subtitle: Text(DateFormat('dd/MM HH:mm').format( DateTime.parse(transaction['created_at']) )), trailing: Icon(Icons.arrow_forward_ios, size: 16), ), ); }).toList(), ], ), ), floatingActionButton: FloatingActionButton.extended( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (_) => CompatibilityCheckScreen()), ); }, backgroundColor: Colors.red.shade700, icon: Icon(Icons.contactless), label: Text('Nouveau paiement'), ), ); } Widget _buildStatCard(String title, int count, int amount, Color color) { return Container( padding: EdgeInsets.all(20), decoration: BoxDecoration( gradient: LinearGradient( colors: [color.withOpacity(0.8), color], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(15), boxShadow: [ BoxShadow( color: color.withOpacity(0.3), blurRadius: 10, offset: Offset(0, 5), ), ], ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle(color: Colors.white, fontSize: 16), ), SizedBox(height: 5), Text( '$count ventes', style: TextStyle( color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold, ), ), ], ), Text( '${(amount / 100).toStringAsFixed(2)} €', style: TextStyle( color: Colors.white, fontSize: 28, fontWeight: FontWeight.bold, ), ), ], ), ); } } ``` --- ## 📅 LUNDI 01/09 - Intégration avec login (8h) ### 🌅 Matin (4h) #### ✅ Vérification statut Stripe au login ```dart // lib/screens/auth/login_screen.dart class LoginScreen extends StatefulWidget { @override _LoginScreenState createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _emailController = TextEditingController(); final _passwordController = TextEditingController(); bool _isLoading = false; Future _login() async { setState(() => _isLoading = true); try { // 1. Authentification final authResponse = await ApiService().post('/auth/login', { 'email': _emailController.text, 'password': _passwordController.text, }); // 2. Stocker token final prefs = await SharedPreferences.getInstance(); await prefs.setString('auth_token', authResponse['token']); await prefs.setString('user_id', authResponse['user']['id'].toString()); // 3. Vérifier statut Stripe de l'amicale final amicaleResponse = await ApiService().get( '/amicales/${authResponse['user']['amicale_id']}/stripe-status' ); if (!amicaleResponse['charges_enabled']) { // Amicale pas encore configurée pour Stripe Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => StripeSetupPendingScreen()), ); } else { // Tout est OK, aller au dashboard Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => PompierDashboardScreen()), ); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Erreur de connexion: ${e.toString()}')), ); } finally { setState(() => _isLoading = false); } } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Padding( padding: EdgeInsets.all(20), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Logo Image.asset('assets/logo_pompiers.png', height: 100), SizedBox(height: 40), Text( 'Calendriers des Pompiers', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), SizedBox(height: 40), // Email field TextField( controller: _emailController, decoration: InputDecoration( labelText: 'Email', prefixIcon: Icon(Icons.email), border: OutlineInputBorder(), ), keyboardType: TextInputType.emailAddress, ), SizedBox(height: 15), // Password field TextField( controller: _passwordController, decoration: InputDecoration( labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock), border: OutlineInputBorder(), ), obscureText: true, ), SizedBox(height: 30), // Login button ElevatedButton( onPressed: _isLoading ? null : _login, style: ElevatedButton.styleFrom( backgroundColor: Colors.red.shade700, padding: EdgeInsets.symmetric(horizontal: 50, vertical: 15), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), child: _isLoading ? CircularProgressIndicator(color: Colors.white) : Text( 'Se connecter', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ], ), ), ), ); } } ``` ### 🌆 Après-midi (4h) #### ✅ Écran attente configuration Stripe ```dart // lib/screens/stripe/stripe_setup_pending_screen.dart class StripeSetupPendingScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Padding( padding: EdgeInsets.all(30), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.hourglass_empty, size: 80, color: Colors.orange, ), SizedBox(height: 30), Text( 'Configuration en attente', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), SizedBox(height: 20), Text( 'Votre amicale n\'a pas encore configuré son compte de paiement Stripe.', textAlign: TextAlign.center, style: TextStyle(fontSize: 16, color: Colors.grey), ), SizedBox(height: 10), Text( 'Contactez votre responsable pour finaliser la configuration.', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: Colors.grey), ), SizedBox(height: 40), ElevatedButton.icon( onPressed: () async { // Vérifier à nouveau le statut final prefs = await SharedPreferences.getInstance(); final amicaleId = prefs.getString('amicale_id'); final response = await ApiService().get( '/amicales/$amicaleId/stripe-status' ); if (response['charges_enabled']) { Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => PompierDashboardScreen()), ); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Configuration toujours en attente')), ); } }, icon: Icon(Icons.refresh), label: Text('Vérifier à nouveau'), style: ElevatedButton.styleFrom( padding: EdgeInsets.symmetric(horizontal: 30, vertical: 12), ), ), TextButton( onPressed: () { Navigator.pushReplacement( context, MaterialPageRoute(builder: (_) => LoginScreen()), ); }, child: Text('Se déconnecter'), ), ], ), ), ), ); } } ``` --- ## 📅 MARDI 02/09 - Tests unitaires (8h) ### 🌅 Matin (4h) #### ✅ Tests du service Terminal ```dart // test/services/stripe_terminal_service_test.dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; void main() { group('StripeTerminalService', () { test('should detect Tap to Pay capability on compatible iPhone', () async { // Test avec iPhone 14 final service = StripeTerminalService(); // Mock device info final isCompatible = await service.checkTapToPayCapability(); expect(isCompatible, true); }); test('should reject old iPhone models', () async { // Test avec iPhone X final service = StripeTerminalService(); // Mock device info pour iPhone X final isCompatible = await service.checkTapToPayCapability(); expect(isCompatible, false); }); }); } ``` ### 🌆 Après-midi (4h) #### ✅ Tests d'intégration ```dart // test/integration/payment_flow_test.dart void main() { testWidgets('Complete payment flow', (WidgetTester tester) async { // Test du flux complet de paiement await tester.pumpWidget(MyApp()); // Login await tester.enterText(find.byType(TextField).first, 'test@pompiers.fr'); await tester.enterText(find.byType(TextField).last, 'password'); await tester.tap(find.text('Se connecter')); await tester.pumpAndSettle(); // Navigate to payment await tester.tap(find.text('Nouveau paiement')); await tester.pumpAndSettle(); // Select amount await tester.tap(find.text('20€')); await tester.tap(find.text('Payer 20.00€')); await tester.pumpAndSettle(); // Verify Tap to Pay screen expect(find.text('Paiement sans contact'), findsOneWidget); }); } ``` --- ## 📅 MERCREDI 03/09 - Optimisations et polish (8h) ### 🌅 Matin (4h) #### ✅ Gestion des erreurs améliorée ```dart // lib/utils/error_handler.dart class ErrorHandler { static String getReadableError(dynamic error) { if (error is StripeException) { switch (error.code) { case 'card_declined': return 'Carte refusée'; case 'insufficient_funds': return 'Solde insuffisant'; case 'lost_card': return 'Carte perdue'; case 'stolen_card': return 'Carte volée'; default: return 'Erreur de paiement: ${error.message}'; } } if (error.toString().contains('SocketException')) { return 'Pas de connexion internet'; } return 'Une erreur est survenue. Veuillez réessayer.'; } } ``` ### 🌆 Après-midi (4h) #### ✅ Performance et cache ```dart // lib/services/cache_service.dart class CacheService { static const Duration _cacheValidity = Duration(minutes: 5); final Map _cache = {}; T? get(String key) { final entry = _cache[key]; if (entry == null) return null; if (DateTime.now().difference(entry.timestamp) > _cacheValidity) { _cache.remove(key); return null; } return entry.data as T; } void set(String key, dynamic data) { _cache[key] = CacheEntry(data: data, timestamp: DateTime.now()); } } class CacheEntry { final dynamic data; final DateTime timestamp; CacheEntry({required this.data, required this.timestamp}); } ``` --- ## 📅 JEUDI 04/09 - Finalisation et déploiement (8h) --- ## 📅 VENDREDI 05/09 - Livraison et support (8h) ### 🌅 Matin (4h) #### ✅ Build et livraison finale ```bash # iOS - Build final flutter build ios --release # Upload vers TestFlight pour distribution beta ``` #### ✅ Tests finaux sur appareils réels - [ ] Test complet sur iPhone XS - [ ] Test complet sur iPhone 14/15 - [ ] Validation avec vraies cartes test Stripe - [ ] Vérification mode offline/online ### 🌆 Après-midi (4h) #### ✅ Support et formation - [ ] Formation équipe support client - [ ] Création guide utilisateur pompier - [ ] Vidéo démo Tap to Pay - [ ] Support hotline pour premiers utilisateurs - [ ] Monitoring des premiers paiements en production --- ## 📊 RÉCAPITULATIF - **Total heures** : 72h sur 10 jours - **Écrans créés** : 8 - **Services** : 5 - **Tests** : 15+ - **Compatibilité** : iOS uniquement (iPhone XS+, iOS 15.4+) ## 🎯 LIVRABLES 1. **App Flutter iOS & Android** avec Tap to Pay 2. **Détection automatique** compatibilité appareil (iOS et Android) 3. **Vérification API** pour appareils Android certifiés 4. **Mode offline** avec synchronisation 5. **Dashboard** des ventes 6. **Reçus** par email/SMS 7. **Liste dynamique** des appareils Android compatibles ## ⚠️ POINTS D'ATTENTION - [ ] Tester sur vrais iPhone XS, 11, 12, 13, 14, 15 - [ ] Valider avec cartes test Stripe - [ ] Former utilisateurs pilotes - [ ] Prévoir support jour J --- *Document créé le 24/08/2024 - À mettre à jour quotidiennement*