- Replace flutter_appauth with custom WebView implementation to resolve deep link issues - Add KeycloakWebViewAuthService with integrated WebView for seamless authentication - Configure Android manifest for HTTP cleartext traffic support - Add network security config for development environment (192.168.1.11) - Update Keycloak client to use HTTP callback endpoint (http://192.168.1.11:8080/auth/callback) - Remove obsolete keycloak_auth_service.dart and temporary scripts - Clean up dependencies and regenerate injection configuration - Tested successfully on multiple Android devices (Xiaomi 2201116TG, SM A725F) BREAKING CHANGE: Authentication flow now uses WebView instead of external browser - Users will see Keycloak login page within the app instead of browser redirect - Resolves ERR_CLEARTEXT_NOT_PERMITTED and deep link state management issues - Maintains full OIDC compliance with PKCE flow and secure token storage Technical improvements: - WebView with custom navigation delegate for callback handling - Automatic token extraction and user info parsing from JWT - Proper error handling and user feedback - Consistent authentication state management across app lifecycle
308 lines
9.0 KiB
Dart
308 lines
9.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import '../../../../shared/theme/app_theme.dart';
|
|
import '../../../../shared/theme/design_system.dart';
|
|
|
|
/// Graphique en secteurs professionnel avec animations et légendes
|
|
class ProfessionalPieChart extends StatefulWidget {
|
|
const ProfessionalPieChart({
|
|
super.key,
|
|
required this.data,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.centerText,
|
|
this.showLegend = true,
|
|
this.showPercentages = true,
|
|
this.animationDuration = const Duration(milliseconds: 1500),
|
|
});
|
|
|
|
final List<ChartDataPoint> data;
|
|
final String title;
|
|
final String? subtitle;
|
|
final String? centerText;
|
|
final bool showLegend;
|
|
final bool showPercentages;
|
|
final Duration animationDuration;
|
|
|
|
@override
|
|
State<ProfessionalPieChart> createState() => _ProfessionalPieChartState();
|
|
}
|
|
|
|
class _ProfessionalPieChartState extends State<ProfessionalPieChart>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _animation;
|
|
int _touchedIndex = -1;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
duration: widget.animationDuration,
|
|
vsync: this,
|
|
);
|
|
|
|
_animation = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: DesignSystem.animationCurve,
|
|
));
|
|
|
|
_animationController.forward();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeader(),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: _buildChart(),
|
|
),
|
|
if (widget.showLegend) ...[
|
|
const SizedBox(width: DesignSystem.spacingLg),
|
|
Expanded(
|
|
flex: 2,
|
|
child: _buildLegend(),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.title,
|
|
style: DesignSystem.titleLarge.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
if (widget.subtitle != null) ...[
|
|
const SizedBox(height: DesignSystem.spacingXs),
|
|
Text(
|
|
widget.subtitle!,
|
|
style: DesignSystem.bodyMedium.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildChart() {
|
|
return AnimatedBuilder(
|
|
animation: _animation,
|
|
builder: (context, child) {
|
|
return Container(
|
|
height: 140, // Hauteur encore plus réduite
|
|
padding: const EdgeInsets.all(4), // Padding minimal pour contenir le graphique
|
|
child: PieChart(
|
|
PieChartData(
|
|
pieTouchData: PieTouchData(
|
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
|
setState(() {
|
|
if (!event.isInterestedForInteractions ||
|
|
pieTouchResponse == null ||
|
|
pieTouchResponse.touchedSection == null) {
|
|
_touchedIndex = -1;
|
|
return;
|
|
}
|
|
_touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
|
|
});
|
|
},
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
sectionsSpace: 1, // Espace réduit entre sections
|
|
centerSpaceRadius: widget.centerText != null ? 45 : 30, // Rayon central réduit
|
|
sections: _buildSections(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<PieChartSectionData> _buildSections() {
|
|
final total = widget.data.fold<double>(0, (sum, item) => sum + item.value);
|
|
|
|
return widget.data.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final data = entry.value;
|
|
final isTouched = index == _touchedIndex;
|
|
final percentage = (data.value / total * 100);
|
|
|
|
return PieChartSectionData(
|
|
color: data.color,
|
|
value: data.value * _animation.value,
|
|
title: widget.showPercentages ? '${percentage.toStringAsFixed(1)}%' : '',
|
|
radius: isTouched ? 70 : 60,
|
|
titleStyle: DesignSystem.labelMedium.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
shadows: [
|
|
Shadow(
|
|
color: Colors.black.withOpacity(0.3),
|
|
offset: const Offset(1, 1),
|
|
blurRadius: 2,
|
|
),
|
|
],
|
|
),
|
|
titlePositionPercentageOffset: 0.6,
|
|
badgeWidget: isTouched ? _buildBadge(data) : null,
|
|
badgePositionPercentageOffset: 1.3,
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Widget _buildBadge(ChartDataPoint data) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: DesignSystem.spacingSm,
|
|
vertical: DesignSystem.spacingXs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: data.color,
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
|
boxShadow: DesignSystem.shadowCard,
|
|
),
|
|
child: Text(
|
|
data.value.toInt().toString(),
|
|
style: DesignSystem.labelMedium.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegend() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (widget.centerText != null) ...[
|
|
_buildCenterInfo(),
|
|
const SizedBox(height: DesignSystem.spacingLg),
|
|
],
|
|
...widget.data.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final data = entry.value;
|
|
final isSelected = index == _touchedIndex;
|
|
|
|
return AnimatedContainer(
|
|
duration: DesignSystem.animationFast,
|
|
margin: const EdgeInsets.only(bottom: DesignSystem.spacingSm),
|
|
padding: const EdgeInsets.all(DesignSystem.spacingSm),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? data.color.withOpacity(0.1) : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusSm),
|
|
border: isSelected ? Border.all(
|
|
color: data.color.withOpacity(0.3),
|
|
width: 1,
|
|
) : null,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: data.color,
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusXs),
|
|
),
|
|
),
|
|
const SizedBox(width: DesignSystem.spacingSm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
data.label,
|
|
style: DesignSystem.labelLarge.copyWith(
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
|
),
|
|
),
|
|
Text(
|
|
data.value.toInt().toString(),
|
|
style: DesignSystem.labelMedium.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildCenterInfo() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(DesignSystem.spacingMd),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(DesignSystem.radiusMd),
|
|
border: Border.all(
|
|
color: AppTheme.primaryColor.withOpacity(0.2),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
'Total',
|
|
style: DesignSystem.labelMedium.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
const SizedBox(height: DesignSystem.spacingXs),
|
|
Text(
|
|
widget.centerText!,
|
|
style: DesignSystem.headlineMedium.copyWith(
|
|
color: AppTheme.primaryColor,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Modèle de données pour le graphique en secteurs
|
|
class ChartDataPoint {
|
|
const ChartDataPoint({
|
|
required this.label,
|
|
required this.value,
|
|
required this.color,
|
|
});
|
|
|
|
final String label;
|
|
final double value;
|
|
final Color color;
|
|
}
|