feat(chart): interactive zoom/pan controls, pattern overlays, predictions, individual pattern filtering and landscape fullscreen mode
This commit is contained in:
@@ -15,5 +15,32 @@ class AssetTechnicalBloc extends Bloc<AssetTechnicalEvent, AssetTechnicalState>
|
|||||||
emit(AssetTechnicalError(e.toString()));
|
emit(AssetTechnicalError(e.toString()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
on<TogglePatternFilter>((event, emit) {
|
||||||
|
if (state is AssetTechnicalLoaded) {
|
||||||
|
final current = state as AssetTechnicalLoaded;
|
||||||
|
final updated = Set<int>.from(current.disabledPatternIndices);
|
||||||
|
if (event.enabled) {
|
||||||
|
updated.remove(event.patternIndex);
|
||||||
|
} else {
|
||||||
|
updated.add(event.patternIndex);
|
||||||
|
}
|
||||||
|
emit(current.copyWith(disabledPatternIndices: updated));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
on<ToggleIndicatorFilter>((event, emit) {
|
||||||
|
if (state is AssetTechnicalLoaded) {
|
||||||
|
final current = state as AssetTechnicalLoaded;
|
||||||
|
emit(current.copyWith(
|
||||||
|
showSma50: event.showSma50,
|
||||||
|
showSma200: event.showSma200,
|
||||||
|
showEma: event.showEma,
|
||||||
|
showSupertrend: event.showSupertrend,
|
||||||
|
showPatterns: event.showPatterns,
|
||||||
|
showSignals: event.showSignals,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
abstract class AssetTechnicalEvent {}
|
abstract class AssetTechnicalEvent {}
|
||||||
|
|
||||||
class LoadAssetTechnical extends AssetTechnicalEvent {
|
class LoadAssetTechnical extends AssetTechnicalEvent {
|
||||||
final String isin;
|
final String isin;
|
||||||
final bool forceRefresh;
|
final bool forceRefresh;
|
||||||
final String? ticker;
|
final String? ticker;
|
||||||
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
|
LoadAssetTechnical(this.isin, {this.forceRefresh = false, this.ticker});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TogglePatternFilter extends AssetTechnicalEvent {
|
||||||
|
final int patternIndex;
|
||||||
|
final bool enabled;
|
||||||
|
TogglePatternFilter({required this.patternIndex, required this.enabled});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToggleIndicatorFilter extends AssetTechnicalEvent {
|
||||||
|
final bool? showSma50;
|
||||||
|
final bool? showSma200;
|
||||||
|
final bool? showEma;
|
||||||
|
final bool? showSupertrend;
|
||||||
|
final bool? showPatterns;
|
||||||
|
final bool? showSignals;
|
||||||
|
|
||||||
|
ToggleIndicatorFilter({
|
||||||
|
this.showSma50,
|
||||||
|
this.showSma200,
|
||||||
|
this.showEma,
|
||||||
|
this.showSupertrend,
|
||||||
|
this.showPatterns,
|
||||||
|
this.showSignals,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
import '../../models/technical_analysis_model.dart';
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
|
||||||
abstract class AssetTechnicalState {}
|
abstract class AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalInitial extends AssetTechnicalState {}
|
class AssetTechnicalInitial extends AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalLoading extends AssetTechnicalState {}
|
class AssetTechnicalLoading extends AssetTechnicalState {}
|
||||||
|
|
||||||
class AssetTechnicalLoaded extends AssetTechnicalState {
|
class AssetTechnicalLoaded extends AssetTechnicalState {
|
||||||
final TechnicalAnalysisModel? data;
|
final TechnicalAnalysisModel? data;
|
||||||
AssetTechnicalLoaded(this.data);
|
final Set<int> disabledPatternIndices;
|
||||||
|
final bool showSma50;
|
||||||
|
final bool showSma200;
|
||||||
|
final bool showEma;
|
||||||
|
final bool showSupertrend;
|
||||||
|
final bool showPatterns;
|
||||||
|
final bool showSignals;
|
||||||
|
|
||||||
|
AssetTechnicalLoaded(
|
||||||
|
this.data, {
|
||||||
|
this.disabledPatternIndices = const {},
|
||||||
|
this.showSma50 = true,
|
||||||
|
this.showSma200 = true,
|
||||||
|
this.showEma = true,
|
||||||
|
this.showSupertrend = true,
|
||||||
|
this.showPatterns = true,
|
||||||
|
this.showSignals = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetTechnicalLoaded copyWith({
|
||||||
|
TechnicalAnalysisModel? data,
|
||||||
|
Set<int>? disabledPatternIndices,
|
||||||
|
bool? showSma50,
|
||||||
|
bool? showSma200,
|
||||||
|
bool? showEma,
|
||||||
|
bool? showSupertrend,
|
||||||
|
bool? showPatterns,
|
||||||
|
bool? showSignals,
|
||||||
|
}) {
|
||||||
|
return AssetTechnicalLoaded(
|
||||||
|
data ?? this.data,
|
||||||
|
disabledPatternIndices: disabledPatternIndices ?? this.disabledPatternIndices,
|
||||||
|
showSma50: showSma50 ?? this.showSma50,
|
||||||
|
showSma200: showSma200 ?? this.showSma200,
|
||||||
|
showEma: showEma ?? this.showEma,
|
||||||
|
showSupertrend: showSupertrend ?? this.showSupertrend,
|
||||||
|
showPatterns: showPatterns ?? this.showPatterns,
|
||||||
|
showSignals: showSignals ?? this.showSignals,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AssetTechnicalError extends AssetTechnicalState {
|
class AssetTechnicalError extends AssetTechnicalState {
|
||||||
final String message;
|
final String message;
|
||||||
AssetTechnicalError(this.message);
|
AssetTechnicalError(this.message);
|
||||||
|
|||||||
@@ -198,15 +198,16 @@ class ChartPatternModel extends Equatable {
|
|||||||
class TechnicalAnalysisModel extends Equatable {
|
class TechnicalAnalysisModel extends Equatable {
|
||||||
final String symbol;
|
final String symbol;
|
||||||
final String currency;
|
final String currency;
|
||||||
|
final double? currentPrice;
|
||||||
final String trend;
|
final String trend;
|
||||||
final String rsi;
|
final String rsi;
|
||||||
final String macd;
|
final String macd;
|
||||||
final String overallSignal;
|
final String overallSignal;
|
||||||
final String sma50;
|
final String sma50;
|
||||||
final String sma200;
|
final String sma200;
|
||||||
final double vix;
|
final double? vix;
|
||||||
final String sp500Trend;
|
final String? sp500Trend;
|
||||||
final double dxy;
|
final double? dxy;
|
||||||
final double? stopLossAtr;
|
final double? stopLossAtr;
|
||||||
final List<CandleModel> candles;
|
final List<CandleModel> candles;
|
||||||
final List<IndicatorModel> indicators;
|
final List<IndicatorModel> indicators;
|
||||||
@@ -216,15 +217,16 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
const TechnicalAnalysisModel({
|
const TechnicalAnalysisModel({
|
||||||
required this.symbol,
|
required this.symbol,
|
||||||
this.currency = 'EUR',
|
this.currency = 'EUR',
|
||||||
|
this.currentPrice,
|
||||||
required this.trend,
|
required this.trend,
|
||||||
required this.rsi,
|
required this.rsi,
|
||||||
required this.macd,
|
required this.macd,
|
||||||
required this.overallSignal,
|
required this.overallSignal,
|
||||||
required this.sma50,
|
required this.sma50,
|
||||||
required this.sma200,
|
required this.sma200,
|
||||||
this.vix = 16.5,
|
this.vix,
|
||||||
this.sp500Trend = 'Bullish',
|
this.sp500Trend,
|
||||||
this.dxy = 104.2,
|
this.dxy,
|
||||||
this.stopLossAtr,
|
this.stopLossAtr,
|
||||||
this.candles = const [],
|
this.candles = const [],
|
||||||
this.indicators = const [],
|
this.indicators = const [],
|
||||||
@@ -245,7 +247,6 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
|
var rawPatterns = json['patterns'] as List<dynamic>? ?? [];
|
||||||
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
var patternsList = rawPatterns.map((p) => ChartPatternModel.fromJson(p as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
|
||||||
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
final lastInd = indicatorsList.isNotEmpty ? indicatorsList.last : null;
|
||||||
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
final regime = json['marketRegime'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
@@ -259,17 +260,18 @@ class TechnicalAnalysisModel extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return TechnicalAnalysisModel(
|
return TechnicalAnalysisModel(
|
||||||
symbol: json['symbol']?.toString() ?? json['isin']?.toString() ?? json['ticker']?.toString() ?? '',
|
symbol: json['symbol']?.toString() ?? '',
|
||||||
currency: json['currency']?.toString() ?? 'EUR',
|
currency: json['currency']?.toString() ?? 'EUR',
|
||||||
|
currentPrice: (json['currentPrice'] as num?)?.toDouble(),
|
||||||
trend: parsedTrend,
|
trend: parsedTrend,
|
||||||
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
rsi: lastInd?.rsi14?.toStringAsFixed(1) ?? 'N/A',
|
||||||
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
macd: lastInd?.macdHistogram?.toStringAsFixed(2) ?? lastInd?.macdLine?.toStringAsFixed(2) ?? 'N/A',
|
||||||
overallSignal: parsedSignal,
|
overallSignal: parsedSignal,
|
||||||
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
sma50: lastInd?.sma50?.toStringAsFixed(2) ?? 'N/A',
|
||||||
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
sma200: lastInd?.sma200?.toStringAsFixed(2) ?? 'N/A',
|
||||||
vix: (regime?['vixValue'] as num?)?.toDouble() ?? 16.5,
|
vix: (regime?['vixValue'] as num?)?.toDouble(),
|
||||||
sp500Trend: regime?['marketTrend']?.toString() ?? 'Bullish',
|
sp500Trend: regime?['marketTrend']?.toString(),
|
||||||
dxy: (regime?['dxyValue'] as num?)?.toDouble() ?? 104.2,
|
dxy: (regime?['dxyValue'] as num?)?.toDouble(),
|
||||||
stopLossAtr: lastInd?.recommendedStopLoss,
|
stopLossAtr: lastInd?.recommendedStopLoss,
|
||||||
candles: candlesList,
|
candles: candlesList,
|
||||||
indicators: indicatorsList,
|
indicators: indicatorsList,
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import '../../../core/theme/app_theme.dart';
|
||||||
|
import '../bloc/technical/asset_technical_bloc.dart';
|
||||||
|
import '../bloc/technical/asset_technical_event.dart';
|
||||||
|
import '../bloc/technical/asset_technical_state.dart';
|
||||||
|
import '../widgets/chart/candlestick_chart.dart';
|
||||||
|
import '../widgets/technical/indicator_ribbon_bar.dart';
|
||||||
|
|
||||||
|
class FullscreenChartScreen extends StatefulWidget {
|
||||||
|
final String isin;
|
||||||
|
final String? symbol;
|
||||||
|
final AssetTechnicalBloc technicalBloc;
|
||||||
|
|
||||||
|
const FullscreenChartScreen({
|
||||||
|
super.key,
|
||||||
|
required this.isin,
|
||||||
|
this.symbol,
|
||||||
|
required this.technicalBloc,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<void> open(BuildContext context, {required String isin, String? symbol}) {
|
||||||
|
final bloc = context.read<AssetTechnicalBloc>();
|
||||||
|
return Navigator.of(context).push(
|
||||||
|
PageRouteBuilder(
|
||||||
|
opaque: true,
|
||||||
|
pageBuilder: (ctx, anim, secAnim) => FullscreenChartScreen(
|
||||||
|
isin: isin,
|
||||||
|
symbol: symbol,
|
||||||
|
technicalBloc: bloc,
|
||||||
|
),
|
||||||
|
transitionsBuilder: (ctx, anim, secAnim, child) {
|
||||||
|
return FadeTransition(opacity: anim, child: child);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FullscreenChartScreen> createState() => _FullscreenChartScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FullscreenChartScreenState extends State<FullscreenChartScreen> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Rotate to landscape on mobile devices
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// Restore orientation back to default portrait/auto
|
||||||
|
SystemChrome.setPreferredOrientations([
|
||||||
|
DeviceOrientation.portraitUp,
|
||||||
|
DeviceOrientation.portraitDown,
|
||||||
|
DeviceOrientation.landscapeLeft,
|
||||||
|
DeviceOrientation.landscapeRight,
|
||||||
|
]);
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: widget.technicalBloc,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: theme.darkBackground,
|
||||||
|
body: SafeArea(
|
||||||
|
child: BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is AssetTechnicalLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is AssetTechnicalLoaded && state.data != null) {
|
||||||
|
final data = state.data!;
|
||||||
|
final activePatterns = <ChartPatternModel>[];
|
||||||
|
for (int i = 0; i < data.patterns.length; i++) {
|
||||||
|
if (!state.disabledPatternIndices.contains(i)) {
|
||||||
|
activePatterns.add(data.patterns[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(context, theme, state),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (ctx, constraints) {
|
||||||
|
return CandlestickChart(
|
||||||
|
candles: data.candles,
|
||||||
|
indicators: data.indicators,
|
||||||
|
patterns: activePatterns,
|
||||||
|
signals: data.signals,
|
||||||
|
showSma50: state.showSma50,
|
||||||
|
showSma200: state.showSma200,
|
||||||
|
showEma: state.showEma,
|
||||||
|
showPatterns: state.showPatterns,
|
||||||
|
showSignals: state.showSignals,
|
||||||
|
showSupertrend: state.showSupertrend,
|
||||||
|
height: constraints.maxHeight,
|
||||||
|
isFullscreen: true,
|
||||||
|
onToggleFullscreen: () => Navigator.of(context).pop(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Text('Keine Chartdaten verfügbar', style: TextStyle(color: theme.textMuted)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context, ThemePreset theme, AssetTechnicalLoaded state) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardSurface,
|
||||||
|
border: Border(bottom: BorderSide(color: theme.glassBorder)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, size: 20, color: Colors.white70),
|
||||||
|
tooltip: 'Zurück',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
widget.symbol ?? widget.isin,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: IndicatorRibbonBar(
|
||||||
|
showSma50: state.showSma50,
|
||||||
|
showSma200: state.showSma200,
|
||||||
|
showEma: state.showEma,
|
||||||
|
showSupertrend: state.showSupertrend,
|
||||||
|
showPatterns: state.showPatterns,
|
||||||
|
showSignals: state.showSignals,
|
||||||
|
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
|
||||||
|
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
|
||||||
|
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
|
||||||
|
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
|
||||||
|
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
|
||||||
|
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.fullscreen_exit, size: 22, color: Colors.white70),
|
||||||
|
tooltip: 'Vollbild beenden',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../../../core/widgets/glass_container.dart';
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
import '../../../../core/widgets/shimmer_loading.dart';
|
import '../../../../core/widgets/shimmer_loading.dart';
|
||||||
import '../../../../core/widgets/status_badge.dart';
|
|
||||||
import '../../bloc/technical/asset_technical_bloc.dart';
|
import '../../bloc/technical/asset_technical_bloc.dart';
|
||||||
import '../../bloc/technical/asset_technical_event.dart';
|
import '../../bloc/technical/asset_technical_event.dart';
|
||||||
import '../../bloc/technical/asset_technical_state.dart';
|
import '../../bloc/technical/asset_technical_state.dart';
|
||||||
import '../../utils/metric_explanations.dart';
|
|
||||||
import '../../utils/pattern_explanations.dart';
|
|
||||||
import '../../widgets/chart/candlestick_chart.dart';
|
import '../../widgets/chart/candlestick_chart.dart';
|
||||||
|
import '../../widgets/technical/pattern_card_item.dart';
|
||||||
|
import '../../widgets/technical/signal_card_item.dart';
|
||||||
|
import '../../widgets/technical/indicator_ribbon_bar.dart';
|
||||||
|
|
||||||
class TechnicalTab extends StatefulWidget {
|
import '../fullscreen_chart_screen.dart';
|
||||||
|
|
||||||
|
class TechnicalTab extends StatelessWidget {
|
||||||
final String isin;
|
final String isin;
|
||||||
final String? symbol;
|
final String? symbol;
|
||||||
final bool isDesktopLeftPanel;
|
final bool isDesktopLeftPanel;
|
||||||
@@ -30,31 +31,6 @@ class TechnicalTab extends StatefulWidget {
|
|||||||
required this.isin,
|
required this.isin,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
|
||||||
State<TechnicalTab> createState() => _TechnicalTabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TechnicalTabState extends State<TechnicalTab> {
|
|
||||||
bool _showSma50 = true;
|
|
||||||
bool _showSma200 = true;
|
|
||||||
bool _showEma = true;
|
|
||||||
bool _showPatterns = true;
|
|
||||||
bool _showSignals = true;
|
|
||||||
bool _showSupertrend = true;
|
|
||||||
|
|
||||||
// Set of disabled pattern indices for individual toggling
|
|
||||||
final Set<int> _disabledPatternIndices = {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant TechnicalTab oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
return BlocBuilder<AssetTechnicalBloc, AssetTechnicalState>(
|
||||||
@@ -74,11 +50,13 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Fehler beim Laden der Technischen Analyse: ${state.message}',
|
'Fehler beim Laden der Technischen Analyse: ${state.message}',
|
||||||
style: const TextStyle(color: Colors.white70)),
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
onPressed: () => context.read<AssetTechnicalBloc>().add(
|
||||||
LoadAssetTechnical(widget.isin, ticker: widget.symbol, forceRefresh: true)),
|
LoadAssetTechnical(isin, ticker: symbol, forceRefresh: true),
|
||||||
|
),
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
label: const Text('Erneut versuchen'),
|
label: const Text('Erneut versuchen'),
|
||||||
),
|
),
|
||||||
@@ -88,221 +66,91 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is AssetTechnicalLoaded) {
|
if (state is AssetTechnicalLoaded && state.data != null) {
|
||||||
final data = state.data;
|
final data = state.data!;
|
||||||
List<CandleModel> candles = [];
|
final candles = data.candles;
|
||||||
List<ChartPatternModel> patterns = [];
|
final patterns = data.patterns;
|
||||||
List<StrategySignalModel> signals = [];
|
final signals = data.signals;
|
||||||
List<IndicatorModel> indicators = [];
|
final indicators = data.indicators;
|
||||||
|
|
||||||
if (data != null) {
|
final activePatterns = <ChartPatternModel>[];
|
||||||
candles = data.candles
|
for (int i = 0; i < patterns.length; i++) {
|
||||||
.map((c) => CandleModel(
|
if (!state.disabledPatternIndices.contains(i)) {
|
||||||
time: c.timestamp,
|
activePatterns.add(patterns[i]);
|
||||||
open: c.open,
|
}
|
||||||
high: c.high,
|
|
||||||
low: c.low,
|
|
||||||
close: c.close,
|
|
||||||
volume: c.volume))
|
|
||||||
.toList();
|
|
||||||
patterns = data.patterns
|
|
||||||
.map((p) => ChartPatternModel(
|
|
||||||
type: p.type,
|
|
||||||
upperLine: p.upperLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
|
|
||||||
lowerLine: p.lowerLine.map((pt) => PatternPoint(pt.time, pt.price)).toList(),
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
signals = data.signals
|
|
||||||
.map((s) => StrategySignalModel(
|
|
||||||
type: 'strategy',
|
|
||||||
timestamp: s.date,
|
|
||||||
direction: s.type,
|
|
||||||
price: s.price,
|
|
||||||
description: s.title))
|
|
||||||
.toList();
|
|
||||||
indicators = data.indicators
|
|
||||||
.map((i) => IndicatorModel(
|
|
||||||
timestamp: i.timestamp,
|
|
||||||
ema20: i.ema20,
|
|
||||||
sma50: i.sma50,
|
|
||||||
sma200: i.sma200,
|
|
||||||
supertrendUpper: i.supertrendUpper,
|
|
||||||
supertrendLower: i.supertrendLower,
|
|
||||||
supertrendDirection: i.supertrendDirection))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter patterns according to individual checkbox states
|
final chartWidget = CandlestickChart(
|
||||||
final activePatterns = [
|
|
||||||
for (int i = 0; i < patterns.length; i++)
|
|
||||||
if (!_disabledPatternIndices.contains(i)) patterns[i]
|
|
||||||
];
|
|
||||||
|
|
||||||
final chartRibbon = GlassContainer(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_buildIndicatorChip(
|
|
||||||
'EMA (20)',
|
|
||||||
_showEma,
|
|
||||||
(v) => setState(() => _showEma = v),
|
|
||||||
Colors.blueAccent),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildIndicatorChip(
|
|
||||||
'SMA (50)',
|
|
||||||
_showSma50,
|
|
||||||
(v) => setState(() => _showSma50 = v),
|
|
||||||
Colors.orangeAccent),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildIndicatorChip(
|
|
||||||
'SMA (200)',
|
|
||||||
_showSma200,
|
|
||||||
(v) => setState(() => _showSma200 = v),
|
|
||||||
Colors.redAccent),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildIndicatorChip(
|
|
||||||
'Supertrend',
|
|
||||||
_showSupertrend,
|
|
||||||
(v) => setState(() => _showSupertrend = v),
|
|
||||||
AppTheme.primaryEmerald),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildIndicatorChip(
|
|
||||||
'Alle Muster',
|
|
||||||
_showPatterns,
|
|
||||||
(v) => setState(() => _showPatterns = v),
|
|
||||||
Colors.amberAccent),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
_buildIndicatorChip(
|
|
||||||
'Signale',
|
|
||||||
_showSignals,
|
|
||||||
(v) => setState(() => _showSignals = v),
|
|
||||||
AppTheme.accentCyan),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final chartWidget = SizedBox(
|
|
||||||
height: widget.chartHeight,
|
|
||||||
width: double.infinity,
|
|
||||||
child: CandlestickChart(
|
|
||||||
candles: candles,
|
candles: candles,
|
||||||
|
indicators: indicators,
|
||||||
patterns: activePatterns,
|
patterns: activePatterns,
|
||||||
signals: signals,
|
signals: signals,
|
||||||
indicators: indicators,
|
showSma50: state.showSma50,
|
||||||
showPatterns: _showPatterns,
|
showSma200: state.showSma200,
|
||||||
showEma: _showEma,
|
showEma: state.showEma,
|
||||||
showSma50: _showSma50,
|
showPatterns: state.showPatterns,
|
||||||
showSma200: _showSma200,
|
showSignals: state.showSignals,
|
||||||
showSignals: _showSignals,
|
showSupertrend: state.showSupertrend,
|
||||||
showSupertrend: _showSupertrend,
|
height: chartHeight,
|
||||||
),
|
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.showChartOnly) {
|
final chartRibbon = IndicatorRibbonBar(
|
||||||
|
showSma50: state.showSma50,
|
||||||
|
showSma200: state.showSma200,
|
||||||
|
showEma: state.showEma,
|
||||||
|
showSupertrend: state.showSupertrend,
|
||||||
|
showPatterns: state.showPatterns,
|
||||||
|
showSignals: state.showSignals,
|
||||||
|
onToggleSma50: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma50: v)),
|
||||||
|
onToggleSma200: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSma200: v)),
|
||||||
|
onToggleEma: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showEma: v)),
|
||||||
|
onToggleSupertrend: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSupertrend: v)),
|
||||||
|
onTogglePatterns: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showPatterns: v)),
|
||||||
|
onToggleSignals: (v) => context.read<AssetTechnicalBloc>().add(ToggleIndicatorFilter(showSignals: v)),
|
||||||
|
onToggleFullscreen: () => FullscreenChartScreen.open(context, isin: isin, symbol: symbol),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showChartOnly) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
chartRibbon,
|
chartRibbon,
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
chartWidget,
|
chartWidget,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final detailsSection = Padding(
|
final detailsSection = Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.architecture_outlined,
|
|
||||||
color: AppTheme.primaryEmerald, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('Erkannte Chart-Muster & Signale',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (patterns.isNotEmpty)
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
if (_disabledPatternIndices.length ==
|
|
||||||
patterns.length) {
|
|
||||||
_disabledPatternIndices.clear();
|
|
||||||
} else {
|
|
||||||
_disabledPatternIndices.addAll(
|
|
||||||
List.generate(
|
|
||||||
patterns.length, (i) => i));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_disabledPatternIndices.isEmpty
|
|
||||||
? Icons.deselect
|
|
||||||
: Icons.select_all,
|
|
||||||
size: 16,
|
|
||||||
color: Colors.amberAccent),
|
|
||||||
label: Text(
|
|
||||||
_disabledPatternIndices.isEmpty
|
|
||||||
? 'Alle abwählen'
|
|
||||||
: 'Alle anwählen',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.amberAccent, fontSize: 12)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (patterns.isEmpty && signals.isEmpty)
|
|
||||||
GlassContainer(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'Zurzeit wurden keine akuten Formationen oder Strategie-Signale identifiziert.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.textMuted, fontSize: 12)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else ...[
|
|
||||||
if (patterns.isNotEmpty) ...[
|
if (patterns.isNotEmpty) ...[
|
||||||
Text(
|
const Text('Erkannte Chartformationen & Muster', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
'Formationen & Trendlinien (Mit Checkbox im Chart schalten):',
|
const SizedBox(height: 8),
|
||||||
style: TextStyle(
|
for (int i = 0; i < patterns.length; i++)
|
||||||
color: AppTheme.textSecondary,
|
PatternCardItem(
|
||||||
fontWeight: FontWeight.w600,
|
pattern: patterns[i],
|
||||||
fontSize: 13)),
|
index: i,
|
||||||
const SizedBox(height: 6),
|
isEnabled: !state.disabledPatternIndices.contains(i),
|
||||||
...List.generate(
|
onToggle: (enabled) {
|
||||||
patterns.length,
|
context.read<AssetTechnicalBloc>().add(
|
||||||
(index) =>
|
TogglePatternFilter(patternIndex: i, enabled: enabled),
|
||||||
_buildPatternCard(patterns[index], index)),
|
);
|
||||||
const SizedBox(height: 12),
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
if (signals.isNotEmpty) ...[
|
if (signals.isNotEmpty) ...[
|
||||||
Text('Strategie-Signale:',
|
const Text('Strategische Kauf- & Verkaufssignale', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
|
||||||
style: TextStyle(
|
const SizedBox(height: 8),
|
||||||
color: AppTheme.textSecondary,
|
for (final sig in signals) SignalCardItem(signal: sig),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
...signals.map((s) => _buildSignalCard(s)),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.showDetailsOnly) {
|
if (showDetailsOnly) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: detailsSection,
|
child: detailsSection,
|
||||||
@@ -325,228 +173,26 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('Keine technisches Indikatoren verfügbar',
|
child: Text('Keine technisches Indikatoren verfügbar', style: TextStyle(color: AppTheme.textMuted)),
|
||||||
style: TextStyle(color: AppTheme.textMuted)),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPatternCard(ChartPatternModel pattern, int index) {
|
|
||||||
final isEnabled = !_disabledPatternIndices.contains(index);
|
|
||||||
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
|
|
||||||
|
|
||||||
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
|
|
||||||
DateTime? startDate;
|
|
||||||
DateTime? endDate;
|
|
||||||
if (allPoints.isNotEmpty) {
|
|
||||||
allPoints.sort((a, b) => a.time.compareTo(b.time));
|
|
||||||
startDate = allPoints.first.time;
|
|
||||||
endDate = allPoints.last.time;
|
|
||||||
}
|
|
||||||
|
|
||||||
final dateFormat = DateFormat('dd.MM.yy');
|
|
||||||
final dateStr = startDate != null && endDate != null
|
|
||||||
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
|
|
||||||
: 'Unbekannt';
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: GlassContainer(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Checkbox for individual pattern toggling on the chart
|
|
||||||
Checkbox(
|
|
||||||
value: isEnabled,
|
|
||||||
activeColor: patternColor,
|
|
||||||
checkColor: Colors.black,
|
|
||||||
side:
|
|
||||||
BorderSide(color: patternColor.withValues(alpha: 0.6)),
|
|
||||||
onChanged: (bool? val) {
|
|
||||||
setState(() {
|
|
||||||
if (val == true) {
|
|
||||||
_disabledPatternIndices.remove(index);
|
|
||||||
} else {
|
|
||||||
_disabledPatternIndices.add(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => PatternExplanations.showPatternDetails(
|
|
||||||
context, pattern.type),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isEnabled
|
|
||||||
? patternColor.withValues(alpha: 0.15)
|
|
||||||
: AppTheme.glassSurface,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.polyline_outlined,
|
|
||||||
color: isEnabled
|
|
||||||
? patternColor
|
|
||||||
: AppTheme.textMuted,
|
|
||||||
size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
pattern.type,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: isEnabled
|
|
||||||
? Colors.white
|
|
||||||
: AppTheme.textMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
decoration: isEnabled
|
|
||||||
? null
|
|
||||||
: TextDecoration.lineThrough,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Icon(Icons.info_outline,
|
|
||||||
size: 14, color: AppTheme.textMuted),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Zeitraum: $dateStr\n'
|
|
||||||
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.textMuted, fontSize: 11, height: 1.3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
StatusBadge(
|
|
||||||
label: isEnabled ? 'AKTIV' : 'AUS',
|
|
||||||
color:
|
|
||||||
isEnabled ? patternColor : AppTheme.textMuted,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSignalCard(StrategySignalModel signal) {
|
|
||||||
final isBuy = signal.type.toUpperCase() == 'BUY';
|
|
||||||
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: GlassContainer(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Icon(isBuy ? Icons.north_east : Icons.south_east,
|
|
||||||
color: color, size: 20),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(signal.type.toUpperCase(),
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
fontSize: 14)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('@ €${signal.price.toStringAsFixed(2)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
signal.description.isNotEmpty
|
|
||||||
? signal.description
|
|
||||||
: 'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.textSecondary, fontSize: 12)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
StatusBadge(label: 'SIGNAL', color: color),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildIndicatorChip(String label, bool isSelected,
|
|
||||||
ValueChanged<bool> onChanged, Color color) {
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
FilterChip(
|
|
||||||
selected: isSelected,
|
|
||||||
label: Text(label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? Colors.black : color,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold)),
|
|
||||||
selectedColor: color,
|
|
||||||
backgroundColor: color.withValues(alpha: 0.15),
|
|
||||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
|
||||||
showCheckmark: false,
|
|
||||||
onSelected: onChanged,
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () => MetricExplanations.show(context, label),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
||||||
child:
|
|
||||||
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTechnicalShimmer(BuildContext context) {
|
Widget _buildTechnicalShimmer(BuildContext context) {
|
||||||
if (widget.showChartOnly) {
|
if (showChartOnly) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
|
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.showDetailsOnly) {
|
if (showDetailsOnly) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -570,7 +216,7 @@ class _TechnicalTabState extends State<TechnicalTab> {
|
|||||||
children: [
|
children: [
|
||||||
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
const ShimmerLoading(width: double.infinity, height: 42, borderRadius: 12),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ShimmerLoading(width: double.infinity, height: widget.chartHeight, borderRadius: 16),
|
ShimmerLoading(width: double.infinity, height: chartHeight, borderRadius: 16),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
const ShimmerLoading(width: 240, height: 20, borderRadius: 6),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
|
|||||||
@@ -1,124 +1,25 @@
|
|||||||
import 'dart:math';
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../../core/theme/app_theme.dart';
|
import '../../../../core/theme/app_theme.dart';
|
||||||
import '../../utils/pattern_explanations.dart';
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import 'candlestick_painter.dart';
|
||||||
|
|
||||||
class CandleModel {
|
export '../../models/technical_analysis_model.dart' show CandleModel, IndicatorModel, ChartPatternModel, PatternPoint, StrategySignalModel;
|
||||||
final DateTime time;
|
|
||||||
final double open;
|
|
||||||
final double high;
|
|
||||||
final double low;
|
|
||||||
final double close;
|
|
||||||
final double volume;
|
|
||||||
|
|
||||||
CandleModel({
|
|
||||||
required this.time,
|
|
||||||
required this.open,
|
|
||||||
required this.high,
|
|
||||||
required this.low,
|
|
||||||
required this.close,
|
|
||||||
required this.volume,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory CandleModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return CandleModel(
|
|
||||||
time: DateTime.tryParse(json['timestamp'] ?? json['time'] ?? '') ?? DateTime.now(),
|
|
||||||
open: (json['open'] ?? 0).toDouble(),
|
|
||||||
high: (json['high'] ?? 0).toDouble(),
|
|
||||||
low: (json['low'] ?? 0).toDouble(),
|
|
||||||
close: (json['close'] ?? 0).toDouble(),
|
|
||||||
volume: (json['volume'] ?? 0).toDouble(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class IndicatorModel {
|
|
||||||
final DateTime timestamp;
|
|
||||||
final double? ema20;
|
|
||||||
final double? sma50;
|
|
||||||
final double? sma200;
|
|
||||||
final double? supertrendUpper;
|
|
||||||
final double? supertrendLower;
|
|
||||||
final String? supertrendDirection;
|
|
||||||
|
|
||||||
IndicatorModel({
|
|
||||||
required this.timestamp,
|
|
||||||
this.ema20,
|
|
||||||
this.sma50,
|
|
||||||
this.sma200,
|
|
||||||
this.supertrendUpper,
|
|
||||||
this.supertrendLower,
|
|
||||||
this.supertrendDirection,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory IndicatorModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return IndicatorModel(
|
|
||||||
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
|
||||||
ema20: json['ema20'] != null ? (json['ema20'] as num).toDouble() : null,
|
|
||||||
sma50: json['sma50'] != null ? (json['sma50'] as num).toDouble() : null,
|
|
||||||
sma200: json['sma200'] != null ? (json['sma200'] as num).toDouble() : null,
|
|
||||||
supertrendUpper: json['supertrendUpper'] != null ? (json['supertrendUpper'] as num).toDouble() : null,
|
|
||||||
supertrendLower: json['supertrendLower'] != null ? (json['supertrendLower'] as num).toDouble() : null,
|
|
||||||
supertrendDirection: json['supertrendDirection']?.toString(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PatternPoint {
|
|
||||||
final DateTime time;
|
|
||||||
final double price;
|
|
||||||
PatternPoint(this.time, this.price);
|
|
||||||
factory PatternPoint.fromJson(Map<String, dynamic> json) => PatternPoint(DateTime.tryParse(json['time'] ?? '') ?? DateTime.now(), (json['price'] as num).toDouble());
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChartPatternModel {
|
|
||||||
final String type;
|
|
||||||
final List<PatternPoint> upperLine;
|
|
||||||
final List<PatternPoint> lowerLine;
|
|
||||||
|
|
||||||
ChartPatternModel({required this.type, required this.upperLine, required this.lowerLine});
|
|
||||||
|
|
||||||
factory ChartPatternModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return ChartPatternModel(
|
|
||||||
type: json['type'] ?? '',
|
|
||||||
upperLine: (json['upperLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
|
|
||||||
lowerLine: (json['lowerLine'] as List<dynamic>? ?? []).map((e) => PatternPoint.fromJson(e)).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class StrategySignalModel {
|
|
||||||
final String type;
|
|
||||||
final DateTime timestamp;
|
|
||||||
final String direction;
|
|
||||||
final double price;
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
StrategySignalModel({required this.type, required this.timestamp, required this.direction, required this.price, required this.description});
|
|
||||||
|
|
||||||
factory StrategySignalModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return StrategySignalModel(
|
|
||||||
type: json['type'] ?? '',
|
|
||||||
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
|
||||||
direction: json['direction'] ?? '',
|
|
||||||
price: (json['price'] as num).toDouble(),
|
|
||||||
description: json['description'] ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CandlestickChart extends StatefulWidget {
|
class CandlestickChart extends StatefulWidget {
|
||||||
final List<CandleModel> candles;
|
final List<CandleModel> candles;
|
||||||
final List<ChartPatternModel> patterns;
|
final List<ChartPatternModel> patterns;
|
||||||
final List<StrategySignalModel> signals;
|
final List<StrategySignalModel> signals;
|
||||||
final List<IndicatorModel> indicators;
|
final List<IndicatorModel> indicators;
|
||||||
final bool showPatterns;
|
|
||||||
final bool showSma50;
|
final bool showSma50;
|
||||||
final bool showSma200;
|
final bool showSma200;
|
||||||
final bool showEma;
|
final bool showEma;
|
||||||
|
final bool showPatterns;
|
||||||
final bool showSignals;
|
final bool showSignals;
|
||||||
final bool showSupertrend;
|
final bool showSupertrend;
|
||||||
|
final double height;
|
||||||
|
final bool isFullscreen;
|
||||||
|
final VoidCallback? onToggleFullscreen;
|
||||||
|
|
||||||
const CandlestickChart({
|
const CandlestickChart({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -126,12 +27,15 @@ class CandlestickChart extends StatefulWidget {
|
|||||||
this.patterns = const [],
|
this.patterns = const [],
|
||||||
this.signals = const [],
|
this.signals = const [],
|
||||||
this.indicators = const [],
|
this.indicators = const [],
|
||||||
this.showPatterns = true,
|
|
||||||
this.showSma50 = true,
|
this.showSma50 = true,
|
||||||
this.showSma200 = true,
|
this.showSma200 = true,
|
||||||
this.showEma = true,
|
this.showEma = true,
|
||||||
|
this.showPatterns = true,
|
||||||
this.showSignals = true,
|
this.showSignals = true,
|
||||||
this.showSupertrend = true,
|
this.showSupertrend = true,
|
||||||
|
this.height = 420,
|
||||||
|
this.isFullscreen = false,
|
||||||
|
this.onToggleFullscreen,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -141,81 +45,142 @@ class CandlestickChart extends StatefulWidget {
|
|||||||
class _CandlestickChartState extends State<CandlestickChart> {
|
class _CandlestickChartState extends State<CandlestickChart> {
|
||||||
double _scale = 1.0;
|
double _scale = 1.0;
|
||||||
double _panOffset = 0.0;
|
double _panOffset = 0.0;
|
||||||
|
double _baseScale = 1.0;
|
||||||
|
double _basePanOffset = 0.0;
|
||||||
|
Offset _startFocalPoint = Offset.zero;
|
||||||
|
bool _isDragging = false;
|
||||||
Offset? _tapPosition;
|
Offset? _tapPosition;
|
||||||
CandleModel? _selectedCandle;
|
CandleModel? _selectedCandle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void initState() {
|
||||||
if (widget.candles.isEmpty) {
|
super.initState();
|
||||||
return const Center(child: Text('No chart data'));
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_fitLatestCandles();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant CandlestickChart oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.candles.length != widget.candles.length) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_fitLatestCandles();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fitLatestCandles() {
|
||||||
|
if (widget.candles.isEmpty || !mounted) return;
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
final width = (renderBox?.size.width ?? 600) - 60;
|
||||||
|
final double candleWidth = 10.0 * _scale;
|
||||||
|
final double totalCandleSpace = candleWidth + (5.0 * _scale);
|
||||||
|
final double futureSpace = totalCandleSpace * 10;
|
||||||
|
final double totalWidth = (widget.candles.length * totalCandleSpace) + futureSpace;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
if (totalWidth > width) {
|
||||||
|
_panOffset = width - totalWidth;
|
||||||
|
} else {
|
||||||
|
_panOffset = 0.0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyZoom(double factor, [double? focalX]) {
|
||||||
|
if (widget.candles.isEmpty || !mounted) return;
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
|
final fx = focalX ?? (chartWidth / 2);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
final oldScale = _scale;
|
||||||
|
_scale = (_scale * factor).clamp(0.1, 6.0);
|
||||||
|
_panOffset = fx - ((fx - _panOffset) * (_scale / oldScale));
|
||||||
|
_clampPanOffset(chartWidth);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clampPanOffset(double chartWidth) {
|
||||||
|
if (widget.candles.isEmpty) return;
|
||||||
|
final double totalCandleSpace = (10.0 + 5.0) * _scale;
|
||||||
|
final double totalWidth = (widget.candles.length * totalCandleSpace) + (totalCandleSpace * 10);
|
||||||
|
|
||||||
|
if (totalWidth <= chartWidth) {
|
||||||
|
_panOffset = 0.0;
|
||||||
|
} else {
|
||||||
|
final double minPan = chartWidth - totalWidth - 30;
|
||||||
|
const double maxPan = 30.0;
|
||||||
|
_panOffset = _panOffset.clamp(minPan, maxPan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final theme = AppTheme.activePreset;
|
final theme = AppTheme.activePreset;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return Container(
|
||||||
builder: (context, constraints) {
|
height: widget.height,
|
||||||
final double baseWidth = 10.0;
|
decoration: BoxDecoration(
|
||||||
final double spacing = 5.0;
|
color: theme.cardSurface,
|
||||||
final double totalCandleSpace = (baseWidth + spacing) * _scale;
|
borderRadius: BorderRadius.circular(16),
|
||||||
final double totalContentWidth = (widget.candles.length + 15) * totalCandleSpace;
|
border: Border.all(color: theme.glassBorder),
|
||||||
|
),
|
||||||
final double minOffset = constraints.maxWidth - totalContentWidth - 60.0;
|
child: ClipRRect(
|
||||||
final double maxOffset = 100.0;
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Listener(
|
||||||
_panOffset = _panOffset.clamp(minOffset < maxOffset ? minOffset : maxOffset, maxOffset);
|
|
||||||
|
|
||||||
return Listener(
|
|
||||||
onPointerSignal: (pointerSignal) {
|
onPointerSignal: (pointerSignal) {
|
||||||
if (pointerSignal is PointerScrollEvent) {
|
if (pointerSignal is PointerScrollEvent) {
|
||||||
GestureBinding.instance.pointerSignalResolver.register(
|
if (pointerSignal.scrollDelta.dx != 0) {
|
||||||
pointerSignal,
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
(event) {
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
if (event is PointerScrollEvent) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final double localX = event.localPosition.dx;
|
_panOffset -= pointerSignal.scrollDelta.dx;
|
||||||
final double zoomFactor = event.scrollDelta.dy > 0 ? 0.9 : 1.1;
|
_clampPanOffset(chartWidth);
|
||||||
final double newScale = (_scale * zoomFactor).clamp(0.2, 5.0);
|
|
||||||
final double scaleRatio = newScale / _scale;
|
|
||||||
|
|
||||||
// Zoom centered on cursor
|
|
||||||
_panOffset = localX - (localX - _panOffset) * scaleRatio;
|
|
||||||
_scale = newScale;
|
|
||||||
|
|
||||||
final double updatedCandleSpace = (baseWidth + spacing) * _scale;
|
|
||||||
final double updatedContentWidth = (widget.candles.length + 15) * updatedCandleSpace;
|
|
||||||
final double newMinOffset = constraints.maxWidth - updatedContentWidth - 60.0;
|
|
||||||
_panOffset = _panOffset.clamp(newMinOffset < maxOffset ? newMinOffset : maxOffset, maxOffset);
|
|
||||||
});
|
});
|
||||||
|
} else if (pointerSignal.scrollDelta.dy != 0) {
|
||||||
|
final zoomFactor = pointerSignal.scrollDelta.dy < 0 ? 1.15 : 0.85;
|
||||||
|
_applyZoom(zoomFactor, pointerSignal.localPosition.dx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
child: MouseRegion(
|
||||||
}
|
cursor: _isDragging ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
|
||||||
},
|
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onScaleStart: (details) {
|
||||||
|
_baseScale = _scale;
|
||||||
|
_basePanOffset = _panOffset;
|
||||||
|
_startFocalPoint = details.focalPoint;
|
||||||
|
setState(() => _isDragging = true);
|
||||||
|
},
|
||||||
onScaleUpdate: (details) {
|
onScaleUpdate: (details) {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
final chartWidth = (renderBox?.size.width ?? 600) - 60;
|
||||||
setState(() {
|
setState(() {
|
||||||
_scale = (_scale * details.scale).clamp(0.2, 5.0);
|
if (details.scale != 1.0) {
|
||||||
_panOffset += details.focalPointDelta.dx;
|
final oldScale = _scale;
|
||||||
_panOffset = _panOffset.clamp(minOffset, maxOffset);
|
_scale = (_baseScale * details.scale).clamp(0.1, 6.0);
|
||||||
if (_tapPosition != null) {
|
final fx = details.localFocalPoint.dx;
|
||||||
_handleTap(Offset(_tapPosition!.dx + details.focalPointDelta.dx, _tapPosition!.dy), constraints.maxWidth);
|
_panOffset = fx - ((fx - _basePanOffset) * (_scale / oldScale));
|
||||||
|
} else {
|
||||||
|
_panOffset = _basePanOffset + (details.focalPoint.dx - _startFocalPoint.dx);
|
||||||
}
|
}
|
||||||
|
_clampPanOffset(chartWidth);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onScaleEnd: (_) => setState(() {
|
onScaleEnd: (details) {
|
||||||
_tapPosition = null;
|
setState(() => _isDragging = false);
|
||||||
_selectedCandle = null;
|
},
|
||||||
}),
|
|
||||||
onTapDown: (details) {
|
onTapDown: (details) {
|
||||||
_handleTap(details.localPosition, constraints.maxWidth);
|
_handleTap(details.localPosition);
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ClipRect(
|
CustomPaint(
|
||||||
child: CustomPaint(
|
|
||||||
size: Size.infinite,
|
size: Size.infinite,
|
||||||
painter: _CandlePainter(
|
painter: CandlestickPainter(
|
||||||
candles: widget.candles,
|
candles: widget.candles,
|
||||||
patterns: widget.patterns,
|
patterns: widget.patterns,
|
||||||
signals: widget.signals,
|
signals: widget.signals,
|
||||||
@@ -232,74 +197,76 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
tapPosition: _tapPosition,
|
tapPosition: _tapPosition,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (_selectedCandle != null) _buildTooltip(theme),
|
if (_selectedCandle != null) _buildTooltip(theme),
|
||||||
// Floating Zoom & Pan Controls (Top-Left)
|
_buildZoomControls(theme),
|
||||||
Positioned(
|
|
||||||
left: 12,
|
|
||||||
top: 12,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.cardSurface.withValues(alpha: 0.85),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(color: theme.glassBorder),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.zoom_in, size: 18),
|
|
||||||
color: theme.primaryColor,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
||||||
onPressed: () => setState(() => _scale = (_scale * 1.25).clamp(0.2, 5.0)),
|
|
||||||
tooltip: 'Zoom In',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.zoom_out, size: 18),
|
|
||||||
color: theme.primaryColor,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
||||||
onPressed: () => setState(() => _scale = (_scale * 0.8).clamp(0.2, 5.0)),
|
|
||||||
tooltip: 'Zoom Out',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.center_focus_strong, size: 18),
|
|
||||||
color: theme.textMuted,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_scale = 1.0;
|
|
||||||
_panOffset = 0.0;
|
|
||||||
}),
|
|
||||||
tooltip: 'Reset Zoom & Pan',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleTap(Offset pos, double width) {
|
Widget _buildZoomControls(ThemePreset theme) {
|
||||||
|
return Positioned(
|
||||||
|
right: 10,
|
||||||
|
bottom: 28,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardSurface.withValues(alpha: 0.9),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: theme.glassBorder),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildZoomButton(icon: Icons.chevron_left, tooltip: 'Nach links bewegen', onTap: () {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
setState(() { _panOffset += 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
|
||||||
|
}),
|
||||||
|
_buildZoomButton(icon: Icons.chevron_right, tooltip: 'Nach rechts bewegen', onTap: () {
|
||||||
|
final renderBox = context.findRenderObject() as RenderBox?;
|
||||||
|
setState(() { _panOffset -= 150; _clampPanOffset((renderBox?.size.width ?? 600) - 60); });
|
||||||
|
}),
|
||||||
|
Container(width: 1, height: 16, color: theme.glassBorder),
|
||||||
|
_buildZoomButton(icon: Icons.add, tooltip: 'Vergrößern', onTap: () => _applyZoom(1.25)),
|
||||||
|
_buildZoomButton(icon: Icons.remove, tooltip: 'Verkleinern', onTap: () => _applyZoom(0.8)),
|
||||||
|
_buildZoomButton(icon: Icons.fit_screen_outlined, tooltip: 'Aktuelle Kerzen einpassen', onTap: _fitLatestCandles),
|
||||||
|
_buildZoomButton(icon: Icons.refresh, tooltip: 'Zoom 1:1 zurücksetzen', onTap: () { setState(() => _scale = 1.0); _fitLatestCandles(); }),
|
||||||
|
if (widget.onToggleFullscreen != null) ...[
|
||||||
|
Container(width: 1, height: 16, color: theme.glassBorder),
|
||||||
|
_buildZoomButton(
|
||||||
|
icon: widget.isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
tooltip: widget.isFullscreen ? 'Vollbild beenden' : 'Vollbildmodus (Querformat)',
|
||||||
|
onTap: widget.onToggleFullscreen!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildZoomButton({required IconData icon, required String tooltip, required VoidCallback onTap}) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: Icon(icon, size: 16, color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(Offset pos) {
|
||||||
if (widget.candles.isEmpty) return;
|
if (widget.candles.isEmpty) return;
|
||||||
|
final double candleWidth = 10.0 * _scale;
|
||||||
// Right side is for axis, don't tap there
|
final double totalCandleSpace = candleWidth + (5.0 * _scale);
|
||||||
if (pos.dx > width - 60) return;
|
|
||||||
|
|
||||||
final double baseWidth = 10.0;
|
|
||||||
final double spacing = 5.0;
|
|
||||||
final double candleWidth = baseWidth * _scale;
|
|
||||||
final double totalCandleSpace = candleWidth + (spacing * _scale);
|
|
||||||
|
|
||||||
// dx = (i * totalCandleSpace) + _panOffset;
|
|
||||||
// (dx - _panOffset) / totalCandleSpace = i;
|
|
||||||
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
|
final int index = ((pos.dx - _panOffset) / totalCandleSpace).round();
|
||||||
|
|
||||||
if (index >= 0 && index < widget.candles.length) {
|
if (index >= 0 && index < widget.candles.length) {
|
||||||
@@ -311,8 +278,8 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTooltip(ThemePreset theme) {
|
Widget _buildTooltip(ThemePreset theme) {
|
||||||
final candle = _selectedCandle!;
|
final c = _selectedCandle!;
|
||||||
final dateStr = "${candle.time.year}-${candle.time.month.toString().padLeft(2,'0')}-${candle.time.day.toString().padLeft(2,'0')}";
|
final dStr = "${c.timestamp.year}-${c.timestamp.month.toString().padLeft(2, '0')}-${c.timestamp.day.toString().padLeft(2, '0')}";
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 10,
|
left: 10,
|
||||||
@@ -328,492 +295,12 @@ class _CandlestickChartState extends State<CandlestickChart> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(dateStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
|
Text(dStr, style: TextStyle(color: theme.textMuted, fontSize: 12)),
|
||||||
Text('O: ${candle.open.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
Text('O: ${c.open.toStringAsFixed(2)} | H: ${c.high.toStringAsFixed(2)} | L: ${c.low.toStringAsFixed(2)} | C: ${c.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
||||||
Text('H: ${candle.high.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
Text('Vol: ${c.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textSecondary, fontSize: 11)),
|
||||||
Text('L: ${candle.low.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
|
||||||
Text('C: ${candle.close.toStringAsFixed(2)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
|
||||||
Text('Vol: ${candle.volume.toStringAsFixed(0)}', style: TextStyle(color: theme.textPrimary, fontSize: 12)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CandlePainter extends CustomPainter {
|
|
||||||
final List<CandleModel> candles;
|
|
||||||
final List<ChartPatternModel> patterns;
|
|
||||||
final List<StrategySignalModel> signals;
|
|
||||||
final List<IndicatorModel> indicators;
|
|
||||||
final double scale;
|
|
||||||
final double panOffset;
|
|
||||||
final ThemePreset theme;
|
|
||||||
final bool showPatterns;
|
|
||||||
final bool showSma50;
|
|
||||||
final bool showSma200;
|
|
||||||
final bool showEma;
|
|
||||||
final bool showSignals;
|
|
||||||
final bool showSupertrend;
|
|
||||||
final Offset? tapPosition;
|
|
||||||
|
|
||||||
final double rightPadding = 60.0; // Space for price axis
|
|
||||||
final double bottomPadding = 20.0; // Space for X-axis labels
|
|
||||||
|
|
||||||
_CandlePainter({
|
|
||||||
required this.candles,
|
|
||||||
required this.patterns,
|
|
||||||
required this.signals,
|
|
||||||
required this.indicators,
|
|
||||||
required this.scale,
|
|
||||||
required this.panOffset,
|
|
||||||
required this.theme,
|
|
||||||
required this.showPatterns,
|
|
||||||
required this.showSma50,
|
|
||||||
required this.showSma200,
|
|
||||||
required this.showEma,
|
|
||||||
required this.showSignals,
|
|
||||||
required this.showSupertrend,
|
|
||||||
this.tapPosition,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
if (candles.isEmpty) return;
|
|
||||||
|
|
||||||
final double chartWidth = size.width - rightPadding;
|
|
||||||
final double baseWidth = 10.0;
|
|
||||||
final double spacing = 5.0;
|
|
||||||
final double candleWidth = baseWidth * scale;
|
|
||||||
final double totalCandleSpace = candleWidth + (spacing * scale);
|
|
||||||
|
|
||||||
double maxPrice = 0;
|
|
||||||
double minPrice = double.infinity;
|
|
||||||
|
|
||||||
// Find min/max in view
|
|
||||||
int firstVisibleIndex = -1;
|
|
||||||
for (int i = 0; i < candles.length; i++) {
|
|
||||||
final dx = (i * totalCandleSpace) + panOffset;
|
|
||||||
if (dx + candleWidth > 0 && dx < chartWidth) {
|
|
||||||
if (firstVisibleIndex == -1) firstVisibleIndex = i;
|
|
||||||
final c = candles[i];
|
|
||||||
if (c.high > maxPrice) maxPrice = c.high;
|
|
||||||
if (c.low < minPrice) minPrice = c.low;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minPrice == double.infinity || maxPrice == 0) return;
|
|
||||||
|
|
||||||
// Add 10% padding to top/bottom
|
|
||||||
final range = maxPrice - minPrice;
|
|
||||||
maxPrice += range * 0.1;
|
|
||||||
minPrice -= range * 0.1;
|
|
||||||
final paddedRange = maxPrice - minPrice;
|
|
||||||
if (paddedRange <= 0) return;
|
|
||||||
|
|
||||||
final double chartHeight = size.height - bottomPadding;
|
|
||||||
final double volumeHeight = chartHeight * 0.15; // Bottom 15% for volume
|
|
||||||
final double candleAreaHeight = chartHeight - volumeHeight;
|
|
||||||
|
|
||||||
double maxVolume = 0;
|
|
||||||
for (int i = 0; i < candles.length; i++) {
|
|
||||||
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
|
|
||||||
}
|
|
||||||
if (maxVolume == 0) maxVolume = 1;
|
|
||||||
|
|
||||||
_drawGridAndAxis(canvas, size, chartWidth, candleAreaHeight, minPrice, maxPrice, paddedRange);
|
|
||||||
|
|
||||||
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
|
|
||||||
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
|
|
||||||
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
|
|
||||||
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
|
|
||||||
|
|
||||||
final ema20Path = Path();
|
|
||||||
final sma50Path = Path();
|
|
||||||
final sma200Path = Path();
|
|
||||||
final supertrendPath = Path();
|
|
||||||
bool firstEma20 = true;
|
|
||||||
bool firstSma50 = true;
|
|
||||||
bool firstSma200 = true;
|
|
||||||
bool firstSupertrend = true;
|
|
||||||
|
|
||||||
// Map DateTime to X for patterns and signals
|
|
||||||
double getXForTime(DateTime t) {
|
|
||||||
int bestIndex = 0;
|
|
||||||
int minDiff = 999999999;
|
|
||||||
for (int i = 0; i < candles.length; i++) {
|
|
||||||
final diff = candles[i].time.difference(t).inSeconds.abs();
|
|
||||||
if (diff < minDiff) {
|
|
||||||
minDiff = diff;
|
|
||||||
bestIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
double getYForPrice(double price) {
|
|
||||||
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clip to chart area so we don't draw over the axis
|
|
||||||
canvas.save();
|
|
||||||
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
|
|
||||||
|
|
||||||
for (int i = 0; i < candles.length; i++) {
|
|
||||||
final candle = candles[i];
|
|
||||||
final isBullish = candle.close >= candle.open;
|
|
||||||
|
|
||||||
final dx = (i * totalCandleSpace) + panOffset;
|
|
||||||
if (dx < -candleWidth || dx > chartWidth) continue; // Culling
|
|
||||||
|
|
||||||
final yHigh = getYForPrice(candle.high);
|
|
||||||
final yLow = getYForPrice(candle.low);
|
|
||||||
final yOpen = getYForPrice(candle.open);
|
|
||||||
final yClose = getYForPrice(candle.close);
|
|
||||||
|
|
||||||
// Draw Wick
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(dx + candleWidth / 2, yHigh),
|
|
||||||
Offset(dx + candleWidth / 2, yLow),
|
|
||||||
isBullish ? paintWickBullish : paintWickBearish,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw Body
|
|
||||||
final top = min(yOpen, yClose);
|
|
||||||
final bottom = max(yOpen, yClose);
|
|
||||||
final bodyHeight = max(bottom - top, 1.0); // minimum 1px height
|
|
||||||
|
|
||||||
canvas.drawRect(
|
|
||||||
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
|
|
||||||
isBullish ? paintBullish : paintBearish,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw Volume
|
|
||||||
final vHeight = (candle.volume / maxVolume) * volumeHeight;
|
|
||||||
final vTop = chartHeight - vHeight;
|
|
||||||
canvas.drawRect(
|
|
||||||
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
|
|
||||||
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Indicators mapping by time
|
|
||||||
if (indicators.isNotEmpty) {
|
|
||||||
final cx = dx + candleWidth / 2;
|
|
||||||
IndicatorModel? match;
|
|
||||||
for (var ind in indicators) {
|
|
||||||
if (ind.timestamp.isAtSameMomentAs(candle.time) || ind.timestamp.difference(candle.time).inHours.abs() < 12) {
|
|
||||||
match = ind;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match != null) {
|
|
||||||
if (showEma && match.ema20 != null) {
|
|
||||||
final y = getYForPrice(match.ema20!);
|
|
||||||
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
|
|
||||||
else { ema20Path.lineTo(cx, y); }
|
|
||||||
}
|
|
||||||
if (showSma50 && match.sma50 != null) {
|
|
||||||
final y = getYForPrice(match.sma50!);
|
|
||||||
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
|
|
||||||
else { sma50Path.lineTo(cx, y); }
|
|
||||||
}
|
|
||||||
if (showSma200 && match.sma200 != null) {
|
|
||||||
final y = getYForPrice(match.sma200!);
|
|
||||||
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
|
|
||||||
else { sma200Path.lineTo(cx, y); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSupertrend) {
|
|
||||||
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
|
|
||||||
if (stVal != null) {
|
|
||||||
final y = getYForPrice(stVal);
|
|
||||||
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
|
|
||||||
else { supertrendPath.lineTo(cx, y); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEma && !firstEma20) {
|
|
||||||
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
|
|
||||||
}
|
|
||||||
if (showSma50 && !firstSma50) {
|
|
||||||
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
|
|
||||||
}
|
|
||||||
if (showSma200 && !firstSma200) {
|
|
||||||
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
|
||||||
}
|
|
||||||
if (showSupertrend && !firstSupertrend) {
|
|
||||||
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showPatterns) {
|
|
||||||
_drawPatterns(canvas, getXForTime, getYForPrice);
|
|
||||||
_drawFutureProjectionZone(canvas, size, chartWidth, candleAreaHeight, getXForTime, getYForPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSignals) {
|
|
||||||
_drawSignals(canvas, getXForTime, getYForPrice);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tapPosition != null && tapPosition!.dx < chartWidth) {
|
|
||||||
_drawCrosshair(canvas, size, chartWidth, chartHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.restore(); // Restore clip
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawFutureProjectionZone(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double Function(DateTime) getX, double Function(double) getY) {
|
|
||||||
if (candles.isEmpty) return;
|
|
||||||
|
|
||||||
final lastCandle = candles.last;
|
|
||||||
final double lastX = getX(lastCandle.time);
|
|
||||||
|
|
||||||
if (lastX < chartWidth) {
|
|
||||||
// 1. Shaded background for Future Zone (No divider line)
|
|
||||||
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
|
|
||||||
final futureBgPaint = Paint()
|
|
||||||
..color = const Color(0xFF001F3F).withValues(alpha: 0.25)
|
|
||||||
..style = PaintingStyle.fill;
|
|
||||||
canvas.drawRect(futureRect, futureBgPaint);
|
|
||||||
|
|
||||||
// Label for Future Zone
|
|
||||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
|
||||||
textPainter.text = TextSpan(
|
|
||||||
text: 'PROGNOSE (MUSTER-SCHÄTZUNG)',
|
|
||||||
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(canvas, Offset(lastX + 8, 8));
|
|
||||||
|
|
||||||
// 2. Projected Ghost Candles & Target Line for active patterns
|
|
||||||
for (var pattern in patterns) {
|
|
||||||
if (pattern.lowerLine.isNotEmpty || pattern.upperLine.isNotEmpty) {
|
|
||||||
final targetPrice = pattern.lowerLine.isNotEmpty ? pattern.lowerLine.last.price : (pattern.upperLine.isNotEmpty ? pattern.upperLine.last.price : 0);
|
|
||||||
if (targetPrice > 0) {
|
|
||||||
final targetY = getY(targetPrice.toDouble());
|
|
||||||
final int numSteps = 10;
|
|
||||||
final double stepWidth = (chartWidth - lastX - 30) / numSteps;
|
|
||||||
if (stepWidth <= 0) continue;
|
|
||||||
|
|
||||||
final isBullish = targetPrice >= lastCandle.close;
|
|
||||||
final projColor = isBullish ? Colors.greenAccent : Colors.redAccent;
|
|
||||||
|
|
||||||
double currX = lastX;
|
|
||||||
double currPrice = lastCandle.close;
|
|
||||||
|
|
||||||
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
|
|
||||||
|
|
||||||
for (int k = 1; k <= numSteps; k++) {
|
|
||||||
final nextX = lastX + k * stepWidth;
|
|
||||||
final waveNoise = sin(k * 0.8) * (priceDeltaPerStep.abs() * 0.3);
|
|
||||||
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
|
|
||||||
|
|
||||||
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.2;
|
|
||||||
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.2;
|
|
||||||
|
|
||||||
final yOpen = getY(currPrice);
|
|
||||||
final yClose = getY(nextPrice);
|
|
||||||
final yHigh = getY(highPrice);
|
|
||||||
final yLow = getY(lowPrice);
|
|
||||||
|
|
||||||
final cWidth = max(stepWidth * 0.6, 3.0);
|
|
||||||
final cLeft = nextX - cWidth / 2;
|
|
||||||
|
|
||||||
final isStepBullish = nextPrice >= currPrice;
|
|
||||||
final stepColor = isStepBullish ? Colors.greenAccent : Colors.redAccent;
|
|
||||||
|
|
||||||
// Draw Ghost Candle Wick
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(nextX, yHigh),
|
|
||||||
Offset(nextX, yLow),
|
|
||||||
Paint()..color = stepColor.withValues(alpha: 0.4)..strokeWidth = 1.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw Ghost Candle Body
|
|
||||||
final top = min(yOpen, yClose);
|
|
||||||
final bottom = max(yOpen, yClose);
|
|
||||||
canvas.drawRect(
|
|
||||||
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.0)),
|
|
||||||
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
|
|
||||||
);
|
|
||||||
|
|
||||||
currX = nextX;
|
|
||||||
currPrice = nextPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Target Price Badge at final step
|
|
||||||
final targetX = currX;
|
|
||||||
final targetBadgePainter = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ',
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
targetBadgePainter.layout();
|
|
||||||
|
|
||||||
final badgeRect = RRect.fromLTRBR(
|
|
||||||
targetX - targetBadgePainter.width / 2,
|
|
||||||
targetY - targetBadgePainter.height / 2 - 2,
|
|
||||||
targetX + targetBadgePainter.width / 2,
|
|
||||||
targetY + targetBadgePainter.height / 2 + 2,
|
|
||||||
const Radius.circular(6),
|
|
||||||
);
|
|
||||||
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.9));
|
|
||||||
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawGridAndAxis(Canvas canvas, Size size, double chartWidth, double candleAreaHeight, double minPrice, double maxPrice, double range) {
|
|
||||||
final gridPaint = Paint()
|
|
||||||
..color = theme.glassBorder
|
|
||||||
..strokeWidth = 1;
|
|
||||||
|
|
||||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
|
||||||
|
|
||||||
// Y Axis
|
|
||||||
final int gridLines = 5;
|
|
||||||
for (int i = 0; i <= gridLines; i++) {
|
|
||||||
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
|
|
||||||
final price = minPrice + (i / gridLines) * range;
|
|
||||||
|
|
||||||
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
|
|
||||||
|
|
||||||
textPainter.text = TextSpan(
|
|
||||||
text: price.toStringAsFixed(2),
|
|
||||||
style: TextStyle(color: theme.textMuted, fontSize: 11),
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
|
|
||||||
}
|
|
||||||
|
|
||||||
// X Axis
|
|
||||||
if (candles.isEmpty) return;
|
|
||||||
|
|
||||||
final double baseWidth = 10.0;
|
|
||||||
final double spacing = 5.0;
|
|
||||||
final double candleWidth = baseWidth * scale;
|
|
||||||
final double totalCandleSpace = candleWidth + (spacing * scale);
|
|
||||||
|
|
||||||
final int xSteps = (chartWidth / 80).floor(); // label every 80px
|
|
||||||
if (xSteps <= 0) return;
|
|
||||||
|
|
||||||
for (int i = 1; i < xSteps; i++) {
|
|
||||||
double x = i * (chartWidth / xSteps);
|
|
||||||
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
|
|
||||||
if (candleIndex >= 0 && candleIndex < candles.length) {
|
|
||||||
final t = candles[candleIndex].time;
|
|
||||||
textPainter.text = TextSpan(
|
|
||||||
text: "${t.month.toString().padLeft(2,'0')}-${t.day.toString().padLeft(2,'0')}",
|
|
||||||
style: TextStyle(color: theme.textMuted, fontSize: 10),
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
|
|
||||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawPatterns(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
|
|
||||||
for (var pattern in patterns) {
|
|
||||||
final color = PatternExplanations.getColorForPattern(pattern.type);
|
|
||||||
final paint = Paint()
|
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.stroke
|
|
||||||
..strokeWidth = 2.0;
|
|
||||||
|
|
||||||
void drawLine(List<PatternPoint> points) {
|
|
||||||
if (points.length < 2) return;
|
|
||||||
final path = Path();
|
|
||||||
path.moveTo(getX(points[0].time), getY(points[0].price));
|
|
||||||
for (int i = 1; i < points.length; i++) {
|
|
||||||
path.lineTo(getX(points[i].time), getY(points[i].price));
|
|
||||||
}
|
|
||||||
canvas.drawPath(path, paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawLine(pattern.upperLine);
|
|
||||||
drawLine(pattern.lowerLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawSignals(Canvas canvas, double Function(DateTime) getX, double Function(double) getY) {
|
|
||||||
for (var signal in signals) {
|
|
||||||
final x = getX(signal.timestamp);
|
|
||||||
final y = getY(signal.price);
|
|
||||||
|
|
||||||
final isBuy = signal.direction.toUpperCase() == 'BUY';
|
|
||||||
final isSell = signal.direction.toUpperCase() == 'SELL';
|
|
||||||
|
|
||||||
if (!isBuy && !isSell) continue;
|
|
||||||
|
|
||||||
final color = isBuy ? theme.primaryColor : theme.accentRed;
|
|
||||||
final label = isBuy ? '▲ BUY' : '▼ SELL';
|
|
||||||
|
|
||||||
// Draw Pill Badge for Signal
|
|
||||||
final textPainter = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: label,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
);
|
|
||||||
textPainter.layout();
|
|
||||||
|
|
||||||
final badgeWidth = textPainter.width + 12;
|
|
||||||
final badgeHeight = textPainter.height + 6;
|
|
||||||
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
|
|
||||||
final badgeRect = RRect.fromLTRBR(
|
|
||||||
x - badgeWidth / 2,
|
|
||||||
badgeY,
|
|
||||||
x + badgeWidth / 2,
|
|
||||||
badgeY + badgeHeight,
|
|
||||||
const Radius.circular(10),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pill Background
|
|
||||||
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
|
|
||||||
|
|
||||||
// Pointer Line to price point
|
|
||||||
canvas.drawLine(
|
|
||||||
Offset(x, y),
|
|
||||||
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
|
|
||||||
Paint()..color = color..strokeWidth = 1.5,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Text paint
|
|
||||||
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _drawCrosshair(Canvas canvas, Size size, double chartWidth, double chartHeight) {
|
|
||||||
final paint = Paint()
|
|
||||||
..color = theme.textMuted.withValues(alpha: 0.5)
|
|
||||||
..strokeWidth = 1
|
|
||||||
..style = PaintingStyle.stroke;
|
|
||||||
|
|
||||||
// Vertical
|
|
||||||
canvas.drawLine(Offset(tapPosition!.dx, 0), Offset(tapPosition!.dx, chartHeight), paint);
|
|
||||||
// Horizontal
|
|
||||||
if (tapPosition!.dy <= chartHeight) {
|
|
||||||
canvas.drawLine(Offset(0, tapPosition!.dy), Offset(chartWidth, tapPosition!.dy), paint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _CandlePainter oldDelegate) {
|
|
||||||
return oldDelegate.scale != scale ||
|
|
||||||
oldDelegate.panOffset != panOffset ||
|
|
||||||
oldDelegate.candles != candles ||
|
|
||||||
oldDelegate.patterns != patterns ||
|
|
||||||
oldDelegate.signals != signals ||
|
|
||||||
oldDelegate.indicators != indicators ||
|
|
||||||
oldDelegate.tapPosition != tapPosition;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import 'chart_overlay_renderer.dart';
|
||||||
|
|
||||||
|
class CandlestickPainter extends CustomPainter {
|
||||||
|
final List<CandleModel> candles;
|
||||||
|
final List<ChartPatternModel> patterns;
|
||||||
|
final List<StrategySignalModel> signals;
|
||||||
|
final List<IndicatorModel> indicators;
|
||||||
|
final double scale;
|
||||||
|
final double panOffset;
|
||||||
|
final ThemePreset theme;
|
||||||
|
final bool showPatterns;
|
||||||
|
final bool showSma50;
|
||||||
|
final bool showSma200;
|
||||||
|
final bool showEma;
|
||||||
|
final bool showSignals;
|
||||||
|
final bool showSupertrend;
|
||||||
|
final Offset? tapPosition;
|
||||||
|
|
||||||
|
final double rightPadding = 60.0;
|
||||||
|
final double bottomPadding = 20.0;
|
||||||
|
|
||||||
|
CandlestickPainter({
|
||||||
|
required this.candles,
|
||||||
|
required this.patterns,
|
||||||
|
required this.signals,
|
||||||
|
required this.indicators,
|
||||||
|
required this.scale,
|
||||||
|
required this.panOffset,
|
||||||
|
required this.theme,
|
||||||
|
required this.showPatterns,
|
||||||
|
required this.showSma50,
|
||||||
|
required this.showSma200,
|
||||||
|
required this.showEma,
|
||||||
|
required this.showSignals,
|
||||||
|
required this.showSupertrend,
|
||||||
|
this.tapPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
if (candles.isEmpty) return;
|
||||||
|
|
||||||
|
final double chartWidth = size.width - rightPadding;
|
||||||
|
final double baseWidth = 10.0;
|
||||||
|
final double spacing = 5.0;
|
||||||
|
final double candleWidth = baseWidth * scale;
|
||||||
|
final double totalCandleSpace = candleWidth + (spacing * scale);
|
||||||
|
|
||||||
|
double maxPrice = 0;
|
||||||
|
double minPrice = double.infinity;
|
||||||
|
|
||||||
|
int visibleCount = 0;
|
||||||
|
for (int i = 0; i < candles.length; i++) {
|
||||||
|
final dx = (i * totalCandleSpace) + panOffset;
|
||||||
|
if (dx + candleWidth > -100 && dx < chartWidth + 100) {
|
||||||
|
visibleCount++;
|
||||||
|
final c = candles[i];
|
||||||
|
if (c.high > maxPrice) maxPrice = c.high;
|
||||||
|
if (c.low < minPrice) minPrice = c.low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleCount == 0 || minPrice == double.infinity || maxPrice <= 0) {
|
||||||
|
for (var c in candles) {
|
||||||
|
if (c.high > maxPrice) maxPrice = c.high;
|
||||||
|
if (c.low < minPrice) minPrice = c.low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minPrice == double.infinity || maxPrice <= 0) return;
|
||||||
|
|
||||||
|
final range = maxPrice - minPrice;
|
||||||
|
maxPrice += max(range * 0.1, 1.0);
|
||||||
|
minPrice -= max(range * 0.1, 1.0);
|
||||||
|
final paddedRange = maxPrice - minPrice;
|
||||||
|
if (paddedRange <= 0) return;
|
||||||
|
|
||||||
|
final double chartHeight = size.height - bottomPadding;
|
||||||
|
final double volumeHeight = chartHeight * 0.15;
|
||||||
|
final double candleAreaHeight = chartHeight - volumeHeight;
|
||||||
|
|
||||||
|
double maxVolume = 0;
|
||||||
|
for (int i = 0; i < candles.length; i++) {
|
||||||
|
if (candles[i].volume > maxVolume) maxVolume = candles[i].volume;
|
||||||
|
}
|
||||||
|
if (maxVolume == 0) maxVolume = 1;
|
||||||
|
|
||||||
|
ChartOverlayRenderer.drawGridAndAxis(
|
||||||
|
canvas: canvas,
|
||||||
|
size: size,
|
||||||
|
chartWidth: chartWidth,
|
||||||
|
candleAreaHeight: candleAreaHeight,
|
||||||
|
minPrice: minPrice,
|
||||||
|
maxPrice: maxPrice,
|
||||||
|
range: paddedRange,
|
||||||
|
candles: candles,
|
||||||
|
scale: scale,
|
||||||
|
panOffset: panOffset,
|
||||||
|
bottomPadding: bottomPadding,
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
|
||||||
|
final paintBullish = Paint()..color = theme.primaryColor..style = PaintingStyle.fill;
|
||||||
|
final paintBearish = Paint()..color = theme.accentRed..style = PaintingStyle.fill;
|
||||||
|
final paintWickBullish = Paint()..color = theme.primaryColor..strokeWidth = 1.5;
|
||||||
|
final paintWickBearish = Paint()..color = theme.accentRed..strokeWidth = 1.5;
|
||||||
|
|
||||||
|
final ema20Path = Path();
|
||||||
|
final sma50Path = Path();
|
||||||
|
final sma200Path = Path();
|
||||||
|
final supertrendPath = Path();
|
||||||
|
bool firstEma20 = true;
|
||||||
|
bool firstSma50 = true;
|
||||||
|
bool firstSma200 = true;
|
||||||
|
bool firstSupertrend = true;
|
||||||
|
|
||||||
|
double getXForTime(DateTime t) {
|
||||||
|
if (candles.isEmpty) return 0.0;
|
||||||
|
final lastCandle = candles.last;
|
||||||
|
if (t.isAfter(lastCandle.timestamp) && candles.length > 1) {
|
||||||
|
final totalSpan = lastCandle.timestamp.difference(candles.first.timestamp).inSeconds;
|
||||||
|
final secPerCandle = totalSpan / (candles.length - 1);
|
||||||
|
if (secPerCandle > 0) {
|
||||||
|
final futureSecs = t.difference(lastCandle.timestamp).inSeconds;
|
||||||
|
final futureCandles = futureSecs / secPerCandle;
|
||||||
|
final lastDx = ((candles.length - 1) * totalCandleSpace) + panOffset + candleWidth / 2;
|
||||||
|
return lastDx + (futureCandles * totalCandleSpace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int bestIndex = 0;
|
||||||
|
int minDiff = 999999999;
|
||||||
|
for (int i = 0; i < candles.length; i++) {
|
||||||
|
final diff = candles[i].timestamp.difference(t).inSeconds.abs();
|
||||||
|
if (diff < minDiff) {
|
||||||
|
minDiff = diff;
|
||||||
|
bestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (bestIndex * totalCandleSpace) + panOffset + candleWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
double getYForPrice(double price) {
|
||||||
|
return candleAreaHeight - ((price - minPrice) / paddedRange) * candleAreaHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(Rect.fromLTWH(0, 0, chartWidth, chartHeight));
|
||||||
|
|
||||||
|
for (int i = 0; i < candles.length; i++) {
|
||||||
|
final candle = candles[i];
|
||||||
|
final isBullish = candle.close >= candle.open;
|
||||||
|
|
||||||
|
final dx = (i * totalCandleSpace) + panOffset;
|
||||||
|
if (dx < -candleWidth || dx > chartWidth) continue;
|
||||||
|
|
||||||
|
final yHigh = getYForPrice(candle.high);
|
||||||
|
final yLow = getYForPrice(candle.low);
|
||||||
|
final yOpen = getYForPrice(candle.open);
|
||||||
|
final yClose = getYForPrice(candle.close);
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(dx + candleWidth / 2, yHigh),
|
||||||
|
Offset(dx + candleWidth / 2, yLow),
|
||||||
|
isBullish ? paintWickBullish : paintWickBearish,
|
||||||
|
);
|
||||||
|
|
||||||
|
final top = min(yOpen, yClose);
|
||||||
|
final bottom = max(yOpen, yClose);
|
||||||
|
final bodyHeight = max(bottom - top, 1.0);
|
||||||
|
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(dx, top, candleWidth, bodyHeight),
|
||||||
|
isBullish ? paintBullish : paintBearish,
|
||||||
|
);
|
||||||
|
|
||||||
|
final vHeight = (candle.volume / maxVolume) * volumeHeight;
|
||||||
|
final vTop = chartHeight - vHeight;
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(dx, vTop, candleWidth, vHeight),
|
||||||
|
Paint()..color = (isBullish ? theme.primaryColor : theme.accentRed).withValues(alpha: 0.3)..style = PaintingStyle.fill,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (indicators.isNotEmpty) {
|
||||||
|
final cx = dx + candleWidth / 2;
|
||||||
|
IndicatorModel? match;
|
||||||
|
for (var ind in indicators) {
|
||||||
|
if (ind.timestamp.isAtSameMomentAs(candle.timestamp) || ind.timestamp.difference(candle.timestamp).inHours.abs() < 12) {
|
||||||
|
match = ind;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match != null) {
|
||||||
|
if (showEma && match.ema20 != null) {
|
||||||
|
final y = getYForPrice(match.ema20!);
|
||||||
|
if (firstEma20) { ema20Path.moveTo(cx, y); firstEma20 = false; }
|
||||||
|
else { ema20Path.lineTo(cx, y); }
|
||||||
|
}
|
||||||
|
if (showSma50 && match.sma50 != null) {
|
||||||
|
final y = getYForPrice(match.sma50!);
|
||||||
|
if (firstSma50) { sma50Path.moveTo(cx, y); firstSma50 = false; }
|
||||||
|
else { sma50Path.lineTo(cx, y); }
|
||||||
|
}
|
||||||
|
if (showSma200 && match.sma200 != null) {
|
||||||
|
final y = getYForPrice(match.sma200!);
|
||||||
|
if (firstSma200) { sma200Path.moveTo(cx, y); firstSma200 = false; }
|
||||||
|
else { sma200Path.lineTo(cx, y); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSupertrend) {
|
||||||
|
final stVal = match.supertrendDirection == 'BULLISH' ? match.supertrendLower : match.supertrendUpper;
|
||||||
|
if (stVal != null) {
|
||||||
|
final y = getYForPrice(stVal);
|
||||||
|
if (firstSupertrend) { supertrendPath.moveTo(cx, y); firstSupertrend = false; }
|
||||||
|
else { supertrendPath.lineTo(cx, y); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEma && !firstEma20) {
|
||||||
|
canvas.drawPath(ema20Path, Paint()..color = Colors.blueAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
|
||||||
|
}
|
||||||
|
if (showSma50 && !firstSma50) {
|
||||||
|
canvas.drawPath(sma50Path, Paint()..color = Colors.orangeAccent..style = PaintingStyle.stroke..strokeWidth = 1.5);
|
||||||
|
}
|
||||||
|
if (showSma200 && !firstSma200) {
|
||||||
|
canvas.drawPath(sma200Path, Paint()..color = Colors.redAccent..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
||||||
|
}
|
||||||
|
if (showSupertrend && !firstSupertrend) {
|
||||||
|
canvas.drawPath(supertrendPath, Paint()..color = AppTheme.primaryEmerald..style = PaintingStyle.stroke..strokeWidth = 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPatterns) {
|
||||||
|
ChartOverlayRenderer.drawPatterns(
|
||||||
|
canvas: canvas,
|
||||||
|
patterns: patterns,
|
||||||
|
getX: getXForTime,
|
||||||
|
getY: getYForPrice,
|
||||||
|
);
|
||||||
|
ChartOverlayRenderer.drawFutureProjectionZone(
|
||||||
|
canvas: canvas,
|
||||||
|
candles: candles,
|
||||||
|
patterns: patterns,
|
||||||
|
chartWidth: chartWidth,
|
||||||
|
candleAreaHeight: candleAreaHeight,
|
||||||
|
getX: getXForTime,
|
||||||
|
getY: getYForPrice,
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSignals) {
|
||||||
|
ChartOverlayRenderer.drawSignals(
|
||||||
|
canvas: canvas,
|
||||||
|
signals: signals,
|
||||||
|
getX: getXForTime,
|
||||||
|
getY: getYForPrice,
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tapPosition != null && tapPosition!.dx < chartWidth) {
|
||||||
|
ChartOverlayRenderer.drawCrosshair(
|
||||||
|
canvas: canvas,
|
||||||
|
tapPosition: tapPosition!,
|
||||||
|
chartWidth: chartWidth,
|
||||||
|
chartHeight: chartHeight,
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CandlestickPainter oldDelegate) {
|
||||||
|
return oldDelegate.scale != scale ||
|
||||||
|
oldDelegate.panOffset != panOffset ||
|
||||||
|
oldDelegate.candles != candles ||
|
||||||
|
oldDelegate.patterns != patterns ||
|
||||||
|
oldDelegate.signals != signals ||
|
||||||
|
oldDelegate.indicators != indicators ||
|
||||||
|
oldDelegate.tapPosition != tapPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import '../../utils/pattern_explanations.dart';
|
||||||
|
|
||||||
|
/// Helper for rendering chart overlays: grid, axes, patterns, future projections, and signals.
|
||||||
|
class ChartOverlayRenderer {
|
||||||
|
static void drawGridAndAxis({
|
||||||
|
required Canvas canvas,
|
||||||
|
required Size size,
|
||||||
|
required double chartWidth,
|
||||||
|
required double candleAreaHeight,
|
||||||
|
required double minPrice,
|
||||||
|
required double maxPrice,
|
||||||
|
required double range,
|
||||||
|
required List<CandleModel> candles,
|
||||||
|
required double scale,
|
||||||
|
required double panOffset,
|
||||||
|
required double bottomPadding,
|
||||||
|
required ThemePreset theme,
|
||||||
|
}) {
|
||||||
|
final gridPaint = Paint()..color = theme.glassBorder..strokeWidth = 1;
|
||||||
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
|
|
||||||
|
const int gridLines = 5;
|
||||||
|
for (int i = 0; i <= gridLines; i++) {
|
||||||
|
final y = candleAreaHeight - (i / gridLines) * candleAreaHeight;
|
||||||
|
final price = minPrice + (i / gridLines) * range;
|
||||||
|
|
||||||
|
canvas.drawLine(Offset(0, y), Offset(chartWidth, y), gridPaint);
|
||||||
|
|
||||||
|
textPainter.text = TextSpan(
|
||||||
|
text: price.toStringAsFixed(2),
|
||||||
|
style: TextStyle(color: theme.textMuted, fontSize: 11),
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(canvas, Offset(chartWidth + 5, y - 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candles.isEmpty) return;
|
||||||
|
|
||||||
|
const double baseWidth = 10.0;
|
||||||
|
const double spacing = 5.0;
|
||||||
|
final double candleWidth = baseWidth * scale;
|
||||||
|
final double totalCandleSpace = candleWidth + (spacing * scale);
|
||||||
|
|
||||||
|
final int xSteps = (chartWidth / 80).floor();
|
||||||
|
if (xSteps <= 0) return;
|
||||||
|
|
||||||
|
for (int i = 1; i < xSteps; i++) {
|
||||||
|
double x = i * (chartWidth / xSteps);
|
||||||
|
int candleIndex = ((x - panOffset) / totalCandleSpace).round();
|
||||||
|
if (candleIndex >= 0 && candleIndex < candles.length) {
|
||||||
|
final t = candles[candleIndex].timestamp;
|
||||||
|
textPainter.text = TextSpan(
|
||||||
|
text: "${t.month.toString().padLeft(2, '0')}-${t.day.toString().padLeft(2, '0')}",
|
||||||
|
style: TextStyle(color: theme.textMuted, fontSize: 10),
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(canvas, Offset(x - textPainter.width / 2, size.height - bottomPadding + 4));
|
||||||
|
canvas.drawLine(Offset(x, 0), Offset(x, size.height - bottomPadding), gridPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawFutureProjectionZone({
|
||||||
|
required Canvas canvas,
|
||||||
|
required List<CandleModel> candles,
|
||||||
|
required List<ChartPatternModel> patterns,
|
||||||
|
required double chartWidth,
|
||||||
|
required double candleAreaHeight,
|
||||||
|
required double Function(DateTime) getX,
|
||||||
|
required double Function(double) getY,
|
||||||
|
required ThemePreset theme,
|
||||||
|
}) {
|
||||||
|
if (candles.isEmpty) return;
|
||||||
|
|
||||||
|
final lastCandle = candles.last;
|
||||||
|
final double lastX = getX(lastCandle.timestamp);
|
||||||
|
|
||||||
|
if (lastX < chartWidth - 10) {
|
||||||
|
final futureRect = Rect.fromLTRB(lastX, 0, chartWidth, candleAreaHeight);
|
||||||
|
final futureBgPaint = Paint()
|
||||||
|
..color = theme.primaryColor.withValues(alpha: 0.05)
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
canvas.drawRect(futureRect, futureBgPaint);
|
||||||
|
|
||||||
|
final sepPaint = Paint()
|
||||||
|
..color = theme.primaryColor.withValues(alpha: 0.3)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.0;
|
||||||
|
canvas.drawLine(Offset(lastX, 0), Offset(lastX, candleAreaHeight), sepPaint);
|
||||||
|
|
||||||
|
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
|
textPainter.text = TextSpan(
|
||||||
|
text: 'PROGNOSE (KI & MUSTER)',
|
||||||
|
style: TextStyle(color: theme.primaryColor, fontSize: 9, fontWeight: FontWeight.bold, letterSpacing: 0.8),
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(canvas, Offset(lastX + 8, 8));
|
||||||
|
|
||||||
|
for (var pattern in patterns) {
|
||||||
|
double targetPrice = 0.0;
|
||||||
|
if (pattern.breakoutSignal != null && pattern.breakoutSignal!.targetPrice > 0) {
|
||||||
|
targetPrice = pattern.breakoutSignal!.targetPrice;
|
||||||
|
} else if (pattern.lowerLine.isNotEmpty && pattern.upperLine.isNotEmpty) {
|
||||||
|
final diff = (pattern.upperLine.last.price - pattern.lowerLine.last.price).abs();
|
||||||
|
targetPrice = lastCandle.close >= pattern.lowerLine.last.price
|
||||||
|
? lastCandle.close + (diff > 0 ? diff : lastCandle.close * 0.05)
|
||||||
|
: lastCandle.close - (diff > 0 ? diff : lastCandle.close * 0.05);
|
||||||
|
} else if (pattern.upperLine.isNotEmpty) {
|
||||||
|
targetPrice = pattern.upperLine.last.price;
|
||||||
|
} else if (pattern.lowerLine.isNotEmpty) {
|
||||||
|
targetPrice = pattern.lowerLine.last.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPrice > 0) {
|
||||||
|
final targetY = getY(targetPrice);
|
||||||
|
const int numSteps = 8;
|
||||||
|
final double availableWidth = max(chartWidth - lastX - 40, 60.0);
|
||||||
|
final double stepWidth = availableWidth / numSteps;
|
||||||
|
|
||||||
|
final isBullish = targetPrice >= lastCandle.close;
|
||||||
|
final projColor = isBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
|
||||||
|
double currX = lastX;
|
||||||
|
double currPrice = lastCandle.close;
|
||||||
|
final double priceDeltaPerStep = (targetPrice - lastCandle.close) / numSteps;
|
||||||
|
|
||||||
|
for (int k = 1; k <= numSteps; k++) {
|
||||||
|
final nextX = lastX + (k * stepWidth);
|
||||||
|
final waveNoise = sin(k * 0.9) * (priceDeltaPerStep.abs() * 0.25);
|
||||||
|
final nextPrice = lastCandle.close + (priceDeltaPerStep * k) + waveNoise;
|
||||||
|
|
||||||
|
final highPrice = max(currPrice, nextPrice) + priceDeltaPerStep.abs() * 0.15;
|
||||||
|
final lowPrice = min(currPrice, nextPrice) - priceDeltaPerStep.abs() * 0.15;
|
||||||
|
|
||||||
|
final yOpen = getY(currPrice);
|
||||||
|
final yClose = getY(nextPrice);
|
||||||
|
final yHigh = getY(highPrice);
|
||||||
|
final yLow = getY(lowPrice);
|
||||||
|
|
||||||
|
final cWidth = max(stepWidth * 0.55, 3.0);
|
||||||
|
final cLeft = nextX - cWidth / 2;
|
||||||
|
|
||||||
|
final isStepBullish = nextPrice >= currPrice;
|
||||||
|
final stepColor = isStepBullish ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(nextX, yHigh),
|
||||||
|
Offset(nextX, yLow),
|
||||||
|
Paint()..color = stepColor.withValues(alpha: 0.45)..strokeWidth = 1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final top = min(yOpen, yClose);
|
||||||
|
final bottom = max(yOpen, yClose);
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(cLeft, top, cWidth, max(bottom - top, 1.5)),
|
||||||
|
Paint()..color = stepColor.withValues(alpha: 0.35)..style = PaintingStyle.fill,
|
||||||
|
);
|
||||||
|
|
||||||
|
currX = nextX;
|
||||||
|
currPrice = nextPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
final targetX = currX;
|
||||||
|
final pct = ((targetPrice - lastCandle.close) / lastCandle.close) * 100;
|
||||||
|
final pctSign = pct >= 0 ? '+' : '';
|
||||||
|
final targetBadgePainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: ' ZIEL: ${targetPrice.toStringAsFixed(2)} € ($pctSign${pct.toStringAsFixed(1)}%) ',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
targetBadgePainter.layout();
|
||||||
|
|
||||||
|
final badgeRect = RRect.fromLTRBR(
|
||||||
|
targetX - targetBadgePainter.width / 2,
|
||||||
|
targetY - targetBadgePainter.height / 2 - 3,
|
||||||
|
targetX + targetBadgePainter.width / 2,
|
||||||
|
targetY + targetBadgePainter.height / 2 + 3,
|
||||||
|
const Radius.circular(6),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(badgeRect, Paint()..color = projColor.withValues(alpha: 0.92));
|
||||||
|
targetBadgePainter.paint(canvas, Offset(targetX - targetBadgePainter.width / 2, targetY - targetBadgePainter.height / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawPatterns({
|
||||||
|
required Canvas canvas,
|
||||||
|
required List<ChartPatternModel> patterns,
|
||||||
|
required double Function(DateTime) getX,
|
||||||
|
required double Function(double) getY,
|
||||||
|
}) {
|
||||||
|
for (var pattern in patterns) {
|
||||||
|
final color = PatternExplanations.getColorForPattern(pattern.type);
|
||||||
|
final paint = Paint()..color = color..style = PaintingStyle.stroke..strokeWidth = 2.5;
|
||||||
|
final fillPaint = Paint()..color = color.withValues(alpha: 0.12)..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
Offset? firstPoint;
|
||||||
|
|
||||||
|
void drawLine(List<PatternPoint> points) {
|
||||||
|
if (points.length < 2) return;
|
||||||
|
final path = Path();
|
||||||
|
final startX = getX(points[0].time);
|
||||||
|
final startY = getY(points[0].price);
|
||||||
|
path.moveTo(startX, startY);
|
||||||
|
firstPoint ??= Offset(startX, startY);
|
||||||
|
|
||||||
|
for (int i = 1; i < points.length; i++) {
|
||||||
|
final px = getX(points[i].time);
|
||||||
|
final py = getY(points[i].price);
|
||||||
|
path.lineTo(px, py);
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
|
||||||
|
for (var p in points) {
|
||||||
|
final px = getX(p.time);
|
||||||
|
final py = getY(p.price);
|
||||||
|
canvas.drawCircle(Offset(px, py), 4, Paint()..color = color);
|
||||||
|
canvas.drawCircle(Offset(px, py), 2, Paint()..color = Colors.white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern.upperLine.length >= 2 && pattern.lowerLine.length >= 2) {
|
||||||
|
final polyPath = Path();
|
||||||
|
polyPath.moveTo(getX(pattern.upperLine[0].time), getY(pattern.upperLine[0].price));
|
||||||
|
for (int i = 1; i < pattern.upperLine.length; i++) {
|
||||||
|
polyPath.lineTo(getX(pattern.upperLine[i].time), getY(pattern.upperLine[i].price));
|
||||||
|
}
|
||||||
|
for (int i = pattern.lowerLine.length - 1; i >= 0; i--) {
|
||||||
|
polyPath.lineTo(getX(pattern.lowerLine[i].time), getY(pattern.lowerLine[i].price));
|
||||||
|
}
|
||||||
|
polyPath.close();
|
||||||
|
canvas.drawPath(polyPath, fillPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLine(pattern.upperLine);
|
||||||
|
drawLine(pattern.lowerLine);
|
||||||
|
|
||||||
|
if (firstPoint != null) {
|
||||||
|
final label = PatternExplanations.getGermanName(pattern.type);
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: ' $label ',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
|
||||||
|
final badgeX = firstPoint!.dx;
|
||||||
|
final badgeY = firstPoint!.dy - 18;
|
||||||
|
final badgeRect = RRect.fromLTRBR(
|
||||||
|
badgeX,
|
||||||
|
badgeY,
|
||||||
|
badgeX + textPainter.width + 4,
|
||||||
|
badgeY + textPainter.height + 4,
|
||||||
|
const Radius.circular(4),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.85));
|
||||||
|
textPainter.paint(canvas, Offset(badgeX + 2, badgeY + 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawSignals({
|
||||||
|
required Canvas canvas,
|
||||||
|
required List<StrategySignalModel> signals,
|
||||||
|
required double Function(DateTime) getX,
|
||||||
|
required double Function(double) getY,
|
||||||
|
required ThemePreset theme,
|
||||||
|
}) {
|
||||||
|
for (var signal in signals) {
|
||||||
|
final x = getX(signal.date);
|
||||||
|
final y = getY(signal.price);
|
||||||
|
|
||||||
|
final isBuy = signal.type.toUpperCase() == 'BUY';
|
||||||
|
final isSell = signal.type.toUpperCase() == 'SELL';
|
||||||
|
if (!isBuy && !isSell) continue;
|
||||||
|
|
||||||
|
final color = isBuy ? theme.primaryColor : theme.accentRed;
|
||||||
|
final label = isBuy ? '▲ BUY' : '▼ SELL';
|
||||||
|
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(text: label, style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
|
||||||
|
final badgeWidth = textPainter.width + 12;
|
||||||
|
final badgeHeight = textPainter.height + 6;
|
||||||
|
final badgeY = isBuy ? y + 12 : y - badgeHeight - 12;
|
||||||
|
final badgeRect = RRect.fromLTRBR(
|
||||||
|
x - badgeWidth / 2,
|
||||||
|
badgeY,
|
||||||
|
x + badgeWidth / 2,
|
||||||
|
badgeY + badgeHeight,
|
||||||
|
const Radius.circular(10),
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.drawRRect(badgeRect, Paint()..color = color.withValues(alpha: 0.95));
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(x, y),
|
||||||
|
Offset(x, isBuy ? badgeY : badgeY + badgeHeight),
|
||||||
|
Paint()..color = color..strokeWidth = 1.5,
|
||||||
|
);
|
||||||
|
textPainter.paint(canvas, Offset(x - textPainter.width / 2, badgeY + 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void drawCrosshair({
|
||||||
|
required Canvas canvas,
|
||||||
|
required Offset tapPosition,
|
||||||
|
required double chartWidth,
|
||||||
|
required double chartHeight,
|
||||||
|
required ThemePreset theme,
|
||||||
|
}) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = theme.textMuted.withValues(alpha: 0.5)
|
||||||
|
..strokeWidth = 1
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
canvas.drawLine(Offset(tapPosition.dx, 0), Offset(tapPosition.dx, chartHeight), paint);
|
||||||
|
if (tapPosition.dy <= chartHeight) {
|
||||||
|
canvas.drawLine(Offset(0, tapPosition.dy), Offset(chartWidth, tapPosition.dy), paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
|
import '../../utils/metric_explanations.dart';
|
||||||
|
|
||||||
|
class IndicatorRibbonBar extends StatelessWidget {
|
||||||
|
final bool showSma50;
|
||||||
|
final bool showSma200;
|
||||||
|
final bool showEma;
|
||||||
|
final bool showSupertrend;
|
||||||
|
final bool showPatterns;
|
||||||
|
final bool showSignals;
|
||||||
|
final ValueChanged<bool> onToggleSma50;
|
||||||
|
final ValueChanged<bool> onToggleSma200;
|
||||||
|
final ValueChanged<bool> onToggleEma;
|
||||||
|
final ValueChanged<bool> onToggleSupertrend;
|
||||||
|
final ValueChanged<bool> onTogglePatterns;
|
||||||
|
final ValueChanged<bool> onToggleSignals;
|
||||||
|
final VoidCallback? onToggleFullscreen;
|
||||||
|
final bool isFullscreen;
|
||||||
|
|
||||||
|
const IndicatorRibbonBar({
|
||||||
|
super.key,
|
||||||
|
required this.showSma50,
|
||||||
|
required this.showSma200,
|
||||||
|
required this.showEma,
|
||||||
|
required this.showSupertrend,
|
||||||
|
required this.showPatterns,
|
||||||
|
required this.showSignals,
|
||||||
|
required this.onToggleSma50,
|
||||||
|
required this.onToggleSma200,
|
||||||
|
required this.onToggleEma,
|
||||||
|
required this.onToggleSupertrend,
|
||||||
|
required this.onTogglePatterns,
|
||||||
|
required this.onToggleSignals,
|
||||||
|
this.onToggleFullscreen,
|
||||||
|
this.isFullscreen = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Widget _buildChip(BuildContext context, String label, bool isSelected, ValueChanged<bool> onChanged, Color color) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
selected: isSelected,
|
||||||
|
label: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.black : color,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedColor: color,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.15),
|
||||||
|
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||||
|
showCheckmark: false,
|
||||||
|
onSelected: onChanged,
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: () => MetricExplanations.show(context, label),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GlassContainer(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildChip(context, 'SMA 50', showSma50, onToggleSma50, AppTheme.accentCyan),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(context, 'SMA 200', showSma200, onToggleSma200, Colors.amber),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(context, 'EMA 20', showEma, onToggleEma, Colors.purpleAccent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(context, 'Supertrend', showSupertrend, onToggleSupertrend, AppTheme.primaryEmerald),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(context, 'Muster', showPatterns, onTogglePatterns, Colors.orangeAccent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(context, 'Signale', showSignals, onToggleSignals, Colors.greenAccent),
|
||||||
|
if (onToggleFullscreen != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Container(width: 1, height: 20, color: AppTheme.activePreset.glassBorder),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: onToggleFullscreen,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.activePreset.cardSurface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppTheme.activePreset.glassBorder),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
isFullscreen ? 'Normal' : 'Vollbild',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
import '../../utils/pattern_explanations.dart';
|
||||||
|
|
||||||
|
class PatternCardItem extends StatelessWidget {
|
||||||
|
final ChartPatternModel pattern;
|
||||||
|
final int index;
|
||||||
|
final bool isEnabled;
|
||||||
|
final ValueChanged<bool> onToggle;
|
||||||
|
|
||||||
|
const PatternCardItem({
|
||||||
|
super.key,
|
||||||
|
required this.pattern,
|
||||||
|
required this.index,
|
||||||
|
required this.isEnabled,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final patternColor = PatternExplanations.getColorForPattern(pattern.type);
|
||||||
|
|
||||||
|
final allPoints = [...pattern.upperLine, ...pattern.lowerLine];
|
||||||
|
DateTime? startDate;
|
||||||
|
DateTime? endDate;
|
||||||
|
if (allPoints.isNotEmpty) {
|
||||||
|
allPoints.sort((a, b) => a.time.compareTo(b.time));
|
||||||
|
startDate = allPoints.first.time;
|
||||||
|
endDate = allPoints.last.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dateFormat = DateFormat('dd.MM.yy');
|
||||||
|
final dateStr = startDate != null && endDate != null
|
||||||
|
? '${dateFormat.format(startDate)} - ${dateFormat.format(endDate)}'
|
||||||
|
: 'Unbekannt';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: GlassContainer(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: isEnabled,
|
||||||
|
activeColor: patternColor,
|
||||||
|
checkColor: Colors.black,
|
||||||
|
side: BorderSide(color: patternColor.withValues(alpha: 0.6)),
|
||||||
|
onChanged: (bool? val) => onToggle(val ?? false),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => PatternExplanations.showPatternDetails(context, pattern.type),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isEnabled ? patternColor.withValues(alpha: 0.15) : AppTheme.glassSurface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.polyline_outlined,
|
||||||
|
color: isEnabled ? patternColor : AppTheme.textMuted,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
pattern.type,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isEnabled ? Colors.white : AppTheme.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
decoration: isEnabled ? null : TextDecoration.lineThrough,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(Icons.info_outline, size: 14, color: AppTheme.textMuted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Zeitraum: $dateStr\n'
|
||||||
|
'Linien: Oben (${pattern.upperLine.length} Pkt.) / Unten (${pattern.lowerLine.length} Pkt.)',
|
||||||
|
style: TextStyle(color: AppTheme.textMuted, fontSize: 11, height: 1.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StatusBadge(
|
||||||
|
label: isEnabled ? 'AKTIV' : 'AUS',
|
||||||
|
color: isEnabled ? patternColor : AppTheme.textMuted,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../../core/theme/app_theme.dart';
|
||||||
|
import '../../../../core/widgets/glass_container.dart';
|
||||||
|
import '../../../../core/widgets/status_badge.dart';
|
||||||
|
import '../../models/technical_analysis_model.dart';
|
||||||
|
|
||||||
|
class SignalCardItem extends StatelessWidget {
|
||||||
|
final StrategySignalModel signal;
|
||||||
|
|
||||||
|
const SignalCardItem({super.key, required this.signal});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isBuy = signal.type.toUpperCase() == 'BUY';
|
||||||
|
final color = isBuy ? AppTheme.primaryEmerald : AppTheme.accentRed;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: GlassContainer(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(isBuy ? Icons.north_east : Icons.south_east, color: color, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
signal.type.toUpperCase(),
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: color, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'@ €${signal.price.toStringAsFixed(2)}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Strategisches Kaufsignal ausgelöst durch technische Indikatoren.',
|
||||||
|
style: TextStyle(color: AppTheme.textSecondary, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StatusBadge(label: 'SIGNAL', color: color),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user