import 'dart:async'; import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; class RealtimeChart extends StatefulWidget { const RealtimeChart({ this.title = '실시간 데이터', this.dataStream, this.maxDataPoints = 30, this.lineColor, super.key, }); final String title; final Stream? dataStream; final int maxDataPoints; final Color? lineColor; @override State createState() => _RealtimeChartState(); } class _RealtimeChartState extends State { final List _spots = []; StreamSubscription? _subscription; Timer? _mockTimer; int _index = 0; final _random = Random(); @override void initState() { super.initState(); if (widget.dataStream != null) { _subscription = widget.dataStream!.listen(_addDataPoint); } else { // Mock 데이터 생성 (WebSocket 미연결 시) _mockTimer = Timer.periodic(const Duration(seconds: 1), (_) { _addDataPoint(50 + _random.nextDouble() * 50); }); } } void _addDataPoint(double value) { setState(() { _spots.add(FlSpot(_index.toDouble(), value)); if (_spots.length > widget.maxDataPoints) { _spots.removeAt(0); } _index++; }); } @override void dispose() { _subscription?.cancel(); _mockTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final color = widget.lineColor ?? theme.colorScheme.primary; return Card( child: Padding( padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( widget.title, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 8, height: 8, decoration: const BoxDecoration( color: Colors.green, shape: BoxShape.circle, ), ), const Gap(4), Text( 'LIVE', style: theme.textTheme.bodySmall?.copyWith( color: Colors.green, fontWeight: FontWeight.bold, ), ), ], ), ), ], ), const Gap(16), Expanded( child: _spots.length < 2 ? const Center(child: CircularProgressIndicator()) : LineChart( LineChartData( gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: 25, getDrawingHorizontalLine: (value) => FlLine( color: theme.colorScheme.outlineVariant .withValues(alpha: 0.3), strokeWidth: 1, ), ), titlesData: const FlTitlesData( topTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), rightTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), bottomTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, ), ), ), borderData: FlBorderData(show: false), minY: 0, maxY: 100, lineBarsData: [ LineChartBarData( spots: _spots, isCurved: true, color: color, barWidth: 2, isStrokeCapRound: true, dotData: const FlDotData(show: false), belowBarData: BarAreaData( show: true, gradient: LinearGradient( colors: [ color.withValues(alpha: 0.3), color.withValues(alpha: 0.0), ], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), ), ], ), duration: const Duration(milliseconds: 150), ), ), ], ), ), ); } }