# 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