diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml
index 24e9e1e..c7e857b 100644
--- a/.idea/libraries/Dart_Packages.xml
+++ b/.idea/libraries/Dart_Packages.xml
@@ -5,1134 +5,1134 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml
index fc8e6ba..77152dd 100644
--- a/.idea/libraries/Dart_SDK.xml
+++ b/.idea/libraries/Dart_SDK.xml
@@ -1,29 +1,29 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/libraries/Flutter_Plugins.xml b/.idea/libraries/Flutter_Plugins.xml
index ab15c86..760fc68 100644
--- a/.idea/libraries/Flutter_Plugins.xml
+++ b/.idea/libraries/Flutter_Plugins.xml
@@ -1,28 +1,28 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/routes/stats.ts b/backend/src/routes/stats.ts
index 899fa07..acbd841 100644
--- a/backend/src/routes/stats.ts
+++ b/backend/src/routes/stats.ts
@@ -17,6 +17,30 @@ router.get('/stats', async (req: AuthRequest, res: Response, next: NextFunction)
}
});
+// POST /user/stats - Update gamification stats
+router.post('/stats', async (req: AuthRequest, res: Response, next: NextFunction) => {
+ try {
+ const userId = req.user!.userId;
+ // Expecting body to have keys matching our snake_case logic from frontend?
+ // Wait, the frontend sends snake_case keys now: current_level, current_xp, etc.
+ // But the service expects UserStatsResult which has camelCase keys!
+ // I need to map the request body to the service interface.
+
+ const { current_level, current_xp, total_gold, avatar_stage } = req.body;
+
+ const updatedStats = await statsService.updateUserStats(userId, {
+ currentLevel: current_level,
+ currentXP: current_xp,
+ totalGold: total_gold,
+ avatarStage: avatar_stage
+ });
+
+ res.json({ stats: updatedStats });
+ } catch (e) {
+ next(e);
+ }
+});
+
// GET /user/progress - Study progress
router.get('/progress', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
diff --git a/backend/src/services/stats.service.ts b/backend/src/services/stats.service.ts
index 5a774df..cfe67ae 100644
--- a/backend/src/services/stats.service.ts
+++ b/backend/src/services/stats.service.ts
@@ -44,6 +44,27 @@ export async function getUserStats(userId: string): Promise {
};
}
+export async function updateUserStats(userId: string, stats: UserStatsResult): Promise {
+ const res = await pool.query(
+ `INSERT INTO user_stats (user_id, current_level, current_xp, total_gold, avatar_stage)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (user_id) DO UPDATE SET
+ current_level = EXCLUDED.current_level,
+ current_xp = EXCLUDED.current_xp,
+ total_gold = EXCLUDED.total_gold,
+ avatar_stage = EXCLUDED.avatar_stage
+ RETURNING current_level, current_xp, total_gold, avatar_stage`,
+ [userId, stats.currentLevel, stats.currentXP, stats.totalGold, stats.avatarStage]
+ );
+
+ return {
+ currentLevel: res.rows[0].current_level,
+ currentXP: res.rows[0].current_xp,
+ totalGold: res.rows[0].total_gold,
+ avatarStage: res.rows[0].avatar_stage
+ };
+}
+
export async function getUserProgress(userId: string): Promise {
// Calculate aggregate study stats
// We sum duration_seconds / 60 for minutes
diff --git a/lib/features/auth/application/auth_controller.dart b/lib/features/auth/application/auth_controller.dart
index 0a39141..94cf376 100644
--- a/lib/features/auth/application/auth_controller.dart
+++ b/lib/features/auth/application/auth_controller.dart
@@ -34,16 +34,26 @@ class AuthController extends _$AuthController {
// Check for existing session on app init
_checkSession();
- // Initially show unauthenticated state
- // Will update if session exists
- return const AuthStateUnauthenticated();
+ // Initially show loading state to prevent flash of login screen
+ return const AuthStateLoading();
}
/// Check if user session exists (called on app init)
Future _checkSession() async {
- final user = await _repository.getCurrentUser();
- if (user != null && ref.mounted) {
- state = AuthStateAuthenticated(user);
+ try {
+ final user = await _repository.getCurrentUser();
+
+ if (!ref.mounted) return;
+
+ if (user != null) {
+ state = AuthStateAuthenticated(user);
+ } else {
+ state = const AuthStateUnauthenticated();
+ }
+ } catch (e) {
+ if (ref.mounted) {
+ state = const AuthStateUnauthenticated();
+ }
}
}
diff --git a/lib/features/goals/presentation/goal_detail_screen.dart b/lib/features/goals/presentation/goal_detail_screen.dart
index b68f04a..9428538 100644
--- a/lib/features/goals/presentation/goal_detail_screen.dart
+++ b/lib/features/goals/presentation/goal_detail_screen.dart
@@ -37,11 +37,16 @@ class _GoalDetailScreenState extends ConsumerState {
.read(apiGoalRepositoryProvider)
.updateTask(widget.goal.id, updatedTask);
- // Award gold for newly completed tasks
+ // Award XP for newly completed tasks
if (!wasCompleted && updatedTask.isCompleted) {
- final currentStats = ref.read(userStatsProvider);
- final newStats = GamificationService.awardTaskRewards(currentStats);
- ref.read(userStatsProvider.notifier).updateStats(newStats);
+ // Award rewards logic
+ ref.read(userStatsProvider.notifier).awardTaskRewards();
+
+ // Get XP amount for popup display
+ final currentStage = ref.read(userStatsProvider).stage;
+ final xpEarned = GamificationService.calculateTaskXpReward(
+ currentStage,
+ );
// Show reward popup
if (mounted) {
@@ -49,10 +54,8 @@ class _GoalDetailScreenState extends ConsumerState {
showDialog(
context: context,
builder: (context) => TaskCompletionPopup(
- goldEarned: GamificationService.calculateTaskGoldReward(
- currentStats.stage,
- ),
taskName: task.title,
+ xpEarned: xpEarned,
onClose: () => Navigator.of(context).pop(),
),
);
diff --git a/lib/features/home/presentation/home_screen.dart b/lib/features/home/presentation/home_screen.dart
index 862cdfb..5cbe650 100644
--- a/lib/features/home/presentation/home_screen.dart
+++ b/lib/features/home/presentation/home_screen.dart
@@ -4,7 +4,6 @@ import 'package:learning_coach/core/constants/app_strings.dart';
import 'package:learning_coach/core/providers/locale_provider.dart';
import 'package:learning_coach/features/garden/presentation/garden_screen.dart';
import 'package:learning_coach/features/home/presentation/widgets/home_widgets.dart';
-import 'package:learning_coach/features/shop/presentation/shop_screen.dart';
import 'package:learning_coach/shared/data/providers.dart';
import 'package:learning_coach/shared/models/gamification_models.dart';
import 'package:learning_coach/shared/services/gamification_service.dart';
@@ -55,8 +54,14 @@ class _HomeScreenState extends ConsumerState
userStats.xp,
userStats.level,
);
- final stageName = GamificationService.getLocalizedStageName(userStats.stage, locale);
- final powerName = GamificationService.getLocalizedPowerName(userStats.stage, locale);
+ final stageName = GamificationService.getLocalizedStageName(
+ userStats.stage,
+ locale,
+ );
+ final powerName = GamificationService.getLocalizedPowerName(
+ userStats.stage,
+ locale,
+ );
final stageFeatures = GamificationService.getStageFeatures(userStats.stage);
return Scaffold(
@@ -81,99 +86,51 @@ class _HomeScreenState extends ConsumerState
padding: const EdgeInsets.fromLTRB(20, 60, 20, 30),
child: Column(
children: [
- // Gold & Level Row
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- // Gold Display (Tappable to open shop)
- GestureDetector(
- onTap: () {
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => const ShopScreen(),
- ),
- );
- },
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 10,
- ),
- decoration: BoxDecoration(
- color: Colors.white.withOpacity(0.95),
- borderRadius: BorderRadius.circular(20),
- boxShadow: [
- BoxShadow(
- color: const Color(
- 0xFFFBBF24,
- ).withOpacity(0.3),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
- ],
- ),
- child: Row(
- children: [
- const Text(
- '💰',
- style: TextStyle(fontSize: 20),
- ),
- const SizedBox(width: 8),
- Text(
- '${userStats.gold}',
- style: const TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.bold,
- color: Color(0xFFF59E0B),
- ),
- ),
- ],
- ),
- ),
+ // Level Badge Centered or Leading?
+ // Let's keep it clean. Just show Level Badge.
+ Center(
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 20,
+ vertical: 12,
),
- // Level Badge
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 10,
- ),
- decoration: BoxDecoration(
- gradient: LinearGradient(
- colors: [
- stageFeatures.primaryColor,
- stageFeatures.primaryColor.withOpacity(0.7),
- ],
- ),
- borderRadius: BorderRadius.circular(20),
- boxShadow: [
- BoxShadow(
- color: stageFeatures.primaryColor.withOpacity(
- 0.3,
- ),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [
+ stageFeatures.primaryColor,
+ stageFeatures.primaryColor.withOpacity(0.8),
],
),
- child: Row(
- children: [
- Text(
- stageFeatures.emoji,
- style: const TextStyle(fontSize: 20),
+ borderRadius: BorderRadius.circular(24),
+ boxShadow: [
+ BoxShadow(
+ color: stageFeatures.primaryColor.withOpacity(
+ 0.3,
),
- const SizedBox(width: 8),
- Text(
- 'Lv ${userStats.level}',
- style: const TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.bold,
- color: Colors.white,
- ),
+ blurRadius: 12,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ stageFeatures.emoji,
+ style: const TextStyle(fontSize: 24),
+ ),
+ const SizedBox(width: 12),
+ Text(
+ 'Lv ${userStats.level}',
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
),
- ],
- ),
+ ),
+ ],
),
- ],
+ ),
),
const SizedBox(height: 30),
@@ -274,28 +231,16 @@ class _HomeScreenState extends ConsumerState
const SizedBox(height: 16),
// Bonus Indicators
- if (stageFeatures.xpBonus > 0 ||
- stageFeatures.goldBonus > 0)
+ if (stageFeatures.xpBonus > 0)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(16),
),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- if (stageFeatures.xpBonus > 0)
- _buildBonusBadge(
- '⚡ +${stageFeatures.xpBonus}% XP',
- stageFeatures.primaryColor,
- ),
- if (stageFeatures.goldBonus > 0)
- _buildBonusBadge(
- '💰 +${stageFeatures.goldBonus}% ${AppStrings.getGoldBonus(locale)}',
- const Color(0xFFF59E0B),
- ),
- ],
+ child: _buildBonusBadge(
+ '⚡ +${stageFeatures.xpBonus}% XP',
+ stageFeatures.primaryColor,
),
),
@@ -349,10 +294,14 @@ class _HomeScreenState extends ConsumerState
// Compact size for home screen preview
const double containerSize = 150.0;
- const double treeHeight = containerSize * 0.75;
+
+ // Pot logic (Static base)
const double potHeight = containerSize * 0.35;
+ const double potWidth = containerSize * 0.55;
+
+ // Tree dimensions relative to asset aspect ratio (approx)
+ const double treeHeight = containerSize * 0.8;
const double treeWidth = treeHeight * 0.85;
- const double potWidth = treeWidth * 0.65;
return SizedBox(
width: containerSize,
@@ -361,9 +310,9 @@ class _HomeScreenState extends ConsumerState
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
- // Tree Layer (Behind)
+ // Tree Layer (Image Asset)
Positioned(
- bottom: containerSize * 0.32, // Tree base enters pot
+ bottom: containerSize * 0.28, // Just above pot bottom
child: SizedBox(
width: treeWidth,
height: treeHeight,
diff --git a/lib/features/home/presentation/widgets/home_widgets.dart b/lib/features/home/presentation/widgets/home_widgets.dart
index 8f3ebcc..7674374 100644
--- a/lib/features/home/presentation/widgets/home_widgets.dart
+++ b/lib/features/home/presentation/widgets/home_widgets.dart
@@ -434,14 +434,6 @@ class ProgressSummaryCard extends ConsumerWidget {
Icons.event_note_rounded,
scheme,
),
- _buildDivider(scheme),
- _buildStatItem(
- context,
- '${data['averageScore'] ?? 0}%',
- AppStrings.getAvgScore(locale),
- Icons.star_rounded,
- scheme,
- ),
],
),
),
diff --git a/lib/features/home/presentation/widgets/procedural_tree.dart b/lib/features/home/presentation/widgets/procedural_tree.dart
new file mode 100644
index 0000000..efc4e68
--- /dev/null
+++ b/lib/features/home/presentation/widgets/procedural_tree.dart
@@ -0,0 +1,214 @@
+import 'dart:math';
+
+import 'package:flutter/material.dart';
+
+class ProceduralTree extends StatelessWidget {
+ final int level;
+ final double size;
+
+ const ProceduralTree({super.key, required this.level, required this.size});
+
+ @override
+ Widget build(BuildContext context) {
+ // Determine visuals based on level
+ // Cap visual evolution at level 10, but allow color changes after
+ final int visualLevel = level.clamp(1, 10);
+ final bool isMaxGrowth = level >= 10;
+
+ // Trunk Color: Green (Lvl 1) -> Brown (Lvl 10)
+ final Color trunkColor = Color.lerp(
+ const Color(0xFF86EFAC), // Light Green
+ const Color(0xFF5D4037), // Dark Brown
+ (visualLevel - 1) / 9,
+ )!;
+
+ // Leaf Color
+ Color leafColor = const Color(0xFF22C55E); // Base Green
+ if (level > 10) {
+ // Shift hue for levels > 10
+ final double hue = ((level - 10) * 37.0) % 360;
+ leafColor = HSLColor.fromAHSL(1.0, hue, 0.7, 0.45).toColor();
+ }
+
+ // Fruit?
+ final bool showFruits = visualLevel >= 9;
+
+ return CustomPaint(
+ size: Size(size, size),
+ painter: _TreePainter(
+ level: visualLevel,
+ isMaxGrowth: isMaxGrowth,
+ trunkColor: trunkColor,
+ leafColor: leafColor,
+ showFruits: showFruits,
+ ),
+ );
+ }
+}
+
+class _TreePainter extends CustomPainter {
+ final int level;
+ final bool isMaxGrowth;
+ final Color trunkColor;
+ final Color leafColor;
+ final bool showFruits;
+
+ Random _rng = Random(42); // Seeded for consistency
+
+ _TreePainter({
+ required this.level,
+ required this.isMaxGrowth,
+ required this.trunkColor,
+ required this.leafColor,
+ required this.showFruits,
+ });
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ _rng = Random(42); // Reset seed each paint
+
+ final centerBottom = Offset(size.width / 2, size.height);
+
+ // Properties based on level
+ // Height: Grows from 65% to 95% of container
+ final double treeHeight = size.height * (0.65 + (level / 10) * 0.3);
+
+ // Trunk Thickness: Grows from 6 to 18
+ final double trunkWidth = 6.0 + (level * 1.2);
+
+ // Recursion Depth (Complexity)
+ final int depth = level <= 2
+ ? 1
+ : level <= 5
+ ? 2
+ : 3;
+
+ // Draw Trunk & Branches
+ _drawBranch(
+ canvas,
+ centerBottom,
+ -pi / 2, // Upwards
+ treeHeight,
+ trunkWidth,
+ depth,
+ level,
+ );
+ }
+
+ void _drawBranch(
+ Canvas canvas,
+ Offset start,
+ double angle,
+ double length,
+ double width,
+ int currentDepth,
+ int currentLevel,
+ ) {
+ if (currentDepth < 0) return;
+
+ final Paint trunkPaint = Paint()
+ ..color = trunkColor
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = width
+ ..strokeCap = StrokeCap.round;
+
+ // Calculate end point
+ final double endX = start.dx + cos(angle) * length;
+ final double endY = start.dy + sin(angle) * length;
+ final Offset end = Offset(endX, endY);
+
+ // Quadratic Bezier for slight curve
+ // Curve intensity changes slightly pseudo-randomly but deterministically
+ final double curveFactor =
+ (_rng.nextDouble() - 0.5) * 20 * (4 - currentDepth);
+ final Offset control = Offset(
+ (start.dx + endX) / 2 + curveFactor,
+ (start.dy + endY) / 2,
+ );
+
+ final Path path = Path();
+ path.moveTo(start.dx, start.dy);
+ path.quadraticBezierTo(control.dx, control.dy, endX, endY);
+
+ canvas.drawPath(path, trunkPaint);
+
+ // Draw Leaves/Fruits at ends or joints if complex enough
+ // Draw leaves on trunk too for bushier look at higher levels
+ if (currentDepth == 0 || (currentDepth == 1 && currentLevel >= 4)) {
+ _drawFoliage(canvas, end, width, currentLevel);
+ }
+
+ // Recursive branches
+ if (currentDepth > 0) {
+ const int branches = 2; // Split into 2
+ final double spreadAngle =
+ 0.6 + (currentLevel * 0.04).clamp(0.0, 0.4); // Wider spread
+
+ _drawBranch(
+ canvas,
+ end,
+ angle - spreadAngle / 2,
+ length * 0.65,
+ width * 0.7,
+ currentDepth - 1,
+ currentLevel,
+ );
+
+ _drawBranch(
+ canvas,
+ end,
+ angle + spreadAngle / 2,
+ length * 0.65,
+ width * 0.7,
+ currentDepth - 1,
+ currentLevel,
+ );
+ }
+ }
+
+ void _drawFoliage(
+ Canvas canvas,
+ Offset center,
+ double stemWidth,
+ int currentLevel,
+ ) {
+ // Leaf Cluster
+ final Paint leafPaint = Paint()
+ ..color = leafColor
+ ..style = PaintingStyle.fill;
+
+ // Cluster size grows with level
+ final double clusterRadius = 12.0 + (currentLevel * 1.8);
+ final int leafCount = 5 + currentLevel; // More density
+
+ for (int i = 0; i < leafCount; i++) {
+ final double leafAngle = _rng.nextDouble() * 2 * pi;
+ final double distance = _rng.nextDouble() * clusterRadius;
+
+ final double lx = center.dx + cos(leafAngle) * distance;
+ final double ly = center.dy + sin(leafAngle) * distance;
+
+ // Draw individual leaf (Ellipse)
+ canvas.drawOval(
+ Rect.fromCenter(center: Offset(lx, ly), width: 8, height: 12),
+ leafPaint,
+ );
+ }
+
+ // Fruits
+ if (showFruits && _rng.nextDouble() > 0.6) {
+ final Paint fruitPaint = Paint()..color = const Color(0xFFEF4444); // Red
+ // Draw a fruit
+ canvas.drawCircle(
+ center + Offset(_rng.nextDouble() * 10 - 5, _rng.nextDouble() * 10 - 5),
+ 4.0,
+ fruitPaint,
+ );
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant _TreePainter oldDelegate) {
+ return oldDelegate.level != level || oldDelegate.leafColor != leafColor;
+ }
+}
diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart
index 51cea7c..72d93f1 100644
--- a/lib/features/profile/presentation/profile_screen.dart
+++ b/lib/features/profile/presentation/profile_screen.dart
@@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart';
import 'package:learning_coach/core/constants/app_strings.dart';
import 'package:learning_coach/core/providers/locale_provider.dart';
import 'package:learning_coach/features/auth/application/auth_controller.dart';
+import 'package:learning_coach/features/auth/domain/auth_state.dart';
+import 'package:learning_coach/features/auth/domain/auth_user.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@@ -174,31 +176,48 @@ class _ProfileScreenState extends ConsumerState {
),
),
const SizedBox(height: 20),
- Text(
- 'Öğrenci Adı',
- style: Theme.of(context).textTheme.headlineSmall?.copyWith(
- fontWeight: FontWeight.bold,
- color: Colors.white,
- letterSpacing: -0.5,
- ),
- ),
- const SizedBox(height: 8),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 8,
- ),
- decoration: BoxDecoration(
- color: Colors.white.withOpacity(0.2),
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- 'ogrenci@email.com',
- style: Theme.of(context).textTheme.bodyMedium?.copyWith(
- color: Colors.white.withOpacity(0.95),
- fontWeight: FontWeight.w500,
- ),
- ),
+ Consumer(
+ builder: (context, ref, child) {
+ final authState = ref.watch(authControllerProvider);
+
+ final user = switch (authState) {
+ AuthStateAuthenticated(user: final u) => u,
+ _ => AuthUser.guest,
+ };
+
+ return Column(
+ children: [
+ Text(
+ user.displayName,
+ style: Theme.of(context).textTheme.headlineSmall
+ ?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: Colors.white,
+ letterSpacing: -0.5,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 8,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.2),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ user.email,
+ style: Theme.of(context).textTheme.bodyMedium
+ ?.copyWith(
+ color: Colors.white.withOpacity(0.95),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ );
+ },
),
],
),
diff --git a/lib/features/shop/presentation/shop_screen.dart b/lib/features/shop/presentation/shop_screen.dart
index 3684703..e821ca5 100644
--- a/lib/features/shop/presentation/shop_screen.dart
+++ b/lib/features/shop/presentation/shop_screen.dart
@@ -26,35 +26,35 @@ class _ShopScreenState extends ConsumerState
id: 'pot_terracotta',
name: '🪴 Terracotta Saksı',
category: ItemCategory.pot,
- goldCost: 150,
+ goldCost: 0,
assetPath: 'pot_terracotta',
),
const InventoryItem(
id: 'pot_ceramic',
name: '🏺 Seramik Saksı',
category: ItemCategory.pot,
- goldCost: 250,
+ goldCost: 0,
assetPath: 'pot_ceramic',
),
const InventoryItem(
id: 'pot_wooden',
name: '🪵 Ahşap Saksı',
category: ItemCategory.pot,
- goldCost: 200,
+ goldCost: 0,
assetPath: 'pot_wooden',
),
const InventoryItem(
id: 'pot_gold',
name: '✨ Altın Saksı',
category: ItemCategory.pot,
- goldCost: 500,
+ goldCost: 0,
assetPath: 'pot_gold',
),
const InventoryItem(
id: 'pot_crystal',
name: '💎 Kristal Saksı',
category: ItemCategory.pot,
- goldCost: 600,
+ goldCost: 0,
assetPath: 'pot_crystal',
),
@@ -63,35 +63,35 @@ class _ShopScreenState extends ConsumerState
id: 'bg_night',
name: '🌙 Gece Gökyüzü',
category: ItemCategory.background,
- goldCost: 600,
+ goldCost: 0,
assetPath: 'space_bg',
),
const InventoryItem(
id: 'bg_forest',
name: '🌲 Orman Arka Planı',
category: ItemCategory.background,
- goldCost: 500,
+ goldCost: 0,
assetPath: 'forest_bg',
),
const InventoryItem(
id: 'bg_ocean',
name: '🌊 Okyanus Arka Planı',
category: ItemCategory.background,
- goldCost: 550,
+ goldCost: 0,
assetPath: 'ocean_bg',
),
const InventoryItem(
id: 'bg_sunny',
name: '☀️ Güneşli Park',
category: ItemCategory.background,
- goldCost: 400,
+ goldCost: 0,
assetPath: 'sunny_bg',
),
const InventoryItem(
id: 'bg_rainbow',
name: '🌈 Gökkuşağı',
category: ItemCategory.background,
- goldCost: 700,
+ goldCost: 0,
assetPath: 'rainbow_bg',
),
@@ -100,35 +100,35 @@ class _ShopScreenState extends ConsumerState
id: 'comp_cat',
name: '🐱 Uyuyan Kedi',
category: ItemCategory.companion,
- goldCost: 300,
+ goldCost: 0,
assetPath: 'cat_companion',
),
const InventoryItem(
id: 'comp_bird',
name: '🐦 Cıvıldayan Kuş',
category: ItemCategory.companion,
- goldCost: 250,
+ goldCost: 0,
assetPath: 'bird_companion',
),
const InventoryItem(
id: 'comp_butterfly',
name: '🦋 Kelebek',
category: ItemCategory.companion,
- goldCost: 200,
+ goldCost: 0,
assetPath: 'butterfly_companion',
),
const InventoryItem(
id: 'comp_owl',
name: '🦉 Bilge Baykuş',
category: ItemCategory.companion,
- goldCost: 400,
+ goldCost: 0,
assetPath: 'owl_companion',
),
const InventoryItem(
id: 'comp_dragon',
name: '🐉 Mini Ejderha',
category: ItemCategory.companion,
- goldCost: 800,
+ goldCost: 0,
assetPath: 'dragon_companion',
),
];
@@ -192,41 +192,8 @@ class _ShopScreenState extends ConsumerState
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
- // Gold Balance
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 16,
- vertical: 8,
- ),
- decoration: BoxDecoration(
- gradient: const LinearGradient(
- colors: [Color(0xFFFBBF24), Color(0xFFF59E0B)],
- ),
- borderRadius: BorderRadius.circular(20),
- boxShadow: [
- BoxShadow(
- color: const Color(0xFFF59E0B).withOpacity(0.3),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
- ],
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Text('💰', style: TextStyle(fontSize: 20)),
- const SizedBox(width: 8),
- Text(
- '${userStats.gold}',
- style: const TextStyle(
- fontSize: 18,
- fontWeight: FontWeight.bold,
- color: Colors.white,
- ),
- ),
- ],
- ),
- ),
+ // Gold removed
+ const SizedBox.shrink(),
],
),
),
@@ -572,27 +539,16 @@ class _ShopItemCard extends ConsumerWidget {
vertical: 6,
),
decoration: BoxDecoration(
- color: canAfford
- ? const Color(0xFFFBBF24).withOpacity(0.2)
- : Colors.grey.withOpacity(0.2),
+ color: const Color(0xFF10B981).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- const Text('💰', style: TextStyle(fontSize: 14)),
- const SizedBox(width: 4),
- Text(
- '${item.goldCost}',
- style: TextStyle(
- color: canAfford
- ? const Color(0xFFF59E0B)
- : Colors.grey,
- fontWeight: FontWeight.bold,
- fontSize: 14,
- ),
- ),
- ],
+ child: const Text(
+ 'Ücretsiz', // Free
+ style: TextStyle(
+ color: Color(0xFF10B981),
+ fontWeight: FontWeight.bold,
+ fontSize: 12,
+ ),
),
),
],
@@ -627,19 +583,13 @@ class _PurchaseConfirmDialog extends ConsumerWidget {
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Text('Fiyat: 💰 ', style: TextStyle(fontSize: 18)),
- Text(
- '${item.goldCost}',
- style: const TextStyle(
- fontSize: 24,
- fontWeight: FontWeight.bold,
- color: Color(0xFFF59E0B),
- ),
- ),
- ],
+ const Text(
+ 'Ücretsiz',
+ style: TextStyle(
+ fontSize: 24,
+ fontWeight: FontWeight.bold,
+ color: Color(0xFF10B981),
+ ),
),
],
),
diff --git a/lib/features/study/presentation/session_running_screen.dart b/lib/features/study/presentation/session_running_screen.dart
index 77aab25..1b90bc3 100644
--- a/lib/features/study/presentation/session_running_screen.dart
+++ b/lib/features/study/presentation/session_running_screen.dart
@@ -6,8 +6,8 @@ import 'package:go_router/go_router.dart';
import 'package:learning_coach/core/constants/app_strings.dart';
import 'package:learning_coach/core/providers/locale_provider.dart';
import 'package:learning_coach/shared/data/providers.dart';
+import 'package:learning_coach/shared/models/gamification_models.dart';
import 'package:learning_coach/shared/services/gamification_service.dart';
-import 'package:learning_coach/shared/widgets/avatar_character.dart';
import 'package:learning_coach/shared/widgets/reward_popups.dart';
class SessionRunningScreen extends ConsumerStatefulWidget {
@@ -25,17 +25,31 @@ class SessionRunningScreen extends ConsumerStatefulWidget {
_SessionRunningScreenState();
}
-class _SessionRunningScreenState extends ConsumerState {
+class _SessionRunningScreenState extends ConsumerState
+ with SingleTickerProviderStateMixin {
// Timer
late int _secondsRemaining;
Timer? _timer;
bool _isPaused = false;
+ // Animation
+ late AnimationController _swayController;
+ late Animation _swayAnimation;
+
@override
void initState() {
super.initState();
_secondsRemaining = widget.durationMinutes * 60;
_startTimer();
+
+ _swayController = AnimationController(
+ duration: const Duration(seconds: 4),
+ vsync: this,
+ )..repeat(reverse: true);
+
+ _swayAnimation = Tween(begin: -0.05, end: 0.05).animate(
+ CurvedAnimation(parent: _swayController, curve: Curves.easeInOut),
+ );
}
void _startTimer() {
@@ -58,6 +72,7 @@ class _SessionRunningScreenState extends ConsumerState {
@override
void dispose() {
_timer?.cancel();
+ _swayController.dispose();
super.dispose();
}
@@ -127,12 +142,8 @@ class _SessionRunningScreenState extends ConsumerState {
color: scheme.primary,
),
),
- // Animated Avatar in Training Pose
- AvatarCharacter(
- stage: userStats.stage.name,
- size: 180,
- isAnimating: !_isPaused,
- ),
+ // Plant with Swaying Animation
+ _buildSwayingPlant(userStats, scheme),
],
),
const SizedBox(height: 32),
@@ -209,20 +220,57 @@ class _SessionRunningScreenState extends ConsumerState {
);
}
- void _finishSession() async {
+ void _finishSession() {
_timer?.cancel();
- // Calculate study time based on elapsed time (not just full duration if finished early)
- // Assuming button press finishes session with current elapsed time?
- // User requested: "bu hedefin ilerlemesini ne kadar süre çalıştığını db'ye kaydedilsin"
- // Usually "finish" means "done for now".
-
final elapsedSeconds = (widget.durationMinutes * 60) - _secondsRemaining;
final elapsedMinutes = elapsedSeconds ~/ 60;
+ final currentStats = ref.read(userStatsProvider);
- // Minimum 1 minutes to record? or just record whatever.
- // Let's record.
+ // Minimum 10 minutes required for rewards
+ if (elapsedMinutes < 10) {
+ // Show VictoryPopup in "Early Exit" mode (0 XP)
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (context) => VictoryPopup(
+ xpEarned: 0,
+ newLevel: currentStats.level,
+ leveledUp: false,
+ onContinue: () {
+ // "Bitir" pressed
+ Navigator.of(context).pop(); // Close dialog
+ _completeSession(
+ elapsedSeconds,
+ elapsedMinutes,
+ showSuccessDialog: false,
+ );
+ if (mounted && context.canPop()) {
+ context.pop(); // Close screen
+ }
+ },
+ onResume: () {
+ // "Devam Et" pressed
+ Navigator.of(context).pop(); // Close dialog
+ // Timer is already paused by logic flow? No, _finishSession cancels it.
+ // We need to decide if we restart it or if we just let it be.
+ // _finishSession cancelled it. We should probably restart it or not cancel it until confirmed.
+ // Wait, the previous logic cancelled timer at start of _finishSession.
+ // If we resume, we need to restart timer.
+ _startTimer();
+ },
+ ),
+ );
+ } else {
+ _completeSession(elapsedSeconds, elapsedMinutes);
+ }
+ }
+ Future _completeSession(
+ int elapsedSeconds,
+ int elapsedMinutes, {
+ bool showSuccessDialog = true,
+ }) async {
try {
await ref
.read(apiStudySessionRepositoryProvider)
@@ -236,34 +284,31 @@ class _SessionRunningScreenState extends ConsumerState {
final currentStats = ref.read(userStatsProvider);
final newStats = GamificationService.awardStudyRewards(
currentStats,
- elapsedMinutes > 0
- ? elapsedMinutes
- : 1, // Minimum 1 minute for reward calc
+ elapsedMinutes, // Corrected to use minutes
);
final leveledUp = newStats.level > currentStats.level;
// Update stats
ref.read(userStatsProvider.notifier).updateStats(newStats);
- // Show victory popup
- if (mounted) {
+ // Show victory popup if requested (and if we have rewards or it's a normal finish)
+ // Actually strictly respecting the flag is safer for the double-dialog issue.
+ if (showSuccessDialog && mounted) {
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => VictoryPopup(
xpEarned: GamificationService.calculateXpReward(
- elapsedMinutes > 0 ? elapsedMinutes : 1,
- currentStats.stage,
- ),
- goldEarned: GamificationService.calculateSessionGoldReward(
+ elapsedMinutes,
currentStats.stage,
),
newLevel: newStats.level,
leveledUp: leveledUp,
onContinue: () {
Navigator.of(context).pop(); // Close dialog
- context.pop(); // Close running screen, return to study menu
+ context.pop(); // Close running screen
},
+ // onResume not needed here
),
);
}
@@ -275,4 +320,125 @@ class _SessionRunningScreenState extends ConsumerState {
}
}
}
+
+ Widget _buildSwayingPlant(UserStats userStats, ColorScheme scheme) {
+ final treeAsset = GamificationService.getTreeAssetPath(userStats.level);
+ const double containerSize = 220.0;
+
+ // Pot logic (Static base)
+ const double potHeight = containerSize * 0.35;
+ const double potWidth = containerSize * 0.55;
+
+ // Tree dimensions
+ const double treeHeight = containerSize * 0.8;
+ const double treeWidth = treeHeight * 0.85;
+
+ return SizedBox(
+ width: containerSize,
+ height: containerSize,
+ child: Stack(
+ alignment: Alignment.center,
+ clipBehavior: Clip.none,
+ children: [
+ // Tree Layer (Animated)
+ Positioned(
+ bottom: containerSize * 0.28,
+ child: AnimatedBuilder(
+ animation: _swayAnimation,
+ builder: (context, child) {
+ return Transform.rotate(
+ angle: _isPaused ? 0 : _swayAnimation.value,
+ alignment: Alignment.bottomCenter,
+ child: SizedBox(
+ width: treeWidth,
+ height: treeHeight,
+ child: Image.asset(
+ treeAsset,
+ fit: BoxFit.contain,
+ errorBuilder: (context, error, stackTrace) {
+ return const Icon(
+ Icons.park,
+ size: 80,
+ color: Colors.green,
+ );
+ },
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+
+ // Pot Layer (Static Front)
+ Positioned(
+ bottom: containerSize * 0.25,
+ child: Container(
+ width: potWidth,
+ height: potHeight,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [Colors.brown[400]!, Colors.brown[700]!],
+ ),
+ borderRadius: const BorderRadius.only(
+ bottomLeft: Radius.circular(12),
+ bottomRight: Radius.circular(12),
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.2),
+ blurRadius: 8,
+ offset: const Offset(0, 3),
+ ),
+ ],
+ ),
+ child: Stack(
+ children: [
+ // Pot rim
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ height: potHeight * 0.2,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Colors.brown[300]!, Colors.brown[500]!],
+ ),
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(20),
+ topRight: Radius.circular(20),
+ ),
+ ),
+ ),
+ ),
+ // Soil or Label area
+ Positioned(
+ top: potHeight * 0.3,
+ left: potWidth * 0.2,
+ right: potWidth * 0.2,
+ child: Container(
+ height: potHeight * 0.4,
+ decoration: BoxDecoration(
+ color: Colors.brown[900]?.withOpacity(0.3),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Center(
+ child: Icon(
+ Icons.eco,
+ color: Colors.green[200]!.withOpacity(0.5),
+ size: 16,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
}
diff --git a/lib/shared/data/api_stats_repository.dart b/lib/shared/data/api_stats_repository.dart
index 2d117c9..5900db7 100644
--- a/lib/shared/data/api_stats_repository.dart
+++ b/lib/shared/data/api_stats_repository.dart
@@ -40,6 +40,22 @@ class ApiStatsRepository {
}
}
+ Future updateUserStats(UserStats stats) async {
+ try {
+ await _dio.post(
+ '/user/stats',
+ data: {
+ 'current_level': stats.currentLevel,
+ 'current_xp': stats.currentXP,
+ 'total_gold': stats.totalGold,
+ 'avatar_stage': stats.stage.name, // Enum to string
+ },
+ );
+ } catch (e) {
+ rethrow;
+ }
+ }
+
Future> getDailyStats() async {
try {
final response = await _dio.get