Commit 8f5af7a6 authored by Administrator's avatar Administrator

Update 25 files via Son of Anton

parent 7636a1d3
...@@ -9,6 +9,11 @@ import '../../features/auth/presentation/screens/login_screen.dart'; ...@@ -9,6 +9,11 @@ import '../../features/auth/presentation/screens/login_screen.dart';
import '../../features/auth/presentation/screens/register_screen.dart'; import '../../features/auth/presentation/screens/register_screen.dart';
import '../../features/dashboard/presentation/screens/dashboard_screen.dart'; import '../../features/dashboard/presentation/screens/dashboard_screen.dart';
import '../../features/dashboard/presentation/screens/profile_screen.dart'; import '../../features/dashboard/presentation/screens/profile_screen.dart';
import '../../features/chat/presentation/screens/chat_screen.dart';
import '../../features/akinator/presentation/screens/akinator_screen.dart';
import '../../features/generator/presentation/screens/generator_screen.dart';
import '../../features/generator/presentation/screens/generator_results_screen.dart';
import '../../features/generator/data/datasources/generator_remote_datasource.dart';
import '../../shared/bottom_nav_shell.dart'; import '../../shared/bottom_nav_shell.dart';
import '../../shared/more_menu_screen.dart'; import '../../shared/more_menu_screen.dart';
import '../../shared/settings_screen.dart'; import '../../shared/settings_screen.dart';
...@@ -55,29 +60,21 @@ class AppRouter { ...@@ -55,29 +60,21 @@ class AppRouter {
), ),
], ],
), ),
// ── Tab 1: Chat ── // ── Tab 1: Chat ── ✅ PHASE 3
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
path: '/app/chat', path: '/app/chat',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const ChatScreen(),
icon: '🤖',
title: 'AI Investment Advisor',
subtitle: 'Coming in Phase 3',
),
), ),
], ],
), ),
// ── Tab 2: Akinator ── // ── Tab 2: Akinator ── ✅ PHASE 3
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
path: '/app/akinator', path: '/app/akinator',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const AkinatorScreen(),
icon: '🔮',
title: 'Akinator 2.0',
subtitle: 'Coming in Phase 3',
),
), ),
], ],
), ),
...@@ -109,13 +106,20 @@ class AppRouter { ...@@ -109,13 +106,20 @@ class AppRouter {
subtitle: 'Coming in Phase 4', subtitle: 'Coming in Phase 4',
), ),
), ),
// ── Generator ── ✅ PHASE 3
GoRoute( GoRoute(
path: 'generator', path: 'generator',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const GeneratorScreen(),
icon: '⚡', routes: [
title: 'Scenario Generator', GoRoute(
subtitle: 'Coming in Phase 3', path: 'results',
), builder: (_, state) {
final result =
state.extra as GenerateResultModel;
return GeneratorResultsScreen(result: result);
},
),
],
), ),
GoRoute( GoRoute(
path: 'kb', path: 'kb',
...@@ -154,24 +158,20 @@ class AppRouter { ...@@ -154,24 +158,20 @@ class AppRouter {
final isSplash = location == '/splash'; final isSplash = location == '/splash';
final isAuthScreen = location == '/login' || location == '/register'; final isAuthScreen = location == '/login' || location == '/register';
// Still loading — stay on splash
if (authState is AuthInitial || authState is AuthLoading) { if (authState is AuthInitial || authState is AuthLoading) {
return isSplash ? null : '/splash'; return isSplash ? null : '/splash';
} }
// Authenticated — get off auth screens
if (authState is Authenticated) { if (authState is Authenticated) {
if (isSplash || isAuthScreen) return '/app/dashboard'; if (isSplash || isAuthScreen) return '/app/dashboard';
return null; return null;
} }
// Not authenticated — must be on auth screen
if (isSplash || isAuthScreen) return null; if (isSplash || isAuthScreen) return null;
return '/login'; return '/login';
} }
} }
/// Converts AuthCubit's stream into a Listenable for GoRouter.
class _GoRouterAuthRefresh extends ChangeNotifier { class _GoRouterAuthRefresh extends ChangeNotifier {
late final StreamSubscription<dynamic> _sub; late final StreamSubscription<dynamic> _sub;
......
...@@ -21,6 +21,18 @@ class ErrorInterceptor extends Interceptor { ...@@ -21,6 +21,18 @@ class ErrorInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
// Already typed by onResponse — pass through
final existing = err.error;
if (existing is RateLimitException ||
existing is AuthException ||
existing is NotFoundException ||
existing is ConflictException ||
existing is NetworkException ||
existing is ServerException) {
handler.next(err);
return;
}
final statusCode = err.response?.statusCode; final statusCode = err.response?.statusCode;
switch (statusCode) { switch (statusCode) {
...@@ -33,7 +45,6 @@ class ErrorInterceptor extends Interceptor { ...@@ -33,7 +45,6 @@ class ErrorInterceptor extends Interceptor {
), ),
); );
return; return;
case 404: case 404:
handler.reject( handler.reject(
DioException( DioException(
...@@ -43,7 +54,6 @@ class ErrorInterceptor extends Interceptor { ...@@ -43,7 +54,6 @@ class ErrorInterceptor extends Interceptor {
), ),
); );
return; return;
case 409: case 409:
handler.reject( handler.reject(
DioException( DioException(
...@@ -53,7 +63,6 @@ class ErrorInterceptor extends Interceptor { ...@@ -53,7 +63,6 @@ class ErrorInterceptor extends Interceptor {
), ),
); );
return; return;
case 429: case 429:
final retryAfter = err.response?.headers.value('retry-after'); final retryAfter = err.response?.headers.value('retry-after');
final waitSeconds = final waitSeconds =
......
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class AkinatorResponseModel {
final String sessionId;
final String reply;
final int confidenceScore;
final Map<String, dynamic>? newsSentiment;
final Map<String, String> jargonDefinitions;
final String? memo;
final String? whatifResult;
final String? critiqueNote;
final bool ragUsed;
const AkinatorResponseModel({
required this.sessionId,
required this.reply,
this.confidenceScore = 0,
this.newsSentiment,
this.jargonDefinitions = const {},
this.memo,
this.whatifResult,
this.critiqueNote,
this.ragUsed = false,
});
factory AkinatorResponseModel.fromJson(Map<String, dynamic> json) {
// Parse jargon_definitions — API sends Map<String, dynamic>
final rawJargon = json['jargon_definitions'];
final jargon = <String, String>{};
if (rawJargon is Map) {
rawJargon.forEach((k, v) => jargon[k.toString()] = v.toString());
}
return AkinatorResponseModel(
sessionId: (json['session_id'] as String?) ?? '',
reply: (json['reply'] as String?) ?? '',
confidenceScore: (json['confidence_score'] as num?)?.toInt() ?? 0,
newsSentiment: json['news_sentiment'] as Map<String, dynamic>?,
jargonDefinitions: jargon,
memo: json['memo'] as String?,
whatifResult: json['whatif_result'] as String?,
critiqueNote: json['critique_note'] as String?,
ragUsed: (json['rag_used'] as bool?) ?? false,
);
}
String? get sentimentLabel {
if (newsSentiment == null) return null;
return newsSentiment!['label'] as String?;
}
int get sentimentScore {
if (newsSentiment == null) return 50;
return (newsSentiment!['score'] as num?)?.toInt() ?? 50;
}
}
class AkinatorRemoteDatasource {
final Dio _dio;
AkinatorRemoteDatasource(this._dio);
Future<AkinatorResponseModel> predict({
required String sessionId,
required String message,
bool panelMode = false,
String? knowledgeBaseId,
}) async {
final response = await _dio.post(
ApiEndpoints.akinator,
data: {
'session_id': sessionId,
'message': message,
'panel_mode': panelMode,
if (knowledgeBaseId != null) 'knowledge_base_id': knowledgeBaseId,
},
);
return AkinatorResponseModel.fromJson(
response.data as Map<String, dynamic>);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/akinator_repository.dart';
import '../datasources/akinator_remote_datasource.dart';
class AkinatorRepositoryImpl implements AkinatorRepository {
final AkinatorRemoteDatasource remote;
AkinatorRepositoryImpl(this.remote);
@override
Future<AkinatorResponseModel> predict({
required String sessionId,
required String message,
bool panelMode = false,
String? knowledgeBaseId,
}) async {
try {
return await remote.predict(
sessionId: sessionId,
message: message,
panelMode: panelMode,
knowledgeBaseId: knowledgeBaseId,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
import '../../data/datasources/akinator_remote_datasource.dart';
abstract class AkinatorRepository {
Future<AkinatorResponseModel> predict({
required String sessionId,
required String message,
bool panelMode,
String? knowledgeBaseId,
});
}
\ No newline at end of file
import 'dart:async';
import 'dart:math';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/dio_client.dart';
import '../../../knowledge_base/data/datasources/kb_remote_datasource.dart';
import '../../data/datasources/akinator_remote_datasource.dart';
import '../../data/repositories/akinator_repository_impl.dart';
import 'akinator_state.dart';
class AkinatorCubit extends Cubit<AkinatorState> {
final AkinatorRepositoryImpl _repo;
Timer? _rateLimitTimer;
AkinatorCubit()
: _repo = AkinatorRepositoryImpl(
AkinatorRemoteDatasource(DioClient.instance)),
super(AkinatorState(
sessionId: 'ak_${Random().nextInt(999999)}',
messages: [
AkinatorMessage(
id: 'welcome',
text: '🔮 **Akinator 2.0 — Multi-Agent Prediction Engine**\n\n'
'I run **4 expert analyst agents** simultaneously:\n\n'
'📈 **Market Data Analyst** — Live prices, ratios & returns\n'
'📰 **News & Sentiment Analyst** — Breaking news & market mood\n'
'⚠️ **Risk Assessment Analyst** — Volatility & downside risk\n'
'💼 **Portfolio Strategy Advisor** — Allocation & strategy\n\n'
'_Try: "Should I invest in AAPL?" or "What if I invested \$10K in Tesla 3 years ago?"_',
timestamp: DateTime.now(),
),
],
));
Future<void> loadKnowledgeBases() async {
try {
final kbs =
await KBRemoteDatasource(DioClient.instance).getKnowledgeBases();
emit(state.copyWith(knowledgeBases: kbs));
} catch (_) {}
}
void selectKB(String? kbId) {
emit(state.copyWith(selectedKbId: () => kbId));
}
void togglePanelMode() {
emit(state.copyWith(panelMode: !state.panelMode));
}
Future<void> sendQuery(String text) async {
if (text.trim().isEmpty || state.isTyping || state.isRateLimited) return;
final userMsg = AkinatorMessage(
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
text: text.trim(),
isUser: true,
timestamp: DateTime.now(),
);
emit(state.copyWith(
messages: [...state.messages, userMsg],
isTyping: true,
));
try {
final response = await _repo.predict(
sessionId: state.sessionId,
message: text.trim(),
panelMode: state.panelMode,
knowledgeBaseId: state.selectedKbId,
);
final botMsg = AkinatorMessage(
id: 'bot_${DateTime.now().millisecondsSinceEpoch}',
text: response.reply,
confidenceScore: response.confidenceScore,
sentimentLabel: response.sentimentLabel,
sentimentScore: response.sentimentScore,
critiqueNote: response.critiqueNote,
jargonDefinitions: response.jargonDefinitions,
memo: response.memo,
whatifResult: response.whatifResult,
ragUsed: response.ragUsed,
timestamp: DateTime.now(),
);
emit(state.copyWith(
messages: [...state.messages, botMsg],
isTyping: false,
));
} on RateLimitException catch (e) {
emit(state.copyWith(isTyping: false));
_startRateLimitCountdown(e.waitSeconds);
} catch (e) {
final errorMsg = AkinatorMessage(
id: 'error_${DateTime.now().millisecondsSinceEpoch}',
text: '❌ Error: ${e.toString()}',
timestamp: DateTime.now(),
);
emit(state.copyWith(
messages: [...state.messages, errorMsg],
isTyping: false,
));
}
}
void _startRateLimitCountdown(int seconds) {
_rateLimitTimer?.cancel();
emit(state.copyWith(rateLimitSeconds: () => seconds));
_rateLimitTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final remaining = (state.rateLimitSeconds ?? 0) - 1;
if (remaining <= 0) {
timer.cancel();
emit(state.copyWith(rateLimitSeconds: () => null));
} else {
emit(state.copyWith(rateLimitSeconds: () => remaining));
}
});
}
@override
Future<void> close() {
_rateLimitTimer?.cancel();
return super.close();
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
class AkinatorMessage extends Equatable {
final String id;
final String text;
final bool isUser;
final bool isTyping;
final int confidenceScore;
final String? sentimentLabel;
final int sentimentScore;
final String? critiqueNote;
final Map<String, String> jargonDefinitions;
final String? memo;
final String? whatifResult;
final bool ragUsed;
final DateTime timestamp;
const AkinatorMessage({
required this.id,
required this.text,
this.isUser = false,
this.isTyping = false,
this.confidenceScore = 0,
this.sentimentLabel,
this.sentimentScore = 50,
this.critiqueNote,
this.jargonDefinitions = const {},
this.memo,
this.whatifResult,
this.ragUsed = false,
required this.timestamp,
});
bool get hasMetadata =>
!isUser &&
!isTyping &&
(confidenceScore > 0 ||
sentimentLabel != null ||
(critiqueNote != null && critiqueNote!.isNotEmpty) ||
jargonDefinitions.isNotEmpty ||
(memo != null && memo!.isNotEmpty));
@override
List<Object?> get props => [id];
}
class AkinatorState extends Equatable {
final List<AkinatorMessage> messages;
final bool isTyping;
final bool panelMode;
final int? rateLimitSeconds;
final String? selectedKbId;
final String sessionId;
final List<String> knowledgeBases;
const AkinatorState({
this.messages = const [],
this.isTyping = false,
this.panelMode = false,
this.rateLimitSeconds,
this.selectedKbId,
required this.sessionId,
this.knowledgeBases = const [],
});
bool get isRateLimited => rateLimitSeconds != null && rateLimitSeconds! > 0;
AkinatorState copyWith({
List<AkinatorMessage>? messages,
bool? isTyping,
bool? panelMode,
int? Function()? rateLimitSeconds,
String? Function()? selectedKbId,
List<String>? knowledgeBases,
}) {
return AkinatorState(
messages: messages ?? this.messages,
isTyping: isTyping ?? this.isTyping,
panelMode: panelMode ?? this.panelMode,
rateLimitSeconds:
rateLimitSeconds != null ? rateLimitSeconds() : this.rateLimitSeconds,
selectedKbId:
selectedKbId != null ? selectedKbId() : this.selectedKbId,
sessionId: sessionId,
knowledgeBases: knowledgeBases ?? this.knowledgeBases,
);
}
@override
List<Object?> get props =>
[messages, isTyping, panelMode, rateLimitSeconds, selectedKbId, knowledgeBases];
}
\ No newline at end of file
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/theme/app_gradients.dart';
import '../../../../core/widgets/fs_badge.dart';
class AkinatorBanner extends StatelessWidget {
const AkinatorBanner({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: AppGradients.akinatorBanner,
border: Border.all(color: AppColors.purple.withOpacity(0.25)),
borderRadius: AppRadius.mdAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShaderMask(
shaderCallback: (b) => AppGradients.akinatorButton.createShader(b),
child: Text(
'🔮 Akinator 2.0',
style: AppTextStyles.titleLarge.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 6),
Text(
'4 expert AI analysts working in parallel — live data, news, risk & strategy.',
style: AppTextStyles.bodySmall.copyWith(color: AppColors.textSecondary),
),
const SizedBox(height: 10),
const Wrap(
spacing: 6,
runSpacing: 6,
children: [
FSBadge(text: '📈 Market Data', variant: FSBadgeVariant.purple),
FSBadge(text: '📰 News', variant: FSBadgeVariant.blue),
FSBadge(text: '⚠️ Risk', variant: FSBadgeVariant.orange),
FSBadge(text: '💼 Strategy', variant: FSBadgeVariant.green),
],
),
],
),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/chat_bubble.dart';
import '../../../../core/widgets/confidence_meter.dart';
import '../../../../core/widgets/sentiment_badge.dart';
import '../../../../core/widgets/jargon_panel.dart';
import '../../../../core/widgets/memo_card.dart';
import '../bloc/akinator_state.dart';
class AkinatorMessageCard extends StatelessWidget {
final AkinatorMessage message;
const AkinatorMessageCard({super.key, required this.message});
@override
Widget build(BuildContext context) {
if (message.isUser) {
return ChatBubble(text: message.text, isUser: true);
}
if (message.isTyping) {
return const ChatBubble(text: '', isTyping: true, isAkinator: true);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Main bubble
ChatBubble(
text: message.text,
isAkinator: true,
ragUsed: message.ragUsed,
),
// Metadata
if (message.hasMetadata) ...[
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(left: 4, right: 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Confidence + Sentiment row
if (message.confidenceScore > 0 ||
message.sentimentLabel != null)
Wrap(
spacing: 8,
runSpacing: 6,
children: [
if (message.confidenceScore > 0)
ConfidenceMeter(
score: message.confidenceScore,
size: 48,
strokeWidth: 4,
),
if (message.sentimentLabel != null)
SentimentBadge(
label: message.sentimentLabel!,
score: message.sentimentScore,
),
],
),
// Critique
if (message.critiqueNote != null &&
message.critiqueNote!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.warningBg,
border: Border.all(
color: AppColors.warning.withOpacity(0.2)),
borderRadius: AppRadius.smAll,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('⚠️', style: TextStyle(fontSize: 14)),
const SizedBox(width: 6),
Expanded(
child: Text(
message.critiqueNote!,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.warningLight,
height: 1.5,
),
),
),
],
),
),
],
// Jargon
if (message.jargonDefinitions.isNotEmpty) ...[
const SizedBox(height: 8),
JargonPanel(definitions: message.jargonDefinitions),
],
// Memo
if (message.memo != null && message.memo!.isNotEmpty) ...[
const SizedBox(height: 8),
MemoCard(memo: message.memo!),
],
],
),
),
],
],
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
class PanelModeToggle extends StatelessWidget {
final bool isOn;
final ValueChanged<bool> onChanged;
const PanelModeToggle({
super.key,
required this.isOn,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: AppColors.purple.withOpacity(0.15)),
bottom: BorderSide(color: AppColors.purple.withOpacity(0.15)),
),
),
child: Row(
children: [
const Text('🎭', style: TextStyle(fontSize: 18)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Panel Discussion Mode',
style: AppTextStyles.labelMedium),
Text(
'10 investor personas debate before predicting',
style: AppTextStyles.caption,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: isOn
? AppColors.purple.withOpacity(0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Text(
isOn ? '🎭 ON' : 'OFF',
style: TextStyle(
color: isOn ? AppColors.purpleLight : AppColors.textTertiary,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 6),
Switch(
value: isOn,
onChanged: onChanged,
),
],
),
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class ChatResponseModel {
final String sessionId;
final String reply;
final bool ragUsed;
const ChatResponseModel({
required this.sessionId,
required this.reply,
this.ragUsed = false,
});
factory ChatResponseModel.fromJson(Map<String, dynamic> json) {
return ChatResponseModel(
sessionId: (json['session_id'] as String?) ?? '',
reply: (json['reply'] as String?) ?? '',
ragUsed: (json['rag_used'] as bool?) ?? false,
);
}
}
class ChatRemoteDatasource {
final Dio _dio;
ChatRemoteDatasource(this._dio);
Future<ChatResponseModel> sendMessage({
required String sessionId,
required String message,
String? knowledgeBaseId,
}) async {
final response = await _dio.post(
ApiEndpoints.chat,
data: {
'session_id': sessionId,
'message': message,
if (knowledgeBaseId != null) 'knowledge_base_id': knowledgeBaseId,
},
);
return ChatResponseModel.fromJson(response.data as Map<String, dynamic>);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/chat_repository.dart';
import '../datasources/chat_remote_datasource.dart';
class ChatRepositoryImpl implements ChatRepository {
final ChatRemoteDatasource remote;
ChatRepositoryImpl(this.remote);
@override
Future<ChatResponseModel> sendMessage({
required String sessionId,
required String message,
String? knowledgeBaseId,
}) async {
try {
return await remote.sendMessage(
sessionId: sessionId,
message: message,
knowledgeBaseId: knowledgeBaseId,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
import '../../data/datasources/chat_remote_datasource.dart';
abstract class ChatRepository {
Future<ChatResponseModel> sendMessage({
required String sessionId,
required String message,
String? knowledgeBaseId,
});
}
\ No newline at end of file
import 'dart:async';
import 'dart:math';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/network/dio_client.dart';
import '../../../knowledge_base/data/datasources/kb_remote_datasource.dart';
import '../../data/datasources/chat_remote_datasource.dart';
import '../../data/repositories/chat_repository_impl.dart';
import 'chat_state.dart';
class ChatCubit extends Cubit<ChatState> {
final ChatRepositoryImpl _repo;
Timer? _rateLimitTimer;
ChatCubit()
: _repo = ChatRepositoryImpl(ChatRemoteDatasource(DioClient.instance)),
super(ChatState(
sessionId: 'sess_${Random().nextInt(999999)}',
messages: [
ChatMessage(
id: 'welcome',
text: '👋 Hello! I\'m your FinSim Investment Advisor. I can help you with:\n\n'
'• Real-time market data & prices\n'
'• Investment strategy questions\n'
'• Practice MCQ scenarios\n'
'• Financial concept explanations\n\n'
'What would you like to explore?',
isUser: false,
timestamp: DateTime.now(),
),
],
));
Future<void> loadKnowledgeBases() async {
try {
final kbs = await KBRemoteDatasource(DioClient.instance).getKnowledgeBases();
emit(state.copyWith(knowledgeBases: kbs));
} catch (_) {}
}
void selectKB(String? kbId) {
emit(state.copyWith(selectedKbId: () => kbId));
}
Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isTyping || state.isRateLimited) return;
final userMsg = ChatMessage(
id: 'user_${DateTime.now().millisecondsSinceEpoch}',
text: text.trim(),
isUser: true,
timestamp: DateTime.now(),
);
emit(state.copyWith(
messages: [...state.messages, userMsg],
isTyping: true,
));
try {
final response = await _repo.sendMessage(
sessionId: state.sessionId,
message: text.trim(),
knowledgeBaseId: state.selectedKbId,
);
final botMsg = ChatMessage(
id: 'bot_${DateTime.now().millisecondsSinceEpoch}',
text: response.reply,
isUser: false,
ragUsed: response.ragUsed,
timestamp: DateTime.now(),
);
emit(state.copyWith(
messages: [...state.messages, botMsg],
isTyping: false,
));
} on RateLimitException catch (e) {
emit(state.copyWith(isTyping: false));
_startRateLimitCountdown(e.waitSeconds);
} catch (e) {
final errorMsg = ChatMessage(
id: 'error_${DateTime.now().millisecondsSinceEpoch}',
text: '❌ Error: ${e.toString()}',
isUser: false,
timestamp: DateTime.now(),
);
emit(state.copyWith(
messages: [...state.messages, errorMsg],
isTyping: false,
));
}
}
void _startRateLimitCountdown(int seconds) {
_rateLimitTimer?.cancel();
emit(state.copyWith(rateLimitSeconds: () => seconds));
_rateLimitTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final remaining = (state.rateLimitSeconds ?? 0) - 1;
if (remaining <= 0) {
timer.cancel();
emit(state.copyWith(rateLimitSeconds: () => null));
} else {
emit(state.copyWith(rateLimitSeconds: () => remaining));
}
});
}
void clearChat() {
emit(ChatState(
sessionId: 'sess_${Random().nextInt(999999)}',
knowledgeBases: state.knowledgeBases,
messages: [state.messages.first], // keep welcome message
));
}
@override
Future<void> close() {
_rateLimitTimer?.cancel();
return super.close();
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
class ChatMessage extends Equatable {
final String id;
final String text;
final bool isUser;
final bool isTyping;
final bool ragUsed;
final DateTime timestamp;
const ChatMessage({
required this.id,
required this.text,
this.isUser = false,
this.isTyping = false,
this.ragUsed = false,
required this.timestamp,
});
@override
List<Object?> get props => [id];
}
class ChatState extends Equatable {
final List<ChatMessage> messages;
final bool isTyping;
final int? rateLimitSeconds;
final String? selectedKbId;
final String sessionId;
final List<String> knowledgeBases;
const ChatState({
this.messages = const [],
this.isTyping = false,
this.rateLimitSeconds,
this.selectedKbId,
required this.sessionId,
this.knowledgeBases = const [],
});
bool get isRateLimited => rateLimitSeconds != null && rateLimitSeconds! > 0;
ChatState copyWith({
List<ChatMessage>? messages,
bool? isTyping,
int? Function()? rateLimitSeconds,
String? Function()? selectedKbId,
List<String>? knowledgeBases,
}) {
return ChatState(
messages: messages ?? this.messages,
isTyping: isTyping ?? this.isTyping,
rateLimitSeconds:
rateLimitSeconds != null ? rateLimitSeconds() : this.rateLimitSeconds,
selectedKbId:
selectedKbId != null ? selectedKbId() : this.selectedKbId,
sessionId: sessionId,
knowledgeBases: knowledgeBases ?? this.knowledgeBases,
);
}
@override
List<Object?> get props =>
[messages, isTyping, rateLimitSeconds, selectedKbId, knowledgeBases];
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/chat_bubble.dart';
import '../../../../core/widgets/rate_limit_timer.dart';
import '../../../../core/widgets/kb_selector.dart';
import '../bloc/chat_cubit.dart';
import '../bloc/chat_state.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
late final ChatCubit _cubit;
final _inputCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
@override
void initState() {
super.initState();
_cubit = ChatCubit()..loadKnowledgeBases();
}
@override
void dispose() {
_inputCtrl.dispose();
_scrollCtrl.dispose();
_cubit.close();
super.dispose();
}
void _send() {
final text = _inputCtrl.text.trim();
if (text.isEmpty) return;
_inputCtrl.clear();
_cubit.sendMessage(text);
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) {
_scrollCtrl.animateTo(
_scrollCtrl.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(
title: const Text('AI Investment Advisor'),
actions: [
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
onPressed: () => _cubit.clearChat(),
tooltip: 'Clear chat',
),
],
),
body: Column(
children: [
// ── Messages ──
Expanded(
child: BlocConsumer<ChatCubit, ChatState>(
listener: (_, __) => _scrollToBottom(),
builder: (context, state) {
return ListView.builder(
controller: _scrollCtrl,
padding: const EdgeInsets.all(12),
itemCount: state.messages.length +
(state.isTyping ? 1 : 0) +
(state.isRateLimited ? 1 : 0),
itemBuilder: (context, index) {
// Rate limit timer at the end
if (state.isRateLimited &&
index ==
state.messages.length +
(state.isTyping ? 1 : 0)) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: RateLimitTimer(
totalSeconds: state.rateLimitSeconds!,
),
);
}
// Typing indicator
if (state.isTyping && index == state.messages.length) {
return const Padding(
padding: EdgeInsets.only(top: 8),
child: ChatBubble(text: '', isTyping: true),
);
}
final msg = state.messages[index];
return Padding(
padding: const EdgeInsets.only(top: 8),
child: ChatBubble(
text: msg.text,
isUser: msg.isUser,
ragUsed: msg.ragUsed,
),
);
},
);
},
),
),
// ── Input Bar ──
BlocBuilder<ChatCubit, ChatState>(
builder: (context, state) {
return Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: const BoxDecoration(
color: AppColors.panel,
border:
Border(top: BorderSide(color: AppColors.border)),
),
child: SafeArea(
top: false,
child: Row(
children: [
// KB Selector
SizedBox(
width: 100,
child: KBSelector(
knowledgeBases: state.knowledgeBases,
selectedKB: state.selectedKbId,
onChanged: _cubit.selectKB,
compact: true,
),
),
const SizedBox(width: 8),
// Input
Expanded(
child: TextField(
controller: _inputCtrl,
enabled: !state.isTyping && !state.isRateLimited,
style: AppTextStyles.bodyMedium,
decoration: InputDecoration(
hintText: 'Ask about markets...',
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
border: OutlineInputBorder(
borderRadius: AppRadius.smAll,
borderSide: BorderSide.none,
),
filled: true,
fillColor: AppColors.bgSecondary,
),
onSubmitted: (_) => _send(),
),
),
const SizedBox(width: 8),
// Send
Material(
color: state.isTyping || state.isRateLimited
? AppColors.accent.withOpacity(0.4)
: AppColors.accent,
borderRadius: AppRadius.smAll,
child: InkWell(
onTap: state.isTyping || state.isRateLimited
? null
: _send,
borderRadius: AppRadius.smAll,
child: const SizedBox(
width: 44,
height: 44,
child: Icon(Icons.send_rounded,
color: Colors.white, size: 20),
),
),
),
],
),
),
);
},
),
],
),
),
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class GenerateResultModel {
final String status;
final String stockSymbol;
final String? message;
final int eventsAnalyzed;
final int scenariosGenerated;
final int scenariosSavedToDb;
const GenerateResultModel({
required this.status,
this.stockSymbol = '',
this.message,
this.eventsAnalyzed = 0,
this.scenariosGenerated = 0,
this.scenariosSavedToDb = 0,
});
factory GenerateResultModel.fromJson(Map<String, dynamic> json) {
return GenerateResultModel(
status: (json['status'] as String?) ?? 'unknown',
stockSymbol: (json['stock_symbol'] as String?) ?? 'Mixed Basket',
message: json['message'] as String?,
eventsAnalyzed: (json['events_analyzed'] as num?)?.toInt() ?? 0,
scenariosGenerated: (json['scenarios_generated'] as num?)?.toInt() ?? 0,
scenariosSavedToDb: (json['scenarios_saved_to_db'] as num?)?.toInt() ?? 0,
);
}
bool get hasScenarios => scenariosGenerated > 0;
}
class GeneratorRemoteDatasource {
final Dio _dio;
GeneratorRemoteDatasource(this._dio);
Future<GenerateResultModel> generate({
required String stockSymbol,
required int zscoreWindow,
required double zscoreTriggerMin,
required double zscoreTriggerMax,
String? knowledgeBaseId,
}) async {
final response = await _dio.post(
ApiEndpoints.generate,
data: {
'stock_symbol': stockSymbol,
'zscore_window': zscoreWindow,
'zscore_trigger_min': zscoreTriggerMin,
'zscore_trigger_max': zscoreTriggerMax,
if (knowledgeBaseId != null) 'knowledge_base_id': knowledgeBaseId,
},
);
return GenerateResultModel.fromJson(
response.data as Map<String, dynamic>);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/generator_repository.dart';
import '../datasources/generator_remote_datasource.dart';
class GeneratorRepositoryImpl implements GeneratorRepository {
final GeneratorRemoteDatasource remote;
GeneratorRepositoryImpl(this.remote);
@override
Future<GenerateResultModel> generate({
required String stockSymbol,
required int zscoreWindow,
required double zscoreTriggerMin,
required double zscoreTriggerMax,
String? knowledgeBaseId,
}) async {
try {
return await remote.generate(
stockSymbol: stockSymbol,
zscoreWindow: zscoreWindow,
zscoreTriggerMin: zscoreTriggerMin,
zscoreTriggerMax: zscoreTriggerMax,
knowledgeBaseId: knowledgeBaseId,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
import '../../data/datasources/generator_remote_datasource.dart';
abstract class GeneratorRepository {
Future<GenerateResultModel> generate({
required String stockSymbol,
required int zscoreWindow,
required double zscoreTriggerMin,
required double zscoreTriggerMax,
String? knowledgeBaseId,
});
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/dio_client.dart';
import '../../../knowledge_base/data/datasources/kb_remote_datasource.dart';
import '../../data/datasources/generator_remote_datasource.dart';
import '../../data/repositories/generator_repository_impl.dart';
import 'generator_state.dart';
class GeneratorCubit extends Cubit<GeneratorState> {
final GeneratorRepositoryImpl _repo;
List<String> _kbs = [];
GeneratorCubit()
: _repo = GeneratorRepositoryImpl(
GeneratorRemoteDatasource(DioClient.instance)),
super(const GeneratorInitial());
Future<void> loadKnowledgeBases() async {
try {
_kbs = await KBRemoteDatasource(DioClient.instance).getKnowledgeBases();
if (state is GeneratorInitial) {
emit(GeneratorInitial(knowledgeBases: _kbs));
}
} catch (_) {}
}
List<String> get knowledgeBases => _kbs;
Future<void> generate({
required String stockSymbol,
required int zscoreWindow,
required double zscoreTriggerMin,
required double zscoreTriggerMax,
String? knowledgeBaseId,
}) async {
emit(const GeneratorRunning());
try {
final result = await _repo.generate(
stockSymbol: stockSymbol,
zscoreWindow: zscoreWindow,
zscoreTriggerMin: zscoreTriggerMin,
zscoreTriggerMax: zscoreTriggerMax,
knowledgeBaseId: knowledgeBaseId,
);
emit(GeneratorSuccess(result));
} catch (e) {
emit(GeneratorError(e.toString()));
}
}
void reset() {
emit(GeneratorInitial(knowledgeBases: _kbs));
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
import '../../data/datasources/generator_remote_datasource.dart';
sealed class GeneratorState extends Equatable {
const GeneratorState();
@override
List<Object?> get props => [];
}
class GeneratorInitial extends GeneratorState {
final List<String> knowledgeBases;
const GeneratorInitial({this.knowledgeBases = const []});
@override
List<Object?> get props => [knowledgeBases];
}
class GeneratorRunning extends GeneratorState {
const GeneratorRunning();
}
class GeneratorSuccess extends GeneratorState {
final GenerateResultModel result;
const GeneratorSuccess(this.result);
@override
List<Object?> get props => [result.scenariosGenerated];
}
class GeneratorError extends GeneratorState {
final String message;
const GeneratorError(this.message);
@override
List<Object?> get props => [message];
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/theme/app_gradients.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../data/datasources/generator_remote_datasource.dart';
class GeneratorResultsScreen extends StatelessWidget {
final GenerateResultModel result;
const GeneratorResultsScreen({super.key, required this.result});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Generation Complete')),
body: ListView(
padding: AppSpacing.screenAll,
children: [
// Success icon
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.successBg,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.success.withOpacity(0.3), width: 2),
),
child: const Center(
child: Text('✅', style: TextStyle(fontSize: 36)),
),
),
),
AppSpacing.vLg,
Center(
child: ShaderMask(
shaderCallback: (b) =>
AppGradients.scoreGood.createShader(b),
child: Text(
'Scenarios Generated!',
style: AppTextStyles.headlineLarge
.copyWith(color: Colors.white),
),
),
),
AppSpacing.vXl,
// Stats
..._buildStatRow('📈', 'Symbol', result.stockSymbol),
..._buildStatRow('🔍', 'Events Analyzed',
result.eventsAnalyzed.toString()),
..._buildStatRow('🧠', 'Scenarios Generated',
result.scenariosGenerated.toString()),
..._buildStatRow('💾', 'Saved to Database',
result.scenariosSavedToDb.toString()),
if (result.message != null) ...[
AppSpacing.vMd,
Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.infoBg,
borderRadius: AppRadius.smAll,
),
child: Text(result.message!,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.infoLight)),
),
],
AppSpacing.vXl,
if (result.hasScenarios) ...[
FSButton(
label: '📚 Browse Scenarios',
onPressed: () => context.go('/app/more/scenarios'),
variant: FSButtonVariant.gradient,
),
AppSpacing.vSm,
FSButton(
label: '📝 Start Quiz',
onPressed: () => context.go('/app/quiz'),
variant: FSButtonVariant.secondary,
),
],
AppSpacing.vSm,
FSButton(
label: '⚡ Generate More',
onPressed: () => context.pop(),
variant: FSButtonVariant.ghost,
),
],
),
);
}
List<Widget> _buildStatRow(String icon, String label, String value) {
return [
Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.smAll,
),
child: Row(
children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
Expanded(
child: Text(label, style: AppTextStyles.bodyMedium),
),
Text(
value,
style: AppTextStyles.titleMedium
.copyWith(color: AppColors.accentLight),
),
],
),
),
];
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../../core/widgets/fs_text_field.dart';
import '../../../../core/widgets/kb_selector.dart';
import '../bloc/generator_cubit.dart';
import '../bloc/generator_state.dart';
class GeneratorScreen extends StatefulWidget {
const GeneratorScreen({super.key});
@override
State<GeneratorScreen> createState() => _GeneratorScreenState();
}
class _GeneratorScreenState extends State<GeneratorScreen> {
late final GeneratorCubit _cubit;
final _symbolCtrl = TextEditingController();
final _windowCtrl = TextEditingController(text: '100');
final _zMinCtrl = TextEditingController(text: '-2.5');
final _zMaxCtrl = TextEditingController(text: '2.5');
String? _selectedKb;
@override
void initState() {
super.initState();
_cubit = GeneratorCubit()..loadKnowledgeBases();
}
@override
void dispose() {
_symbolCtrl.dispose();
_windowCtrl.dispose();
_zMinCtrl.dispose();
_zMaxCtrl.dispose();
_cubit.close();
super.dispose();
}
void _generate() {
_cubit.generate(
stockSymbol: _symbolCtrl.text.trim(),
zscoreWindow: int.tryParse(_windowCtrl.text) ?? 100,
zscoreTriggerMin: double.tryParse(_zMinCtrl.text) ?? -2.5,
zscoreTriggerMax: double.tryParse(_zMaxCtrl.text) ?? 2.5,
knowledgeBaseId: _selectedKb,
);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(title: const Text('Scenario Generator')),
body: BlocConsumer<GeneratorCubit, GeneratorState>(
listener: (context, state) {
if (state is GeneratorSuccess) {
context.push('/app/more/generator/results', extra: state.result);
}
},
builder: (context, state) {
final isRunning = state is GeneratorRunning;
return ListView(
padding: AppSpacing.screenAll,
children: [
Text('Generate investment scenarios from real market data.',
style: AppTextStyles.bodySmall),
AppSpacing.vLg,
// Config card
Container(
padding: AppSpacing.cardInnerLarge,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Configuration', style: AppTextStyles.titleMedium),
AppSpacing.vMd,
FSTextField(
label: 'Stock Symbol',
hint: 'Leave empty for mixed basket (SPY, AAPL, TSLA...)',
controller: _symbolCtrl,
enabled: !isRunning,
),
AppSpacing.vMd,
Row(
children: [
Expanded(
child: FSTextField(
label: 'Z-Score Min',
controller: _zMinCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true, signed: true),
enabled: !isRunning,
),
),
const SizedBox(width: 12),
Expanded(
child: FSTextField(
label: 'Z-Score Max',
controller: _zMaxCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true, signed: true),
enabled: !isRunning,
),
),
const SizedBox(width: 12),
Expanded(
child: FSTextField(
label: 'Window',
controller: _windowCtrl,
keyboardType: TextInputType.number,
enabled: !isRunning,
),
),
],
),
AppSpacing.vMd,
Text('STYLE KNOWLEDGE BASE',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textSecondary,
letterSpacing: 1)),
const SizedBox(height: 6),
KBSelector(
knowledgeBases: _cubit.knowledgeBases,
selectedKB: _selectedKb,
onChanged: (v) => setState(() => _selectedKb = v),
),
],
),
),
AppSpacing.vLg,
FSButton(
label: '⚡ Generate Scenarios',
isLoading: isRunning,
onPressed: isRunning ? null : _generate,
variant: FSButtonVariant.gradient,
),
AppSpacing.vLg,
// Status
Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.bgSecondary,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.smAll,
),
child: _buildStatus(state),
),
],
);
},
),
),
);
}
Widget _buildStatus(GeneratorState state) {
if (state is GeneratorRunning) {
return Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation(AppColors.warning),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'⏳ Fetching market data & running AI analysis...\nThis may take 15-30 seconds.',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.warningLight),
),
),
],
);
}
if (state is GeneratorError) {
return Text(
'❌ ${state.message}',
style: AppTextStyles.bodySmall.copyWith(color: AppColors.errorLight),
);
}
return Text(
'Ready. Configure parameters and tap Generate.\n\n'
'The pipeline will:\n'
'1. Fetch 5 years of stock data (yfinance)\n'
'2. Calculate rolling Z-Scores (Polars)\n'
'3. Detect market anomalies\n'
'4. Generate MCQ scenarios (Groq AI)\n'
'5. Save to database',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textTertiary,
height: 1.6,
),
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class KBRemoteDatasource {
final Dio _dio;
KBRemoteDatasource(this._dio);
Future<List<String>> getKnowledgeBases() async {
final response = await _dio.get(ApiEndpoints.ragKbs);
final data = response.data as Map<String, dynamic>;
return List<String>.from(data['knowledge_bases'] ?? []);
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment