diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c2dd560..1b6089d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,40 +1,59 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS - connectivity_plus (0.0.1): - Flutter - Flutter (1.0.0) - flutter_secure_storage (6.0.0): - Flutter + - flutter_tts (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - record_ios (1.1.0): + - Flutter - sqflite_darwin (0.0.4): - Flutter - FlutterMacOS DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_tts: + :path: ".symlinks/plugins/flutter_tts/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" sqflite_darwin: :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d52c9d8..bb7e942 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -20,6 +20,8 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + NSMicrophoneUsageDescription + Some message to describe why you need this permission CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 8ffe5cc..eb11b7a 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -1,7 +1,10 @@ +import 'package:base_flutter/features/lesson/lesson_screen.dart'; +import 'package:base_flutter/features/loading_lesson/loading_lesson_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../features/home/models.dart'; import 'route_names.dart'; import 'route_paths.dart'; import 'route_guards.dart'; @@ -27,11 +30,39 @@ final routerProvider = Provider((ref) { path: RoutePaths.home, name: RouteNames.home, pageBuilder: (context, state) => _buildPageWithTransition( - child: const HomePage(), + child: const LevelMapScreen(), state: state, ), ), + GoRoute( + path: RoutePaths.lesson, + name: RouteNames.lesson, + pageBuilder: (context, state) => _buildPageWithTransition( + child: const LoadingLessonScreen(), + state: state, + ), + routes: [ + GoRoute( + path: 'test', + name: 'test', + pageBuilder: (context, state) { + final lessonResponse = state.extra as LessonResponse?; + if (lessonResponse == null) { + return _buildPageWithTransition( + child: const _PlaceholderPage(title: 'No lesson data provided'), + state: state, + ); + } + return _buildPageWithTransition( + child: LessonScreen(lessonResponse: lessonResponse), + state: state, + ); + }, + ), + ] + ), + // Settings routes with nested navigation GoRoute( path: RoutePaths.settings, diff --git a/lib/core/routing/route_names.dart b/lib/core/routing/route_names.dart index 9e03c78..b2f82ed 100644 --- a/lib/core/routing/route_names.dart +++ b/lib/core/routing/route_names.dart @@ -7,6 +7,7 @@ class RouteNames { static const String settings = 'settings'; static const String profile = 'profile'; static const String about = 'about'; + static const String lesson = 'lesson'; // Auth routes static const String login = 'login'; diff --git a/lib/core/routing/route_paths.dart b/lib/core/routing/route_paths.dart index 04f9146..6b38cde 100644 --- a/lib/core/routing/route_paths.dart +++ b/lib/core/routing/route_paths.dart @@ -7,6 +7,7 @@ class RoutePaths { static const String settings = '/settings'; static const String profile = '/profile'; static const String about = '/about'; + static const String lesson = '/lesson'; // Auth routes static const String login = '/auth/login'; diff --git a/lib/features/home/models.dart b/lib/features/home/models.dart new file mode 100644 index 0000000..86d4293 --- /dev/null +++ b/lib/features/home/models.dart @@ -0,0 +1,79 @@ + + +class LessonResponse { + final String image; // base64 string + final String response; + final WidgetData widget; + + LessonResponse({ + required this.image, + required this.response, + required this.widget, + }); + + factory LessonResponse.fromJson(Map json) { + return LessonResponse( + image: json['image'] as String, + response: json['response'] as String, + widget: WidgetData.fromJson(json['widget']), + ); + } + + Map toJson() { + return { + 'image': image, + 'response': response, + 'widget': widget.toJson(), + }; + } +} + +class WidgetData { + final TapWidget tapToRecordWidget; + final TapWidget tapToSpeechWidget; + + WidgetData({ + required this.tapToRecordWidget, + required this.tapToSpeechWidget, + }); + + factory WidgetData.fromJson(Map json) { + return WidgetData( + tapToRecordWidget: + TapWidget.fromJson(json['tap_to_record_widget']), + tapToSpeechWidget: + TapWidget.fromJson(json['tap_to_speech_widget']), + ); + } + + Map toJson() { + return { + 'tap_to_record_widget': tapToRecordWidget.toJson(), + 'tap_to_speech_widget': tapToSpeechWidget.toJson(), + }; + } +} + +class TapWidget { + final String content; + final String position; + + TapWidget({ + required this.content, + required this.position, + }); + + factory TapWidget.fromJson(Map json) { + return TapWidget( + content: json['content'] as String, + position: json['position'] as String, + ); + } + + Map toJson() { + return { + 'content': content, + 'position': position, + }; + } +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 3e1b3d3..bf9548e 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -6,506 +8,505 @@ import '../../../../core/routing/route_guards.dart'; import '../../../../shared/presentation/providers/app_providers.dart'; /// Home page with navigation to different features -class HomePage extends ConsumerWidget { - const HomePage({super.key}); +class LevelMapScreen extends StatefulWidget { + const LevelMapScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final authState = ref.watch(authStateProvider); - final themeMode = ref.watch(themeModeProvider); + State createState() => _LevelMapScreenState(); +} +class _LevelMapScreenState extends State { + final ScrollController _scroll = ScrollController(); + + // ---- Dynamic level data (change these) ---- + final int totalLevels = 60; // total levels you want to show + final int currentLevelIndex = 4; // 0-based index of current level + + // ---- Layout knobs (tweak to taste) ---- + static const double kNodeSpacing = 220; // px along the path between bubbles + static const double kStartOffset = 140; // first bubble offset from path start + static const double kVerticalStep = 270; // vertical descent per circle loop + + // The scrollable map height is finite and derived from how many levels we show. + double get mapHeight { + return _calculateRequiredHeight( + totalLevels: totalLevels, + nodeSpacing: kNodeSpacing, + startOffset: kStartOffset, + ); + } + + /// Calculate the actual vertical height needed for circular path + double _calculateRequiredHeight({ + required int totalLevels, + required double nodeSpacing, + required double startOffset, + }) { + // Path parameters (must match _buildCircularPath) + const double baseRadius = 0.32; // percentage of width + const double topPadding = 100.0; + const double bottomPadding = 100.0; + + // Estimate circle circumference (approximation for cubic bezier circle) + final screenWidth = MediaQuery.of(context).size.width; + final radiusInPixels = screenWidth * baseRadius; + final approximateCircleLength = 2 * math.pi * radiusInPixels; + + // Total path length needed for all nodes + final totalPathLength = startOffset + (totalLevels - 1) * nodeSpacing; + + // Calculate how many circles we need + final numberOfCircles = (totalPathLength / approximateCircleLength).ceil(); + + // Calculate actual vertical height + final contentHeight = numberOfCircles * kVerticalStep; + final totalHeight = topPadding + contentHeight + bottomPadding; + + return math.max(1200, totalHeight); + } + + double? _currentLevelY; // used to auto-scroll near the current level + + @override + void initState() { + super.initState(); + // Auto-scroll after first layout + WidgetsBinding.instance.addPostFrameCallback((_) { + final y = _currentLevelY ?? 0; + final screenH = MediaQuery.of(context).size.height; + final target = (y - screenH * 0.45).clamp(0.0, mapHeight); + _scroll.animateTo( + target, + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + ); + }); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Base Flutter App'), - actions: [ - // Theme toggle button - IconButton( - onPressed: () { - ref.read(themeModeProvider.notifier).toggleTheme(); + backgroundColor: const Color(0xFFFFB43E), // warm island background + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: LayoutBuilder( + builder: (ctx, c) { + return SingleChildScrollView( + controller: _scroll, + child: SizedBox( + height: mapHeight, // <-- finite height + width: c.maxWidth, + child: LevelMap( + totalLevels: totalLevels, + currentIndex: currentLevelIndex, + nodeSpacing: kNodeSpacing, + startOffsetOnPath: kStartOffset, + verticalStep: kVerticalStep, + showFinishAtEnd: true, + onCurrentLevelPositionResolved: (dy) { + _currentLevelY = dy; + }, + ), + ), + ); + }, + ), + ), + _buildBottomNav(), + ], + ), + ), + ); + } + + Widget _buildHeader() => Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 12), + child: Row( + children: [ + IconButton(onPressed: () {}, icon: const Icon(Icons.arrow_back)), + const Expanded( + child: Text( + 'Kho báu đảo hoang', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + IconButton(onPressed: () {}, icon: const Icon(Icons.refresh)), + ], + ), + ); + + Widget _buildBottomNav() => Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), topRight: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, -2), + ) + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: const [ + _Nav(icon: Icons.home, label: 'Trang chủ'), + _Nav(icon: Icons.location_on, label: 'Khám phá', active: true), + _Nav(icon: Icons.bar_chart, label: 'Xếp hạng'), + _Nav(icon: Icons.people, label: 'Kết nối'), + _Nav(icon: Icons.person, label: 'Cá nhân'), + ], + ), + ), + ), + ); +} + +class _Nav extends StatelessWidget { + final IconData icon; + final String label; + final bool active; + const _Nav({required this.icon, required this.label, this.active = false, super.key}); + @override + Widget build(BuildContext context) { + return Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: active ? Colors.orange : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: active ? Colors.white : Colors.grey)), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: active ? Colors.orange : Colors.grey, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), + ), + ]); + } +} + +/// Draws the path and positions dynamic level nodes **along the path**. +class LevelMap extends StatelessWidget { + final int totalLevels; + final int currentIndex; + final double nodeSpacing; + final double startOffsetOnPath; + final double verticalStep; + final bool showFinishAtEnd; + final ValueChanged? onCurrentLevelPositionResolved; + + const LevelMap({ + super.key, + required this.totalLevels, + required this.currentIndex, + required this.nodeSpacing, + required this.startOffsetOnPath, + required this.verticalStep, + this.showFinishAtEnd = false, + this.onCurrentLevelPositionResolved, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (ctx, c) { + final size = Size(c.maxWidth, c.maxHeight); + + // Build a circular/spiral path that fills this finite canvas. + final path = _buildCircularPath(size, verticalStep); + + // Measure the path + final metrics = path.computeMetrics().toList(); + if (metrics.isEmpty) return const SizedBox.shrink(); + final metric = metrics.first; // single contour + final totalLen = metric.length; + + // Required length to host all visible levels + final requiredLen = + startOffsetOnPath + (math.max(1, totalLevels) - 1) * nodeSpacing; + + // Clamp to available path length + final effectiveLen = requiredLen.clamp(0, totalLen - 1); + + // Compute bubble positions + final nodes = []; + const bubbleRadius = 28.0; // 56x56 + const eps = 0.001; + + double d = startOffsetOnPath; + Offset? lastNodePosition; // Track last node position for finish flag + + for (int i = 0; i < totalLevels; i++) { + final clamped = d.clamp(0, totalLen - eps); + final tan = metric.getTangentForOffset(clamped.toDouble())!; + final pos = tan.position; + + // Store last node position + if (i == totalLevels - 1) { + lastNodePosition = pos; + } + + nodes.add(Positioned( + left: pos.dx - bubbleRadius, + top: pos.dy - bubbleRadius, + child: LevelNode( + level: i + 1, + isCompleted: i < currentIndex, + isCurrent: i == currentIndex, + isLocked: i > currentIndex, + onTap: i > currentIndex ? null : () { + debugPrint('Level ${i + 1} tapped'); + context.pushNamed('lesson'); }, - icon: Icon( - themeMode == ThemeMode.dark - ? Icons.light_mode - : themeMode == ThemeMode.light - ? Icons.dark_mode - : Icons.brightness_auto, - ), - tooltip: 'Toggle theme', ), - // Settings button - IconButton( - onPressed: () => context.push(RoutePaths.settings), - icon: const Icon(Icons.settings), - tooltip: 'Settings', - ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Welcome section - _WelcomeCard(authState: authState), + )); - const SizedBox(height: 24), + d += nodeSpacing; + if (d > totalLen - eps) break; // stop when path ends + } - // Quick actions section - Text( - 'Quick Actions', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), + // Report current Y for auto-scroll + WidgetsBinding.instance.addPostFrameCallback((_) { + if (onCurrentLevelPositionResolved != null && + currentIndex >= 0 && + currentIndex < nodes.length) { + final clamped = (startOffsetOnPath + currentIndex * nodeSpacing) + .clamp(0, totalLen - eps); + final tan = metric.getTangentForOffset(clamped.toDouble())!; + onCurrentLevelPositionResolved!(tan.position.dy); + } + }); - _QuickActionsGrid(), + // Optional finish flag at last level position + if (showFinishAtEnd && lastNodePosition != null) { + nodes.add(Positioned( + left: lastNodePosition.dx - 18, + top: lastNodePosition.dy - 60, + child: const Icon(Icons.flag, color: Colors.red, size: 36), + )); + } - const SizedBox(height: 24), - - // Features section - Text( - 'Features', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - - _FeaturesGrid(), - - const SizedBox(height: 24), - - // Recent activity section - _RecentActivityCard(), - ], - ), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.push(RoutePaths.addTodo), - icon: const Icon(Icons.add), - label: const Text('Add Todo'), - ), - ); - } -} - -class _WelcomeCard extends StatelessWidget { - final AuthState authState; - - const _WelcomeCard({ - required this.authState, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - CircleAvatar( - radius: 24, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Icon( - Icons.person, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - authState == AuthState.authenticated - ? 'Welcome back!' - : 'Welcome!', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - authState == AuthState.authenticated - ? 'Ready to be productive today?' - : 'Get started with the base Flutter app.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - ), - ), - ], - ), - ), - ], - ), - if (authState == AuthState.unauthenticated) ...[ - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => context.push(RoutePaths.login), - child: const Text('Login'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton( - onPressed: () => context.push(RoutePaths.register), - child: const Text('Register'), - ), - ), - ], - ), - ], - ], - ), - ), - ); - } -} - -class _QuickActionsGrid extends StatelessWidget { - @override - Widget build(BuildContext context) { - final quickActions = [ - _QuickAction( - icon: Icons.add_task, - title: 'Add Todo', - subtitle: 'Create a new task', - onTap: () => context.push(RoutePaths.addTodo), - ), - _QuickAction( - icon: Icons.list, - title: 'View Todos', - subtitle: 'See all tasks', - onTap: () => context.push(RoutePaths.todos), - ), - _QuickAction( - icon: Icons.settings, - title: 'Settings', - subtitle: 'App preferences', - onTap: () => context.push(RoutePaths.settings), - ), - _QuickAction( - icon: Icons.info, - title: 'About', - subtitle: 'App information', - onTap: () => context.push(RoutePaths.about), - ), - ]; - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.2, - ), - itemCount: quickActions.length, - itemBuilder: (context, index) { - return _QuickActionCard(action: quickActions[index]); - }, - ); - } -} - -class _FeaturesGrid extends StatelessWidget { - @override - Widget build(BuildContext context) { - final features = [ - _Feature( - icon: Icons.check_circle_outline, - title: 'Todo Management', - description: 'Create, edit, and track your tasks efficiently.', - color: Colors.blue, - onTap: () => context.push(RoutePaths.todos), - ), - _Feature( - icon: Icons.palette, - title: 'Theming', - description: 'Switch between light, dark, and system themes.', - color: Colors.purple, - onTap: () => context.push(RoutePaths.settingsTheme), - ), - _Feature( - icon: Icons.storage, - title: 'Local Storage', - description: 'Hive database integration for offline support.', - color: Colors.orange, - onTap: () => context.push(RoutePaths.settings), - ), - _Feature( - icon: Icons.security, - title: 'Secure Storage', - description: 'Protected storage for sensitive data.', - color: Colors.green, - onTap: () => context.push(RoutePaths.settingsPrivacy), - ), - ]; - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 0.8, - ), - itemCount: features.length, - itemBuilder: (context, index) { - return _FeatureCard(feature: features[index]); - }, - ); - } -} - -class _RecentActivityCard extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Recent Activity', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () => context.push(RoutePaths.todos), - child: const Text('View All'), - ), - ], - ), - const SizedBox(height: 12), - _ActivityItem( - icon: Icons.add_task, - title: 'Welcome to Base Flutter App', - subtitle: 'Your journey starts here', - time: 'Just now', - ), - _ActivityItem( - icon: Icons.info, - title: 'App initialized', - subtitle: 'Database and services ready', - time: 'A few seconds ago', - ), - ], - ), - ), - ); - } -} - -class _QuickActionCard extends StatelessWidget { - final _QuickAction action; - - const _QuickActionCard({ - required this.action, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: InkWell( - onTap: action.onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - action.icon, - size: 32, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(height: 8), - Text( - action.title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text( - action.subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ); - } -} - -class _FeatureCard extends StatelessWidget { - final _Feature feature; - - const _FeatureCard({ - required this.feature, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: InkWell( - onTap: feature.onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: feature.color.withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - feature.icon, - color: feature.color, - size: 24, - ), - ), - const SizedBox(height: 12), - Text( - feature.title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Expanded( - child: Text( - feature.description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _ActivityItem extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final String time; - - const _ActivityItem({ - required this.icon, - required this.title, - required this.subtitle, - required this.time, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Row( + return Stack( + clipBehavior: Clip.none, children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - icon, - size: 16, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - Text( - subtitle, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - ), - ), - ], - ), - ), - Text( - time, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5), - ), - ), + // Cute background bits (optional) + Positioned.fill(child: CustomPaint(painter: DecorationPainter())), + // Sandy road + Positioned.fill(child: CustomPaint(painter: SandPathPainter(path))), + // Bubbles + ...nodes, ], - ), - ); + ); + }); + } + + /// Creates a circular/spiral path that goes in circles while progressing downward + Path _buildCircularPath(Size size, double verticalStep) { + final path = Path(); + + final centerX = size.width * 0.5; + final baseRadius = size.width * 0.32; // radius of the circular motion + + double y = 100; + + // Start at top center + path.moveTo(centerX, y); + + // Create circular loops going downward + while (y < size.height - 100) { + // Create a full circle using cubic bezier curves (4 curves make a circle) + final startY = y; + + // Right curve (0° to 90°) + path.cubicTo( + centerX + baseRadius * 0.55, startY, + centerX + baseRadius, startY + verticalStep * 0.25 - baseRadius * 0.55, + centerX + baseRadius, startY + verticalStep * 0.25, + ); + + // Bottom curve (90° to 180°) + path.cubicTo( + centerX + baseRadius, startY + verticalStep * 0.25 + baseRadius * 0.55, + centerX + baseRadius * 0.55, startY + verticalStep * 0.5, + centerX, startY + verticalStep * 0.5, + ); + + // Left curve (180° to 270°) + path.cubicTo( + centerX - baseRadius * 0.55, startY + verticalStep * 0.5, + centerX - baseRadius, startY + verticalStep * 0.75 - baseRadius * 0.55, + centerX - baseRadius, startY + verticalStep * 0.75, + ); + + // Top curve (270° to 360°) + path.cubicTo( + centerX - baseRadius, startY + verticalStep * 0.75 + baseRadius * 0.55, + centerX - baseRadius * 0.55, startY + verticalStep, + centerX, startY + verticalStep, + ); + + y += verticalStep; + } + + return path; } } -class _QuickAction { - final IconData icon; - final String title; - final String subtitle; - final VoidCallback onTap; +/// Painter for the sand road with subtle depth. +class SandPathPainter extends CustomPainter { + final Path path; + SandPathPainter(this.path); - const _QuickAction({ - required this.icon, - required this.title, - required this.subtitle, - required this.onTap, - }); + @override + void paint(Canvas canvas, Size size) { + // Glow/soft highlight + final glow = Paint() + ..color = const Color(0x26FFFFFF) + ..style = PaintingStyle.stroke + ..strokeWidth = 84 + ..strokeCap = StrokeCap.round + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10); + canvas.drawPath(path, glow); + + // Main sand + final sand = Paint() + ..color = const Color(0xFFFFD18C) + ..style = PaintingStyle.stroke + ..strokeWidth = 64 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + canvas.drawPath(path, sand); + + // Inner shade for depth + final inner = Paint() + ..color = const Color(0xFFEAB66F) + ..style = PaintingStyle.stroke + ..strokeWidth = 36 + ..strokeCap = StrokeCap.round; + canvas.drawPath(path, inner); + } + + @override + bool shouldRepaint(covariant SandPathPainter oldDelegate) => + oldDelegate.path != path; } -class _Feature { - final IconData icon; - final String title; - final String description; - final Color color; - final VoidCallback onTap; +/// Simple decorative painter (palms/hills placeholders). +class DecorationPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final palm = Paint()..color = const Color(0xFF66A060); + final hill = Paint()..color = const Color(0xFFFFE1A8).withOpacity(.35); - const _Feature({ - required this.icon, - required this.title, - required this.description, - required this.color, - required this.onTap, + for (double y = 160; y < size.height; y += 260) { + canvas.drawCircle(Offset(size.width * 0.18, y), 10, palm); + canvas.drawCircle(Offset(size.width * 0.82, y + 70), 10, palm); + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(size.width * 0.5, y + 120), + width: 140, + height: 60, + ), + const Radius.circular(30), + ), + hill, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class LevelNode extends StatelessWidget { + final int level; + final bool isCompleted; + final bool isCurrent; + final bool isLocked; + final VoidCallback? onTap; + + const LevelNode({ + super.key, + required this.level, + required this.isCompleted, + required this.isCurrent, + required this.isLocked, + this.onTap, }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: isLocked ? null : onTap, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: isCurrent ? Colors.orange : Colors.white, + width: 4, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Center(child: _inner()), + ), + ); + } + + Widget _inner() { + if (isCompleted) { + return const Icon(Icons.check_circle, color: Colors.green, size: 28); + } + if (isLocked) { + return const Icon(Icons.lock, color: Colors.grey, size: 22); + } + if (isCurrent) { + return Container( + width: 44, + height: 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [Colors.orange.shade300, Colors.orange.shade700], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: const Icon(Icons.play_arrow, color: Colors.white), + ); + } + return Text( + '$level', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, color: Colors.orange), + ); + } } \ No newline at end of file diff --git a/lib/features/lesson/lesson_screen.dart b/lib/features/lesson/lesson_screen.dart new file mode 100644 index 0000000..0d054a5 --- /dev/null +++ b/lib/features/lesson/lesson_screen.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'package:base_flutter/features/lesson/record_dialog.dart'; +import 'package:record/record.dart'; + +import 'package:base_flutter/features/home/models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_tts/flutter_tts.dart'; + +FlutterTts flutterTts = FlutterTts(); + +enum PositionEnum { + topLeft('top_left'), + topRight('top_right'), + bottomLeft('bottom_left'), + bottomRight('bottom_right'); + + final String value; + const PositionEnum(this.value); +} + +class LessonScreen extends StatefulWidget { + const LessonScreen({super.key, required this.lessonResponse}); + + final LessonResponse lessonResponse; + + @override + State createState() => _LessonScreenState(); +} + +class _LessonScreenState extends State { + @override + Widget build(BuildContext context) { + + final record = AudioRecorder(); + + + + Widget containerText(String text) { + return InkWell( + onTap: () async { + var result = await flutterTts.speak(text); + }, + child: Container( + padding: const EdgeInsets.all(8.0), + margin: const EdgeInsets.all(16.0), + width: 300, + height: 120, + color: Colors.black54, + child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 16)), + ), + ); + } + + Widget buildTapToRecordWidget(String position, String content) { + switch (position) { + case 'top_left': + return Align(alignment: Alignment.topLeft, child: containerText(content)); + case 'top_right': + return Align(alignment: Alignment.topRight, child: containerText(content)); + case 'bottom_left': + return Align(alignment: Alignment.bottomLeft, child: containerText(content)); + case 'bottom_right': + return Align(alignment: Alignment.bottomRight, child: containerText(content)); + default: + return const SizedBox.shrink(); + } + } + + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + + ), + body: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: MemoryImage(base64Decode(widget.lessonResponse.image)), + fit: BoxFit.cover, // Optional: Adjust as needed + ), + ), + child: Column( + children: [ + SizedBox(height: kToolbarHeight,), + Text(widget.lessonResponse.widget.tapToRecordWidget.position), + buildTapToRecordWidget( + widget.lessonResponse.widget.tapToRecordWidget.position, + widget.lessonResponse.widget.tapToRecordWidget.content, + ), + Spacer(), + Text(widget.lessonResponse.widget.tapToSpeechWidget.position), + buildTapToRecordWidget( + widget.lessonResponse.widget.tapToSpeechWidget.position, + widget.lessonResponse.widget.tapToSpeechWidget.content, + ), + IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + content: RecordDialog(), + ), + ); + }, + icon: Icon(Icons.mic, size: 48), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/lesson/record_dialog.dart b/lib/features/lesson/record_dialog.dart new file mode 100644 index 0000000..ede40bf --- /dev/null +++ b/lib/features/lesson/record_dialog.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:record/record.dart'; +import 'package:audioplayers/audioplayers.dart'; + +class RecordDialog extends StatefulWidget { + const RecordDialog({super.key}); + + @override + State createState() => _RecordDialogState(); +} + +class _RecordDialogState extends State { + final AudioRecorder _recorder = AudioRecorder(); + final AudioPlayer _player = AudioPlayer(); + String? _recordedPath; + bool _isRecording = false; + + @override + void dispose() { + _recorder.dispose(); + _player.dispose(); + super.dispose(); + } + + Future _toggleRecord() async { + if (!_isRecording) { + if (await _recorder.hasPermission()) { + await _recorder.start(const RecordConfig(), path: 'myFile.m4a'); + setState(() => _isRecording = true); + } + } else { + final path = await _recorder.stop(); + setState(() { + _isRecording = false; + _recordedPath = path; + }); + } + } + + Future _playRecording() async { + if (_recordedPath != null) { + await _player.play(DeviceFileSource(_recordedPath!)); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 300, + height: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 150), + IconButton( + icon: Icon(_isRecording ? Icons.stop : Icons.mic_none_sharp, size: 96), + onPressed: _toggleRecord, + ), + if (_recordedPath != null && !_isRecording) + IconButton( + icon: const Icon(Icons.play_arrow, size: 48), + onPressed: _playRecording, + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), + // Add more buttons as needed + ], + ), + ], + ), + ); + } +} + +// Usage in your code: + diff --git a/lib/features/loading_lesson/loading_lesson_screen.dart b/lib/features/loading_lesson/loading_lesson_screen.dart new file mode 100644 index 0000000..dbcabb4 --- /dev/null +++ b/lib/features/loading_lesson/loading_lesson_screen.dart @@ -0,0 +1,52 @@ +import 'package:base_flutter/features/home/models.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class LoadingLessonScreen extends StatefulWidget { + const LoadingLessonScreen({super.key}); + + @override + State createState() => _LoadingLessonScreenState(); +} + +class _LoadingLessonScreenState extends State { + + LessonResponse? lessonResponse; + + + @override + void initState() { + // TODO: implement initState + super.initState(); + callDio(); + } + + + void callDio() async { + final dio = Dio(); + final response = await dio.get('https://37b530e059dd.ngrok-free.app/'); + setState(() { + lessonResponse = LessonResponse.fromJson(response.data); + }); + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: lessonResponse == null ? const CircularProgressIndicator() : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Lesson Title: ${lessonResponse!.response}'), + TextButton(onPressed: () { + context.pushNamed('test', extra: lessonResponse); + }, child: Text('Next')), + ], + ) + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index b4ceb4a..75875f9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -379,6 +435,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: bdf2fc4483e74450dc9fc6fe6a9b6a5663e108d4d0dad3324a22c8e26bf48af4 + url: "https://pub.dev" + source: hosted + version: "4.2.3" flutter_web_plugins: dependency: transitive description: flutter @@ -728,6 +792,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + record: + dependency: "direct main" + description: + name: record + sha256: "9dbc6ff3e784612f90a9b001373c45ff76b7a08abd2bd9fdf72c242320c8911c" + url: "https://pub.dev" + source: hosted + version: "6.1.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: "854627cd78d8d66190377f98477eee06ca96ab7c9f2e662700daf33dbf7e6673" + url: "https://pub.dev" + source: hosted + version: "1.4.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "13e241ed9cbc220534a40ae6b66222e21288db364d96dd66fb762ebd3cb77c71" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "235b1f1fb84e810f8149cc0c2c731d7d697f8d1c333b32cb820c449bf7bb72d8" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "2849068bb59072f300ad63ed146e543d66afaef8263edba4de4834fc7c8d4d35" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: b0065fdf1ec28f5a634d676724d388a77e43ce7646fb049949f58c69f3fcb4ed + url: "https://pub.dev" + source: hosted + version: "1.4.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "4f0adf20c9ccafcc02d71111fd91fba1ca7b17a7453902593e5a9b25b74a5c56" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" riverpod: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5fb3408..22d7e74 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,11 @@ dependencies: # Image Caching cached_network_image: ^3.3.1 + + flutter_tts: ^4.2.3 + record: ^6.1.1 + audioplayers: ^6.5.1 + dev_dependencies: flutter_test: sdk: flutter