- 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
283 lines
8.3 KiB
Dart
283 lines
8.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../../../shared/theme/app_theme.dart';
|
|
import '../../../../shared/theme/design_system.dart';
|
|
|
|
/// Graphique linéaire professionnel avec animations et interactions
|
|
class ProfessionalLineChart extends StatefulWidget {
|
|
const ProfessionalLineChart({
|
|
super.key,
|
|
required this.data,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.showGrid = true,
|
|
this.showDots = true,
|
|
this.showArea = false,
|
|
this.animationDuration = const Duration(milliseconds: 1500),
|
|
this.lineColor,
|
|
this.gradientColors,
|
|
});
|
|
|
|
final List<LineDataPoint> data;
|
|
final String title;
|
|
final String? subtitle;
|
|
final bool showGrid;
|
|
final bool showDots;
|
|
final bool showArea;
|
|
final Duration animationDuration;
|
|
final Color? lineColor;
|
|
final List<Color>? gradientColors;
|
|
|
|
@override
|
|
State<ProfessionalLineChart> createState() => _ProfessionalLineChartState();
|
|
}
|
|
|
|
class _ProfessionalLineChartState extends State<ProfessionalLineChart>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _animation;
|
|
List<int> _showingTooltipOnSpots = [];
|
|
|
|
@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: _buildChart(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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 LineChart(
|
|
LineChartData(
|
|
lineTouchData: LineTouchData(
|
|
touchTooltipData: LineTouchTooltipData(
|
|
tooltipBgColor: AppTheme.textPrimary.withOpacity(0.9),
|
|
tooltipRoundedRadius: DesignSystem.radiusSm,
|
|
tooltipPadding: const EdgeInsets.all(DesignSystem.spacingSm),
|
|
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
|
|
return touchedBarSpots.map((barSpot) {
|
|
final data = widget.data[barSpot.x.toInt()];
|
|
return LineTooltipItem(
|
|
'${data.label}\n${barSpot.y.toInt()}',
|
|
DesignSystem.labelMedium.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
}).toList();
|
|
},
|
|
),
|
|
handleBuiltInTouches: true,
|
|
getTouchedSpotIndicator: (LineChartBarData barData, List<int> spotIndexes) {
|
|
return spotIndexes.map((index) {
|
|
return TouchedSpotIndicatorData(
|
|
FlLine(
|
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
|
strokeWidth: 2,
|
|
dashArray: [3, 3],
|
|
),
|
|
FlDotData(
|
|
getDotPainter: (spot, percent, barData, index) =>
|
|
FlDotCirclePainter(
|
|
radius: 6,
|
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
|
strokeWidth: 2,
|
|
strokeColor: Colors.white,
|
|
),
|
|
),
|
|
);
|
|
}).toList();
|
|
},
|
|
),
|
|
gridData: FlGridData(
|
|
show: widget.showGrid,
|
|
drawVerticalLine: false,
|
|
horizontalInterval: _getMaxY() / 5,
|
|
getDrawingHorizontalLine: (value) {
|
|
return FlLine(
|
|
color: AppTheme.borderColor.withOpacity(0.3),
|
|
strokeWidth: 1,
|
|
);
|
|
},
|
|
),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
rightTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
topTitles: const AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: _buildBottomTitles,
|
|
reservedSize: 42,
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: _buildLeftTitles,
|
|
reservedSize: 40,
|
|
),
|
|
),
|
|
),
|
|
borderData: FlBorderData(show: false),
|
|
minX: 0,
|
|
maxX: widget.data.length.toDouble() - 1,
|
|
minY: 0,
|
|
maxY: _getMaxY() * 1.2,
|
|
lineBarsData: [
|
|
_buildLineBarData(),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
LineChartBarData _buildLineBarData() {
|
|
final spots = widget.data.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final data = entry.value;
|
|
return FlSpot(index.toDouble(), data.value * _animation.value);
|
|
}).toList();
|
|
|
|
return LineChartBarData(
|
|
spots: spots,
|
|
isCurved: true,
|
|
curveSmoothness: 0.3,
|
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
|
barWidth: 3,
|
|
isStrokeCapRound: true,
|
|
dotData: FlDotData(
|
|
show: widget.showDots,
|
|
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
|
|
radius: 4,
|
|
color: widget.lineColor ?? AppTheme.primaryColor,
|
|
strokeWidth: 2,
|
|
strokeColor: Colors.white,
|
|
),
|
|
),
|
|
belowBarData: widget.showArea ? BarAreaData(
|
|
show: true,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: widget.gradientColors ?? [
|
|
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.3),
|
|
(widget.lineColor ?? AppTheme.primaryColor).withOpacity(0.05),
|
|
],
|
|
),
|
|
) : BarAreaData(show: false),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomTitles(double value, TitleMeta meta) {
|
|
if (value.toInt() >= widget.data.length) return const SizedBox.shrink();
|
|
|
|
final data = widget.data[value.toInt()];
|
|
return SideTitleWidget(
|
|
axisSide: meta.axisSide,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: DesignSystem.spacingXs),
|
|
child: Text(
|
|
data.label,
|
|
style: DesignSystem.labelSmall.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLeftTitles(double value, TitleMeta meta) {
|
|
return SideTitleWidget(
|
|
axisSide: meta.axisSide,
|
|
child: Text(
|
|
value.toInt().toString(),
|
|
style: DesignSystem.labelSmall.copyWith(
|
|
color: AppTheme.textSecondary,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double _getMaxY() {
|
|
if (widget.data.isEmpty) return 10;
|
|
return widget.data.map((e) => e.value).reduce((a, b) => a > b ? a : b);
|
|
}
|
|
}
|
|
|
|
/// Modèle de données pour le graphique linéaire
|
|
class LineDataPoint {
|
|
const LineDataPoint({
|
|
required this.label,
|
|
required this.value,
|
|
});
|
|
|
|
final String label;
|
|
final double value;
|
|
}
|