From dd7189d3ec5ee2e2ad4477a232cd6d7c28cbb053 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 15:15:45 +0000 Subject: [PATCH 1/9] Major UI/UX overhaul: Implement industry-standard features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This comprehensive update transforms the Flutter app to meet modern language learning app standards with 10+ new screens and advanced features. New Features: ✨ Bottom navigation with 4 main tabs (Home, Achievements, Profile, Settings) ✨ Onboarding flow with 4 animated welcome screens ✨ Dark mode support with automatic theme switching ✨ Accessibility features (text scaling 0.85x-1.3x, high contrast) ✨ Gamification system with 17 unique achievements ✨ Streak tracking (current streak, longest streak, total lessons) ✨ Daily goal system (customizable 1-10 lessons/day) ✨ Enhanced audio player (speed control 0.5x-2.0x, loop mode, restart) ✨ Profile screen with comprehensive stats and charts ✨ Achievement gallery with confetti celebrations ✨ Settings screen (dark mode, text size, daily goals, notifications) New Files: - lib/models/achievement.dart - Achievement definitions and categories - lib/models/streak.dart - Streak tracking data model - lib/screens/onboarding_screen.dart - 4-page welcome tutorial - lib/screens/main_navigation_screen.dart - Bottom nav container - lib/screens/profile_screen.dart - User stats and progress charts - lib/screens/achievements_screen.dart - Achievement gallery with tabs - lib/screens/settings_screen.dart - Comprehensive settings - lib/services/settings_service.dart - Settings state management - lib/services/gamification_service.dart - Achievements and streaks logic - lib/theme/app_theme.dart - Light/dark theme configuration Updated Files: - lib/main.dart - Multi-provider setup, theme switching, onboarding - lib/screens/home_screen.dart - Streak display, daily goals, animations - lib/screens/lesson_screen.dart - Speed control, loop, achievement alerts - pubspec.yaml - Added dependencies (flutter_animate, fl_chart, badges, confetti) - README.md - Comprehensive documentation of all new features Technical Improvements: - Provider-based state management for all new services - Persistent storage using SharedPreferences - Smooth animations with flutter_animate - Interactive charts with fl_chart - Material Design 3 throughout - Text scaling for accessibility - Achievement unlocking with visual feedback Resolves requirements for: - Industry-standard bottom navigation - Onboarding flow for new users - Profile, settings, and achievements screens - Enhanced progress visualization - Gamification (streaks, badges, achievements) - Advanced audio player controls - Accessibility features (dark mode, text sizing) --- README.md | 223 ++++++----- lib/main.dart | 57 +-- lib/models/achievement.dart | 223 +++++++++++ lib/models/streak.dart | 87 +++++ lib/screens/achievements_screen.dart | 354 +++++++++++++++++ lib/screens/home_screen.dart | 238 ++++++++++-- lib/screens/lesson_screen.dart | 134 ++++++- lib/screens/main_navigation_screen.dart | 124 ++++++ lib/screens/onboarding_screen.dart | 248 ++++++++++++ lib/screens/profile_screen.dart | 485 ++++++++++++++++++++++++ lib/screens/settings_screen.dart | 407 ++++++++++++++++++++ lib/services/gamification_service.dart | 306 +++++++++++++++ lib/services/settings_service.dart | 139 +++++++ lib/theme/app_theme.dart | 268 +++++++++++++ pubspec.yaml | 10 + 15 files changed, 3161 insertions(+), 142 deletions(-) create mode 100644 lib/models/achievement.dart create mode 100644 lib/models/streak.dart create mode 100644 lib/screens/achievements_screen.dart create mode 100644 lib/screens/main_navigation_screen.dart create mode 100644 lib/screens/onboarding_screen.dart create mode 100644 lib/screens/profile_screen.dart create mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/services/gamification_service.dart create mode 100644 lib/services/settings_service.dart create mode 100644 lib/theme/app_theme.dart diff --git a/README.md b/README.md index 5a1cf66..703619d 100644 --- a/README.md +++ b/README.md @@ -51,39 +51,75 @@ graph TD ``` polyglot-pathways/ │ -├── lib/ # Flutter source code -│ ├── main.dart # App entry point -│ ├── models/ # Data models -│ │ ├── language.dart -│ │ ├── lesson.dart -│ │ └── progress.dart -│ ├── screens/ # UI screens -│ │ ├── home_screen.dart -│ │ └── lesson_screen.dart -│ ├── widgets/ # Reusable widgets +├── lib/ # Flutter source code +│ ├── main.dart # App entry point with multi-provider setup +│ │ +│ ├── models/ # Data models +│ │ ├── language.dart # Language enum and properties +│ │ ├── lesson.dart # Lesson data model +│ │ ├── progress.dart # User progress tracking +│ │ ├── achievement.dart # Achievement definitions (NEW) +│ │ └── streak.dart # Streak tracking model (NEW) +│ │ +│ ├── screens/ # UI screens +│ │ ├── onboarding_screen.dart # 4-page onboarding (NEW) +│ │ ├── main_navigation_screen.dart # Bottom nav container (NEW) +│ │ ├── home_screen.dart # Enhanced home with stats (UPDATED) +│ │ ├── lesson_screen.dart # Enhanced audio player (UPDATED) +│ │ ├── profile_screen.dart # User profile & stats (NEW) +│ │ ├── achievements_screen.dart # Achievement gallery (NEW) +│ │ └── settings_screen.dart # App settings (NEW) +│ │ +│ ├── widgets/ # Reusable widgets │ │ ├── language_card.dart │ │ ├── course_structure.dart │ │ └── day_grid.dart -│ ├── services/ # Business logic -│ │ ├── language_service.dart -│ │ └── progress_service.dart -│ └── utils/ # Utilities +│ │ +│ ├── services/ # Business logic +│ │ ├── language_service.dart # UI language management +│ │ ├── progress_service.dart # Lesson progress tracking +│ │ ├── settings_service.dart # App settings (NEW) +│ │ └── gamification_service.dart # Achievements & streaks (NEW) +│ │ +│ ├── theme/ # Theme configuration (NEW) +│ │ └── app_theme.dart # Light/dark themes, colors, styles +│ │ +│ └── utils/ # Utilities │ └── app_localizations.dart │ -├── assets/ # Application assets -│ ├── audio/ # Multilingual audio content -│ │ └── day*_*.mp3 # Audio files for each day and language -│ └── translations/ # Language resource files -│ └── *.json +├── assets/ # Application assets +│ ├── audio/ # Multilingual audio content +│ │ └── day*_*.mp3 # 250 audio files (50 days × 5 languages) +│ ├── translations/ # Language resource files +│ │ ├── en.json # English UI translations +│ │ ├── es.json # Spanish UI translations +│ │ ├── pt.json # Portuguese UI translations +│ │ ├── fr.json # French UI translations +│ │ ├── de.json # German UI translations +│ │ └── day.*.json # Lesson-specific translations +│ └── lessons/ # Lesson text content │ -├── android/ # Android platform code -├── ios/ # iOS platform code -├── web/ # Web platform code +├── android/ # Android platform code +├── ios/ # iOS platform code +├── web/ # Web platform code │ -├── pubspec.yaml # Flutter dependencies +├── pubspec.yaml # Flutter dependencies └── language_phrases_days_*.py # Content generation scripts ``` +### Key Architecture Components + +#### New Files Added (UI/UX Overhaul) +- **7 new screens**: Onboarding, MainNavigation, Profile, Achievements, Settings +- **2 new models**: Achievement, Streak +- **2 new services**: SettingsService, GamificationService +- **1 new theme system**: Comprehensive light/dark theme configuration + +#### Updated Files +- **main.dart**: Multi-provider setup, theme switching, onboarding logic +- **home_screen.dart**: Streak display, daily goals, enhanced stats +- **lesson_screen.dart**: Speed control, loop mode, achievement notifications + ## Key Technologies and Skills Demonstrated ### 1. Flutter Mobile Development @@ -259,78 +295,93 @@ flutter build web --release ## Features -```mermaid -sequenceDiagram - participant U as User - participant P as Page - participant A as Audio - participant S as Storage - participant C as Cache - - rect rgb(240, 240, 255) - Note over U,C: Initial Load Phase - U->>P: Select Day - P->>S: Check Connection - alt Online Mode - S-->>P: Load Progress - else Offline Mode - S->>C: Fetch Cached Data - C-->>P: Return Cached Progress - end - end - - rect rgb(255, 240, 240) - Note over P,A: Resource Loading Phase - par Translations and Audio - P->>P: Load Translations - alt Translation Error - P-->>U: Use Default Language - Note over P,U: Fallback to English - end - P->>A: Load Audio Files - alt Audio Load Failed - A-->>P: Error Loading Audio - P-->>U: Enable Text-Only Mode - end - end - end - - rect rgb(240, 255, 240) - Note over U,P: Interaction Phase - U->>P: Select Language - P->>P: Update Interface - U->>A: Play Audio - alt Playback Error - A-->>U: Show Retry Button - U->>A: Retry Playback - end - end - - rect rgb(255, 255, 240) - Note over P,S: Progress Saving Phase - U->>P: Complete Lesson - P->>S: Save Progress - alt Save Failed - S->>C: Save to Cache - Note over S,C: Sync when online - end - P->>A: Preload Next Lesson - end - - Note over U,C: Progress persists across sessions - Note over U,C: Offline-first architecture -``` +### 🎨 Modern UI/UX (Industry-Standard Design) +- **Bottom Navigation**: 4-tab navigation (Home, Achievements, Profile, Settings) +- **Onboarding Flow**: Beautiful welcome screens with smooth animations +- **Dark Mode**: Full dark theme support with automatic switching +- **Accessibility**: Text scaling (0.85x - 1.3x), high contrast, screen reader support +- **Animations**: Smooth transitions and micro-interactions using flutter_animate +- **Material Design 3**: Modern, polished interface following latest design guidelines + +### 🎮 Gamification System +- **Achievements**: 17 unique achievements across 4 categories + - Lesson milestones (First Lesson, 10/25/50 lessons completed) + - Streak rewards (7, 14, 30, 100 day streaks) + - Multilingual badges (Bronze, Silver, Gold polyglot) + - Special achievements (Early Bird, Night Owl, Speed Learner) +- **Streak Tracking**: Daily learning streak with longest streak record +- **Progress Visualization**: Interactive charts showing progress across all languages +- **Daily Goals**: Customizable daily lesson targets (1-10 lessons/day) +- **Achievement Notifications**: Celebrate unlocks with confetti and snackbars + +### 🎵 Enhanced Audio Player +- **Playback Speed Control**: 0.5x to 2.0x speed (6 preset speeds) +- **Repeat/Loop Mode**: Continuous playback for practice +- **Quick Navigation**: 10-second forward/backward buttons +- **Restart Function**: One-tap restart to beginning +- **Progress Slider**: Precise seeking to any position +- **Real-time Duration**: Current position and total duration display + +### 📊 Advanced Progress Tracking +- **Multi-Language Dashboard**: Track progress across all 5 languages +- **Interactive Charts**: Bar charts showing lessons completed per language +- **Streak Visualization**: Current streak, longest streak, total lessons +- **Daily Goal Progress**: Real-time progress toward daily targets +- **Recent Activity**: Timeline of recent achievements and completions +- **Overall Statistics**: Comprehensive stats on profile screen + +### 🎯 Profile & Settings +- **User Profile**: Personal stats, achievement count, language progress +- **Customizable Settings**: + - Dark/Light theme toggle + - Text size adjustment (4 presets) + - Daily goal configuration + - Sound effects toggle + - Notification preferences + - Interface language selection +- **Data Management**: Reset settings or progress options +- **Tutorial Access**: Re-view onboarding anytime + +### 🌐 Core Features - Cross-platform mobile application (Android, iOS, Web) -- Beautiful Material Design 3 UI - Progress tracking with local persistence - Multilingual content in 5 languages -- High-quality audio playback with controls (play/pause, seek, 10s forward/backward) +- High-quality audio playback with advanced controls - Responsive design optimized for mobile devices - SharedPreferences-based session persistence - Offline-first architecture - Provider-based state management - Custom internationalization system +### 📱 Navigation Structure +``` +App Entry +├── Onboarding (First Launch) +│ └── 4-screen tutorial with animations +└── Main Navigation (Bottom Tabs) + ├── Home Tab + │ ├── Streak display + │ ├── Daily goal tracker + │ ├── Language selection cards + │ └── Day grid for selected language + ├── Achievements Tab + │ ├── Progress header (X/17 unlocked) + │ ├── Category tabs (All, Lessons, Streaks, Languages, Special) + │ └── Achievement cards with unlock status + ├── Profile Tab + │ ├── Profile header with streak + │ ├── Statistics overview (4 stat cards) + │ ├── Progress by language (bar chart) + │ └── Recent activity timeline + └── Settings Tab + ├── Appearance (dark mode, text size) + ├── Learning (daily goal, hints) + ├── Audio & Sound (effects toggle) + ├── Notifications (reminders) + ├── Interface Language + └── Data Management +``` + ## Global Impact - Communicate with ~2 billion people - Access to international job markets diff --git a/lib/main.dart b/lib/main.dart index 8c132e2..f524c71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,10 +3,14 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'screens/home_screen.dart'; +import 'screens/onboarding_screen.dart'; +import 'screens/main_navigation_screen.dart'; import 'services/language_service.dart'; import 'services/progress_service.dart'; +import 'services/settings_service.dart'; +import 'services/gamification_service.dart'; import 'utils/app_localizations.dart'; +import 'theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -23,39 +27,40 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider( + create: (_) => SettingsService(), + ), ChangeNotifierProvider( create: (_) => LanguageService(prefs), ), ChangeNotifierProvider( create: (_) => ProgressService(prefs), ), + ChangeNotifierProvider( + create: (_) => GamificationService(), + ), ], - child: Consumer( - builder: (context, languageService, _) { + child: Consumer2( + builder: (context, languageService, settingsService, _) { return MaterialApp( title: 'Polyglot Pathways', debugShowCheckedModeBanner: false, - theme: ThemeData( - primarySwatch: Colors.blue, - fontFamily: 'Poppins', - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF4A90E2), - primary: const Color(0xFF4A90E2), - secondary: const Color(0xFF50C878), - ), - useMaterial3: true, - appBarTheme: const AppBarTheme( - backgroundColor: Color(0xFF4A90E2), - foregroundColor: Colors.white, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + + // Theme configuration with dark mode support + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: settingsService.themeMode, + + // Text scaling for accessibility + builder: (context, child) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(settingsService.textScale), ), - ), - ), + child: child!, + ); + }, + locale: languageService.currentLocale, supportedLocales: const [ Locale('en'), @@ -70,7 +75,11 @@ class MyApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - home: const HomeScreen(), + + // Show onboarding on first launch, otherwise show main navigation + home: settingsService.hasCompletedOnboarding + ? const MainNavigationScreen() + : const OnboardingScreen(), ); }, ), diff --git a/lib/models/achievement.dart b/lib/models/achievement.dart new file mode 100644 index 0000000..5a0946d --- /dev/null +++ b/lib/models/achievement.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; + +enum AchievementType { + firstLesson, + firstWeek, + firstMonth, + streak7, + streak14, + streak30, + streak100, + complete10Lessons, + complete25Lessons, + complete50Lessons, + multilingualBronze, // 2 languages + multilingualSilver, // 3 languages + multilingualGold, // 5 languages + earlyBird, // Complete lesson before 9 AM + nightOwl, // Complete lesson after 9 PM + speedLearner, // Complete 3 lessons in one day + perfectWeek, // 7 day streak +} + +class Achievement { + final AchievementType type; + final String title; + final String description; + final IconData icon; + final Color color; + final int requiredValue; + final bool isUnlocked; + final DateTime? unlockedAt; + + Achievement({ + required this.type, + required this.title, + required this.description, + required this.icon, + required this.color, + required this.requiredValue, + this.isUnlocked = false, + this.unlockedAt, + }); + + Achievement copyWith({ + AchievementType? type, + String? title, + String? description, + IconData? icon, + Color? color, + int? requiredValue, + bool? isUnlocked, + DateTime? unlockedAt, + }) { + return Achievement( + type: type ?? this.type, + title: title ?? this.title, + description: description ?? this.description, + icon: icon ?? this.icon, + color: color ?? this.color, + requiredValue: requiredValue ?? this.requiredValue, + isUnlocked: isUnlocked ?? this.isUnlocked, + unlockedAt: unlockedAt ?? this.unlockedAt, + ); + } + + Map toJson() { + return { + 'type': type.toString(), + 'isUnlocked': isUnlocked, + 'unlockedAt': unlockedAt?.toIso8601String(), + }; + } + + factory Achievement.fromJson(Map json, Achievement template) { + return template.copyWith( + isUnlocked: json['isUnlocked'] as bool? ?? false, + unlockedAt: json['unlockedAt'] != null + ? DateTime.parse(json['unlockedAt'] as String) + : null, + ); + } + + static List getAllAchievements() { + return [ + Achievement( + type: AchievementType.firstLesson, + title: 'First Steps', + description: 'Complete your first lesson', + icon: Icons.rocket_launch, + color: const Color(0xFF4A90E2), + requiredValue: 1, + ), + Achievement( + type: AchievementType.firstWeek, + title: 'Week Warrior', + description: 'Complete 7 lessons', + icon: Icons.calendar_view_week, + color: const Color(0xFF50C878), + requiredValue: 7, + ), + Achievement( + type: AchievementType.firstMonth, + title: 'Monthly Master', + description: 'Complete 30 lessons', + icon: Icons.calendar_month, + color: const Color(0xFF9B59B6), + requiredValue: 30, + ), + Achievement( + type: AchievementType.streak7, + title: '7 Day Streak', + description: 'Learn for 7 days in a row', + icon: Icons.local_fire_department, + color: const Color(0xFFFF6B6B), + requiredValue: 7, + ), + Achievement( + type: AchievementType.streak14, + title: '14 Day Streak', + description: 'Learn for 14 days in a row', + icon: Icons.whatshot, + color: const Color(0xFFFF4757), + requiredValue: 14, + ), + Achievement( + type: AchievementType.streak30, + title: '30 Day Streak', + description: 'Learn for 30 days in a row', + icon: Icons.fireplace, + color: const Color(0xFFE74C3C), + requiredValue: 30, + ), + Achievement( + type: AchievementType.streak100, + title: 'Century Streak', + description: 'Learn for 100 days in a row', + icon: Icons.military_tech, + color: const Color(0xFFFFD700), + requiredValue: 100, + ), + Achievement( + type: AchievementType.complete10Lessons, + title: 'Getting Started', + description: 'Complete 10 lessons', + icon: Icons.looks_one, + color: const Color(0xFF3498DB), + requiredValue: 10, + ), + Achievement( + type: AchievementType.complete25Lessons, + title: 'Quarter Century', + description: 'Complete 25 lessons', + icon: Icons.looks_two, + color: const Color(0xFF2ECC71), + requiredValue: 25, + ), + Achievement( + type: AchievementType.complete50Lessons, + title: 'Half Century', + description: 'Complete 50 lessons - Full course!', + icon: Icons.emoji_events, + color: const Color(0xFFFFD700), + requiredValue: 50, + ), + Achievement( + type: AchievementType.multilingualBronze, + title: 'Bilingual Bronze', + description: 'Start learning 2 languages', + icon: Icons.translate, + color: const Color(0xFFCD7F32), + requiredValue: 2, + ), + Achievement( + type: AchievementType.multilingualSilver, + title: 'Trilingual Silver', + description: 'Start learning 3 languages', + icon: Icons.language, + color: const Color(0xFFC0C0C0), + requiredValue: 3, + ), + Achievement( + type: AchievementType.multilingualGold, + title: 'Polyglot Gold', + description: 'Start learning all 5 languages', + icon: Icons.public, + color: const Color(0xFFFFD700), + requiredValue: 5, + ), + Achievement( + type: AchievementType.earlyBird, + title: 'Early Bird', + description: 'Complete a lesson before 9 AM', + icon: Icons.wb_sunny, + color: const Color(0xFFFFA500), + requiredValue: 1, + ), + Achievement( + type: AchievementType.nightOwl, + title: 'Night Owl', + description: 'Complete a lesson after 9 PM', + icon: Icons.nightlight_round, + color: const Color(0xFF9B59B6), + requiredValue: 1, + ), + Achievement( + type: AchievementType.speedLearner, + title: 'Speed Learner', + description: 'Complete 3 lessons in one day', + icon: Icons.speed, + color: const Color(0xFFE74C3C), + requiredValue: 3, + ), + Achievement( + type: AchievementType.perfectWeek, + title: 'Perfect Week', + description: 'Maintain a 7 day learning streak', + icon: Icons.star, + color: const Color(0xFFFFD700), + requiredValue: 7, + ), + ]; + } +} diff --git a/lib/models/streak.dart b/lib/models/streak.dart new file mode 100644 index 0000000..61e5ce2 --- /dev/null +++ b/lib/models/streak.dart @@ -0,0 +1,87 @@ +class Streak { + final int currentStreak; + final int longestStreak; + final DateTime? lastCompletionDate; + final int totalLessonsCompleted; + final Map lessonsPerDay; // date -> count + + Streak({ + this.currentStreak = 0, + this.longestStreak = 0, + this.lastCompletionDate, + this.totalLessonsCompleted = 0, + this.lessonsPerDay = const {}, + }); + + bool get isActiveToday { + if (lastCompletionDate == null) return false; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final lastDate = DateTime( + lastCompletionDate!.year, + lastCompletionDate!.month, + lastCompletionDate!.day, + ); + return today == lastDate; + } + + bool get isStreakBroken { + if (lastCompletionDate == null) return false; + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final lastDate = DateTime( + lastCompletionDate!.year, + lastCompletionDate!.month, + lastCompletionDate!.day, + ); + final difference = today.difference(lastDate).inDays; + return difference > 1; + } + + Streak copyWith({ + int? currentStreak, + int? longestStreak, + DateTime? lastCompletionDate, + int? totalLessonsCompleted, + Map? lessonsPerDay, + }) { + return Streak( + currentStreak: currentStreak ?? this.currentStreak, + longestStreak: longestStreak ?? this.longestStreak, + lastCompletionDate: lastCompletionDate ?? this.lastCompletionDate, + totalLessonsCompleted: totalLessonsCompleted ?? this.totalLessonsCompleted, + lessonsPerDay: lessonsPerDay ?? this.lessonsPerDay, + ); + } + + Map toJson() { + return { + 'currentStreak': currentStreak, + 'longestStreak': longestStreak, + 'lastCompletionDate': lastCompletionDate?.toIso8601String(), + 'totalLessonsCompleted': totalLessonsCompleted, + 'lessonsPerDay': lessonsPerDay, + }; + } + + factory Streak.fromJson(Map json) { + return Streak( + currentStreak: json['currentStreak'] as int? ?? 0, + longestStreak: json['longestStreak'] as int? ?? 0, + lastCompletionDate: json['lastCompletionDate'] != null + ? DateTime.parse(json['lastCompletionDate'] as String) + : null, + totalLessonsCompleted: json['totalLessonsCompleted'] as int? ?? 0, + lessonsPerDay: Map.from(json['lessonsPerDay'] as Map? ?? {}), + ); + } + + int getLessonsCompletedOnDate(DateTime date) { + final dateKey = _formatDate(date); + return lessonsPerDay[dateKey] ?? 0; + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/screens/achievements_screen.dart b/lib/screens/achievements_screen.dart new file mode 100644 index 0000000..6b23a63 --- /dev/null +++ b/lib/screens/achievements_screen.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:confetti/confetti.dart'; +import '../services/gamification_service.dart'; +import '../theme/app_theme.dart'; + +class AchievementsScreen extends StatefulWidget { + const AchievementsScreen({super.key}); + + @override + State createState() => _AchievementsScreenState(); +} + +class _AchievementsScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late ConfettiController _confettiController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 5, vsync: this); + _confettiController = ConfettiController(duration: const Duration(seconds: 3)); + + // Show confetti if there are newly unlocked achievements + WidgetsBinding.instance.addPostFrameCallback((_) { + final gamificationService = + Provider.of(context, listen: false); + if (gamificationService.recentlyUnlocked.isNotEmpty) { + _confettiController.play(); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + _confettiController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final gamificationService = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Achievements'), + centerTitle: true, + bottom: TabBar( + controller: _tabController, + isScrollable: true, + indicatorColor: Colors.white, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + tabs: const [ + Tab(text: 'All'), + Tab(text: 'Lessons'), + Tab(text: 'Streaks'), + Tab(text: 'Languages'), + Tab(text: 'Special'), + ], + ), + ), + body: Stack( + children: [ + Column( + children: [ + // Progress Header + _buildProgressHeader(gamificationService) + .animate() + .fadeIn() + .slideY(begin: -0.2, duration: 500.ms), + + // Tabs Content + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildAchievementsList(gamificationService.achievements), + _buildAchievementsList( + gamificationService.getLessonAchievements()), + _buildAchievementsList( + gamificationService.getStreakAchievements()), + _buildAchievementsList( + gamificationService.getMultilingualAchievements()), + _buildAchievementsList( + gamificationService.getSpecialAchievements()), + ], + ), + ), + ], + ), + + // Confetti overlay + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + colors: const [ + AppTheme.primaryBlue, + AppTheme.secondaryGreen, + AppTheme.accentOrange, + AppTheme.accentPurple, + AppTheme.goldBadge, + ], + ), + ), + ], + ), + ); + } + + Widget _buildProgressHeader(GamificationService gamificationService) { + final unlocked = gamificationService.achievementCount; + final total = gamificationService.totalAchievements; + final progress = unlocked / total; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppTheme.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.emoji_events, + color: AppTheme.goldBadge, + size: 32, + ), + const SizedBox(width: 12), + Text( + '$unlocked / $total', + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Achievements Unlocked', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + value: progress, + minHeight: 10, + backgroundColor: Colors.white.withOpacity(0.3), + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + const SizedBox(height: 8), + Text( + '${(progress * 100).toStringAsFixed(0)}% Complete', + style: const TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + Widget _buildAchievementsList(List achievements) { + if (achievements.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.emoji_events_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No achievements in this category', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: achievements.length, + itemBuilder: (context, index) { + final achievement = achievements[index]; + return _buildAchievementCard(achievement, index) + .animate() + .fadeIn(delay: (index * 50).ms) + .slideX(begin: -0.1); + }, + ); + } + + Widget _buildAchievementCard(achievement, int index) { + final isUnlocked = achievement.isUnlocked; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: isUnlocked ? 4 : 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: isUnlocked + ? LinearGradient( + colors: [ + achievement.color.withOpacity(0.1), + achievement.color.withOpacity(0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + ), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isUnlocked + ? achievement.color.withOpacity(0.2) + : Colors.grey[300], + shape: BoxShape.circle, + boxShadow: isUnlocked + ? [ + BoxShadow( + color: achievement.color.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Icon( + achievement.icon, + color: isUnlocked ? achievement.color : Colors.grey[500], + size: 32, + ), + ), + title: Text( + achievement.title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: isUnlocked ? null : Colors.grey[600], + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + achievement.description, + style: TextStyle( + fontSize: 14, + color: isUnlocked ? Colors.grey[700] : Colors.grey[500], + ), + ), + if (isUnlocked && achievement.unlockedAt != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + size: 14, + color: achievement.color, + ), + const SizedBox(width: 4), + Text( + 'Unlocked ${_formatDate(achievement.unlockedAt!)}', + style: TextStyle( + fontSize: 12, + color: achievement.color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ], + ), + trailing: isUnlocked + ? Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: achievement.color, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Colors.white, + size: 20, + ), + ) + : Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey[300], + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock, + color: Colors.grey[500], + size: 20, + ), + ), + ), + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays == 0) { + return 'today'; + } else if (difference.inDays == 1) { + return 'yesterday'; + } else if (difference.inDays < 7) { + return '${difference.inDays} days ago'; + } else { + return 'on ${date.month}/${date.day}/${date.year}'; + } + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5af6efa..ae64200 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,12 +1,17 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:fl_chart/fl_chart.dart'; import '../models/language.dart'; import '../services/language_service.dart'; import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; +import '../services/settings_service.dart'; import '../utils/app_localizations.dart'; import '../widgets/language_card.dart'; import '../widgets/course_structure.dart'; import '../widgets/day_grid.dart'; +import '../theme/app_theme.dart'; import 'lesson_screen.dart'; class HomeScreen extends StatefulWidget { @@ -23,6 +28,8 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { final languageService = Provider.of(context); final progressService = Provider.of(context); + final gamificationService = Provider.of(context); + final settingsService = Provider.of(context); final loc = AppLocalizations.of(context); return Scaffold( @@ -77,7 +84,7 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Hero Section + // Hero Section with Streak Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( @@ -92,42 +99,92 @@ class _HomeScreenState extends State { ), child: Column( children: [ - Text( - loc.heroTitle, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - loc.heroSubtitle, - style: const TextStyle( - fontSize: 16, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.local_fire_department, + color: gamificationService.streak.currentStreak > 0 + ? Colors.orange + : Colors.white70, + size: 32, + ), + const SizedBox(width: 8), + Text( + '${gamificationService.streak.currentStreak} Day Streak', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ).animate().fadeIn().slideY(begin: -0.3, duration: 600.ms), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatCard( + icon: Icons.emoji_events, + value: gamificationService.achievementCount.toString(), + label: 'Achievements', + ), + _buildStatCard( + icon: Icons.check_circle, + value: gamificationService.streak.totalLessonsCompleted + .toString(), + label: 'Lessons', + ), + _buildStatCard( + icon: Icons.military_tech, + value: gamificationService.streak.longestStreak + .toString(), + label: 'Best Streak', + ), + ], + ).animate().fadeIn(delay: 300.ms).slideY(begin: -0.2), ], ), ), + // Daily Goal Progress + if (settingsService.dailyGoal > 0) + _buildDailyGoalCard(gamificationService, settingsService) + .animate() + .fadeIn(delay: 400.ms) + .slideY(begin: 0.1), + // Language Selection Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Select Learning Language', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Select Learning Language', + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (_selectedLanguage != null) + IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + _selectedLanguage = null; + }); + }, ), + ], ), const SizedBox(height: 16), - ...Language.values.map((language) { + ...Language.values.asMap().entries.map((entry) { + final index = entry.key; + final language = entry.value; final completedCount = progressService.getCompletedCount(language); final currentDay = progressService.getCurrentDay(language); @@ -144,7 +201,10 @@ class _HomeScreenState extends State { _selectedLanguage = language; }); }, - ); + ) + .animate() + .fadeIn(delay: (500 + index * 100).ms) + .slideX(begin: -0.1); }), ], ), @@ -155,7 +215,7 @@ class _HomeScreenState extends State { const Padding( padding: EdgeInsets.all(16), child: CourseStructure(), - ), + ).animate().fadeIn(delay: 800.ms), // Day Grid (shown when language is selected) if (_selectedLanguage != null) @@ -175,10 +235,134 @@ class _HomeScreenState extends State { ); }, ), - ), + ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.1), ], ), ), ); } + + Widget _buildStatCard({ + required IconData icon, + required String value, + required String label, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(icon, color: Colors.white, size: 24), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + ), + ), + ], + ), + ); + } + + Widget _buildDailyGoalCard( + GamificationService gamificationService, + SettingsService settingsService, + ) { + final now = DateTime.now(); + final lessonsToday = gamificationService.streak.getLessonsCompletedOnDate(now); + final dailyGoal = settingsService.dailyGoal; + final progress = (lessonsToday / dailyGoal).clamp(0.0, 1.0); + + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daily Goal', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: progress >= 1.0 + ? AppTheme.secondaryGreen + : AppTheme.primaryBlue, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$lessonsToday / $dailyGoal', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + progress >= 1.0 + ? AppTheme.secondaryGreen + : AppTheme.primaryBlue, + ), + ), + ), + if (progress >= 1.0) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Row( + children: [ + Icon( + Icons.celebration, + color: AppTheme.secondaryGreen, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Daily goal completed! Great job!', + style: TextStyle( + color: AppTheme.secondaryGreen, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/screens/lesson_screen.dart b/lib/screens/lesson_screen.dart index 59da2ce..08c5d0c 100644 --- a/lib/screens/lesson_screen.dart +++ b/lib/screens/lesson_screen.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import '../models/language.dart'; import '../models/lesson.dart'; import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; import '../utils/app_localizations.dart'; +import '../theme/app_theme.dart'; class LessonScreen extends StatefulWidget { final Language language; @@ -28,6 +31,8 @@ class _LessonScreenState extends State { Duration _duration = Duration.zero; Duration _position = Duration.zero; String _lessonText = ''; + double _playbackSpeed = 1.0; + bool _isLooping = false; @override void initState() { @@ -65,10 +70,28 @@ class _LessonScreenState extends State { }); _audioPlayer.onPlayerComplete.listen((_) { - setState(() { - _isPlaying = false; - _position = Duration.zero; - }); + if (_isLooping) { + _audioPlayer.seek(Duration.zero); + _audioPlayer.resume(); + } else { + setState(() { + _isPlaying = false; + _position = Duration.zero; + }); + } + }); + } + + Future _setPlaybackSpeed(double speed) async { + await _audioPlayer.setPlaybackRate(speed); + setState(() { + _playbackSpeed = speed; + }); + } + + void _toggleLoop() { + setState(() { + _isLooping = !_isLooping; }); } @@ -110,6 +133,7 @@ class _LessonScreenState extends State { @override Widget build(BuildContext context) { final progressService = Provider.of(context); + final gamificationService = Provider.of(context); final loc = AppLocalizations.of(context); final isCompleted = progressService.isLessonCompleted(widget.language, _currentDay); @@ -190,6 +214,7 @@ class _LessonScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ + // Main playback controls Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -232,6 +257,8 @@ class _LessonScreenState extends State { ], ), const SizedBox(height: 16), + + // Progress slider Slider( value: _position.inSeconds.toDouble(), max: _duration.inSeconds.toDouble() > 0 @@ -248,6 +275,59 @@ class _LessonScreenState extends State { Text(_formatDuration(_duration)), ], ), + + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + + // Advanced controls + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Speed control + PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.speed, size: 20), + const SizedBox(width: 4), + Text( + '${_playbackSpeed}x', + style: const TextStyle(fontSize: 12), + ), + ], + ), + onSelected: _setPlaybackSpeed, + itemBuilder: (context) => [ + const PopupMenuItem(value: 0.5, child: Text('0.5x - Slow')), + const PopupMenuItem(value: 0.75, child: Text('0.75x')), + const PopupMenuItem(value: 1.0, child: Text('1.0x - Normal')), + const PopupMenuItem(value: 1.25, child: Text('1.25x')), + const PopupMenuItem(value: 1.5, child: Text('1.5x - Fast')), + const PopupMenuItem(value: 2.0, child: Text('2.0x - Very Fast')), + ], + ), + + // Loop toggle + IconButton( + icon: Icon( + _isLooping ? Icons.repeat_on : Icons.repeat, + color: _isLooping ? AppTheme.primaryBlue : null, + ), + onPressed: _toggleLoop, + tooltip: 'Repeat', + ), + + // Restart button + IconButton( + icon: const Icon(Icons.restart_alt), + onPressed: () { + _audioPlayer.seek(Duration.zero); + }, + tooltip: 'Restart', + ), + ], + ), ], ), ), @@ -308,7 +388,11 @@ class _LessonScreenState extends State { widget.language, _currentDay, ); + // Record in gamification service + await gamificationService.recordLessonCompletion(widget.language); + if (context.mounted) { + // Show completion message ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(loc.dayMarkedComplete), @@ -316,6 +400,46 @@ class _LessonScreenState extends State { backgroundColor: Theme.of(context).colorScheme.secondary, ), ); + + // Show achievement notifications if any + if (gamificationService.recentlyUnlocked.isNotEmpty) { + for (var achievement in gamificationService.recentlyUnlocked) { + await Future.delayed(const Duration(milliseconds: 500)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(achievement.icon, color: Colors.white), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Achievement Unlocked!', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + achievement.title, + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ), + duration: const Duration(seconds: 3), + backgroundColor: achievement.color, + ), + ); + } + } + } } } }, @@ -328,7 +452,7 @@ class _LessonScreenState extends State { : Theme.of(context).colorScheme.primary, foregroundColor: Colors.white, ), - ), + ).animate().scale(delay: 100.ms), const SizedBox(height: 24), diff --git a/lib/screens/main_navigation_screen.dart b/lib/screens/main_navigation_screen.dart new file mode 100644 index 0000000..9f31925 --- /dev/null +++ b/lib/screens/main_navigation_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:badges/badges.dart' as badges; +import 'package:provider/provider.dart'; +import '../services/gamification_service.dart'; +import 'home_screen.dart'; +import 'profile_screen.dart'; +import 'achievements_screen.dart'; +import 'settings_screen.dart'; + +class MainNavigationScreen extends StatefulWidget { + const MainNavigationScreen({super.key}); + + @override + State createState() => _MainNavigationScreenState(); +} + +class _MainNavigationScreenState extends State { + int _currentIndex = 0; + + final List _screens = [ + const HomeScreen(), + const AchievementsScreen(), + const ProfileScreen(), + const SettingsScreen(), + ]; + + @override + Widget build(BuildContext context) { + final gamificationService = Provider.of(context); + final hasNewAchievements = gamificationService.recentlyUnlocked.isNotEmpty; + + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: _screens, + ), + bottomNavigationBar: Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (index) { + setState(() { + _currentIndex = index; + }); + + // Clear new achievements notification when viewing achievements screen + if (index == 1 && hasNewAchievements) { + Future.delayed(const Duration(seconds: 1), () { + gamificationService.clearRecentlyUnlocked(); + }); + } + }, + type: BottomNavigationBarType.fixed, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Colors.grey, + selectedFontSize: 12, + unselectedFontSize: 12, + items: [ + const BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + activeIcon: Icon(Icons.home), + label: 'Home', + ), + BottomNavigationBarItem( + icon: hasNewAchievements + ? badges.Badge( + badgeContent: Text( + gamificationService.recentlyUnlocked.length.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + badgeStyle: badges.BadgeStyle( + badgeColor: Colors.red, + padding: const EdgeInsets.all(4), + ), + child: const Icon(Icons.emoji_events_outlined), + ) + : const Icon(Icons.emoji_events_outlined), + activeIcon: hasNewAchievements + ? badges.Badge( + badgeContent: Text( + gamificationService.recentlyUnlocked.length.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + badgeStyle: badges.BadgeStyle( + badgeColor: Colors.red, + padding: const EdgeInsets.all(4), + ), + child: const Icon(Icons.emoji_events), + ) + : const Icon(Icons.emoji_events), + label: 'Achievements', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.person_outline), + activeIcon: Icon(Icons.person), + label: 'Profile', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + activeIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..c54ecb4 --- /dev/null +++ b/lib/screens/onboarding_screen.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import '../services/settings_service.dart'; +import '../theme/app_theme.dart'; +import 'main_navigation_screen.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key}); + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List _pages = [ + OnboardingPage( + title: 'Welcome to Polyglot Pathways', + description: + 'Master 5 languages with our structured 50-day curriculum designed for effective learning', + icon: Icons.public, + gradient: AppTheme.primaryGradient, + ), + OnboardingPage( + title: 'Track Your Progress', + description: + 'Monitor your learning journey with detailed progress tracking, streaks, and achievements', + icon: Icons.trending_up, + gradient: [AppTheme.secondaryGreen, AppTheme.accentOrange], + ), + OnboardingPage( + title: 'Learn at Your Pace', + description: + 'Audio lessons, interactive content, and flexible daily goals adapted to your schedule', + icon: Icons.headphones, + gradient: [AppTheme.accentOrange, AppTheme.accentPurple], + ), + OnboardingPage( + title: 'Earn Achievements', + description: + 'Unlock badges, maintain streaks, and celebrate milestones as you progress', + icon: Icons.emoji_events, + gradient: [AppTheme.accentPurple, AppTheme.primaryBlue], + ), + ]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onPageChanged(int page) { + setState(() { + _currentPage = page; + }); + } + + Future _completeOnboarding() async { + final settingsService = Provider.of(context, listen: false); + await settingsService.completeOnboarding(); + + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const MainNavigationScreen(), + ), + ); + } + } + + void _skipOnboarding() { + _completeOnboarding(); + } + + void _nextPage() { + if (_currentPage < _pages.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _completeOnboarding(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // Skip button + Padding( + padding: const EdgeInsets.all(16.0), + child: Align( + alignment: Alignment.topRight, + child: TextButton( + onPressed: _skipOnboarding, + child: Text( + 'Skip', + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + + // Page view + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: _onPageChanged, + itemCount: _pages.length, + itemBuilder: (context, index) { + return _buildOnboardingPage(_pages[index]); + }, + ), + ), + + // Page indicator + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: SmoothPageIndicator( + controller: _pageController, + count: _pages.length, + effect: ExpandingDotsEffect( + activeDotColor: AppTheme.primaryBlue, + dotColor: Colors.grey.shade300, + dotHeight: 8, + dotWidth: 8, + expansionFactor: 3, + ), + ), + ), + + // Next/Get Started button + Padding( + padding: const EdgeInsets.all(24.0), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: _nextPage, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + _currentPage == _pages.length - 1 ? 'Get Started' : 'Next', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildOnboardingPage(OnboardingPage page) { + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon with gradient background + Container( + width: 160, + height: 160, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: page.gradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: page.gradient[0].withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Icon( + page.icon, + size: 80, + color: Colors.white, + ), + ), + + const SizedBox(height: 48), + + // Title + Text( + page.title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 24), + + // Description + Text( + page.description, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +class OnboardingPage { + final String title; + final String description; + final IconData icon; + final List gradient; + + OnboardingPage({ + required this.title, + required this.description, + required this.icon, + required this.gradient, + }); +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart new file mode 100644 index 0000000..970149a --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../models/language.dart'; +import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; +import '../theme/app_theme.dart'; +import 'package:intl/intl.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + final progressService = Provider.of(context); + final gamificationService = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Your Profile'), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Column( + children: [ + // Profile Header + _buildProfileHeader(gamificationService) + .animate() + .fadeIn() + .slideY(begin: -0.2, duration: 500.ms), + + const SizedBox(height: 16), + + // Stats Overview + _buildStatsOverview(progressService, gamificationService) + .animate() + .fadeIn(delay: 200.ms) + .slideY(begin: 0.1), + + const SizedBox(height: 16), + + // Language Progress Chart + _buildLanguageProgressChart(progressService) + .animate() + .fadeIn(delay: 400.ms) + .scale(begin: const Offset(0.9, 0.9)), + + const SizedBox(height: 16), + + // Recent Activity + _buildRecentActivity(gamificationService) + .animate() + .fadeIn(delay: 600.ms) + .slideY(begin: 0.1), + + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _buildProfileHeader(GamificationService gamificationService) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppTheme.primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + Icons.person, + size: 60, + color: AppTheme.primaryBlue, + ), + ), + const SizedBox(height: 16), + const Text( + 'Language Learner', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + 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: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.local_fire_department, + color: Colors.orange, + size: 20, + ), + const SizedBox(width: 8), + Text( + '${gamificationService.streak.currentStreak} Day Streak', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatsOverview( + ProgressService progressService, + GamificationService gamificationService, + ) { + final totalCompleted = gamificationService.streak.totalLessonsCompleted; + final achievementCount = gamificationService.achievementCount; + final longestStreak = gamificationService.streak.longestStreak; + + // Calculate total progress across all languages + int totalPossible = Language.values.length * 50; + double overallProgress = totalCompleted / totalPossible; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Your Stats', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildStatCard( + icon: Icons.check_circle, + value: totalCompleted.toString(), + label: 'Lessons', + color: AppTheme.secondaryGreen, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.emoji_events, + value: achievementCount.toString(), + label: 'Achievements', + color: AppTheme.accentOrange, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildStatCard( + icon: Icons.military_tech, + value: longestStreak.toString(), + label: 'Best Streak', + color: AppTheme.accentPurple, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.trending_up, + value: '${(overallProgress * 100).toStringAsFixed(0)}%', + label: 'Overall', + color: AppTheme.primaryBlue, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatCard({ + required IconData icon, + required String value, + required String label, + required Color color, + }) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Icon(icon, color: color, size: 32), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ); + } + + Widget _buildLanguageProgressChart(ProgressService progressService) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Progress by Language', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 200, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: 50, + barTouchData: BarTouchData(enabled: true), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final languages = Language.values; + if (value.toInt() >= 0 && + value.toInt() < languages.length) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + languages[value.toInt()].flag, + style: const TextStyle(fontSize: 20), + ), + ); + } + return const Text(''); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, meta) { + return Text( + value.toInt().toString(), + style: const TextStyle(fontSize: 10), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 10, + ), + borderData: FlBorderData(show: false), + barGroups: Language.values.asMap().entries.map((entry) { + final index = entry.key; + final language = entry.value; + final completed = progressService.getCompletedCount(language); + return BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: completed.toDouble(), + color: _getLanguageColor(index), + width: 24, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 16), + ...Language.values.map((language) { + final completed = progressService.getCompletedCount(language); + final progress = progressService.getProgress(language); + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Text( + language.flag, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + language.name, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Text( + '$completed/50', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ), + ); + } + + Widget _buildRecentActivity(GamificationService gamificationService) { + final recentAchievements = gamificationService.unlockedAchievements + .where((a) => a.unlockedAt != null) + .toList() + ..sort((a, b) => b.unlockedAt!.compareTo(a.unlockedAt!)); + + final displayAchievements = recentAchievements.take(5).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Recent Activity', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + if (displayAchievements.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + children: [ + Icon( + Icons.emoji_events_outlined, + size: 48, + color: Colors.grey[400], + ), + const SizedBox(height: 8), + Text( + 'Complete lessons to unlock achievements!', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ) + else + ...displayAchievements.map((achievement) { + final timeAgo = _getTimeAgo(achievement.unlockedAt!); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: achievement.color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + achievement.icon, + color: achievement.color, + ), + ), + title: Text( + achievement.title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text(timeAgo), + ); + }), + ], + ), + ), + ), + ); + } + + Color _getLanguageColor(int index) { + final colors = [ + AppTheme.primaryBlue, + AppTheme.secondaryGreen, + AppTheme.accentOrange, + AppTheme.accentPurple, + const Color(0xFFE74C3C), + ]; + return colors[index % colors.length]; + } + + String _getTimeAgo(DateTime dateTime) { + final now = DateTime.now(); + final difference = now.difference(dateTime); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } else if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return DateFormat('MMM d').format(dateTime); + } + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..0bf78b0 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,407 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../models/language.dart'; +import '../services/language_service.dart'; +import '../services/settings_service.dart'; +import '../services/progress_service.dart'; +import '../services/gamification_service.dart'; +import '../theme/app_theme.dart'; +import 'onboarding_screen.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final settingsService = Provider.of(context); + final languageService = Provider.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Column( + children: [ + // Appearance Section + _buildSection( + context, + title: 'Appearance', + icon: Icons.palette, + children: [ + _buildSwitchTile( + context, + title: 'Dark Mode', + subtitle: 'Use dark theme', + icon: Icons.dark_mode, + value: settingsService.isDarkMode, + onChanged: (value) => settingsService.toggleDarkMode(), + ), + _buildSliderTile( + context, + title: 'Text Size', + subtitle: settingsService.getTextScaleLabel(), + icon: Icons.format_size, + value: settingsService.textScale, + min: 0.85, + max: 1.3, + divisions: 9, + onChanged: (value) => settingsService.setTextScale(value), + ), + ], + ).animate().fadeIn(delay: 100.ms).slideX(begin: -0.1), + + // Learning Section + _buildSection( + context, + title: 'Learning', + icon: Icons.school, + children: [ + _buildSliderTile( + context, + title: 'Daily Goal', + subtitle: '${settingsService.dailyGoal} ${settingsService.dailyGoal == 1 ? "lesson" : "lessons"} per day', + icon: Icons.track_changes, + value: settingsService.dailyGoal.toDouble(), + min: 1, + max: 10, + divisions: 9, + onChanged: (value) => + settingsService.setDailyGoal(value.toInt()), + ), + _buildSwitchTile( + context, + title: 'Show Hints', + subtitle: 'Display helpful tips and hints', + icon: Icons.lightbulb_outline, + value: settingsService.showHints, + onChanged: (value) => settingsService.toggleHints(), + ), + ], + ).animate().fadeIn(delay: 200.ms).slideX(begin: -0.1), + + // Audio & Sound Section + _buildSection( + context, + title: 'Audio & Sound', + icon: Icons.volume_up, + children: [ + _buildSwitchTile( + context, + title: 'Sound Effects', + subtitle: 'Play sounds for actions', + icon: Icons.music_note, + value: settingsService.soundEnabled, + onChanged: (value) => settingsService.toggleSound(), + ), + ], + ).animate().fadeIn(delay: 300.ms).slideX(begin: -0.1), + + // Notifications Section + _buildSection( + context, + title: 'Notifications', + icon: Icons.notifications, + children: [ + _buildSwitchTile( + context, + title: 'Daily Reminders', + subtitle: 'Get reminded to practice', + icon: Icons.alarm, + value: settingsService.notificationsEnabled, + onChanged: (value) => settingsService.toggleNotifications(), + ), + ], + ).animate().fadeIn(delay: 400.ms).slideX(begin: -0.1), + + // Language Section + _buildSection( + context, + title: 'Interface Language', + icon: Icons.language, + children: [ + _buildLanguageSelector(context, languageService), + ], + ).animate().fadeIn(delay: 500.ms).slideX(begin: -0.1), + + // About Section + _buildSection( + context, + title: 'About', + icon: Icons.info, + children: [ + _buildActionTile( + context, + title: 'Tutorial', + subtitle: 'View onboarding again', + icon: Icons.help_outline, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const OnboardingScreen(), + ), + ); + }, + ), + _buildActionTile( + context, + title: 'App Version', + subtitle: '1.0.0', + icon: Icons.info_outline, + onTap: null, + ), + ], + ).animate().fadeIn(delay: 600.ms).slideX(begin: -0.1), + + // Danger Zone + _buildSection( + context, + title: 'Data Management', + icon: Icons.warning, + children: [ + _buildActionTile( + context, + title: 'Reset Settings', + subtitle: 'Restore default settings', + icon: Icons.restart_alt, + onTap: () => _showResetSettingsDialog(context), + ), + _buildActionTile( + context, + title: 'Reset Progress', + subtitle: 'Clear all learning progress', + icon: Icons.delete_forever, + onTap: () => _showResetProgressDialog(context), + textColor: Colors.red, + ), + ], + ).animate().fadeIn(delay: 700.ms).slideX(begin: -0.1), + + const SizedBox(height: 24), + ], + ), + ), + ); + } + + Widget _buildSection( + BuildContext context, { + required String title, + required IconData icon, + required List children, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: AppTheme.primaryBlue, size: 24), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const Divider(height: 1), + ...children, + ], + ), + ), + ); + } + + Widget _buildSwitchTile( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required bool value, + required Function(bool) onChanged, + }) { + return ListTile( + leading: Icon(icon, color: AppTheme.primaryBlue), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(subtitle), + trailing: Switch( + value: value, + onChanged: onChanged, + activeColor: AppTheme.primaryBlue, + ), + ); + } + + Widget _buildSliderTile( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required double value, + required double min, + required double max, + required int divisions, + required Function(double) onChanged, + }) { + return ListTile( + leading: Icon(icon, color: AppTheme.primaryBlue), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + Slider( + value: value, + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + activeColor: AppTheme.primaryBlue, + ), + ], + ), + ); + } + + Widget _buildActionTile( + BuildContext context, { + required String title, + required String subtitle, + required IconData icon, + required VoidCallback? onTap, + Color? textColor, + }) { + return ListTile( + leading: Icon(icon, color: textColor ?? AppTheme.primaryBlue), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.w500, + color: textColor, + ), + ), + subtitle: Text(subtitle), + onTap: onTap, + trailing: onTap != null + ? Icon(Icons.chevron_right, color: Colors.grey[400]) + : null, + ); + } + + Widget _buildLanguageSelector( + BuildContext context, + LanguageService languageService, + ) { + return Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: Language.values.map((language) { + final isSelected = + languageService.selectedLanguage?.code == language.code; + return ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(language.flag, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text(language.name), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + languageService.setLanguage(language); + } + }, + selectedColor: AppTheme.primaryBlue.withOpacity(0.2), + backgroundColor: Colors.grey[200], + ); + }).toList(), + ), + ); + } + + void _showResetSettingsDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Reset Settings'), + content: const Text( + 'Are you sure you want to reset all settings to default values? This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + Provider.of(context, listen: false) + .resetAllSettings(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings reset successfully')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBlue, + ), + child: const Text('Reset'), + ), + ], + ); + }, + ); + } + + void _showResetProgressDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Reset Progress'), + content: const Text( + 'Are you sure you want to delete all your learning progress, streaks, and achievements? This action cannot be undone!', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + Provider.of(context, listen: false).resetAll(); + Provider.of(context, listen: false) + .resetAll(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('All progress has been reset'), + backgroundColor: Colors.red, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Delete All'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/services/gamification_service.dart b/lib/services/gamification_service.dart new file mode 100644 index 0000000..d6708ba --- /dev/null +++ b/lib/services/gamification_service.dart @@ -0,0 +1,306 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/achievement.dart'; +import '../models/streak.dart'; +import '../models/language.dart'; + +class GamificationService extends ChangeNotifier { + static const String _streakKey = 'streak_data'; + static const String _achievementsKey = 'achievements_data'; + static const String _languagesStartedKey = 'languages_started'; + + Streak _streak = Streak(); + List _achievements = Achievement.getAllAchievements(); + Set _languagesStarted = {}; + List _recentlyUnlocked = []; + + Streak get streak => _streak; + List get achievements => _achievements; + List get unlockedAchievements => + _achievements.where((a) => a.isUnlocked).toList(); + List get lockedAchievements => + _achievements.where((a) => !a.isUnlocked).toList(); + int get achievementCount => unlockedAchievements.length; + int get totalAchievements => _achievements.length; + List get recentlyUnlocked => _recentlyUnlocked; + + GamificationService() { + _loadData(); + } + + Future _loadData() async { + final prefs = await SharedPreferences.getInstance(); + + // Load streak + final streakJson = prefs.getString(_streakKey); + if (streakJson != null) { + _streak = Streak.fromJson(json.decode(streakJson)); + } + + // Load achievements + final achievementsJson = prefs.getString(_achievementsKey); + if (achievementsJson != null) { + final List savedAchievements = json.decode(achievementsJson); + final allAchievements = Achievement.getAllAchievements(); + + _achievements = allAchievements.map((template) { + final saved = savedAchievements.firstWhere( + (a) => a['type'] == template.type.toString(), + orElse: () => null, + ); + if (saved != null) { + return Achievement.fromJson(saved, template); + } + return template; + }).toList(); + } + + // Load languages started + final languagesJson = prefs.getStringList(_languagesStartedKey); + if (languagesJson != null) { + _languagesStarted = Set.from(languagesJson); + } + + notifyListeners(); + } + + Future _saveData() async { + final prefs = await SharedPreferences.getInstance(); + + // Save streak + await prefs.setString(_streakKey, json.encode(_streak.toJson())); + + // Save achievements + final achievementsJson = _achievements.map((a) => a.toJson()).toList(); + await prefs.setString(_achievementsKey, json.encode(achievementsJson)); + + // Save languages started + await prefs.setStringList(_languagesStartedKey, _languagesStarted.toList()); + } + + Future recordLessonCompletion(Language language) async { + _recentlyUnlocked.clear(); + + // Track language started + _languagesStarted.add(language.code); + + // Update streak + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final dateKey = _formatDate(today); + + Map updatedLessonsPerDay = Map.from(_streak.lessonsPerDay); + updatedLessonsPerDay[dateKey] = (updatedLessonsPerDay[dateKey] ?? 0) + 1; + + int newCurrentStreak = _streak.currentStreak; + if (_streak.lastCompletionDate == null) { + newCurrentStreak = 1; + } else { + final lastDate = DateTime( + _streak.lastCompletionDate!.year, + _streak.lastCompletionDate!.month, + _streak.lastCompletionDate!.day, + ); + final daysDifference = today.difference(lastDate).inDays; + + if (daysDifference == 0) { + // Same day, keep current streak + } else if (daysDifference == 1) { + // Consecutive day + newCurrentStreak = _streak.currentStreak + 1; + } else { + // Streak broken + newCurrentStreak = 1; + } + } + + final newLongestStreak = newCurrentStreak > _streak.longestStreak + ? newCurrentStreak + : _streak.longestStreak; + + _streak = _streak.copyWith( + currentStreak: newCurrentStreak, + longestStreak: newLongestStreak, + lastCompletionDate: now, + totalLessonsCompleted: _streak.totalLessonsCompleted + 1, + lessonsPerDay: updatedLessonsPerDay, + ); + + // Check for achievements + await _checkAchievements(updatedLessonsPerDay[dateKey] ?? 1); + + await _saveData(); + notifyListeners(); + } + + Future _checkAchievements(int lessonsToday) async { + final now = DateTime.now(); + final hour = now.hour; + + // First lesson + _unlockAchievement( + AchievementType.firstLesson, + _streak.totalLessonsCompleted >= 1, + ); + + // Lesson milestones + _unlockAchievement( + AchievementType.firstWeek, + _streak.totalLessonsCompleted >= 7, + ); + _unlockAchievement( + AchievementType.firstMonth, + _streak.totalLessonsCompleted >= 30, + ); + _unlockAchievement( + AchievementType.complete10Lessons, + _streak.totalLessonsCompleted >= 10, + ); + _unlockAchievement( + AchievementType.complete25Lessons, + _streak.totalLessonsCompleted >= 25, + ); + _unlockAchievement( + AchievementType.complete50Lessons, + _streak.totalLessonsCompleted >= 50, + ); + + // Streak achievements + _unlockAchievement( + AchievementType.streak7, + _streak.currentStreak >= 7, + ); + _unlockAchievement( + AchievementType.streak14, + _streak.currentStreak >= 14, + ); + _unlockAchievement( + AchievementType.streak30, + _streak.currentStreak >= 30, + ); + _unlockAchievement( + AchievementType.streak100, + _streak.currentStreak >= 100, + ); + _unlockAchievement( + AchievementType.perfectWeek, + _streak.currentStreak >= 7, + ); + + // Multilingual achievements + _unlockAchievement( + AchievementType.multilingualBronze, + _languagesStarted.length >= 2, + ); + _unlockAchievement( + AchievementType.multilingualSilver, + _languagesStarted.length >= 3, + ); + _unlockAchievement( + AchievementType.multilingualGold, + _languagesStarted.length >= 5, + ); + + // Time-based achievements + _unlockAchievement( + AchievementType.earlyBird, + hour < 9, + ); + _unlockAchievement( + AchievementType.nightOwl, + hour >= 21, + ); + + // Speed learner + _unlockAchievement( + AchievementType.speedLearner, + lessonsToday >= 3, + ); + } + + void _unlockAchievement(AchievementType type, bool condition) { + if (!condition) return; + + final index = _achievements.indexWhere((a) => a.type == type); + if (index != -1 && !_achievements[index].isUnlocked) { + _achievements[index] = _achievements[index].copyWith( + isUnlocked: true, + unlockedAt: DateTime.now(), + ); + _recentlyUnlocked.add(_achievements[index]); + } + } + + Future resetStreak() async { + _streak = Streak(); + await _saveData(); + notifyListeners(); + } + + Future resetAchievements() async { + _achievements = Achievement.getAllAchievements(); + _recentlyUnlocked.clear(); + await _saveData(); + notifyListeners(); + } + + Future resetAll() async { + _streak = Streak(); + _achievements = Achievement.getAllAchievements(); + _languagesStarted = {}; + _recentlyUnlocked.clear(); + await _saveData(); + notifyListeners(); + } + + void clearRecentlyUnlocked() { + _recentlyUnlocked.clear(); + } + + static String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + // Get achievements by category + List getStreakAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.streak7 || + a.type == AchievementType.streak14 || + a.type == AchievementType.streak30 || + a.type == AchievementType.streak100 || + a.type == AchievementType.perfectWeek) + .toList(); + } + + List getLessonAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.firstLesson || + a.type == AchievementType.firstWeek || + a.type == AchievementType.firstMonth || + a.type == AchievementType.complete10Lessons || + a.type == AchievementType.complete25Lessons || + a.type == AchievementType.complete50Lessons) + .toList(); + } + + List getMultilingualAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.multilingualBronze || + a.type == AchievementType.multilingualSilver || + a.type == AchievementType.multilingualGold) + .toList(); + } + + List getSpecialAchievements() { + return _achievements + .where((a) => + a.type == AchievementType.earlyBird || + a.type == AchievementType.nightOwl || + a.type == AchievementType.speedLearner) + .toList(); + } +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..fa61059 --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsService extends ChangeNotifier { + static const String _darkModeKey = 'dark_mode'; + static const String _textScaleKey = 'text_scale'; + static const String _showHintsKey = 'show_hints'; + static const String _dailyGoalKey = 'daily_goal'; + static const String _notificationsEnabledKey = 'notifications_enabled'; + static const String _soundEnabledKey = 'sound_enabled'; + static const String _hasCompletedOnboardingKey = 'has_completed_onboarding'; + + bool _isDarkMode = false; + double _textScale = 1.0; + bool _showHints = true; + int _dailyGoal = 1; // lessons per day + bool _notificationsEnabled = true; + bool _soundEnabled = true; + bool _hasCompletedOnboarding = false; + + bool get isDarkMode => _isDarkMode; + double get textScale => _textScale; + bool get showHints => _showHints; + int get dailyGoal => _dailyGoal; + bool get notificationsEnabled => _notificationsEnabled; + bool get soundEnabled => _soundEnabled; + bool get hasCompletedOnboarding => _hasCompletedOnboarding; + + ThemeMode get themeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light; + + SettingsService() { + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + _isDarkMode = prefs.getBool(_darkModeKey) ?? false; + _textScale = prefs.getDouble(_textScaleKey) ?? 1.0; + _showHints = prefs.getBool(_showHintsKey) ?? true; + _dailyGoal = prefs.getInt(_dailyGoalKey) ?? 1; + _notificationsEnabled = prefs.getBool(_notificationsEnabledKey) ?? true; + _soundEnabled = prefs.getBool(_soundEnabledKey) ?? true; + _hasCompletedOnboarding = prefs.getBool(_hasCompletedOnboardingKey) ?? false; + notifyListeners(); + } + + Future toggleDarkMode() async { + _isDarkMode = !_isDarkMode; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_darkModeKey, _isDarkMode); + notifyListeners(); + } + + Future setTextScale(double scale) async { + if (scale >= 0.8 && scale <= 1.5) { + _textScale = scale; + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_textScaleKey, scale); + notifyListeners(); + } + } + + Future toggleHints() async { + _showHints = !_showHints; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_showHintsKey, _showHints); + notifyListeners(); + } + + Future setDailyGoal(int goal) async { + if (goal >= 1 && goal <= 10) { + _dailyGoal = goal; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_dailyGoalKey, goal); + notifyListeners(); + } + } + + Future toggleNotifications() async { + _notificationsEnabled = !_notificationsEnabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_notificationsEnabledKey, _notificationsEnabled); + notifyListeners(); + } + + Future toggleSound() async { + _soundEnabled = !_soundEnabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_soundEnabledKey, _soundEnabled); + notifyListeners(); + } + + Future completeOnboarding() async { + _hasCompletedOnboarding = true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hasCompletedOnboardingKey, true); + notifyListeners(); + } + + Future resetOnboarding() async { + _hasCompletedOnboarding = false; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_hasCompletedOnboardingKey, false); + notifyListeners(); + } + + Future resetAllSettings() async { + _isDarkMode = false; + _textScale = 1.0; + _showHints = true; + _dailyGoal = 1; + _notificationsEnabled = true; + _soundEnabled = true; + // Don't reset onboarding + + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_darkModeKey, false); + await prefs.setDouble(_textScaleKey, 1.0); + await prefs.setBool(_showHintsKey, true); + await prefs.setInt(_dailyGoalKey, 1); + await prefs.setBool(_notificationsEnabledKey, true); + await prefs.setBool(_soundEnabledKey, true); + + notifyListeners(); + } + + // Text scale presets + static const double textScaleSmall = 0.85; + static const double textScaleNormal = 1.0; + static const double textScaleLarge = 1.15; + static const double textScaleExtraLarge = 1.3; + + String getTextScaleLabel() { + if (_textScale <= 0.9) return 'Small'; + if (_textScale <= 1.05) return 'Normal'; + if (_textScale <= 1.2) return 'Large'; + return 'Extra Large'; + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..0c99167 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Primary colors + static const Color primaryBlue = Color(0xFF4A90E2); + static const Color secondaryGreen = Color(0xFF50C878); + static const Color accentOrange = Color(0xFFFF9500); + static const Color accentPurple = Color(0xFF9B59B6); + + // Light theme colors + static const Color lightBackground = Color(0xFFF8F9FA); + static const Color lightSurface = Colors.white; + static const Color lightOnSurface = Color(0xFF2C3E50); + static const Color lightCardBackground = Colors.white; + + // Dark theme colors + static const Color darkBackground = Color(0xFF121212); + static const Color darkSurface = Color(0xFF1E1E1E); + static const Color darkOnSurface = Color(0xFFE0E0E0); + static const Color darkCardBackground = Color(0xFF2C2C2C); + + // Gradient colors + static const List primaryGradient = [primaryBlue, secondaryGreen]; + static const List accentGradient = [accentOrange, accentPurple]; + + // Achievement badge colors + static const Color bronzeBadge = Color(0xFFCD7F32); + static const Color silverBadge = Color(0xFFC0C0C0); + static const Color goldBadge = Color(0xFFFFD700); + + // Light Theme + static ThemeData lightTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryBlue, + brightness: Brightness.light, + primary: primaryBlue, + secondary: secondaryGreen, + tertiary: accentOrange, + surface: lightSurface, + background: lightBackground, + onSurface: lightOnSurface, + ), + scaffoldBackgroundColor: lightBackground, + appBarTheme: const AppBarTheme( + backgroundColor: primaryBlue, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + iconTheme: IconThemeData(color: Colors.white), + ), + cardTheme: CardTheme( + color: lightCardBackground, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryBlue, + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryBlue, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Colors.white, + selectedItemColor: primaryBlue, + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: primaryBlue, + linearTrackColor: Color(0xFFE0E0E0), + ), + sliderTheme: SliderThemeData( + activeTrackColor: primaryBlue, + inactiveTrackColor: primaryBlue.withOpacity(0.3), + thumbColor: primaryBlue, + overlayColor: primaryBlue.withOpacity(0.2), + ), + chipTheme: ChipThemeData( + backgroundColor: primaryBlue.withOpacity(0.1), + labelStyle: const TextStyle(color: primaryBlue), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryBlue, width: 2), + ), + ), + ); + + // Dark Theme + static ThemeData darkTheme = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryBlue, + brightness: Brightness.dark, + primary: primaryBlue, + secondary: secondaryGreen, + tertiary: accentOrange, + surface: darkSurface, + background: darkBackground, + onSurface: darkOnSurface, + ), + scaffoldBackgroundColor: darkBackground, + appBarTheme: const AppBarTheme( + backgroundColor: darkSurface, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + iconTheme: IconThemeData(color: Colors.white), + ), + cardTheme: CardTheme( + color: darkCardBackground, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryBlue, + foregroundColor: Colors.white, + elevation: 2, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryBlue, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: darkSurface, + selectedItemColor: primaryBlue, + unselectedItemColor: Colors.grey, + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: primaryBlue, + linearTrackColor: Color(0xFF424242), + ), + sliderTheme: SliderThemeData( + activeTrackColor: primaryBlue, + inactiveTrackColor: primaryBlue.withOpacity(0.3), + thumbColor: primaryBlue, + overlayColor: primaryBlue.withOpacity(0.2), + ), + chipTheme: ChipThemeData( + backgroundColor: primaryBlue.withOpacity(0.2), + labelStyle: const TextStyle(color: Colors.white), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: darkSurface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryBlue, width: 2), + ), + ), + ); + + // Text Styles + static TextStyle headlineLarge(BuildContext context) => Theme.of(context).textTheme.headlineLarge!.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle headlineMedium(BuildContext context) => Theme.of(context).textTheme.headlineMedium!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle titleLarge(BuildContext context) => Theme.of(context).textTheme.titleLarge!.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle bodyLarge(BuildContext context) => Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ); + + static TextStyle bodyMedium(BuildContext context) => Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + ); + + // Gradient decorations + static BoxDecoration primaryGradientDecoration({double borderRadius = 0}) { + return BoxDecoration( + gradient: const LinearGradient( + colors: primaryGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(borderRadius), + ); + } + + static BoxDecoration accentGradientDecoration({double borderRadius = 0}) { + return BoxDecoration( + gradient: const LinearGradient( + colors: accentGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(borderRadius), + ); + } + + // Shadow styles + static List cardShadow(BuildContext context) { + return [ + BoxShadow( + color: Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.1) + : Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ]; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9492693..c063924 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,16 @@ dependencies: # Path provider for file access path_provider: ^2.1.1 + # Animations and UI enhancements + flutter_animate: ^4.5.0 + smooth_page_indicator: ^1.1.0 + fl_chart: ^0.66.0 + badges: ^3.1.2 + confetti: ^0.7.0 + + # Icons + cupertino_icons: ^1.0.6 + dev_dependencies: flutter_test: sdk: flutter From b9612489378cc13829c446c42328f57bf938cff6 Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 13:51:42 -0500 Subject: [PATCH 2/9] Fix language selection and add progress reset Replaced selectedLanguage with currentLanguage in settings screen for accurate language selection. Added resetAll method to ProgressService to allow resetting progress. Updated theme to use CardThemeData instead of CardTheme. --- lib/screens/settings_screen.dart | 2 +- lib/services/progress_service.dart | 6 +++ lib/theme/app_theme.dart | 4 +- pubspec.lock | 64 ++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0bf78b0..4fe980a 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -309,7 +309,7 @@ class SettingsScreen extends StatelessWidget { runSpacing: 8, children: Language.values.map((language) { final isSelected = - languageService.selectedLanguage?.code == language.code; + languageService.currentLanguage.code == language.code; return ChoiceChip( label: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/services/progress_service.dart b/lib/services/progress_service.dart index 9c48f54..b544701 100644 --- a/lib/services/progress_service.dart +++ b/lib/services/progress_service.dart @@ -85,4 +85,10 @@ class ProgressService extends ChangeNotifier { double getProgress(Language language) { return _progress.getProgress(language); } + + Future resetAll() async { + _progress = Progress(); + await _saveProgress(); + notifyListeners(); + } } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 0c99167..750aa81 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -50,7 +50,7 @@ class AppTheme { centerTitle: true, iconTheme: IconThemeData(color: Colors.white), ), - cardTheme: CardTheme( + cardTheme: CardThemeData( color: lightCardBackground, elevation: 2, shape: RoundedRectangleBorder( @@ -139,7 +139,7 @@ class AppTheme { centerTitle: true, iconTheme: IconThemeData(color: Colors.white), ), - cardTheme: CardTheme( + cardTheme: CardThemeData( color: darkCardBackground, elevation: 4, shape: RoundedRectangleBorder( diff --git a/pubspec.lock b/pubspec.lock index baf745c..5c9356f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + badges: + dependency: "direct main" + description: + name: badges + sha256: a7b6bbd60dce418df0db3058b53f9d083c22cdb5132a052145dc267494df0b84 + url: "https://pub.dev" + source: hosted + version: "3.1.2" boolean_selector: dependency: transitive description: @@ -185,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "979aafde2428c53947892c95eb244466c109c129b7eee9011f0a66caaca52267" + url: "https://pub.dev" + source: hosted + version: "0.7.0" convert: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" dart_style: dependency: transitive description: @@ -209,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -241,11 +273,27 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "00b74ae680df6b1135bdbea00a7d1fc072a9180b7c3f3702e4b19a9943f5ed7d" + url: "https://pub.dev" + source: hosted + version: "0.66.2" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" flutter_lints: dependency: "direct dev" description: @@ -259,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" flutter_test: dependency: "direct dev" description: flutter @@ -618,6 +674,14 @@ packages: description: flutter source: sdk version: "0.0.0" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c + url: "https://pub.dev" + source: hosted + version: "1.2.1" source_gen: dependency: transitive description: From 9d4eeeac7e274bb74721b2d06ad74f5d29b1bb39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 19:03:30 +0000 Subject: [PATCH 3/9] Add interactive practice and enhanced lesson features This update transforms the passive lesson experience into an active learning system with comprehensive practice features: **New Features:** - Interactive practice screen with multiple exercise types (multiple choice, fill-in-blank, translation) - Real-time progress tracking for exercises with visual indicators - Vocabulary section with translation toggle for better learning - Exercise completion tracking with persistent storage - Smart practice buttons (Start/Continue/Review) based on progress **New Models:** - Exercise models (MultipleChoice, FillInBlank, Matching, Translation) - Vocabulary model with phonetics, examples, and translations - Enhanced Progress model to track exercise completion per lesson - Enhanced Lesson model to load and manage exercises and vocabulary **UI Enhancements:** - Progress indicator showing X/Y exercises completed - Interactive practice screen with immediate feedback - Vocabulary cards with show/hide translations - Completion celebration screen - Navigation between exercises with skip functionality **Sample Content:** - Added exercises for Day 1-3 (Greetings, Numbers, Days/Months) - Added vocabulary with phonetics for Day 1-3 - 8 exercises per lesson covering different question types This implements all recommended best practices for language learning apps including active recall, spaced repetition cues, and clear progress feedback. --- assets/exercises/day1_en.json | 83 +++++ assets/exercises/day2_en.json | 69 ++++ assets/exercises/day3_en.json | 82 ++++ assets/vocabulary/day1_en.json | 60 +++ assets/vocabulary/day2_en.json | 60 +++ assets/vocabulary/day3_en.json | 74 ++++ lib/models/exercise.dart | 309 +++++++++++++++ lib/models/lesson.dart | 50 +++ lib/models/progress.dart | 44 ++- lib/models/vocabulary.dart | 47 +++ lib/screens/lesson_screen.dart | 235 +++++++++++- lib/screens/practice_screen.dart | 579 +++++++++++++++++++++++++++++ lib/services/progress_service.dart | 39 ++ pubspec.yaml | 2 + 14 files changed, 1729 insertions(+), 4 deletions(-) create mode 100644 assets/exercises/day1_en.json create mode 100644 assets/exercises/day2_en.json create mode 100644 assets/exercises/day3_en.json create mode 100644 assets/vocabulary/day1_en.json create mode 100644 assets/vocabulary/day2_en.json create mode 100644 assets/vocabulary/day3_en.json create mode 100644 lib/models/exercise.dart create mode 100644 lib/models/vocabulary.dart create mode 100644 lib/screens/practice_screen.dart diff --git a/assets/exercises/day1_en.json b/assets/exercises/day1_en.json new file mode 100644 index 0000000..0f53276 --- /dev/null +++ b/assets/exercises/day1_en.json @@ -0,0 +1,83 @@ +{ + "exercises": [ + { + "id": "day1_mc1", + "type": "multipleChoice", + "question": "Which greeting is most appropriate in a formal setting?", + "options": [ + "Hey!", + "Good morning", + "Yo!", + "What's up?" + ], + "correctOptionIndex": 1 + }, + { + "id": "day1_mc2", + "type": "multipleChoice", + "question": "How do you respond to 'How are you?'", + "options": [ + "I'm fine, thank you. And you?", + "Nothing", + "Yes", + "Goodbye" + ], + "correctOptionIndex": 0 + }, + { + "id": "day1_fib1", + "type": "fillInBlank", + "question": "Fill in the blank: 'Nice to ____ you!'", + "correctAnswer": "meet", + "caseSensitive": false + }, + { + "id": "day1_fib2", + "type": "fillInBlank", + "question": "Fill in the blank: 'My ____ is John.'", + "correctAnswer": "name", + "acceptableAlternatives": ["Name"], + "caseSensitive": false + }, + { + "id": "day1_trans1", + "type": "translation", + "question": "Translate to English:", + "targetText": "Hello", + "correctTranslation": "Hello", + "acceptableAlternatives": ["Hi", "Hey", "Greetings"] + }, + { + "id": "day1_mc3", + "type": "multipleChoice", + "question": "What is an appropriate way to say goodbye?", + "options": [ + "See you later", + "Go away", + "Stop talking", + "Leave now" + ], + "correctOptionIndex": 0 + }, + { + "id": "day1_fib3", + "type": "fillInBlank", + "question": "Fill in the blank: '____ to meet you!'", + "correctAnswer": "Nice", + "acceptableAlternatives": ["Pleased", "Happy", "Good"], + "caseSensitive": false + }, + { + "id": "day1_mc4", + "type": "multipleChoice", + "question": "Which is a polite way to introduce yourself?", + "options": [ + "I'm the best", + "My name is Sarah", + "You should know me", + "I don't care" + ], + "correctOptionIndex": 1 + } + ] +} diff --git a/assets/exercises/day2_en.json b/assets/exercises/day2_en.json new file mode 100644 index 0000000..dd73f0c --- /dev/null +++ b/assets/exercises/day2_en.json @@ -0,0 +1,69 @@ +{ + "exercises": [ + { + "id": "day2_mc1", + "type": "multipleChoice", + "question": "What comes after 'nine'?", + "options": [ + "eight", + "ten", + "eleven", + "twelve" + ], + "correctOptionIndex": 1 + }, + { + "id": "day2_fib1", + "type": "fillInBlank", + "question": "Fill in the blank: 'One, two, three, ____, five'", + "correctAnswer": "four", + "caseSensitive": false + }, + { + "id": "day2_mc2", + "type": "multipleChoice", + "question": "How do you write the number 15?", + "options": [ + "fiveteen", + "fifty", + "fifteen", + "fivetin" + ], + "correctOptionIndex": 2 + }, + { + "id": "day2_fib2", + "type": "fillInBlank", + "question": "What number comes before 'twenty'?", + "correctAnswer": "nineteen", + "caseSensitive": false + }, + { + "id": "day2_mc3", + "type": "multipleChoice", + "question": "Which is the correct spelling of 30?", + "options": [ + "thirthy", + "thirty", + "therty", + "threety" + ], + "correctOptionIndex": 1 + }, + { + "id": "day2_trans1", + "type": "translation", + "question": "Write this number in words:", + "targetText": "7", + "correctTranslation": "seven", + "acceptableAlternatives": ["Seven"] + }, + { + "id": "day2_fib3", + "type": "fillInBlank", + "question": "Fill in the blank: 'Ten, twenty, ____, forty'", + "correctAnswer": "thirty", + "caseSensitive": false + } + ] +} diff --git a/assets/exercises/day3_en.json b/assets/exercises/day3_en.json new file mode 100644 index 0000000..27a1d44 --- /dev/null +++ b/assets/exercises/day3_en.json @@ -0,0 +1,82 @@ +{ + "exercises": [ + { + "id": "day3_mc1", + "type": "multipleChoice", + "question": "Which day comes after Monday?", + "options": [ + "Sunday", + "Tuesday", + "Wednesday", + "Saturday" + ], + "correctOptionIndex": 1 + }, + { + "id": "day3_mc2", + "type": "multipleChoice", + "question": "What is the first month of the year?", + "options": [ + "December", + "February", + "January", + "March" + ], + "correctOptionIndex": 2 + }, + { + "id": "day3_fib1", + "type": "fillInBlank", + "question": "Fill in the blank: 'Monday, Tuesday, ____'", + "correctAnswer": "Wednesday", + "caseSensitive": false + }, + { + "id": "day3_fib2", + "type": "fillInBlank", + "question": "Fill in the blank: 'January, February, ____'", + "correctAnswer": "March", + "caseSensitive": false + }, + { + "id": "day3_mc3", + "type": "multipleChoice", + "question": "Which month has the shortest name?", + "options": [ + "July", + "June", + "May", + "April" + ], + "correctOptionIndex": 2 + }, + { + "id": "day3_trans1", + "type": "translation", + "question": "What day is the last day of the work week?", + "targetText": "Last work day before weekend", + "correctTranslation": "Friday", + "acceptableAlternatives": ["friday"] + }, + { + "id": "day3_mc4", + "type": "multipleChoice", + "question": "What are the weekend days?", + "options": [ + "Monday and Friday", + "Saturday and Sunday", + "Thursday and Friday", + "Tuesday and Wednesday" + ], + "correctOptionIndex": 1 + }, + { + "id": "day3_fib3", + "type": "fillInBlank", + "question": "Fill in the blank: 'Thursday, Friday, ____'", + "correctAnswer": "Saturday", + "acceptableAlternatives": ["saturday"], + "caseSensitive": false + } + ] +} diff --git a/assets/vocabulary/day1_en.json b/assets/vocabulary/day1_en.json new file mode 100644 index 0000000..5ef9b47 --- /dev/null +++ b/assets/vocabulary/day1_en.json @@ -0,0 +1,60 @@ +{ + "vocabulary": [ + { + "word": "Hello", + "translation": "A greeting used when meeting someone", + "phonetic": "/həˈloʊ/", + "example": "Hello, how are you today?", + "exampleTranslation": "A friendly greeting to start a conversation" + }, + { + "word": "Goodbye", + "translation": "A farewell expression", + "phonetic": "/ɡʊdˈbaɪ/", + "example": "Goodbye, see you tomorrow!", + "exampleTranslation": "A polite way to end a conversation" + }, + { + "word": "Please", + "translation": "A polite word used when making requests", + "phonetic": "/pliːz/", + "example": "Could you help me, please?", + "exampleTranslation": "Shows politeness when asking for something" + }, + { + "word": "Thank you", + "translation": "Expression of gratitude", + "phonetic": "/θæŋk juː/", + "example": "Thank you for your help!", + "exampleTranslation": "Showing appreciation for someone's actions" + }, + { + "word": "My name is", + "translation": "Phrase used to introduce yourself", + "phonetic": "/maɪ neɪm ɪz/", + "example": "Hi, my name is Sarah.", + "exampleTranslation": "Introducing yourself to someone new" + }, + { + "word": "Nice to meet you", + "translation": "Polite expression when meeting someone for the first time", + "phonetic": "/naɪs tə miːt juː/", + "example": "Nice to meet you, John!", + "exampleTranslation": "A friendly greeting for first encounters" + }, + { + "word": "How are you?", + "translation": "A common question to ask about someone's well-being", + "phonetic": "/haʊ ɑːr juː/", + "example": "Hello! How are you doing today?", + "exampleTranslation": "Asking about someone's current state" + }, + { + "word": "I'm fine", + "translation": "A common response indicating you are well", + "phonetic": "/aɪm faɪn/", + "example": "I'm fine, thank you. And you?", + "exampleTranslation": "Positive response to 'How are you?'" + } + ] +} diff --git a/assets/vocabulary/day2_en.json b/assets/vocabulary/day2_en.json new file mode 100644 index 0000000..0be7215 --- /dev/null +++ b/assets/vocabulary/day2_en.json @@ -0,0 +1,60 @@ +{ + "vocabulary": [ + { + "word": "Zero", + "translation": "The number 0", + "phonetic": "/ˈzɪroʊ/", + "example": "I have zero apples.", + "exampleTranslation": "Indicating the absence of quantity" + }, + { + "word": "One", + "translation": "The number 1", + "phonetic": "/wʌn/", + "example": "I need one ticket.", + "exampleTranslation": "A single item" + }, + { + "word": "Five", + "translation": "The number 5", + "phonetic": "/faɪv/", + "example": "There are five books on the table.", + "exampleTranslation": "Counting to five items" + }, + { + "word": "Ten", + "translation": "The number 10", + "phonetic": "/tɛn/", + "example": "I wake up at ten o'clock.", + "exampleTranslation": "The number after nine" + }, + { + "word": "Twenty", + "translation": "The number 20", + "phonetic": "/ˈtwɛnti/", + "example": "The book costs twenty dollars.", + "exampleTranslation": "Two tens" + }, + { + "word": "Hundred", + "translation": "The number 100", + "phonetic": "/ˈhʌndrəd/", + "example": "One hundred people attended.", + "exampleTranslation": "Ten times ten" + }, + { + "word": "First", + "translation": "Position number 1 in a sequence", + "phonetic": "/fɜːrst/", + "example": "She finished first in the race.", + "exampleTranslation": "Ordinal number for one" + }, + { + "word": "Second", + "translation": "Position number 2 in a sequence", + "phonetic": "/ˈsɛkənd/", + "example": "He came in second place.", + "exampleTranslation": "Ordinal number for two" + } + ] +} diff --git a/assets/vocabulary/day3_en.json b/assets/vocabulary/day3_en.json new file mode 100644 index 0000000..88bfe6d --- /dev/null +++ b/assets/vocabulary/day3_en.json @@ -0,0 +1,74 @@ +{ + "vocabulary": [ + { + "word": "Monday", + "translation": "The first day of the work week", + "phonetic": "/ˈmʌndeɪ/", + "example": "I have a meeting on Monday morning.", + "exampleTranslation": "The day after Sunday" + }, + { + "word": "Tuesday", + "translation": "The second day of the work week", + "phonetic": "/ˈtuːzdeɪ/", + "example": "Tuesday is my favorite day.", + "exampleTranslation": "The day between Monday and Wednesday" + }, + { + "word": "Wednesday", + "translation": "The third day of the work week, middle of the week", + "phonetic": "/ˈwɛnzdeɪ/", + "example": "We meet every Wednesday.", + "exampleTranslation": "Often called 'hump day'" + }, + { + "word": "Friday", + "translation": "The last day of the work week", + "phonetic": "/ˈfraɪdeɪ/", + "example": "I'm excited for Friday!", + "exampleTranslation": "The day before the weekend" + }, + { + "word": "Saturday", + "translation": "First day of the weekend", + "phonetic": "/ˈsætərdeɪ/", + "example": "Let's go to the park on Saturday.", + "exampleTranslation": "A day for relaxation and activities" + }, + { + "word": "Sunday", + "translation": "Second day of the weekend, last day of the week", + "phonetic": "/ˈsʌndeɪ/", + "example": "Sunday is a rest day.", + "exampleTranslation": "Often a day for family and rest" + }, + { + "word": "January", + "translation": "First month of the year", + "phonetic": "/ˈdʒænjuˌɛri/", + "example": "January is very cold here.", + "exampleTranslation": "The month after December" + }, + { + "word": "February", + "translation": "Second month of the year, shortest month", + "phonetic": "/ˈfɛbruˌɛri/", + "example": "Valentine's Day is in February.", + "exampleTranslation": "Has 28 or 29 days" + }, + { + "word": "March", + "translation": "Third month of the year, spring begins", + "phonetic": "/mɑːrtʃ/", + "example": "Spring starts in March.", + "exampleTranslation": "The month after February" + }, + { + "word": "December", + "translation": "Last month of the year", + "phonetic": "/dɪˈsɛmbər/", + "example": "December is a holiday month.", + "exampleTranslation": "The 12th and final month" + } + ] +} diff --git a/lib/models/exercise.dart b/lib/models/exercise.dart new file mode 100644 index 0000000..c0dbec2 --- /dev/null +++ b/lib/models/exercise.dart @@ -0,0 +1,309 @@ +enum ExerciseType { + multipleChoice, + fillInBlank, + matching, + translation, + listening, +} + +abstract class Exercise { + final String id; + final ExerciseType type; + final String question; + final String? audioHint; + + Exercise({ + required this.id, + required this.type, + required this.question, + this.audioHint, + }); + + bool checkAnswer(dynamic answer); + dynamic getCorrectAnswer(); + Map toJson(); +} + +class MultipleChoiceExercise extends Exercise { + final List options; + final int correctOptionIndex; + + MultipleChoiceExercise({ + required String id, + required String question, + required this.options, + required this.correctOptionIndex, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.multipleChoice, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! int) return false; + return answer == correctOptionIndex; + } + + @override + dynamic getCorrectAnswer() => correctOptionIndex; + + @override + Map toJson() { + return { + 'id': id, + 'type': 'multipleChoice', + 'question': question, + 'options': options, + 'correctOptionIndex': correctOptionIndex, + 'audioHint': audioHint, + }; + } + + factory MultipleChoiceExercise.fromJson(Map json) { + return MultipleChoiceExercise( + id: json['id'], + question: json['question'], + options: List.from(json['options']), + correctOptionIndex: json['correctOptionIndex'], + audioHint: json['audioHint'], + ); + } +} + +class FillInBlankExercise extends Exercise { + final String correctAnswer; + final List? acceptableAlternatives; + final bool caseSensitive; + + FillInBlankExercise({ + required String id, + required String question, + required this.correctAnswer, + this.acceptableAlternatives, + this.caseSensitive = false, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.fillInBlank, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! String) return false; + + final userAnswer = caseSensitive ? answer : answer.toLowerCase(); + final correct = caseSensitive ? correctAnswer : correctAnswer.toLowerCase(); + + if (userAnswer == correct) return true; + + if (acceptableAlternatives != null) { + for (final alternative in acceptableAlternatives!) { + final alt = caseSensitive ? alternative : alternative.toLowerCase(); + if (userAnswer == alt) return true; + } + } + + return false; + } + + @override + dynamic getCorrectAnswer() => correctAnswer; + + @override + Map toJson() { + return { + 'id': id, + 'type': 'fillInBlank', + 'question': question, + 'correctAnswer': correctAnswer, + 'acceptableAlternatives': acceptableAlternatives, + 'caseSensitive': caseSensitive, + 'audioHint': audioHint, + }; + } + + factory FillInBlankExercise.fromJson(Map json) { + return FillInBlankExercise( + id: json['id'], + question: json['question'], + correctAnswer: json['correctAnswer'], + acceptableAlternatives: json['acceptableAlternatives'] != null + ? List.from(json['acceptableAlternatives']) + : null, + caseSensitive: json['caseSensitive'] ?? false, + audioHint: json['audioHint'], + ); + } +} + +class MatchingPair { + final String left; + final String right; + + MatchingPair({ + required this.left, + required this.right, + }); + + Map toJson() { + return { + 'left': left, + 'right': right, + }; + } + + factory MatchingPair.fromJson(Map json) { + return MatchingPair( + left: json['left'], + right: json['right'], + ); + } +} + +class MatchingExercise extends Exercise { + final List pairs; + + MatchingExercise({ + required String id, + required String question, + required this.pairs, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.matching, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! Map) return false; + + for (final pair in pairs) { + if (answer[pair.left] != pair.right) { + return false; + } + } + + return answer.length == pairs.length; + } + + @override + dynamic getCorrectAnswer() { + return Map.fromEntries( + pairs.map((pair) => MapEntry(pair.left, pair.right)), + ); + } + + @override + Map toJson() { + return { + 'id': id, + 'type': 'matching', + 'question': question, + 'pairs': pairs.map((p) => p.toJson()).toList(), + 'audioHint': audioHint, + }; + } + + factory MatchingExercise.fromJson(Map json) { + return MatchingExercise( + id: json['id'], + question: json['question'], + pairs: (json['pairs'] as List) + .map((p) => MatchingPair.fromJson(p)) + .toList(), + audioHint: json['audioHint'], + ); + } +} + +class TranslationExercise extends Exercise { + final String targetText; + final String correctTranslation; + final List? acceptableAlternatives; + + TranslationExercise({ + required String id, + required String question, + required this.targetText, + required this.correctTranslation, + this.acceptableAlternatives, + String? audioHint, + }) : super( + id: id, + type: ExerciseType.translation, + question: question, + audioHint: audioHint, + ); + + @override + bool checkAnswer(dynamic answer) { + if (answer is! String) return false; + + final userAnswer = answer.trim().toLowerCase(); + final correct = correctTranslation.trim().toLowerCase(); + + if (userAnswer == correct) return true; + + if (acceptableAlternatives != null) { + for (final alternative in acceptableAlternatives!) { + if (userAnswer == alternative.trim().toLowerCase()) { + return true; + } + } + } + + return false; + } + + @override + dynamic getCorrectAnswer() => correctTranslation; + + @override + Map toJson() { + return { + 'id': id, + 'type': 'translation', + 'question': question, + 'targetText': targetText, + 'correctTranslation': correctTranslation, + 'acceptableAlternatives': acceptableAlternatives, + 'audioHint': audioHint, + }; + } + + factory TranslationExercise.fromJson(Map json) { + return TranslationExercise( + id: json['id'], + question: json['question'], + targetText: json['targetText'], + correctTranslation: json['correctTranslation'], + acceptableAlternatives: json['acceptableAlternatives'] != null + ? List.from(json['acceptableAlternatives']) + : null, + audioHint: json['audioHint'], + ); + } +} + +// Factory method to create exercises from JSON +Exercise exerciseFromJson(Map json) { + switch (json['type']) { + case 'multipleChoice': + return MultipleChoiceExercise.fromJson(json); + case 'fillInBlank': + return FillInBlankExercise.fromJson(json); + case 'matching': + return MatchingExercise.fromJson(json); + case 'translation': + return TranslationExercise.fromJson(json); + default: + throw Exception('Unknown exercise type: ${json['type']}'); + } +} diff --git a/lib/models/lesson.dart b/lib/models/lesson.dart index 8e67c79..c941b9d 100644 --- a/lib/models/lesson.dart +++ b/lib/models/lesson.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'package:flutter/services.dart'; import 'language.dart'; +import 'exercise.dart'; +import 'vocabulary.dart'; class Lesson { final int day; @@ -8,6 +11,8 @@ class Lesson { final String description; final String audioPath; final String? textContent; + List? exercises; + List? vocabulary; Lesson({ required this.day, @@ -16,6 +21,8 @@ class Lesson { required this.description, required this.audioPath, this.textContent, + this.exercises, + this.vocabulary, }); String get phase { @@ -54,6 +61,49 @@ class Lesson { } } + String getExercisesFilePath() { + return 'assets/exercises/day${day}_${language.code}.json'; + } + + String getVocabularyFilePath() { + return 'assets/vocabulary/day${day}_${language.code}.json'; + } + + Future loadExercises() async { + try { + final jsonString = await rootBundle.loadString(getExercisesFilePath()); + final jsonData = json.decode(jsonString) as Map; + final exercisesList = jsonData['exercises'] as List; + + exercises = exercisesList + .map((e) => exerciseFromJson(e as Map)) + .toList(); + } catch (e) { + // No exercises available for this lesson + exercises = []; + } + } + + Future loadVocabulary() async { + try { + final jsonString = await rootBundle.loadString(getVocabularyFilePath()); + final jsonData = json.decode(jsonString) as Map; + final vocabularyList = jsonData['vocabulary'] as List; + + vocabulary = vocabularyList + .map((v) => VocabularyItem.fromJson(v as Map)) + .toList(); + } catch (e) { + // No vocabulary available for this lesson + vocabulary = []; + } + } + + int get totalExercises => exercises?.length ?? 0; + int get totalVocabulary => vocabulary?.length ?? 0; + bool get hasExercises => totalExercises > 0; + bool get hasVocabulary => totalVocabulary > 0; + static String _getDescription(int day) { final descriptions = { 1: 'Greetings and Basic Introductions', diff --git a/lib/models/progress.dart b/lib/models/progress.dart index 2a97fd4..c64f90b 100644 --- a/lib/models/progress.dart +++ b/lib/models/progress.dart @@ -3,12 +3,16 @@ import 'language.dart'; class Progress { final Map> completedLessons; final Map currentDay; + // Map of language -> day -> set of completed exercise IDs + final Map>> completedExercises; Progress({ Map>? completedLessons, Map? currentDay, + Map>>? completedExercises, }) : completedLessons = completedLessons ?? {}, - currentDay = currentDay ?? {}; + currentDay = currentDay ?? {}, + completedExercises = completedExercises ?? {}; bool isLessonCompleted(Language language, int day) { return completedLessons[language]?.contains(day) ?? false; @@ -27,13 +31,27 @@ class Progress { return completed / 50.0; } + bool isExerciseCompleted(Language language, int day, String exerciseId) { + return completedExercises[language]?[day]?.contains(exerciseId) ?? false; + } + + int getCompletedExercisesCount(Language language, int day) { + return completedExercises[language]?[day]?.length ?? 0; + } + + Set getCompletedExerciseIds(Language language, int day) { + return completedExercises[language]?[day] ?? {}; + } + Progress copyWith({ Map>? completedLessons, Map? currentDay, + Map>>? completedExercises, }) { return Progress( completedLessons: completedLessons ?? this.completedLessons, currentDay: currentDay ?? this.currentDay, + completedExercises: completedExercises ?? this.completedExercises, ); } @@ -45,12 +63,21 @@ class Progress { 'currentDay': currentDay.map( (key, value) => MapEntry(key.code, value), ), + 'completedExercises': completedExercises.map( + (lang, dayMap) => MapEntry( + lang.code, + dayMap.map( + (day, exerciseIds) => MapEntry(day.toString(), exerciseIds.toList()), + ), + ), + ), }; } factory Progress.fromJson(Map json) { final completedLessonsMap = >{}; final currentDayMap = {}; + final completedExercisesMap = >>{}; if (json['completedLessons'] != null) { (json['completedLessons'] as Map).forEach((key, value) { @@ -66,9 +93,24 @@ class Progress { }); } + if (json['completedExercises'] != null) { + (json['completedExercises'] as Map).forEach((langCode, dayMap) { + final language = Language.fromCode(langCode); + final exercisesByDay = >{}; + + (dayMap as Map).forEach((dayStr, exerciseIds) { + final day = int.parse(dayStr); + exercisesByDay[day] = (exerciseIds as List).cast().toSet(); + }); + + completedExercisesMap[language] = exercisesByDay; + }); + } + return Progress( completedLessons: completedLessonsMap, currentDay: currentDayMap, + completedExercises: completedExercisesMap, ); } } diff --git a/lib/models/vocabulary.dart b/lib/models/vocabulary.dart new file mode 100644 index 0000000..3d4c6c3 --- /dev/null +++ b/lib/models/vocabulary.dart @@ -0,0 +1,47 @@ +class VocabularyItem { + final String word; + final String translation; + final String? phonetic; + final String? example; + final String? exampleTranslation; + final String? audioPath; + final String? imageUrl; + final List? tags; + + VocabularyItem({ + required this.word, + required this.translation, + this.phonetic, + this.example, + this.exampleTranslation, + this.audioPath, + this.imageUrl, + this.tags, + }); + + Map toJson() { + return { + 'word': word, + 'translation': translation, + 'phonetic': phonetic, + 'example': example, + 'exampleTranslation': exampleTranslation, + 'audioPath': audioPath, + 'imageUrl': imageUrl, + 'tags': tags, + }; + } + + factory VocabularyItem.fromJson(Map json) { + return VocabularyItem( + word: json['word'], + translation: json['translation'], + phonetic: json['phonetic'], + example: json['example'], + exampleTranslation: json['exampleTranslation'], + audioPath: json['audioPath'], + imageUrl: json['imageUrl'], + tags: json['tags'] != null ? List.from(json['tags']) : null, + ); + } +} diff --git a/lib/screens/lesson_screen.dart b/lib/screens/lesson_screen.dart index 59da2ce..dcf5065 100644 --- a/lib/screens/lesson_screen.dart +++ b/lib/screens/lesson_screen.dart @@ -5,6 +5,7 @@ import '../models/language.dart'; import '../models/lesson.dart'; import '../services/progress_service.dart'; import '../utils/app_localizations.dart'; +import 'practice_screen.dart'; class LessonScreen extends StatefulWidget { final Language language; @@ -29,19 +30,30 @@ class _LessonScreenState extends State { Duration _position = Duration.zero; String _lessonText = ''; + bool _showTranslations = false; + bool _isLoadingContent = false; + @override void initState() { super.initState(); _currentDay = widget.initialDay; _currentLesson = Lesson.create(_currentDay, widget.language); _setupAudioPlayer(); - _loadLessonText(); + _loadLessonContent(); } - Future _loadLessonText() async { + Future _loadLessonContent() async { + setState(() { + _isLoadingContent = true; + }); + final text = await _currentLesson.loadTextContent(); + await _currentLesson.loadExercises(); + await _currentLesson.loadVocabulary(); + setState(() { _lessonText = text; + _isLoadingContent = false; }); } @@ -94,10 +106,11 @@ class _LessonScreenState extends State { _currentLesson = Lesson.create(_currentDay, widget.language); _position = Duration.zero; _isPlaying = false; + _showTranslations = false; }); _audioPlayer.stop(); - _loadLessonText(); + _loadLessonContent(); } String _formatDuration(Duration duration) { @@ -295,6 +308,222 @@ class _LessonScreenState extends State { const SizedBox(height: 16), + // Practice Section + if (_currentLesson.hasExercises) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.quiz, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Interactive Practice', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Consumer( + builder: (context, progressService, child) { + final completedCount = progressService.getCompletedExercisesCount( + widget.language, + _currentDay, + ); + final totalCount = _currentLesson.totalExercises; + final progress = totalCount > 0 ? completedCount / totalCount : 0.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progress:', + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + '$completedCount / $totalCount exercises', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PracticeScreen( + lesson: _currentLesson, + language: widget.language, + ), + ), + ); + }, + icon: const Icon(Icons.play_arrow), + label: Text( + completedCount == 0 + ? 'Start Practice' + : completedCount == totalCount + ? 'Review Exercises' + : 'Continue Practice', + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + backgroundColor: Theme.of(context).colorScheme.secondary, + foregroundColor: Colors.white, + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + + if (_currentLesson.hasExercises) const SizedBox(height: 16), + + // Vocabulary Section + if (_currentLesson.hasVocabulary) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.book, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Vocabulary', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + TextButton.icon( + onPressed: () { + setState(() { + _showTranslations = !_showTranslations; + }); + }, + icon: Icon(_showTranslations ? Icons.visibility_off : Icons.visibility), + label: Text(_showTranslations ? 'Hide' : 'Show'), + ), + ], + ), + const SizedBox(height: 16), + ..._currentLesson.vocabulary!.map((vocab) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + vocab.word, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (vocab.phonetic != null) + Text( + vocab.phonetic!, + style: TextStyle( + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + if (_showTranslations) ...[ + const SizedBox(height: 8), + Text( + vocab.translation, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + if (vocab.example != null && _showTranslations) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + vocab.example!, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + if (vocab.exampleTranslation != null) ...[ + const SizedBox(height: 4), + Text( + vocab.exampleTranslation!, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ], + ), + ), + ], + ], + ), + ), + ); + }), + ], + ), + ), + ), + + if (_currentLesson.hasVocabulary) const SizedBox(height: 16), + // Mark Complete Button ElevatedButton.icon( onPressed: () async { diff --git a/lib/screens/practice_screen.dart b/lib/screens/practice_screen.dart new file mode 100644 index 0000000..58a02cb --- /dev/null +++ b/lib/screens/practice_screen.dart @@ -0,0 +1,579 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/language.dart'; +import '../models/lesson.dart'; +import '../models/exercise.dart'; +import '../services/progress_service.dart'; +import '../utils/app_localizations.dart'; + +class PracticeScreen extends StatefulWidget { + final Lesson lesson; + final Language language; + + const PracticeScreen({ + super.key, + required this.lesson, + required this.language, + }); + + @override + State createState() => _PracticeScreenState(); +} + +class _PracticeScreenState extends State { + int _currentExerciseIndex = 0; + Map _userAnswers = {}; + Map _exerciseResults = {}; + bool _showFeedback = false; + bool _allExercisesCompleted = false; + + @override + void initState() { + super.initState(); + // Initialize with already completed exercises + final progressService = Provider.of(context, listen: false); + final completedIds = progressService.getCompletedExerciseIds( + widget.language, + widget.lesson.day, + ); + + for (final exerciseId in completedIds) { + _exerciseResults[exerciseId] = true; + } + } + + Exercise get _currentExercise => widget.lesson.exercises![_currentExerciseIndex]; + + bool get _canProceed => _exerciseResults[_currentExercise.id] == true; + + int get _completedCount => _exerciseResults.values.where((v) => v == true).length; + + int get _totalExercises => widget.lesson.exercises?.length ?? 0; + + void _submitAnswer() { + final answer = _userAnswers[_currentExercise.id]; + if (answer == null) return; + + final isCorrect = _currentExercise.checkAnswer(answer); + + setState(() { + _exerciseResults[_currentExercise.id] = isCorrect; + _showFeedback = true; + }); + + if (isCorrect) { + final progressService = Provider.of(context, listen: false); + progressService.markExerciseComplete( + widget.language, + widget.lesson.day, + _currentExercise.id, + ); + } + } + + void _nextExercise() { + if (_currentExerciseIndex < _totalExercises - 1) { + setState(() { + _currentExerciseIndex++; + _showFeedback = false; + }); + } else { + setState(() { + _allExercisesCompleted = true; + }); + } + } + + void _previousExercise() { + if (_currentExerciseIndex > 0) { + setState(() { + _currentExerciseIndex--; + _showFeedback = false; + }); + } + } + + Widget _buildExerciseWidget() { + final exercise = _currentExercise; + + if (exercise is MultipleChoiceExercise) { + return _buildMultipleChoice(exercise); + } else if (exercise is FillInBlankExercise) { + return _buildFillInBlank(exercise); + } else if (exercise is MatchingExercise) { + return _buildMatching(exercise); + } else if (exercise is TranslationExercise) { + return _buildTranslation(exercise); + } + + return const Text('Unknown exercise type'); + } + + Widget _buildMultipleChoice(MultipleChoiceExercise exercise) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + ...List.generate(exercise.options.length, (index) { + final isSelected = _userAnswers[exercise.id] == index; + final showResult = _showFeedback && isSelected; + final isCorrect = index == exercise.correctOptionIndex; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: OutlinedButton( + onPressed: _showFeedback + ? null + : () { + setState(() { + _userAnswers[exercise.id] = index; + }); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.all(16), + backgroundColor: showResult + ? (_exerciseResults[exercise.id] == true + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1)) + : (isSelected + ? Theme.of(context).colorScheme.primaryContainer + : null), + side: BorderSide( + color: showResult + ? (_exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red) + : (isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey), + width: 2, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + exercise.options[index], + style: TextStyle( + color: showResult + ? (_exerciseResults[exercise.id] == true + ? Colors.green.shade700 + : Colors.red.shade700) + : null, + ), + ), + ), + if (showResult) + Icon( + _exerciseResults[exercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red, + ), + if (_showFeedback && isCorrect && !isSelected) + const Icon(Icons.check_circle, color: Colors.green), + ], + ), + ), + ); + }), + ], + ); + } + + Widget _buildFillInBlank(FillInBlankExercise exercise) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + TextField( + enabled: !_showFeedback, + onChanged: (value) { + setState(() { + _userAnswers[exercise.id] = value; + }); + }, + decoration: InputDecoration( + hintText: 'Type your answer here...', + border: const OutlineInputBorder(), + suffixIcon: _showFeedback + ? Icon( + _exerciseResults[exercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red, + ) + : null, + ), + ), + if (_showFeedback && _exerciseResults[exercise.id] == false) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: Text( + 'Correct answer: ${exercise.correctAnswer}', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + } + + Widget _buildMatching(MatchingExercise exercise) { + final leftItems = exercise.pairs.map((p) => p.left).toList(); + final rightItems = exercise.pairs.map((p) => p.right).toList()..shuffle(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + const Text('Match the items (tap to select, then tap the matching item):'), + const SizedBox(height: 16), + // For simplicity, showing a message - full implementation would need drag-and-drop + const Text( + 'Matching exercise UI - Tap items to connect them', + style: TextStyle(fontStyle: FontStyle.italic), + ), + // Simplified version: display pairs + ...exercise.pairs.map((pair) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded(child: Text(pair.left)), + const Icon(Icons.arrow_forward), + Expanded(child: Text(pair.right)), + ], + ), + ), + ); + }), + ], + ); + } + + Widget _buildTranslation(TranslationExercise exercise) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + exercise.question, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + exercise.targetText, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 24), + TextField( + enabled: !_showFeedback, + onChanged: (value) { + setState(() { + _userAnswers[exercise.id] = value; + }); + }, + decoration: InputDecoration( + hintText: 'Type your translation...', + border: const OutlineInputBorder(), + suffixIcon: _showFeedback + ? Icon( + _exerciseResults[exercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[exercise.id] == true + ? Colors.green + : Colors.red, + ) + : null, + ), + maxLines: 3, + ), + if (_showFeedback && _exerciseResults[exercise.id] == false) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Correct translation:', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + exercise.correctTranslation, + style: TextStyle(color: Colors.green.shade700), + ), + ], + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + + if (_allExercisesCompleted) { + return Scaffold( + appBar: AppBar( + title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.celebration, + size: 100, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 24), + Text( + 'Congratulations!', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + 'You\'ve completed all exercises for this lesson!', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + '$_completedCount / $_totalExercises exercises completed', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back), + label: const Text('Back to Lesson'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + ), + ), + ], + ), + ), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'), + actions: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Chip( + avatar: Text( + widget.language.flag, + style: const TextStyle(fontSize: 16), + ), + label: Text( + '$_completedCount / $_totalExercises', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + body: Column( + children: [ + // Progress indicator + LinearProgressIndicator( + value: _totalExercises > 0 ? _completedCount / _totalExercises : 0, + minHeight: 8, + ), + Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Exercise counter + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Exercise ${_currentExerciseIndex + 1} of $_totalExercises', + style: Theme.of(context).textTheme.titleMedium, + ), + if (_exerciseResults[_currentExercise.id] == true) + const Chip( + avatar: Icon(Icons.check, size: 16), + label: Text('Completed'), + backgroundColor: Colors.green, + labelStyle: TextStyle(color: Colors.white), + ), + ], + ), + const SizedBox(height: 24), + + // Exercise content + _buildExerciseWidget(), + + const SizedBox(height: 32), + + // Feedback message + if (_showFeedback) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green + : Colors.red, + width: 2, + ), + ), + child: Row( + children: [ + Icon( + _exerciseResults[_currentExercise.id] == true + ? Icons.check_circle + : Icons.cancel, + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green + : Colors.red, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _exerciseResults[_currentExercise.id] == true + ? 'Excellent! That\'s correct!' + : 'Not quite right. Try reviewing the lesson content.', + style: TextStyle( + color: _exerciseResults[_currentExercise.id] == true + ? Colors.green.shade700 + : Colors.red.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + if (!_showFeedback) + ElevatedButton( + onPressed: _userAnswers.containsKey(_currentExercise.id) + ? _submitAnswer + : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + ), + child: const Text('Submit Answer'), + ), + + if (_showFeedback) + ElevatedButton( + onPressed: _canProceed ? _nextExercise : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(16), + ), + child: Text( + _currentExerciseIndex < _totalExercises - 1 + ? 'Next Exercise' + : 'Finish Practice', + ), + ), + + const SizedBox(height: 16), + + // Navigation buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _currentExerciseIndex > 0 ? _previousExercise : null, + icon: const Icon(Icons.arrow_back), + label: const Text('Previous'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: OutlinedButton.icon( + onPressed: _currentExerciseIndex < _totalExercises - 1 + ? _nextExercise + : null, + icon: const Icon(Icons.arrow_forward), + label: const Text('Skip'), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/progress_service.dart b/lib/services/progress_service.dart index 9c48f54..448a686 100644 --- a/lib/services/progress_service.dart +++ b/lib/services/progress_service.dart @@ -48,6 +48,7 @@ class ProgressService extends ChangeNotifier { _progress = Progress( completedLessons: newCompletedLessons, currentDay: newCurrentDay, + completedExercises: _progress.completedExercises, ); await _saveProgress(); @@ -64,6 +65,7 @@ class ProgressService extends ChangeNotifier { _progress = Progress( completedLessons: newCompletedLessons, currentDay: _progress.currentDay, + completedExercises: _progress.completedExercises, ); await _saveProgress(); @@ -85,4 +87,41 @@ class ProgressService extends ChangeNotifier { double getProgress(Language language) { return _progress.getProgress(language); } + + Future markExerciseComplete(Language language, int day, String exerciseId) async { + final newCompletedExercises = Map>>.from(_progress.completedExercises); + + if (!newCompletedExercises.containsKey(language)) { + newCompletedExercises[language] = {}; + } + + if (!newCompletedExercises[language]!.containsKey(day)) { + newCompletedExercises[language]![day] = {}; + } + + final dayExercises = Set.from(newCompletedExercises[language]![day]!); + dayExercises.add(exerciseId); + newCompletedExercises[language]![day] = dayExercises; + + _progress = Progress( + completedLessons: _progress.completedLessons, + currentDay: _progress.currentDay, + completedExercises: newCompletedExercises, + ); + + await _saveProgress(); + notifyListeners(); + } + + bool isExerciseCompleted(Language language, int day, String exerciseId) { + return _progress.isExerciseCompleted(language, day, exerciseId); + } + + int getCompletedExercisesCount(Language language, int day) { + return _progress.getCompletedExercisesCount(language, day); + } + + Set getCompletedExerciseIds(Language language, int day) { + return _progress.getCompletedExerciseIds(language, day); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 9492693..0c6b229 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,8 @@ flutter: - assets/audio/ - assets/translations/ - assets/lessons/ + - assets/exercises/ + - assets/vocabulary/ # fonts: # - family: Poppins From b841cc8d3be7f11aed817b451f7892a8ade9422d Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 14:56:46 -0500 Subject: [PATCH 4/9] Manage audio player stream subscriptions safely Introduces explicit StreamSubscription fields for audio player events and ensures they are cancelled in dispose. Adds mounted checks before calling setState in stream listeners and async methods to prevent state updates after widget disposal. --- lib/screens/lesson_screen.dart | 61 ++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/lib/screens/lesson_screen.dart b/lib/screens/lesson_screen.dart index dcf5065..fd26154 100644 --- a/lib/screens/lesson_screen.dart +++ b/lib/screens/lesson_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:audioplayers/audioplayers.dart'; @@ -33,6 +34,12 @@ class _LessonScreenState extends State { bool _showTranslations = false; bool _isLoadingContent = false; + // Stream subscriptions for audio player + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playerStateSubscription; + StreamSubscription? _playerCompleteSubscription; + @override void initState() { super.initState(); @@ -43,6 +50,8 @@ class _LessonScreenState extends State { } Future _loadLessonContent() async { + if (!mounted) return; + setState(() { _isLoadingContent = true; }); @@ -51,6 +60,8 @@ class _LessonScreenState extends State { await _currentLesson.loadExercises(); await _currentLesson.loadVocabulary(); + if (!mounted) return; + setState(() { _lessonText = text; _isLoadingContent = false; @@ -58,35 +69,51 @@ class _LessonScreenState extends State { } void _setupAudioPlayer() { - _audioPlayer.onDurationChanged.listen((duration) { - setState(() { - _duration = duration; - }); + _durationSubscription = _audioPlayer.onDurationChanged.listen((duration) { + if (mounted) { + setState(() { + _duration = duration; + }); + } }); - _audioPlayer.onPositionChanged.listen((position) { - setState(() { - _position = position; - }); + _positionSubscription = _audioPlayer.onPositionChanged.listen((position) { + if (mounted) { + setState(() { + _position = position; + }); + } }); - _audioPlayer.onPlayerStateChanged.listen((state) { - setState(() { - _isPlaying = state == PlayerState.playing; - }); + _playerStateSubscription = _audioPlayer.onPlayerStateChanged.listen((state) { + if (mounted) { + setState(() { + _isPlaying = state == PlayerState.playing; + }); + } }); - _audioPlayer.onPlayerComplete.listen((_) { - setState(() { - _isPlaying = false; - _position = Duration.zero; - }); + _playerCompleteSubscription = _audioPlayer.onPlayerComplete.listen((_) { + if (mounted) { + setState(() { + _isPlaying = false; + _position = Duration.zero; + }); + } }); } @override void dispose() { + // Cancel all stream subscriptions before disposing the audio player + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _playerStateSubscription?.cancel(); + _playerCompleteSubscription?.cancel(); + + // Dispose the audio player _audioPlayer.dispose(); + super.dispose(); } From c65176ead239f610e7416fd3f93f19b0f7d44783 Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 15:52:37 -0500 Subject: [PATCH 5/9] Update lib/theme/app_theme.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/theme/app_theme.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 750aa81..aa5e87d 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -39,7 +39,6 @@ class AppTheme { secondary: secondaryGreen, tertiary: accentOrange, surface: lightSurface, - background: lightBackground, onSurface: lightOnSurface, ), scaffoldBackgroundColor: lightBackground, From 4bad0d754fb7e48e1725e86415191e4bd71a871e Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 15:53:00 -0500 Subject: [PATCH 6/9] Update lib/theme/app_theme.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/theme/app_theme.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index aa5e87d..a7f1b76 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -127,7 +127,6 @@ class AppTheme { secondary: secondaryGreen, tertiary: accentOrange, surface: darkSurface, - background: darkBackground, onSurface: darkOnSurface, ), scaffoldBackgroundColor: darkBackground, From 239aacaf358130926399b064ff405c0da044db8d Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 15:53:28 -0500 Subject: [PATCH 7/9] Update lib/services/gamification_service.dart Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/services/gamification_service.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/services/gamification_service.dart b/lib/services/gamification_service.dart index d6708ba..a3a49bc 100644 --- a/lib/services/gamification_service.dart +++ b/lib/services/gamification_service.dart @@ -256,6 +256,7 @@ class GamificationService extends ChangeNotifier { void clearRecentlyUnlocked() { _recentlyUnlocked.clear(); + notifyListeners(); } static String _formatDate(DateTime date) { From 042c7082ef030963f36aa11aca2b8149fde3507f Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 16:07:46 -0500 Subject: [PATCH 8/9] Add Kotlin compiler session file Added a new Kotlin compiler session file to track active sessions. This may assist with incremental compilation or IDE integration. --- .../.kotlin/sessions/kotlin-compiler-17654054680533376704.salive | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 android/.kotlin/sessions/kotlin-compiler-17654054680533376704.salive diff --git a/android/.kotlin/sessions/kotlin-compiler-17654054680533376704.salive b/android/.kotlin/sessions/kotlin-compiler-17654054680533376704.salive new file mode 100644 index 0000000..e69de29 From 6e7aa2d49b8240e9f1ea1b794368ab119073771a Mon Sep 17 00:00:00 2001 From: Donnivis Baker Date: Sat, 8 Nov 2025 16:20:54 -0500 Subject: [PATCH 9/9] Refactor language selection to use bottom sheet Replaces the in-page language selection and day grid in HomeScreen with a modal bottom sheet (DailyLessonsSheet) for daily lesson navigation. Improves UI by decoupling language selection from the main screen and providing paginated lesson access. --- lib/screens/home_screen.dart | 68 ++---- lib/widgets/daily_lessons_sheet.dart | 322 +++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 54 deletions(-) create mode 100644 lib/widgets/daily_lessons_sheet.dart diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ae64200..db8b47c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -10,9 +10,8 @@ import '../services/settings_service.dart'; import '../utils/app_localizations.dart'; import '../widgets/language_card.dart'; import '../widgets/course_structure.dart'; -import '../widgets/day_grid.dart'; +import '../widgets/daily_lessons_sheet.dart'; import '../theme/app_theme.dart'; -import 'lesson_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -22,8 +21,6 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - Language? _selectedLanguage; - @override Widget build(BuildContext context) { final languageService = Provider.of(context); @@ -160,26 +157,12 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Select Learning Language', - style: - Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - if (_selectedLanguage != null) - IconButton( - icon: const Icon(Icons.close), - onPressed: () { - setState(() { - _selectedLanguage = null; - }); - }, - ), - ], + Text( + 'Select Learning Language', + style: + Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), ), const SizedBox(height: 16), ...Language.values.asMap().entries.map((entry) { @@ -195,11 +178,9 @@ class _HomeScreenState extends State { completedCount: completedCount, currentDay: currentDay, progress: progress, - isSelected: _selectedLanguage == language, + isSelected: false, onTap: () { - setState(() { - _selectedLanguage = language; - }); + DailyLessonsSheet.show(context, language); }, ) .animate() @@ -211,31 +192,10 @@ class _HomeScreenState extends State { ), // Course Structure - if (_selectedLanguage == null) - const Padding( - padding: EdgeInsets.all(16), - child: CourseStructure(), - ).animate().fadeIn(delay: 800.ms), - - // Day Grid (shown when language is selected) - if (_selectedLanguage != null) - Padding( - padding: const EdgeInsets.all(16), - child: DayGrid( - language: _selectedLanguage!, - onDaySelected: (day) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LessonScreen( - language: _selectedLanguage!, - initialDay: day, - ), - ), - ); - }, - ), - ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.1), + const Padding( + padding: EdgeInsets.all(16), + child: CourseStructure(), + ).animate().fadeIn(delay: 800.ms), ], ), ), @@ -250,7 +210,7 @@ class _HomeScreenState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), ), child: Column( diff --git a/lib/widgets/daily_lessons_sheet.dart b/lib/widgets/daily_lessons_sheet.dart new file mode 100644 index 0000000..b30126d --- /dev/null +++ b/lib/widgets/daily_lessons_sheet.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../models/language.dart'; +import '../services/progress_service.dart'; +import '../screens/lesson_screen.dart'; + +class DailyLessonsSheet extends StatefulWidget { + final Language language; + + const DailyLessonsSheet({ + super.key, + required this.language, + }); + + @override + State createState() => _DailyLessonsSheetState(); + + static void show(BuildContext context, Language language) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DailyLessonsSheet(language: language), + ); + } +} + +class _DailyLessonsSheetState extends State { + int _currentPage = 0; + final int _daysPerPage = 10; + + @override + Widget build(BuildContext context) { + final progressService = Provider.of(context); + final startDay = _currentPage * _daysPerPage + 1; + final endDay = (startDay + _daysPerPage - 1).clamp(1, 50); + + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.5, + maxChildSize: 0.9, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + spreadRadius: 5, + ), + ], + ), + child: Column( + children: [ + // Drag handle + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header with language info + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Text( + widget.language.flag, + style: const TextStyle(fontSize: 32), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.language.name, + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${progressService.getCompletedCount(widget.language)} of 50 lessons completed', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ).animate().fadeIn(duration: 300.ms).slideY(begin: -0.2), + + const Divider(height: 1), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Page indicator + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Daily Lessons', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Days $startDay-$endDay', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Day grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: endDay - startDay + 1, + itemBuilder: (context, index) { + final day = startDay + index; + final isCompleted = progressService + .isLessonCompleted(widget.language, day); + final isCurrent = + day == progressService.getCurrentDay(widget.language); + + return InkWell( + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => LessonScreen( + language: widget.language, + initialDay: day, + ), + ), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + color: isCompleted + ? Theme.of(context).colorScheme.secondary + : isCurrent + ? Theme.of(context) + .colorScheme + .primaryContainer + : Colors.grey[200], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isCurrent + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + width: 2, + ), + boxShadow: isCurrent + ? [ + BoxShadow( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 1, + ), + ] + : null, + ), + child: Stack( + children: [ + Center( + child: Text( + '$day', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isCompleted + ? Colors.white + : isCurrent + ? Theme.of(context) + .colorScheme + .primary + : Colors.grey[700], + ), + ), + ), + if (isCompleted) + const Positioned( + top: 4, + right: 4, + child: Icon( + Icons.check_circle, + size: 18, + color: Colors.white, + ), + ), + ], + ), + ), + ) + .animate() + .fadeIn(delay: (50 * index).ms) + .scale(begin: const Offset(0.8, 0.8)); + }, + ), + + const SizedBox(height: 24), + + // Navigation buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OutlinedButton.icon( + onPressed: _currentPage > 0 + ? () { + setState(() { + _currentPage--; + }); + } + : null, + icon: const Icon(Icons.arrow_back), + label: const Text('Previous'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primary + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Page ${_currentPage + 1}/5', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + OutlinedButton.icon( + onPressed: _currentPage < 4 + ? () { + setState(() { + _currentPage++; + }); + } + : null, + icon: const Icon(Icons.arrow_forward), + label: const Text('Next'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } +}