Commit 57b01db5 authored by Administrator's avatar Administrator

Update 15 files via Son of Anton

parent cf85f694
...@@ -17,10 +17,12 @@ import '../../features/generator/presentation/screens/generator_results_screen.d ...@@ -17,10 +17,12 @@ import '../../features/generator/presentation/screens/generator_results_screen.d
import '../../features/generator/data/datasources/generator_remote_datasource.dart'; import '../../features/generator/data/datasources/generator_remote_datasource.dart';
import '../../features/scenarios/presentation/screens/scenario_browser_screen.dart'; import '../../features/scenarios/presentation/screens/scenario_browser_screen.dart';
import '../../features/scenarios/presentation/screens/scenario_detail_screen.dart'; import '../../features/scenarios/presentation/screens/scenario_detail_screen.dart';
import '../../features/knowledge_base/presentation/screens/kb_list_screen.dart';
import '../../features/knowledge_base/presentation/screens/kb_upload_screen.dart';
import '../../features/leaderboard/presentation/screens/leaderboard_screen.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';
import '../../shared/placeholder_screen.dart';
class AppRouter { class AppRouter {
final AuthCubit authCubit; final AuthCubit authCubit;
...@@ -81,7 +83,7 @@ class AppRouter { ...@@ -81,7 +83,7 @@ class AppRouter {
), ),
], ],
), ),
// ── Tab 3: Quiz ── ✅ PHASE 4 // ── Tab 3: Quiz ──
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
...@@ -97,7 +99,7 @@ class AppRouter { ...@@ -97,7 +99,7 @@ class AppRouter {
path: '/app/more', path: '/app/more',
builder: (_, __) => const MoreMenuScreen(), builder: (_, __) => const MoreMenuScreen(),
routes: [ routes: [
// ── Scenarios ── ✅ PHASE 4 // Scenarios
GoRoute( GoRoute(
path: 'scenarios', path: 'scenarios',
builder: (_, __) => const ScenarioBrowserScreen(), builder: (_, __) => const ScenarioBrowserScreen(),
...@@ -111,6 +113,7 @@ class AppRouter { ...@@ -111,6 +113,7 @@ class AppRouter {
), ),
], ],
), ),
// Generator
GoRoute( GoRoute(
path: 'generator', path: 'generator',
builder: (_, __) => const GeneratorScreen(), builder: (_, __) => const GeneratorScreen(),
...@@ -125,22 +128,23 @@ class AppRouter { ...@@ -125,22 +128,23 @@ class AppRouter {
), ),
], ],
), ),
// Knowledge Base
GoRoute( GoRoute(
path: 'kb', path: 'kb',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const KBListScreen(),
icon: '🧠', routes: [
title: 'Knowledge Base', GoRoute(
subtitle: 'Coming in Phase 5', path: 'upload',
), builder: (_, __) => const KBUploadScreen(),
),
],
), ),
// Leaderboard
GoRoute( GoRoute(
path: 'leaderboard', path: 'leaderboard',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const LeaderboardScreen(),
icon: '🏆',
title: 'Leaderboard',
subtitle: 'Coming in Phase 5',
),
), ),
// Settings
GoRoute( GoRoute(
path: 'settings', path: 'settings',
builder: (_, __) => const SettingsScreen(), builder: (_, __) => const SettingsScreen(),
......
...@@ -11,4 +11,40 @@ class KBRemoteDatasource { ...@@ -11,4 +11,40 @@ class KBRemoteDatasource {
final data = response.data as Map<String, dynamic>; final data = response.data as Map<String, dynamic>;
return List<String>.from(data['knowledge_bases'] ?? []); return List<String>.from(data['knowledge_bases'] ?? []);
} }
Future<String> uploadToKB({
required String kbName,
List<String>? urls,
List<String>? filePaths,
List<String>? fileNames,
List<List<int>>? fileBytes,
}) async {
final formData = FormData();
formData.fields.add(MapEntry('kb_name', kbName));
if (urls != null && urls.isNotEmpty) {
formData.fields.add(MapEntry('urls', urls.join(',')));
}
if (fileBytes != null && fileNames != null) {
for (var i = 0; i < fileBytes.length; i++) {
formData.files.add(MapEntry(
'files',
MultipartFile.fromBytes(
fileBytes[i],
filename: fileNames[i],
),
));
}
}
final response = await _dio.post(
ApiEndpoints.ragUpload,
data: formData,
options: Options(contentType: 'multipart/form-data'),
);
final data = response.data as Map<String, dynamic>;
return data['message'] as String? ?? 'Upload complete';
}
} }
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/kb_repository.dart';
import '../datasources/kb_remote_datasource.dart';
class KBRepositoryImpl implements KBRepository {
final KBRemoteDatasource remote;
KBRepositoryImpl(this.remote);
@override
Future<List<String>> getKnowledgeBases() async {
try {
return await remote.getKnowledgeBases();
} on DioException catch (e) {
throw e.error ?? e;
}
}
@override
Future<String> uploadToKB({
required String kbName,
List<String>? urls,
List<String>? fileNames,
List<List<int>>? fileBytes,
}) async {
try {
return await remote.uploadToKB(
kbName: kbName,
urls: urls,
fileNames: fileNames,
fileBytes: fileBytes,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
abstract class KBRepository {
Future<List<String>> getKnowledgeBases();
Future<String> uploadToKB({
required String kbName,
List<String>? urls,
List<String>? fileNames,
List<List<int>>? fileBytes,
});
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/dio_client.dart';
import '../../data/datasources/kb_remote_datasource.dart';
import '../../data/repositories/kb_repository_impl.dart';
import 'kb_state.dart';
class KBCubit extends Cubit<KBState> {
final KBRepositoryImpl _repo;
KBCubit()
: _repo = KBRepositoryImpl(KBRemoteDatasource(DioClient.instance)),
super(const KBInitial());
Future<void> loadKnowledgeBases() async {
emit(const KBLoading());
try {
final kbs = await _repo.getKnowledgeBases();
emit(KBLoaded(kbs));
} catch (e) {
emit(KBError(e.toString()));
}
}
Future<void> upload({
required String kbName,
List<String>? urls,
List<String>? fileNames,
List<List<int>>? fileBytes,
}) async {
emit(const KBUploading());
try {
final message = await _repo.uploadToKB(
kbName: kbName,
urls: urls,
fileNames: fileNames,
fileBytes: fileBytes,
);
emit(KBUploadSuccess(message));
} catch (e) {
emit(KBUploadError(e.toString()));
}
}
void resetToLoaded(List<String> kbs) {
emit(KBLoaded(kbs));
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
sealed class KBState extends Equatable {
const KBState();
@override
List<Object?> get props => [];
}
class KBInitial extends KBState {
const KBInitial();
}
class KBLoading extends KBState {
const KBLoading();
}
class KBLoaded extends KBState {
final List<String> knowledgeBases;
const KBLoaded(this.knowledgeBases);
@override
List<Object?> get props => [knowledgeBases];
}
class KBError extends KBState {
final String message;
const KBError(this.message);
@override
List<Object?> get props => [message];
}
class KBUploading extends KBState {
const KBUploading();
}
class KBUploadSuccess extends KBState {
final String message;
const KBUploadSuccess(this.message);
@override
List<Object?> get props => [message];
}
class KBUploadError extends KBState {
final String message;
const KBUploadError(this.message);
@override
List<Object?> get props => [message];
}
\ 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_empty_state.dart';
import '../../../../core/widgets/fs_error_state.dart';
import '../../../../core/widgets/fs_shimmer.dart';
import '../bloc/kb_cubit.dart';
import '../bloc/kb_state.dart';
class KBListScreen extends StatefulWidget {
const KBListScreen({super.key});
@override
State<KBListScreen> createState() => _KBListScreenState();
}
class _KBListScreenState extends State<KBListScreen> {
late final KBCubit _cubit;
@override
void initState() {
super.initState();
_cubit = KBCubit()..loadKnowledgeBases();
}
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(
title: const Text('Knowledge Base'),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () => _cubit.loadKnowledgeBases(),
tooltip: 'Refresh',
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.push('/app/more/kb/upload'),
backgroundColor: AppColors.accent,
icon: const Icon(Icons.upload_file, color: Colors.white),
label: Text('Upload',
style: AppTextStyles.labelMedium.copyWith(color: Colors.white)),
),
body: BlocBuilder<KBCubit, KBState>(
builder: (context, state) {
if (state is KBLoading) {
return Padding(
padding: AppSpacing.screenAll,
child: FSShimmerList(itemCount: 5),
);
}
if (state is KBError) {
return FSErrorState(
message: state.message,
onRetry: () => _cubit.loadKnowledgeBases(),
);
}
if (state is KBLoaded) {
if (state.knowledgeBases.isEmpty) {
return const FSEmptyState(
icon: '🧠',
title: 'No Knowledge Bases',
subtitle:
'Upload PDFs, documents, or URLs to create a knowledge base that grounds AI responses.',
);
}
return RefreshIndicator(
color: AppColors.accent,
backgroundColor: AppColors.panel,
onRefresh: () => _cubit.loadKnowledgeBases(),
child: ListView.separated(
padding: AppSpacing.screenAll,
itemCount: state.knowledgeBases.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final kb = state.knowledgeBases[index];
return _buildKBCard(kb);
},
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
Widget _buildKBCard(String name) {
return Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.purpleBg,
borderRadius: AppRadius.smAll,
),
alignment: Alignment.center,
child: const Text('🧠', style: TextStyle(fontSize: 22)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: AppTextStyles.titleSmall),
const SizedBox(height: 2),
Text(
'ChromaDB Collection',
style: AppTextStyles.caption,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: AppRadius.pill,
),
child: Text(
'ACTIVE',
style: TextStyle(
color: AppColors.successLight,
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
],
),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.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/utils/validators.dart';
import '../bloc/kb_cubit.dart';
import '../bloc/kb_state.dart';
class KBUploadScreen extends StatefulWidget {
const KBUploadScreen({super.key});
@override
State<KBUploadScreen> createState() => _KBUploadScreenState();
}
class _KBUploadScreenState extends State<KBUploadScreen> {
late final KBCubit _cubit;
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _urlsCtrl = TextEditingController();
List<PlatformFile> _selectedFiles = [];
@override
void initState() {
super.initState();
_cubit = KBCubit();
}
@override
void dispose() {
_nameCtrl.dispose();
_urlsCtrl.dispose();
_cubit.close();
super.dispose();
}
Future<void> _pickFiles() async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['pdf', 'txt', 'json', 'doc', 'docx'],
withData: true,
);
if (result != null) {
setState(() {
_selectedFiles = [..._selectedFiles, ...result.files];
});
}
}
void _removeFile(int index) {
setState(() {
_selectedFiles.removeAt(index);
});
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final urls = _urlsCtrl.text.trim().isNotEmpty
? _urlsCtrl.text
.split(',')
.map((u) => u.trim())
.where((u) => u.isNotEmpty)
.toList()
: null;
final fileNames =
_selectedFiles.map((f) => f.name).toList();
final fileBytes =
_selectedFiles.map((f) => f.bytes!.toList()).toList();
if ((urls == null || urls.isEmpty) && _selectedFiles.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please add at least one file or URL'),
backgroundColor: AppColors.error,
),
);
return;
}
_cubit.upload(
kbName: _nameCtrl.text.trim(),
urls: urls,
fileNames: fileNames.isNotEmpty ? fileNames : null,
fileBytes: fileBytes.isNotEmpty ? fileBytes : null,
);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(title: const Text('Upload to Knowledge Base')),
body: BlocConsumer<KBCubit, KBState>(
listener: (context, state) {
if (state is KBUploadSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
context.pop();
}
if (state is KBUploadError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
final isUploading = state is KBUploading;
return ListView(
padding: AppSpacing.screenAll,
children: [
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// KB Name
FSTextField(
label: 'Knowledge Base Name',
hint: 'e.g. personal_finance_101',
controller: _nameCtrl,
validator: (v) =>
Validators.required(v, 'KB name'),
enabled: !isUploading,
),
AppSpacing.vLg,
// File Picker
Text('FILES',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textSecondary,
letterSpacing: 1)),
const SizedBox(height: 6),
GestureDetector(
onTap: isUploading ? null : _pickFiles,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 24, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.bgSecondary,
border: Border.all(
color: AppColors.border,
style: BorderStyle.solid,
),
borderRadius: AppRadius.smAll,
),
child: Column(
children: [
Icon(Icons.cloud_upload_outlined,
color: AppColors.textTertiary, size: 36),
const SizedBox(height: 8),
Text('Tap to select files',
style: AppTextStyles.bodySmall),
const SizedBox(height: 4),
Text('PDF, TXT, JSON, DOC, DOCX',
style: AppTextStyles.caption),
],
),
),
),
// Selected files list
if (_selectedFiles.isNotEmpty) ...[
AppSpacing.vSm,
...List.generate(_selectedFiles.length, (i) {
final file = _selectedFiles[i];
final sizeKb =
((file.size) / 1024).toStringAsFixed(1);
return Container(
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.panel,
border:
Border.all(color: AppColors.border),
borderRadius: AppRadius.smAll,
),
child: Row(
children: [
Text(_fileIcon(file.extension ?? ''),
style:
const TextStyle(fontSize: 18)),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(file.name,
style:
AppTextStyles.bodySmall,
maxLines: 1,
overflow:
TextOverflow.ellipsis),
Text('${sizeKb}KB',
style:
AppTextStyles.caption),
],
),
),
if (!isUploading)
GestureDetector(
onTap: () => _removeFile(i),
child: Icon(Icons.close,
size: 18,
color:
AppColors.textTertiary),
),
],
),
);
}),
],
AppSpacing.vLg,
// URLs
FSTextField(
label: 'Website URLs (Optional)',
hint: 'https://site1.com, https://site2.com',
controller: _urlsCtrl,
maxLines: 3,
enabled: !isUploading,
),
const SizedBox(height: 4),
Text(
'Separate multiple URLs with commas',
style: AppTextStyles.caption,
),
],
),
),
AppSpacing.vXl,
FSButton(
label: '📤 Upload to Knowledge Base',
isLoading: isUploading,
onPressed: isUploading ? null : _submit,
variant: FSButtonVariant.gradient,
),
AppSpacing.vXl,
],
);
},
),
),
);
}
String _fileIcon(String ext) {
return switch (ext.toLowerCase()) {
'pdf' => '📄',
'txt' => '📝',
'json' => '📊',
'doc' || 'docx' => '📃',
_ => '📎',
};
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class LeaderboardEntryModel {
final String username;
final int totalScore;
final int quizzesTaken;
final String? avatar;
final int avgScore;
const LeaderboardEntryModel({
required this.username,
required this.totalScore,
required this.quizzesTaken,
this.avatar,
required this.avgScore,
});
factory LeaderboardEntryModel.fromJson(Map<String, dynamic> json) {
return LeaderboardEntryModel(
username: json['username'] as String,
totalScore: (json['total_score'] as num).toInt(),
quizzesTaken: (json['quizzes_taken'] as num).toInt(),
avatar: json['avatar'] as String?,
avgScore: (json['avg_score'] as num).toInt(),
);
}
}
class LeaderboardRemoteDatasource {
final Dio _dio;
LeaderboardRemoteDatasource(this._dio);
Future<List<LeaderboardEntryModel>> getLeaderboard() async {
final response = await _dio.get(ApiEndpoints.leaderboard);
final data = response.data as Map<String, dynamic>;
return (data['leaderboard'] as List<dynamic>)
.map((e) => LeaderboardEntryModel.fromJson(e as Map<String, dynamic>))
.toList();
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/leaderboard_repository.dart';
import '../datasources/leaderboard_remote_datasource.dart';
class LeaderboardRepositoryImpl implements LeaderboardRepository {
final LeaderboardRemoteDatasource remote;
LeaderboardRepositoryImpl(this.remote);
@override
Future<List<LeaderboardEntryModel>> getLeaderboard() async {
try {
return await remote.getLeaderboard();
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
import '../../data/datasources/leaderboard_remote_datasource.dart';
abstract class LeaderboardRepository {
Future<List<LeaderboardEntryModel>> getLeaderboard();
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/dio_client.dart';
import '../../data/datasources/leaderboard_remote_datasource.dart';
import '../../data/repositories/leaderboard_repository_impl.dart';
import 'leaderboard_state.dart';
class LeaderboardCubit extends Cubit<LeaderboardState> {
final LeaderboardRepositoryImpl _repo;
LeaderboardCubit()
: _repo = LeaderboardRepositoryImpl(
LeaderboardRemoteDatasource(DioClient.instance)),
super(const LeaderboardLoading());
Future<void> loadLeaderboard() async {
emit(const LeaderboardLoading());
try {
final entries = await _repo.getLeaderboard();
emit(LeaderboardLoaded(entries));
} catch (e) {
emit(LeaderboardError(e.toString()));
}
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
import '../../data/datasources/leaderboard_remote_datasource.dart';
sealed class LeaderboardState extends Equatable {
const LeaderboardState();
@override
List<Object?> get props => [];
}
class LeaderboardLoading extends LeaderboardState {
const LeaderboardLoading();
}
class LeaderboardLoaded extends LeaderboardState {
final List<LeaderboardEntryModel> entries;
const LeaderboardLoaded(this.entries);
@override
List<Object?> get props => [entries.length];
}
class LeaderboardError extends LeaderboardState {
final String message;
const LeaderboardError(this.message);
@override
List<Object?> get props => [message];
}
\ 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_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/theme/app_gradients.dart';
import '../../../../core/widgets/fs_shimmer.dart';
import '../../../../core/widgets/fs_empty_state.dart';
import '../../../../core/widgets/fs_error_state.dart';
import '../../data/datasources/leaderboard_remote_datasource.dart';
import '../bloc/leaderboard_cubit.dart';
import '../bloc/leaderboard_state.dart';
class LeaderboardScreen extends StatefulWidget {
const LeaderboardScreen({super.key});
@override
State<LeaderboardScreen> createState() => _LeaderboardScreenState();
}
class _LeaderboardScreenState extends State<LeaderboardScreen> {
late final LeaderboardCubit _cubit;
@override
void initState() {
super.initState();
_cubit = LeaderboardCubit()..loadLeaderboard();
}
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(
title: const Text('Leaderboard'),
actions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: () => _cubit.loadLeaderboard(),
),
],
),
body: BlocBuilder<LeaderboardCubit, LeaderboardState>(
builder: (context, state) {
if (state is LeaderboardLoading) {
return Padding(
padding: AppSpacing.screenAll,
child: FSShimmerList(itemCount: 8),
);
}
if (state is LeaderboardError) {
return FSErrorState(
message: state.message,
onRetry: () => _cubit.loadLeaderboard(),
);
}
if (state is LeaderboardLoaded) {
if (state.entries.isEmpty) {
return const FSEmptyState(
icon: '🏆',
title: 'No Rankings Yet',
subtitle: 'Be the first to take a quiz and claim the top spot!',
);
}
return RefreshIndicator(
color: AppColors.accent,
backgroundColor: AppColors.panel,
onRefresh: () => _cubit.loadLeaderboard(),
child: ListView.builder(
padding: AppSpacing.screenAll,
itemCount: state.entries.length + 1, // +1 for podium
itemBuilder: (context, index) {
if (index == 0) {
return _buildPodium(state.entries);
}
final entry = state.entries[index - 1];
final rank = index;
return _buildRow(entry, rank);
},
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
Widget _buildPodium(List<LeaderboardEntryModel> entries) {
if (entries.length < 3) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.symmetric(vertical: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 2nd place
_podiumItem(entries[1], 2, 80, AppColors.textSecondary),
const SizedBox(width: 12),
// 1st place
_podiumItem(entries[0], 1, 110, AppColors.warning),
const SizedBox(width: 12),
// 3rd place
_podiumItem(entries[2], 3, 60, AppColors.cyan),
],
),
);
}
Widget _podiumItem(
LeaderboardEntryModel entry, int rank, double height, Color accent) {
final medals = ['', '🥇', '🥈', '🥉'];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Avatar
Container(
width: rank == 1 ? 56 : 44,
height: rank == 1 ? 56 : 44,
decoration: BoxDecoration(
gradient: rank == 1
? AppGradients.primaryButton
: LinearGradient(colors: [accent, accent.withOpacity(0.6)]),
shape: BoxShape.circle,
border: Border.all(
color: accent.withOpacity(0.5),
width: rank == 1 ? 3 : 2,
),
),
alignment: Alignment.center,
child: Text(
entry.username.isNotEmpty
? entry.username[0].toUpperCase()
: '?',
style: AppTextStyles.titleMedium.copyWith(
color: Colors.white,
fontSize: rank == 1 ? 20 : 16,
),
),
),
const SizedBox(height: 6),
Text(medals[rank], style: const TextStyle(fontSize: 18)),
const SizedBox(height: 4),
Text(
entry.username,
style: AppTextStyles.labelMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${entry.totalScore} pts',
style: AppTextStyles.caption.copyWith(color: accent),
),
const SizedBox(height: 8),
// Podium block
Container(
width: 80,
height: height,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [accent.withOpacity(0.3), accent.withOpacity(0.08)],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
border: Border.all(color: accent.withOpacity(0.2)),
),
alignment: Alignment.center,
child: Text(
'#$rank',
style: AppTextStyles.headlineMedium.copyWith(
color: accent.withOpacity(0.6),
),
),
),
],
);
}
Widget _buildRow(LeaderboardEntryModel entry, int rank) {
final isTop3 = rank <= 3;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: isTop3
? AppColors.accentGlow
: AppColors.panel,
border: Border.all(
color: isTop3 ? AppColors.accent.withOpacity(0.2) : AppColors.border,
),
borderRadius: AppRadius.smAll,
),
child: Row(
children: [
// Rank
SizedBox(
width: 32,
child: Text(
'#$rank',
style: AppTextStyles.titleSmall.copyWith(
color: isTop3 ? AppColors.warningLight : AppColors.textTertiary,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 10),
// Avatar
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.panelLight,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
entry.username.isNotEmpty
? entry.username[0].toUpperCase()
: '?',
style: AppTextStyles.labelLarge.copyWith(
color: AppColors.accentLight,
),
),
),
const SizedBox(width: 12),
// Name + stats
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(entry.username, style: AppTextStyles.titleSmall),
Text(
'${entry.quizzesTaken} quizzes · avg ${entry.avgScore}%',
style: AppTextStyles.caption,
),
],
),
),
// Score
Text(
'${entry.totalScore}',
style: AppTextStyles.titleMedium.copyWith(
color: AppColors.accentLight,
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 4),
Text('pts', style: AppTextStyles.caption),
],
),
);
}
}
\ No newline at end of file
...@@ -14,12 +14,36 @@ class SettingsScreen extends StatelessWidget { ...@@ -14,12 +14,36 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final user = context.read<AuthCubit>().currentUser;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Settings')), appBar: AppBar(title: const Text('Settings')),
body: ListView( body: ListView(
padding: AppSpacing.screenAll, padding: AppSpacing.screenAll,
children: [ children: [
// ── Server Info ── // ── Account ──
_SectionHeader('Account'),
AppSpacing.vSm,
Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
children: [
_infoRow('Username', user?.username ?? '-'),
const Divider(height: 20),
_infoRow('Email', user?.email ?? '-'),
const Divider(height: 20),
_infoRow('Role', user?.role.toUpperCase() ?? 'USER'),
],
),
),
AppSpacing.vLg,
// ── Server ──
_SectionHeader('Server Configuration'), _SectionHeader('Server Configuration'),
AppSpacing.vSm, AppSpacing.vSm,
Container( Container(
...@@ -63,13 +87,14 @@ class SettingsScreen extends StatelessWidget { ...@@ -63,13 +87,14 @@ class SettingsScreen extends StatelessWidget {
borderRadius: AppRadius.mdAll, borderRadius: AppRadius.mdAll,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_infoRow('App', '${AppConfig.appName} v${AppConfig.appVersion}'), _infoRow('App', '${AppConfig.appName} v${AppConfig.appVersion}'),
const Divider(height: 20), const Divider(height: 20),
_infoRow('Platform', 'Flutter + FastAPI + LangGraph'), _infoRow('Platform', 'Flutter + FastAPI + LangGraph'),
const Divider(height: 20), const Divider(height: 20),
_infoRow('AI Model', 'LLaMA 3.3 70B (Groq)'), _infoRow('AI Model', 'LLaMA 3.3 70B (Groq)'),
const Divider(height: 20),
_infoRow('Data', 'yfinance + SerpAPI + ChromaDB'),
], ],
), ),
), ),
...@@ -93,9 +118,12 @@ class SettingsScreen extends StatelessWidget { ...@@ -93,9 +118,12 @@ class SettingsScreen extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(label, style: AppTextStyles.bodySmall), Text(label, style: AppTextStyles.bodySmall),
Text(value, Flexible(
style: AppTextStyles.labelMedium child: Text(value,
.copyWith(color: AppColors.textSecondary)), style: AppTextStyles.labelMedium
.copyWith(color: AppColors.textSecondary),
textAlign: TextAlign.end),
),
], ],
); );
} }
......
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