Commit 7636a1d3 authored by Administrator's avatar Administrator

Update 27 files via Son of Anton

parent 2a9d7f25
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'core/config/router.dart';
import 'core/theme/app_theme.dart';
import 'features/auth/presentation/bloc/auth_cubit.dart';
class FinSimApp extends StatefulWidget {
final AuthCubit authCubit;
const FinSimApp({super.key, required this.authCubit});
@override
State<FinSimApp> createState() => _FinSimAppState();
}
class _FinSimAppState extends State<FinSimApp> {
late final AppRouter _appRouter;
class FinSimApp extends StatelessWidget {
const FinSimApp({super.key});
@override
void initState() {
super.initState();
_appRouter = AppRouter(widget.authCubit);
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'FinSim',
debugShowCheckedModeBanner: false,
theme: AppTheme.dark,
routerConfig: appRouter,
return BlocProvider<AuthCubit>.value(
value: widget.authCubit,
child: MaterialApp.router(
title: 'FinSim',
debugShowCheckedModeBanner: false,
theme: AppTheme.dark,
routerConfig: _appRouter.router,
),
);
}
}
\ No newline at end of file
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../shared/phase1_showcase_screen.dart';
/// GoRouter configuration — Phase 1 skeleton.
/// Phase 2 will replace this with auth guards + full route tree.
final GoRouter appRouter = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Phase1ShowcaseScreen(),
),
],
);
\ No newline at end of file
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/auth/presentation/screens/splash_screen.dart';
import '../../features/auth/presentation/screens/login_screen.dart';
import '../../features/auth/presentation/screens/register_screen.dart';
import '../../features/dashboard/presentation/screens/dashboard_screen.dart';
import '../../features/dashboard/presentation/screens/profile_screen.dart';
import '../../shared/bottom_nav_shell.dart';
import '../../shared/more_menu_screen.dart';
import '../../shared/settings_screen.dart';
import '../../shared/placeholder_screen.dart';
class AppRouter {
final AuthCubit authCubit;
late final GoRouter router;
AppRouter(this.authCubit) {
router = GoRouter(
initialLocation: '/splash',
refreshListenable: _GoRouterAuthRefresh(authCubit.stream),
redirect: _redirect,
routes: [
GoRoute(
path: '/splash',
builder: (_, __) => const SplashScreen(),
),
GoRoute(
path: '/login',
builder: (_, __) => const LoginScreen(),
),
GoRoute(
path: '/register',
builder: (_, __) => const RegisterScreen(),
),
StatefulShellRoute.indexedStack(
builder: (_, __, navigationShell) =>
BottomNavShell(navigationShell: navigationShell),
branches: [
// ── Tab 0: Home ──
StatefulShellBranch(
routes: [
GoRoute(
path: '/app/dashboard',
builder: (_, __) => const DashboardScreen(),
routes: [
GoRoute(
path: 'profile',
builder: (_, __) => const ProfileScreen(),
),
],
),
],
),
// ── Tab 1: Chat ──
StatefulShellBranch(
routes: [
GoRoute(
path: '/app/chat',
builder: (_, __) => const PlaceholderScreen(
icon: '🤖',
title: 'AI Investment Advisor',
subtitle: 'Coming in Phase 3',
),
),
],
),
// ── Tab 2: Akinator ──
StatefulShellBranch(
routes: [
GoRoute(
path: '/app/akinator',
builder: (_, __) => const PlaceholderScreen(
icon: '🔮',
title: 'Akinator 2.0',
subtitle: 'Coming in Phase 3',
),
),
],
),
// ── Tab 3: Quiz ──
StatefulShellBranch(
routes: [
GoRoute(
path: '/app/quiz',
builder: (_, __) => const PlaceholderScreen(
icon: '📝',
title: 'Practice MCQ',
subtitle: 'Coming in Phase 4',
),
),
],
),
// ── Tab 4: More ──
StatefulShellBranch(
routes: [
GoRoute(
path: '/app/more',
builder: (_, __) => const MoreMenuScreen(),
routes: [
GoRoute(
path: 'scenarios',
builder: (_, __) => const PlaceholderScreen(
icon: '📚',
title: 'Scenario Browser',
subtitle: 'Coming in Phase 4',
),
),
GoRoute(
path: 'generator',
builder: (_, __) => const PlaceholderScreen(
icon: '⚡',
title: 'Scenario Generator',
subtitle: 'Coming in Phase 3',
),
),
GoRoute(
path: 'kb',
builder: (_, __) => const PlaceholderScreen(
icon: '🧠',
title: 'Knowledge Base',
subtitle: 'Coming in Phase 5',
),
),
GoRoute(
path: 'leaderboard',
builder: (_, __) => const PlaceholderScreen(
icon: '🏆',
title: 'Leaderboard',
subtitle: 'Coming in Phase 5',
),
),
GoRoute(
path: 'settings',
builder: (_, __) => const SettingsScreen(),
),
],
),
],
),
],
),
],
);
}
String? _redirect(BuildContext context, GoRouterState state) {
final authState = authCubit.state;
final location = state.matchedLocation;
final isSplash = location == '/splash';
final isAuthScreen = location == '/login' || location == '/register';
// Still loading — stay on splash
if (authState is AuthInitial || authState is AuthLoading) {
return isSplash ? null : '/splash';
}
// Authenticated — get off auth screens
if (authState is Authenticated) {
if (isSplash || isAuthScreen) return '/app/dashboard';
return null;
}
// Not authenticated — must be on auth screen
if (isSplash || isAuthScreen) return null;
return '/login';
}
}
/// Converts AuthCubit's stream into a Listenable for GoRouter.
class _GoRouterAuthRefresh extends ChangeNotifier {
late final StreamSubscription<dynamic> _sub;
_GoRouterAuthRefresh(Stream<dynamic> stream) {
_sub = stream.asBroadcastStream().listen((_) => notifyListeners());
}
@override
void dispose() {
_sub.cancel();
super.dispose();
}
}
\ No newline at end of file
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class AuthLocalDatasource {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
Future<void> saveToken(String token) async {
await _storage.write(key: _tokenKey, value: token);
}
Future<String?> getToken() async {
return await _storage.read(key: _tokenKey);
}
Future<void> clearToken() async {
await _storage.delete(key: _tokenKey);
}
Future<bool> hasToken() async {
final token = await getToken();
return token != null && token.isNotEmpty;
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
import '../models/user_model.dart';
class AuthRemoteDatasource {
final Dio _dio;
AuthRemoteDatasource(this._dio);
/// POST /api/login → { token, user }
Future<({String token, UserModel user})> login(
String email, String password) async {
final response = await _dio.post(
ApiEndpoints.login,
data: {'email': email, 'password': password},
);
final data = response.data as Map<String, dynamic>;
return (
token: data['token'] as String,
user: UserModel.fromJson(data['user'] as Map<String, dynamic>),
);
}
/// POST /api/register → { token, user }
Future<({String token, UserModel user})> register(
String username, String email, String password) async {
final response = await _dio.post(
ApiEndpoints.register,
data: {'username': username, 'email': email, 'password': password},
);
final data = response.data as Map<String, dynamic>;
return (
token: data['token'] as String,
user: UserModel.fromJson(data['user'] as Map<String, dynamic>),
);
}
/// GET /api/me → { user }
Future<UserModel> getMe() async {
final response = await _dio.get(ApiEndpoints.me);
final data = response.data as Map<String, dynamic>;
return UserModel.fromJson(data['user'] as Map<String, dynamic>);
}
/// POST /api/logout
Future<void> logout() async {
try {
await _dio.post(ApiEndpoints.logout);
} catch (_) {
// Ignore logout errors — we clear local token regardless
}
}
}
\ No newline at end of file
class UserModel {
final int userId;
final String username;
final String email;
final String role;
final String? avatar;
final int totalScore;
final int quizzesTaken;
const UserModel({
required this.userId,
required this.username,
required this.email,
required this.role,
this.avatar,
this.totalScore = 0,
this.quizzesTaken = 0,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
userId: json['user_id'] as int,
username: json['username'] as String,
email: json['email'] as String,
role: (json['role'] as String?) ?? 'user',
avatar: json['avatar'] as String?,
totalScore: (json['total_score'] as num?)?.toInt() ?? 0,
quizzesTaken: (json['quizzes_taken'] as num?)?.toInt() ?? 0,
);
}
UserModel copyWith({int? totalScore, int? quizzesTaken}) {
return UserModel(
userId: userId,
username: username,
email: email,
role: role,
avatar: avatar,
totalScore: totalScore ?? this.totalScore,
quizzesTaken: quizzesTaken ?? this.quizzesTaken,
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/error/exceptions.dart';
import '../../domain/repositories/auth_repository.dart';
import '../datasources/auth_remote_datasource.dart';
import '../datasources/auth_local_datasource.dart';
import '../models/user_model.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDatasource remote;
final AuthLocalDatasource local;
AuthRepositoryImpl({required this.remote, required this.local});
@override
Future<UserModel> login(String email, String password) async {
try {
final result = await remote.login(email, password);
await local.saveToken(result.token);
return result.user;
} on DioException catch (e) {
throw _unwrap(e);
}
}
@override
Future<UserModel> register(
String username, String email, String password) async {
try {
final result = await remote.register(username, email, password);
await local.saveToken(result.token);
return result.user;
} on DioException catch (e) {
throw _unwrap(e);
}
}
@override
Future<UserModel?> checkAuth() async {
final hasToken = await local.hasToken();
if (!hasToken) return null;
try {
return await remote.getMe();
} on DioException catch (_) {
await local.clearToken();
return null;
} catch (_) {
await local.clearToken();
return null;
}
}
@override
Future<void> logout() async {
await remote.logout();
await local.clearToken();
}
Exception _unwrap(DioException e) {
final error = e.error;
if (error is AuthException) return error;
if (error is ConflictException) return error;
if (error is NetworkException) return error;
if (error is ServerException) return error;
return ServerException(e.message ?? 'An unknown error occurred');
}
}
\ No newline at end of file
import '../../data/models/user_model.dart';
abstract class AuthRepository {
Future<UserModel> login(String email, String password);
Future<UserModel> register(String username, String email, String password);
Future<UserModel?> checkAuth();
Future<void> logout();
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/error/exceptions.dart';
import '../../domain/repositories/auth_repository.dart';
import 'auth_state.dart';
class AuthCubit extends Cubit<AuthState> {
final AuthRepository _repo;
AuthCubit(this._repo) : super(const AuthInitial());
/// Auto-check on app launch — reads stored token, validates with API
Future<void> checkAuth() async {
emit(const AuthLoading());
try {
final user = await _repo.checkAuth();
if (user != null) {
emit(Authenticated(user));
} else {
emit(const Unauthenticated());
}
} catch (_) {
emit(const Unauthenticated());
}
}
/// Login with email + password
Future<void> login(String email, String password) async {
emit(const AuthLoading());
try {
final user = await _repo.login(email, password);
emit(Authenticated(user));
} on AuthException catch (e) {
emit(AuthError(e.message));
} on NetworkException catch (e) {
emit(AuthError(e.message));
} catch (e) {
emit(AuthError(e.toString()));
}
}
/// Register new account
Future<void> register(String username, String email, String password) async {
emit(const AuthLoading());
try {
final user = await _repo.register(username, email, password);
emit(Authenticated(user));
} on ConflictException catch (e) {
emit(AuthError(e.message));
} on NetworkException catch (e) {
emit(AuthError(e.message));
} catch (e) {
emit(AuthError(e.toString()));
}
}
/// Logout — clear token, reset state
Future<void> logout() async {
try {
await _repo.logout();
} catch (_) {}
emit(const Unauthenticated());
}
/// Convenience getter for current user
UserModel? get currentUser {
final s = state;
return s is Authenticated ? s.user : null;
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
import '../../data/models/user_model.dart';
sealed class AuthState extends Equatable {
const AuthState();
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthLoading extends AuthState {
const AuthLoading();
}
class Authenticated extends AuthState {
final UserModel user;
const Authenticated(this.user);
@override
List<Object?> get props => [user.userId, user.username];
}
class Unauthenticated extends AuthState {
const Unauthenticated();
}
class AuthError extends AuthState {
final String message;
const AuthError(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_gradients.dart';
import '../../../../core/utils/validators.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../../core/widgets/fs_text_field.dart';
import '../bloc/auth_cubit.dart';
import '../bloc/auth_state.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
context.read<AuthCubit>().login(
_emailCtrl.text.trim(),
_passwordCtrl.text,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: BlocListener<AuthCubit, AuthState>(
listener: (context, state) {
// Navigation is handled by GoRouter redirect — no need here
},
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ── Logo ──
const Text('📊', style: TextStyle(fontSize: 48)),
const SizedBox(height: 12),
ShaderMask(
shaderCallback: (b) =>
AppGradients.textAccent.createShader(b),
child: Text(
'FinSim',
style: AppTextStyles.headlineLarge
.copyWith(color: Colors.white),
),
),
const SizedBox(height: 4),
Text(
'Investment Scenario Engine',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary),
),
const SizedBox(height: 36),
// ── Error Banner ──
BlocBuilder<AuthCubit, AuthState>(
buildWhen: (prev, curr) =>
curr is AuthError || prev is AuthError,
builder: (context, state) {
if (state is! AuthError) {
return const SizedBox.shrink();
}
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.errorBg,
border: Border.all(
color: AppColors.error.withOpacity(0.25)),
borderRadius: BorderRadius.circular(8),
),
child: Text(
state.message,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.errorLight),
),
);
},
),
// ── Fields ──
FSTextField(
label: 'Email',
hint: 'your@email.com',
controller: _emailCtrl,
validator: Validators.email,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
),
AppSpacing.vMd,
FSTextField(
label: 'Password',
hint: '••••••••',
controller: _passwordCtrl,
validator: Validators.password,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onEditingComplete: _submit,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: AppColors.textTertiary,
size: 20,
),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
AppSpacing.vLg,
// ── Submit ──
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) {
return FSButton(
label: 'Sign In',
isLoading: state is AuthLoading,
onPressed: state is AuthLoading ? null : _submit,
);
},
),
AppSpacing.vLg,
// ── Toggle ──
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Don't have an account? ",
style: AppTextStyles.bodySmall,
),
GestureDetector(
onTap: () => context.go('/register'),
child: Text(
'Create one',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.accentLight,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
),
),
),
),
);
}
}
\ 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_gradients.dart';
import '../../../../core/utils/validators.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../../core/widgets/fs_text_field.dart';
import '../bloc/auth_cubit.dart';
import '../bloc/auth_state.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameCtrl.dispose();
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
context.read<AuthCubit>().register(
_usernameCtrl.text.trim(),
_emailCtrl.text.trim(),
_passwordCtrl.text,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📊', style: TextStyle(fontSize: 48)),
const SizedBox(height: 12),
ShaderMask(
shaderCallback: (b) =>
AppGradients.textAccent.createShader(b),
child: Text(
'FinSim',
style: AppTextStyles.headlineLarge
.copyWith(color: Colors.white),
),
),
const SizedBox(height: 4),
Text(
'Create your account',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary),
),
const SizedBox(height: 36),
// ── Error Banner ──
BlocBuilder<AuthCubit, AuthState>(
buildWhen: (prev, curr) =>
curr is AuthError || prev is AuthError,
builder: (context, state) {
if (state is! AuthError) return const SizedBox.shrink();
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.errorBg,
border: Border.all(
color: AppColors.error.withOpacity(0.25)),
borderRadius: BorderRadius.circular(8),
),
child: Text(
state.message,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.errorLight),
),
);
},
),
FSTextField(
label: 'Username',
hint: 'johndoe',
controller: _usernameCtrl,
validator: Validators.username,
textInputAction: TextInputAction.next,
),
AppSpacing.vMd,
FSTextField(
label: 'Email',
hint: 'your@email.com',
controller: _emailCtrl,
validator: Validators.email,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
),
AppSpacing.vMd,
FSTextField(
label: 'Password',
hint: 'Min 4 characters',
controller: _passwordCtrl,
validator: Validators.password,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onEditingComplete: _submit,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: AppColors.textTertiary,
size: 20,
),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
),
AppSpacing.vLg,
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) {
return FSButton(
label: 'Create Account',
isLoading: state is AuthLoading,
onPressed: state is AuthLoading ? null : _submit,
);
},
),
AppSpacing.vLg,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Already have an account? ',
style: AppTextStyles.bodySmall,
),
GestureDetector(
onTap: () => context.go('/login'),
child: Text(
'Sign in',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.accentLight,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
),
),
),
),
);
}
}
\ 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_gradients.dart';
import '../bloc/auth_cubit.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fadeAnim;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
_fadeAnim = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_scaleAnim = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
);
_controller.forward();
// Trigger auth check after a short delay for visual polish
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) {
context.read<AuthCubit>().checkAuth();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
color: AppColors.bg,
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
AppColors.accent.withOpacity(0.06),
AppColors.bg,
],
),
),
child: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (_, __) => Opacity(
opacity: _fadeAnim.value,
child: Transform.scale(
scale: _scaleAnim.value,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📊', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
ShaderMask(
shaderCallback: (bounds) =>
AppGradients.textAccent.createShader(bounds),
child: Text(
'FinSim',
style: AppTextStyles.displayLarge.copyWith(
color: Colors.white,
),
),
),
const SizedBox(height: 8),
Text(
'AI-Powered Investment Simulation',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.textTertiary,
),
),
const SizedBox(height: 48),
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
AppColors.accent.withOpacity(0.5),
),
),
),
],
),
),
),
),
),
),
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
import '../models/user_stats_model.dart';
class DashboardRemoteDatasource {
final Dio _dio;
DashboardRemoteDatasource(this._dio);
Future<UserStatsModel> getStats() async {
final response = await _dio.get(ApiEndpoints.stats);
return UserStatsModel.fromJson(response.data as Map<String, dynamic>);
}
}
\ No newline at end of file
class UserStatsModel {
final int totalScore;
final int quizzesTaken;
final int avgScore;
final int totalQuestionsAnswered;
final int totalCorrect;
final double accuracy;
final List<QuizAttemptModel> recentAttempts;
final List<CategoryBreakdownModel> categories;
const UserStatsModel({
this.totalScore = 0,
this.quizzesTaken = 0,
this.avgScore = 0,
this.totalQuestionsAnswered = 0,
this.totalCorrect = 0,
this.accuracy = 0,
this.recentAttempts = const [],
this.categories = const [],
});
factory UserStatsModel.fromJson(Map<String, dynamic> json) {
return UserStatsModel(
totalScore: (json['total_score'] as num?)?.toInt() ?? 0,
quizzesTaken: (json['quizzes_taken'] as num?)?.toInt() ?? 0,
avgScore: (json['avg_score'] as num?)?.toInt() ?? 0,
totalQuestionsAnswered:
(json['total_questions_answered'] as num?)?.toInt() ?? 0,
totalCorrect: (json['total_correct'] as num?)?.toInt() ?? 0,
accuracy: (json['accuracy'] as num?)?.toDouble() ?? 0,
recentAttempts: (json['recent_attempts'] as List<dynamic>?)
?.map((e) =>
QuizAttemptModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
categories: (json['categories'] as List<dynamic>?)
?.map((e) =>
CategoryBreakdownModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
}
class QuizAttemptModel {
final int id;
final int? score;
final int? correctAnswers;
final int? totalQuestions;
final String status;
final String? startedAt;
final String? completedAt;
const QuizAttemptModel({
required this.id,
this.score,
this.correctAnswers,
this.totalQuestions,
required this.status,
this.startedAt,
this.completedAt,
});
factory QuizAttemptModel.fromJson(Map<String, dynamic> json) {
return QuizAttemptModel(
id: (json['id'] as num).toInt(),
score: (json['score'] as num?)?.toInt(),
correctAnswers: (json['correct_answers'] as num?)?.toInt(),
totalQuestions: (json['total_questions'] as num?)?.toInt(),
status: (json['status'] as String?) ?? 'unknown',
startedAt: json['started_at'] as String?,
completedAt: json['completed_at'] as String?,
);
}
}
class CategoryBreakdownModel {
final String category;
final int attempts;
final int correct;
const CategoryBreakdownModel({
required this.category,
required this.attempts,
this.correct = 0,
});
factory CategoryBreakdownModel.fromJson(Map<String, dynamic> json) {
return CategoryBreakdownModel(
category: json['category'] as String,
attempts: (json['attempts'] as num).toInt(),
correct: (json['correct'] as num?)?.toInt() ?? 0,
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/error/exceptions.dart';
import '../../domain/repositories/dashboard_repository.dart';
import '../datasources/dashboard_remote_datasource.dart';
import '../models/user_stats_model.dart';
class DashboardRepositoryImpl implements DashboardRepository {
final DashboardRemoteDatasource remote;
DashboardRepositoryImpl({required this.remote});
@override
Future<UserStatsModel> getStats() async {
try {
return await remote.getStats();
} on DioException catch (e) {
final error = e.error;
if (error is Exception) throw error;
throw ServerException(e.message ?? 'Failed to load stats');
}
}
}
\ No newline at end of file
import '../../data/models/user_stats_model.dart';
abstract class DashboardRepository {
Future<UserStatsModel> getStats();
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/repositories/dashboard_repository.dart';
import 'dashboard_state.dart';
class DashboardCubit extends Cubit<DashboardState> {
final DashboardRepository _repo;
DashboardCubit(this._repo) : super(const DashboardInitial());
Future<void> loadStats() async {
emit(const DashboardLoading());
try {
final stats = await _repo.getStats();
emit(DashboardLoaded(stats));
} catch (e) {
emit(DashboardError(e.toString()));
}
}
Future<void> refresh() async => loadStats();
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
import '../../data/models/user_stats_model.dart';
sealed class DashboardState extends Equatable {
const DashboardState();
@override
List<Object?> get props => [];
}
class DashboardInitial extends DashboardState {
const DashboardInitial();
}
class DashboardLoading extends DashboardState {
const DashboardLoading();
}
class DashboardLoaded extends DashboardState {
final UserStatsModel stats;
const DashboardLoaded(this.stats);
@override
List<Object?> get props => [stats.totalScore, stats.quizzesTaken];
}
class DashboardError extends DashboardState {
final String message;
const DashboardError(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/network/dio_client.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/stat_card.dart';
import '../../../../core/widgets/fs_shimmer.dart';
import '../../../../core/widgets/fs_error_state.dart';
import '../../../auth/presentation/bloc/auth_cubit.dart';
import '../../data/datasources/dashboard_remote_datasource.dart';
import '../../data/repositories/dashboard_repository_impl.dart';
import '../bloc/dashboard_cubit.dart';
import '../bloc/dashboard_state.dart';
import '../widgets/quick_action_card.dart';
import '../widgets/recent_attempts_list.dart';
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
final remote = DashboardRemoteDatasource(DioClient.instance);
final repo = DashboardRepositoryImpl(remote: remote);
return DashboardCubit(repo)..loadStats();
},
child: const _DashboardView(),
);
}
}
class _DashboardView extends StatelessWidget {
const _DashboardView();
@override
Widget build(BuildContext context) {
final user = context.read<AuthCubit>().currentUser;
final username = user?.username ?? 'User';
final initial = username.isNotEmpty ? username[0].toUpperCase() : '?';
return Scaffold(
body: SafeArea(
child: BlocBuilder<DashboardCubit, DashboardState>(
builder: (context, state) {
return RefreshIndicator(
color: AppColors.accent,
backgroundColor: AppColors.panel,
onRefresh: () => context.read<DashboardCubit>().refresh(),
child: ListView(
padding: AppSpacing.screenAll,
children: [
// ── Greeting ──
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.accent, Color(0xFFA855F7)],
),
borderRadius: BorderRadius.circular(22),
),
alignment: Alignment.center,
child: Text(
initial,
style: AppTextStyles.titleMedium
.copyWith(color: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Welcome back,',
style: AppTextStyles.bodySmall),
Text(username, style: AppTextStyles.titleMedium),
],
),
),
IconButton(
icon: const Icon(Icons.person_outline,
color: AppColors.textSecondary),
onPressed: () => context.go('/app/dashboard/profile'),
),
],
),
AppSpacing.vLg,
// ── Stats ──
if (state is DashboardLoading)
_buildStatsShimmer()
else if (state is DashboardLoaded) ...[
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.35,
children: [
StatCard(
icon: '🎯',
value: '${state.stats.totalScore}',
label: 'Total Score',
),
StatCard(
icon: '📝',
value: '${state.stats.quizzesTaken}',
label: 'Quizzes',
),
StatCard(
icon: '✅',
value: '${state.stats.accuracy.toStringAsFixed(1)}%',
label: 'Accuracy',
),
StatCard(
icon: '📊',
value: '${state.stats.avgScore}',
label: 'Avg Score',
),
],
),
] else if (state is DashboardError)
FSErrorState(
message: state.message,
onRetry: () =>
context.read<DashboardCubit>().loadStats(),
),
AppSpacing.vLg,
// ── Quick Actions ──
Text('Quick Actions', style: AppTextStyles.titleMedium),
AppSpacing.vSm,
Row(
children: [
QuickActionCard(
icon: '⚡',
label: 'Generate',
accentColor: AppColors.warning,
onTap: () => context.go('/app/more/generator'),
),
const SizedBox(width: 10),
QuickActionCard(
icon: '📝',
label: 'Quiz',
accentColor: AppColors.success,
onTap: () => context.go('/app/quiz'),
),
const SizedBox(width: 10),
QuickActionCard(
icon: '🔮',
label: 'Akinator',
accentColor: AppColors.purple,
onTap: () => context.go('/app/akinator'),
),
],
),
AppSpacing.vLg,
// ── Recent Attempts ──
Container(
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Recent Quiz Attempts',
style: AppTextStyles.titleSmall),
),
if (state is DashboardLoading)
const Padding(
padding: EdgeInsets.all(16),
child: FSShimmerList(itemCount: 3),
)
else if (state is DashboardLoaded)
RecentAttemptsList(
attempts: state.stats.recentAttempts),
const SizedBox(height: 8),
],
),
),
],
),
);
},
),
),
);
}
Widget _buildStatsShimmer() {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.35,
children: List.generate(
4,
(_) => FSShimmer.card(height: 100),
),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../../core/network/dio_client.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_error_state.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../auth/presentation/bloc/auth_cubit.dart';
import '../../data/datasources/dashboard_remote_datasource.dart';
import '../../data/repositories/dashboard_repository_impl.dart';
import '../../data/models/user_stats_model.dart';
import '../bloc/dashboard_cubit.dart';
import '../bloc/dashboard_state.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) {
final remote = DashboardRemoteDatasource(DioClient.instance);
final repo = DashboardRepositoryImpl(remote: remote);
return DashboardCubit(repo)..loadStats();
},
child: const _ProfileView(),
);
}
}
class _ProfileView extends StatelessWidget {
const _ProfileView();
@override
Widget build(BuildContext context) {
final authCubit = context.read<AuthCubit>();
final user = authCubit.currentUser;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: BlocBuilder<DashboardCubit, DashboardState>(
builder: (context, state) {
if (state is DashboardLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is DashboardError) {
return FSErrorState(
message: state.message,
onRetry: () => context.read<DashboardCubit>().loadStats(),
);
}
if (state is! DashboardLoaded) {
return const SizedBox.shrink();
}
final stats = state.stats;
return ListView(
padding: AppSpacing.screenAll,
children: [
// ── User Header ──
Center(
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: AppGradients.primaryButton,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
(user?.username ?? '?')[0].toUpperCase(),
style: AppTextStyles.headlineLarge
.copyWith(color: Colors.white),
),
),
AppSpacing.vSm,
Text(user?.username ?? 'User',
style: AppTextStyles.titleLarge),
Text(user?.email ?? '',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary)),
AppSpacing.vXs,
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.accentGlow,
borderRadius: AppRadius.pill,
),
child: Text(
user?.role.toUpperCase() ?? 'USER',
style: AppTextStyles.labelSmall
.copyWith(color: AppColors.accentLight),
),
),
],
),
),
AppSpacing.vXl,
// ── Accuracy Donut ──
_buildAccuracyChart(stats),
AppSpacing.vLg,
// ── Category Breakdown ──
if (stats.categories.isNotEmpty) ...[
Text('Category Breakdown', style: AppTextStyles.titleMedium),
AppSpacing.vSm,
...stats.categories.map((c) => _buildCategoryBar(c)),
AppSpacing.vLg,
],
// ── Logout ──
FSButton(
label: 'Sign Out',
variant: FSButtonVariant.danger,
icon: Icons.logout,
onPressed: () => authCubit.logout(),
),
AppSpacing.vXl,
],
);
},
),
);
}
Widget _buildAccuracyChart(UserStatsModel stats) {
final correct = stats.totalCorrect.toDouble();
final wrong = (stats.totalQuestionsAnswered - stats.totalCorrect).toDouble();
final hasData = stats.totalQuestionsAnswered > 0;
return Container(
padding: AppSpacing.cardInnerLarge,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
children: [
Text('Overall Accuracy', style: AppTextStyles.titleSmall),
AppSpacing.vMd,
SizedBox(
height: 160,
child: hasData
? PieChart(
PieChartData(
sectionsSpace: 3,
centerSpaceRadius: 40,
sections: [
PieChartSectionData(
value: correct,
color: AppColors.success,
title: '${stats.accuracy.toStringAsFixed(0)}%',
titleStyle: AppTextStyles.titleSmall
.copyWith(color: Colors.white, fontSize: 13),
radius: 35,
),
PieChartSectionData(
value: wrong > 0 ? wrong : 0.01,
color: AppColors.error.withOpacity(0.4),
title: '',
radius: 28,
),
],
),
)
: Center(
child: Text(
'No data yet',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary),
),
),
),
AppSpacing.vSm,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_legendDot(AppColors.success, 'Correct (${stats.totalCorrect})'),
const SizedBox(width: 20),
_legendDot(
AppColors.error.withOpacity(0.4),
'Wrong (${stats.totalQuestionsAnswered - stats.totalCorrect})',
),
],
),
],
),
);
}
Widget _legendDot(Color color, String label) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: AppTextStyles.caption),
],
);
}
Widget _buildCategoryBar(CategoryBreakdownModel cat) {
final pct =
cat.attempts > 0 ? (cat.correct / cat.attempts * 100).round() : 0;
final fill = cat.attempts > 0 ? cat.correct / cat.attempts : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(cat.category, style: AppTextStyles.bodySmall),
Text(
'$pct% (${cat.correct}/${cat.attempts})',
style: AppTextStyles.caption,
),
],
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: fill,
minHeight: 6,
backgroundColor: AppColors.border,
valueColor: AlwaysStoppedAnimation(
pct >= 70
? AppColors.success
: pct >= 40
? AppColors.warning
: AppColors.error,
),
),
),
],
),
);
}
}
\ 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';
class QuickActionCard extends StatelessWidget {
final String icon;
final String label;
final Color accentColor;
final VoidCallback onTap;
const QuickActionCard({
super.key,
required this.icon,
required this.label,
required this.accentColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Material(
color: AppColors.panel,
borderRadius: AppRadius.mdAll,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.mdAll,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: accentColor.withOpacity(0.12),
borderRadius: AppRadius.smAll,
),
alignment: Alignment.center,
child: Text(icon, style: const TextStyle(fontSize: 22)),
),
const SizedBox(height: 8),
Text(
label,
style: AppTextStyles.labelMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
);
}
}
\ 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/utils/formatters.dart';
import '../../../../core/widgets/fs_badge.dart';
import '../../data/models/user_stats_model.dart';
class RecentAttemptsList extends StatelessWidget {
final List<QuizAttemptModel> attempts;
const RecentAttemptsList({super.key, required this.attempts});
@override
Widget build(BuildContext context) {
if (attempts.isEmpty) {
return Container(
padding: const EdgeInsets.all(24),
alignment: Alignment.center,
child: Text(
'No quiz attempts yet.\nStart practicing to see your results!',
style: AppTextStyles.bodySmall.copyWith(color: AppColors.textTertiary),
textAlign: TextAlign.center,
),
);
}
return Column(
children: attempts.map((a) {
final score = a.score ?? 0;
final badgeVariant = score >= 70
? FSBadgeVariant.green
: score >= 40
? FSBadgeVariant.orange
: FSBadgeVariant.red;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.border.withOpacity(0.5)),
),
),
child: Row(
children: [
Expanded(
flex: 3,
child: Text(
Formatters.date(a.startedAt),
style: AppTextStyles.bodySmall,
),
),
Expanded(
flex: 2,
child: FSBadge(
text: '$score%',
variant: badgeVariant,
),
),
Expanded(
flex: 2,
child: Text(
'${a.correctAnswers ?? 0}/${a.totalQuestions ?? 0}',
style: AppTextStyles.bodySmall,
textAlign: TextAlign.center,
),
),
Expanded(
flex: 2,
child: Text(
a.status,
style: AppTextStyles.caption,
textAlign: TextAlign.end,
),
),
],
),
);
}).toList(),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'app.dart';
import 'core/network/dio_client.dart';
import 'features/auth/data/datasources/auth_remote_datasource.dart';
import 'features/auth/data/datasources/auth_local_datasource.dart';
import 'features/auth/data/repositories/auth_repository_impl.dart';
import 'features/auth/presentation/bloc/auth_cubit.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
......@@ -21,5 +27,11 @@ void main() async {
DeviceOrientation.portraitDown,
]);
runApp(const FinSimApp());
final dio = DioClient.instance;
final authLocal = AuthLocalDatasource();
final authRemote = AuthRemoteDatasource(dio);
final authRepo = AuthRepositoryImpl(remote: authRemote, local: authLocal);
final authCubit = AuthCubit(authRepo);
runApp(FinSimApp(authCubit: authCubit));
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/theme/app_colors.dart';
class BottomNavShell extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const BottomNavShell({super.key, required this.navigationShell});
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: AppColors.border, width: 1)),
),
child: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
items: const [
BottomNavigationBarItem(
icon: Text('🏠', style: TextStyle(fontSize: 20)),
activeIcon: Text('🏠', style: TextStyle(fontSize: 22)),
label: 'Home',
),
BottomNavigationBarItem(
icon: Text('🤖', style: TextStyle(fontSize: 20)),
activeIcon: Text('🤖', style: TextStyle(fontSize: 22)),
label: 'Chat',
),
BottomNavigationBarItem(
icon: Text('🔮', style: TextStyle(fontSize: 20)),
activeIcon: Text('🔮', style: TextStyle(fontSize: 22)),
label: 'Akinator',
),
BottomNavigationBarItem(
icon: Text('📝', style: TextStyle(fontSize: 20)),
activeIcon: Text('📝', style: TextStyle(fontSize: 22)),
label: 'Quiz',
),
BottomNavigationBarItem(
icon: Text('☰', style: TextStyle(fontSize: 20)),
activeIcon: Text('☰', style: TextStyle(fontSize: 22)),
label: 'More',
),
],
),
),
);
}
}
\ 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';
class MoreMenuScreen extends StatelessWidget {
const MoreMenuScreen({super.key});
static const _items = [
_MenuItem('📚', 'Scenarios', 'Browse all scenarios', 'scenarios'),
_MenuItem('⚡', 'Generator', 'Generate new scenarios', 'generator'),
_MenuItem('🧠', 'Knowledge Base', 'Manage documents', 'kb'),
_MenuItem('🏆', 'Leaderboard', 'Top performers', 'leaderboard'),
_MenuItem('👤', 'Profile', 'Your stats & history', null),
_MenuItem('⚙️', 'Settings', 'App configuration', 'settings'),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: ListView(
padding: AppSpacing.screenAll,
children: [
Text('More', style: AppTextStyles.headlineLarge),
AppSpacing.vXs,
Text('Additional features & settings',
style: AppTextStyles.bodySmall),
AppSpacing.vLg,
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 1.15,
),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return _buildTile(context, item);
},
),
],
),
),
);
}
Widget _buildTile(BuildContext context, _MenuItem item) {
return Material(
color: AppColors.panel,
borderRadius: AppRadius.mdAll,
child: InkWell(
onTap: () {
if (item.route == null) {
// Profile — navigate to dashboard's profile
context.go('/app/dashboard/profile');
} else {
context.go('/app/more/${item.route}');
}
},
borderRadius: AppRadius.mdAll,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(item.icon, style: const TextStyle(fontSize: 32)),
const SizedBox(height: 10),
Text(item.label, style: AppTextStyles.titleSmall),
const SizedBox(height: 4),
Text(
item.subtitle,
style: AppTextStyles.caption,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
}
class _MenuItem {
final String icon;
final String label;
final String subtitle;
final String? route;
const _MenuItem(this.icon, this.label, this.subtitle, this.route);
}
\ 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_spacing.dart';
import '../core/theme/app_radius.dart';
class PlaceholderScreen extends StatelessWidget {
final String icon;
final String title;
final String subtitle;
const PlaceholderScreen({
super.key,
required this.icon,
required this.title,
required this.subtitle,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: AppSpacing.screenAll,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(icon, style: const TextStyle(fontSize: 64)),
AppSpacing.vMd,
Text(title, style: AppTextStyles.headlineMedium),
AppSpacing.vSm,
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.accentGlow,
border:
Border.all(color: AppColors.accent.withOpacity(0.2)),
borderRadius: AppRadius.smAll,
),
child: Text(
subtitle,
style: AppTextStyles.labelMedium
.copyWith(color: AppColors.accentLight),
),
),
],
),
),
),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../core/config/app_config.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 '../features/auth/presentation/bloc/auth_cubit.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: AppSpacing.screenAll,
children: [
// ── Server Info ──
_SectionHeader('Server Configuration'),
AppSpacing.vSm,
Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Row(
children: [
const Icon(Icons.dns_outlined,
color: AppColors.textTertiary, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Base URL', style: AppTextStyles.labelMedium),
Text(
AppConfig.baseUrl,
style: AppTextStyles.monoSmall
.copyWith(color: AppColors.accentLight),
),
],
),
),
],
),
),
AppSpacing.vLg,
// ── About ──
_SectionHeader('About'),
AppSpacing.vSm,
Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_infoRow('App', '${AppConfig.appName} v${AppConfig.appVersion}'),
const Divider(height: 20),
_infoRow('Platform', 'Flutter + FastAPI + LangGraph'),
const Divider(height: 20),
_infoRow('AI Model', 'LLaMA 3.3 70B (Groq)'),
],
),
),
AppSpacing.vXl,
// ── Logout ──
FSButton(
label: 'Sign Out',
variant: FSButtonVariant.danger,
icon: Icons.logout,
onPressed: () => context.read<AuthCubit>().logout(),
),
AppSpacing.vXl,
],
),
);
}
Widget _infoRow(String label, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: AppTextStyles.bodySmall),
Text(value,
style: AppTextStyles.labelMedium
.copyWith(color: AppColors.textSecondary)),
],
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
return Text(
title.toUpperCase(),
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textTertiary,
letterSpacing: 1.5,
),
);
}
}
\ 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