fix: refactoring loading handling in various feature and improve performance

This commit is contained in:
Naresh Pratista 2024-11-26 06:19:28 +07:00
parent e8980afc60
commit 88ceaf7163
13 changed files with 1052 additions and 581 deletions

View File

@ -1 +1,2 @@
const String baseUrl = 'http://54.173.167.62/'; const String baseUrl =
'https://e901-2001-448a-50a0-656c-cd9-ff82-635b-5b6e.ngrok-free.app/';

View File

@ -10,17 +10,36 @@ class CompletedTopicsRepository {
Future<List<CompletedTopic>> getCompletedTopics(String token) async { Future<List<CompletedTopic>> getCompletedTopics(String token) async {
try { try {
final response = await _dioClient.getCompletedTopics(token); final response = await _dioClient.getCompletedTopics(token);
// Tambahkan pengecekan status code dan payload
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> topicsData = response.data['payload']; // Cek apakah payload null atau bukan list
return topicsData.map((data) => CompletedTopic.fromJson(data)).toList(); if (response.data['payload'] == null) {
return []; // Kembalikan list kosong jika payload null
}
// Pastikan payload adalah list
final dynamic payloadData = response.data['payload'];
if (payloadData is List) {
return payloadData
.map((data) => CompletedTopic.fromJson(data))
.toList();
} else { } else {
throw Exception( return []; // Kembalikan list kosong jika payload bukan list
'Failed to load completed topics: ${response.statusMessage}'); }
} else {
// Tangani status code selain 200
return [];
} }
} on DioException catch (e) { } on DioException catch (e) {
throw Exception('Network error: ${e.message}'); // Log error jika perlu
print('Network error: ${e.message}');
return []; // Kembalikan list kosong untuk error jaringan
} catch (e) { } catch (e) {
throw Exception('Unexpected error: $e'); // Log error tidak terduga
print('Unexpected error: $e');
return []; // Kembalikan list kosong untuk error lainnya
} }
} }
} }

View File

@ -21,6 +21,7 @@ class CustomFieldWidget extends StatefulWidget {
final Function()? onSuffixIconTap; final Function()? onSuffixIconTap;
final bool isRequired; final bool isRequired;
final bool isEnabled; final bool isEnabled;
final Function(String?)? onFieldSubmitted;
const CustomFieldWidget({ const CustomFieldWidget({
super.key, super.key,
@ -40,6 +41,7 @@ class CustomFieldWidget extends StatefulWidget {
this.onSuffixIconTap, this.onSuffixIconTap,
this.isRequired = false, this.isRequired = false,
this.isEnabled = true, this.isEnabled = true,
this.onFieldSubmitted,
}); });
@override @override
@ -52,7 +54,8 @@ class _CustomFieldWidgetState extends State<CustomFieldWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = TextEditingController(); _controller = widget.controller ?? TextEditingController();
context context
.read<ValidatorProvider>() .read<ValidatorProvider>()
.setController(widget.fieldName, _controller); .setController(widget.fieldName, _controller);
@ -61,7 +64,9 @@ class _CustomFieldWidgetState extends State<CustomFieldWidget> {
@override @override
void dispose() { void dispose() {
context.read<ValidatorProvider>().removeController(widget.fieldName); context.read<ValidatorProvider>().removeController(widget.fieldName);
if (widget.controller == null) {
_controller.dispose(); _controller.dispose();
}
super.dispose(); super.dispose();
} }
@ -100,6 +105,7 @@ class _CustomFieldWidgetState extends State<CustomFieldWidget> {
obscureText: widget.obscureText, obscureText: widget.obscureText,
focusNode: widget.focusNode, focusNode: widget.focusNode,
onChanged: widget.onChanged, onChanged: widget.onChanged,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.isEnabled, enabled: widget.isEnabled,
decoration: InputDecoration( decoration: InputDecoration(
hintText: widget.hintText, hintText: widget.hintText,

View File

@ -11,36 +11,134 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class SigninScreen extends StatelessWidget { class SigninScreen extends StatefulWidget {
const SigninScreen({super.key});
@override
State<SigninScreen> createState() => _SigninScreenState();
}
class _SigninScreenState extends State<SigninScreen> {
final TextEditingController _emailController = TextEditingController(); final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
final FocusNode _emailFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
final _formKey = GlobalKey<FormState>();
SigninScreen({super.key}); @override
void initState() {
super.initState();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
Future<void> _handleLogin(BuildContext context) async {
if (!_formKey.currentState!.validate()) return;
final userProvider = context.read<UserProvider>();
final validatorProvider = context.read<ValidatorProvider>();
try {
final isSuccess = await userProvider.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (!mounted) return;
if (isSuccess) {
// Hide keyboard before navigation
FocusScope.of(context).unfocus();
await Navigator.pushAndRemoveUntil(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const HomeScreen(),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 300),
),
(route) => false,
);
// Reset form
validatorProvider.resetFields();
_emailController.clear();
_passwordController.clear();
} else {
_handleLoginError(context, userProvider.errorCode);
}
} catch (e) {
if (mounted) {
CustomSnackBar.show(
context,
message: 'An unexpected error occurred. Please try again.',
isError: true,
);
}
}
}
void _handleLoginError(BuildContext context, int? errorCode) {
String message = 'Login failed, please check your credentials';
switch (errorCode) {
case 403:
message = 'Please verify your email first to continue.';
break;
case 401:
message = 'Invalid email or password. Please try again.';
break;
case 429:
message = 'Too many attempts. Please try again later.';
break;
}
CustomSnackBar.show(context, message: message, isError: true);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context, listen: false); return GestureDetector(
Provider.of<ValidatorProvider>(context, listen: false); onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
return Scaffold(
backgroundColor: AppColors.whiteColor, backgroundColor: AppColors.whiteColor,
body: Center( body: SafeArea(
child: Center(
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
child: SingleChildScrollView( child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Consumer<ValidatorProvider>( child: Consumer<ValidatorProvider>(
builder: (context, validatorProvider, child) { builder: (context, validatorProvider, child) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 32), const SizedBox(height: 24),
Text( Hero(
tag: 'welcome_text',
child: Material(
color: Colors.transparent,
child: Text(
'Welcome Back!', 'Welcome Back!',
style: AppTextStyles.blueTextStyle.copyWith( style: AppTextStyles.blueTextStyle.copyWith(
fontSize: 30, fontSize: 30,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
), ),
), ),
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Login to continue your personalized learning journey.', 'Login to continue your personalized learning journey.',
@ -50,27 +148,34 @@ class SigninScreen extends StatelessWidget {
), ),
const SizedBox(height: 26), const SizedBox(height: 26),
Center( Center(
child: Hero(
tag: 'login_illustration',
child: SvgPicture.asset( child: SvgPicture.asset(
'lib/features/auth/assets/images/login_illustration.svg', 'lib/features/auth/assets/images/login_illustration.svg',
width: 200, width: 200,
), ),
), ),
),
const SizedBox(height: 30), const SizedBox(height: 30),
CustomFieldWidget( CustomFieldWidget(
fieldName: 'email', fieldName: 'email',
controller: _emailController, controller: _emailController,
focusNode: _emailFocus,
isRequired: true, isRequired: true,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
labelText: 'Email Address', labelText: 'Email Address',
hintText: 'Enter Your Email Address', hintText: 'Enter Your Email Address',
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
validator: validatorProvider.emailValidator, validator: validatorProvider.emailValidator,
onChanged: (value) { onChanged: (value) =>
validatorProvider.validateField( validatorProvider.validateField(
'email', 'email',
value, value?.trim(),
validator: validatorProvider.emailValidator, validator: validatorProvider.emailValidator,
); ),
onFieldSubmitted: (_) {
FocusScope.of(context)
.requestFocus(_passwordFocus);
}, },
errorText: validatorProvider.getError('email'), errorText: validatorProvider.getError('email'),
), ),
@ -78,41 +183,49 @@ class SigninScreen extends StatelessWidget {
CustomFieldWidget( CustomFieldWidget(
fieldName: 'password', fieldName: 'password',
controller: _passwordController, controller: _passwordController,
focusNode: _passwordFocus,
isRequired: true, isRequired: true,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.done,
labelText: 'Password', labelText: 'Password',
hintText: 'Create a Strong Password', hintText: 'Enter Your Password',
obscureText: validatorProvider.isObscure('password'), obscureText:
validatorProvider.isObscure('password'),
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
validator: validatorProvider.passwordValidator, validator: validatorProvider.passwordValidator,
onChanged: (value) { onChanged: (value) =>
validatorProvider.validateField( validatorProvider.validateField(
'password', 'password',
value, value,
validator: validatorProvider.passwordValidator, validator: validatorProvider.passwordValidator,
); ),
}, onFieldSubmitted: (_) => _handleLogin(context),
onSuffixIconTap: () => onSuffixIconTap: () =>
validatorProvider.toggleVisibility('password'), validatorProvider.toggleVisibility('password'),
errorText: validatorProvider.getError('password'), errorText: validatorProvider.getError('password'),
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
Row( Align(
mainAxisAlignment: MainAxisAlignment.end, alignment: Alignment.centerRight,
children: [ child: TextButton(
GestureDetector( onPressed: () {
onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => builder: (context) =>
const ForgotPasswordScreen()), const ForgotPasswordScreen(),
),
); );
}, },
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: ShaderMask( child: ShaderMask(
shaderCallback: (bounds) => shaderCallback: (bounds) =>
AppColors.gradientTheme.createShader( AppColors.gradientTheme.createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height), Rect.fromLTWH(
0, 0, bounds.width, bounds.height),
), ),
child: const Text( child: const Text(
'Forgot Password?', 'Forgot Password?',
@ -124,73 +237,20 @@ class SigninScreen extends StatelessWidget {
), ),
), ),
), ),
],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
GlobalButton( Consumer<UserProvider>(
builder: (context, userProvider, _) {
return GlobalButton(
text: 'Login', text: 'Login',
isLoading: userProvider.isLoading, isLoading: userProvider.isLoading,
onPressed: userProvider.isLoading onPressed: userProvider.isLoading
? null ? null
: () async { : () => _handleLogin(context),
// Validate email and password fields
validatorProvider.validateField(
'email',
_emailController.text,
validator: validatorProvider.emailValidator,
); );
validatorProvider.validateField(
'password',
_passwordController.text,
validator: validatorProvider.passwordValidator,
);
// If no errors, proceed with login
if (validatorProvider.getError('email') == null &&
validatorProvider.getError('password') ==
null) {
final isSuccess = await userProvider.login(
email: _emailController.text,
password: _passwordController.text,
);
if (isSuccess) {
// Navigate to HomeScreen after successful login
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) =>
const HomeScreen()),
(Route<dynamic> route) =>
false, // Remove all previous routes
).then((_) {
// Reset the fields after login
validatorProvider.resetFields();
_emailController.clear();
_passwordController.clear();
});
} else {
// Handle specific error for unverified user
if (userProvider.errorCode == 403) {
CustomSnackBar.show(
context,
message:
'User is not validated! Please verify your email first.',
isError: true,
);
} else {
CustomSnackBar.show(
context,
message:
'Login failed, please check your credentials',
isError: true,
);
}
}
}
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -199,15 +259,33 @@ class SigninScreen extends StatelessWidget {
style: AppTextStyles.blackTextStyle style: AppTextStyles.blackTextStyle
.copyWith(fontSize: 14), .copyWith(fontSize: 14),
), ),
GestureDetector( TextButton(
onTap: () { onPressed: () {
context.read<ValidatorProvider>().resetFields(); context
.read<ValidatorProvider>()
.resetFields();
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( PageRouteBuilder(
builder: (context) => SignupScreen()), pageBuilder: (context, animation,
secondaryAnimation) =>
SignupScreen(),
transitionsBuilder: (context, animation,
secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
); );
}, },
),
);
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
child: Text( child: Text(
'Sign Up Here', 'Sign Up Here',
style: AppTextStyles.blueTextStyle.copyWith( style: AppTextStyles.blueTextStyle.copyWith(
@ -218,6 +296,7 @@ class SigninScreen extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 16),
], ],
); );
}, },
@ -225,6 +304,9 @@ class SigninScreen extends StatelessWidget {
), ),
), ),
), ),
),
),
),
); );
} }
} }

View File

@ -19,22 +19,53 @@ class HistoryScreen extends StatefulWidget {
} }
class _HistoryScreenState extends State<HistoryScreen> { class _HistoryScreenState extends State<HistoryScreen> {
bool _isInitialLoading = true;
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Memuat data saat HistoryScreen diinisialisasi _initializeHistory();
WidgetsBinding.instance.addPostFrameCallback((_) { }
Future<void> _initializeHistory() async {
final historyProvider = final historyProvider =
Provider.of<HistoryProvider>(context, listen: false); Provider.of<HistoryProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false); final userProvider = Provider.of<UserProvider>(context, listen: false);
historyProvider.fetchLearningHistory(userProvider.jwtToken!);
try {
await historyProvider.fetchLearningHistory(userProvider.jwtToken!);
} catch (e) {
print('Error initializing history: $e');
} finally {
setState(() {
_isInitialLoading = false;
}); });
} }
}
Future<void> _refreshHistory() async {
final historyProvider =
Provider.of<HistoryProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false);
try {
await historyProvider.fetchLearningHistory(userProvider.jwtToken!);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh history: $e'),
backgroundColor: Colors.red,
),
);
}
}
// Tambahkan method untuk shimmer loading // Tambahkan method untuk shimmer loading
Widget _buildShimmerLoading() { Widget _buildShimmerLoading() {
return ListView.builder( return ListView.builder(
itemCount: 5, // Jumlah item shimmer itemCount: 5,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
@ -43,10 +74,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
highlightColor: Colors.grey[100]!, highlightColor: Colors.grey[100]!,
child: Container( child: Container(
width: double.infinity, width: double.infinity,
height: 100, // Sesuaikan dengan tinggi card history height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
Colors.grey[200]!,
Colors.grey[100]!,
Colors.grey[200]!,
],
),
), ),
), ),
), ),
@ -93,38 +131,47 @@ class _HistoryScreenState extends State<HistoryScreen> {
} }
Widget _buildContent(BuildContext context, HistoryProvider historyProvider) { Widget _buildContent(BuildContext context, HistoryProvider historyProvider) {
if (historyProvider.isLoading) { // Prioritaskan initial loading
if (_isInitialLoading || historyProvider.isLoading) {
return _buildShimmerLoading(); return _buildShimmerLoading();
} }
// Tangani error
if (historyProvider.error != null) { if (historyProvider.error != null) {
return isNotFoundError(historyProvider.error!) return isNotFoundError(historyProvider.error!)
? _buildEmptyState(context) ? _buildEmptyState(context)
: _buildErrorState(context, historyProvider.error!); : _buildErrorState(context, historyProvider.error!);
} }
// Tampilkan empty state jika tidak ada history
if (historyProvider.learningHistory.isEmpty) { if (historyProvider.learningHistory.isEmpty) {
return _buildEmptyState(context); return _buildEmptyState(context);
} }
// Tampilkan daftar history dengan refresh indicator
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: _refreshHistory,
final userProvider = Provider.of<UserProvider>(context, listen: false); child: AnimatedSwitcher(
await historyProvider.fetchLearningHistory(userProvider.jwtToken!); duration: const Duration(milliseconds: 300),
},
child: ListView.builder( child: ListView.builder(
key: ValueKey(historyProvider.learningHistory.length),
itemCount: historyProvider.learningHistory.length, itemCount: historyProvider.learningHistory.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Column( return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(milliseconds: 500),
child: Column(
children: [ children: [
ExerciseHistoryCard( ExerciseHistoryCard(
exercise: historyProvider.learningHistory[index], exercise: historyProvider.learningHistory[index],
), ),
const SizedBox(height: 8.0), const SizedBox(height: 8.0),
], ],
),
); );
}, },
), ),
),
); );
} }

View File

@ -1,5 +1,7 @@
// lib/features/home/providers/completed_topics_provider.dart // lib/features/home/providers/completed_topics_provider.dart
// ignore_for_file: unnecessary_null_comparison
import 'package:english_learning/core/services/repositories/completed_topics_repository.dart'; import 'package:english_learning/core/services/repositories/completed_topics_repository.dart';
import 'package:english_learning/features/home/models/completed_topics_model.dart'; import 'package:english_learning/features/home/models/completed_topics_model.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -30,8 +32,16 @@ class CompletedTopicsProvider with ChangeNotifier {
try { try {
final result = await _repository.getCompletedTopics(token); final result = await _repository.getCompletedTopics(token);
// Tambahkan pengecekan null secara eksplisit
if (result == null) {
_completedTopics = []; // Set ke list kosong
_error = 'No topics found'; // Beri pesan default
} else {
// Pastikan result adalah List
_completedTopics = result; _completedTopics = result;
_error = null; _error = null;
}
} catch (e) { } catch (e) {
// Tangani error // Tangani error
_completedTopics = []; _completedTopics = [];

View File

@ -7,7 +7,6 @@ import 'package:english_learning/features/history/screens/history_screen.dart';
import 'package:english_learning/features/home/data/card_data.dart'; import 'package:english_learning/features/home/data/card_data.dart';
import 'package:english_learning/features/home/provider/completed_topics_provider.dart'; import 'package:english_learning/features/home/provider/completed_topics_provider.dart';
import 'package:english_learning/features/home/widgets/progress_card.dart'; import 'package:english_learning/features/home/widgets/progress_card.dart';
import 'package:english_learning/features/home/widgets/progress_card_loading.dart';
import 'package:english_learning/features/home/widgets/welcome_card.dart'; import 'package:english_learning/features/home/widgets/welcome_card.dart';
import 'package:english_learning/features/learning/screens/learning_screen.dart'; import 'package:english_learning/features/learning/screens/learning_screen.dart';
import 'package:english_learning/features/settings/modules/edit_profile/screens/edit_profile_screen.dart'; import 'package:english_learning/features/settings/modules/edit_profile/screens/edit_profile_screen.dart';
@ -18,6 +17,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:google_nav_bar/google_nav_bar.dart'; import 'package:google_nav_bar/google_nav_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@ -158,14 +158,76 @@ class _HomeContentState extends State<HomeContent> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Memanggil fetchCompletedTopics saat HomeContent diinisialisasi
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeData();
});
}
Future<void> _initializeData() async {
final userProvider = Provider.of<UserProvider>(context, listen: false); final userProvider = Provider.of<UserProvider>(context, listen: false);
final completedTopicsProvider = final completedTopicsProvider =
Provider.of<CompletedTopicsProvider>(context, listen: false); Provider.of<CompletedTopicsProvider>(context, listen: false);
try {
// Reset data sebelum fetch
completedTopicsProvider.resetData(); completedTopicsProvider.resetData();
completedTopicsProvider.fetchCompletedTopics(userProvider.jwtToken!);
}); // Fetch completed topics
await completedTopicsProvider
.fetchCompletedTopics(userProvider.jwtToken!);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to load data: ${e.toString()}'),
action: SnackBarAction(
label: 'Retry',
onPressed: _initializeData,
),
),
);
}
}
}
Widget _buildErrorWidget(String error) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 48,
color: Colors.red[300],
),
const SizedBox(height: 16),
Text(
'Oops! Something went wrong',
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
error,
textAlign: TextAlign.center,
style: AppTextStyles.disableTextStyle.copyWith(fontSize: 14),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _initializeData,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
),
),
],
),
);
} }
Widget _buildNoDataWidget() { Widget _buildNoDataWidget() {
@ -203,33 +265,110 @@ class _HomeContentState extends State<HomeContent> {
); );
} }
Widget _buildProgressCard(CompletedTopicsProvider provider) {
if (provider.error != null) {
return _buildErrorWidget(provider.error!);
}
if (provider.completedTopics.isEmpty) {
return _buildNoDataWidget();
}
return _buildCompletedTopicsContent(provider);
}
Widget _buildCompletedTopicsContent(CompletedTopicsProvider provider) { Widget _buildCompletedTopicsContent(CompletedTopicsProvider provider) {
return ListView.builder( return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: ListView.builder(
key: ValueKey<int>(provider.completedTopics.length),
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 16.0),
horizontal: 16.0,
),
itemCount: 1, itemCount: 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return ProgressCard( return ProgressCard(
completedTopic: provider.completedTopics, completedTopic: provider.completedTopics,
); );
}, },
),
);
}
Widget _buildLoadingWidget() {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: Container(
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
],
),
),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer2<UserProvider, CompletedTopicsProvider>(builder: ( return RefreshIndicator(
context, onRefresh: _initializeData,
authProvider, child: Consumer2<UserProvider, CompletedTopicsProvider>(
completedTopicsProvider, builder: (context, authProvider, completedTopicsProvider, child) {
child,
) {
final userName = authProvider.getUserName() ?? 'Guest'; final userName = authProvider.getUserName() ?? 'Guest';
return SingleChildScrollView( return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -258,7 +397,8 @@ class _HomeContentState extends State<HomeContent> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
children: [ children: [
@ -269,8 +409,8 @@ class _HomeContentState extends State<HomeContent> {
const SizedBox(width: 4.34), const SizedBox(width: 4.34),
Text( Text(
'SEALS', 'SEALS',
style: style: AppTextStyles.logoTextStyle
AppTextStyles.logoTextStyle.copyWith( .copyWith(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@ -306,8 +446,8 @@ class _HomeContentState extends State<HomeContent> {
children: <TextSpan>[ children: <TextSpan>[
TextSpan( TextSpan(
text: userName, text: userName,
style: style: AppTextStyles.yellowTextStyle
AppTextStyles.yellowTextStyle.copyWith( .copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
fontSize: 18, fontSize: 18,
), ),
@ -412,12 +552,12 @@ class _HomeContentState extends State<HomeContent> {
], ],
), ),
), ),
completedTopicsProvider.isLoading AnimatedSwitcher(
? const ProgressCardLoading() duration: const Duration(milliseconds: 300),
: completedTopicsProvider.completedTopics.isEmpty child: completedTopicsProvider.isLoading
? _buildNoDataWidget() ? _buildLoadingWidget()
: _buildCompletedTopicsContent( : _buildProgressCard(completedTopicsProvider),
completedTopicsProvider), ),
], ],
), ),
), ),
@ -425,6 +565,7 @@ class _HomeContentState extends State<HomeContent> {
], ],
), ),
); );
}); }),
);
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:english_learning/core/services/constants.dart'; import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/core/widgets/loading/shimmer_loading_widget.dart';
import 'package:english_learning/features/auth/provider/user_provider.dart'; import 'package:english_learning/features/auth/provider/user_provider.dart';
import 'package:english_learning/features/learning/modules/level/screens/level_list_screen.dart'; import 'package:english_learning/features/learning/modules/level/screens/level_list_screen.dart';
import 'package:english_learning/features/learning/modules/topics/providers/topic_provider.dart'; import 'package:english_learning/features/learning/modules/topics/providers/topic_provider.dart';
@ -199,11 +200,9 @@ class _TopicsListScreenState extends State<TopicsListScreen> {
}, },
loadingBuilder: (context, child, loadingProgress) { loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;
return Container( return const ShimmerLoadingWidget(
width: 90, height: 140,
height: 104, width: double.infinity,
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
); );
}, },
), ),

View File

@ -8,37 +8,74 @@ import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LearningScreen extends StatefulWidget { class LearningScreen extends StatefulWidget {
const LearningScreen({ const LearningScreen({super.key});
super.key,
});
@override @override
State<LearningScreen> createState() => _LearningScreenState(); State<LearningScreen> createState() => _LearningScreenState();
} }
class _LearningScreenState extends State<LearningScreen> { class _LearningScreenState extends State<LearningScreen>
with AutomaticKeepAliveClientMixin {
bool _isInitialLoading = true;
@override
bool get wantKeepAlive => true;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { _initializeSections();
_fetchSections();
});
} }
Future<void> _fetchSections() async { Future<void> _initializeSections() async {
final userProvider = Provider.of<UserProvider>(context, listen: false); final userProvider = Provider.of<UserProvider>(context, listen: false);
final token = await userProvider.getValidToken(); final sectionProvider =
Provider.of<SectionProvider>(context, listen: false);
// Cek apakah sections sudah ada
if (sectionProvider.sections.isEmpty) {
try {
final token = await userProvider.getValidToken();
if (token != null) { if (token != null) {
await Provider.of<SectionProvider>(context, listen: false) await sectionProvider.fetchSections(token);
.fetchSections(token); }
} catch (e) {
print('Error initializing sections: $e');
} finally {
setState(() {
_isInitialLoading = false;
});
}
} else { } else {
print('No valid token found. User might need to log in.'); setState(() {
_isInitialLoading = false;
});
}
}
Future<void> _refreshSections() async {
final userProvider = Provider.of<UserProvider>(context, listen: false);
final sectionProvider =
Provider.of<SectionProvider>(context, listen: false);
try {
final token = await userProvider.getValidToken();
if (token != null) {
await sectionProvider.fetchSections(token);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh sections: $e'),
backgroundColor: Colors.red,
),
);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
return Scaffold( return Scaffold(
backgroundColor: AppColors.bgSoftColor, backgroundColor: AppColors.bgSoftColor,
body: SafeArea( body: SafeArea(
@ -66,15 +103,23 @@ class _LearningScreenState extends State<LearningScreen> {
Expanded( Expanded(
child: Consumer<SectionProvider>( child: Consumer<SectionProvider>(
builder: (context, sectionProvider, _) { builder: (context, sectionProvider, _) {
if (sectionProvider.isLoading) { // Prioritaskan loading state
if (_isInitialLoading || sectionProvider.isLoading) {
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
itemCount: 6, itemCount: 4,
itemBuilder: (context, index) => itemBuilder: (context, index) =>
const SectionCardLoading(), const SectionCardLoading(),
); );
} else if (sectionProvider.error != null) { }
return Center(
// Tampilkan error jika ada
if (sectionProvider.error != null) {
return RefreshIndicator(
onRefresh: _refreshSections,
child: ListView(
children: [
Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -82,23 +127,59 @@ class _LearningScreenState extends State<LearningScreen> {
'Error: ${sectionProvider.error}', 'Error: ${sectionProvider.error}',
style: AppTextStyles.greyTextStyle, style: AppTextStyles.greyTextStyle,
), ),
SizedBox(height: 16), const SizedBox(height: 16),
// Tambahkan tombol retry jika diperlukan
ElevatedButton( ElevatedButton(
onPressed: _fetchSections, onPressed: _refreshSections,
child: Text('Retry'), child: const Text('Retry'),
) )
], ],
)); ),
} else if (sectionProvider.sections.isEmpty) { ),
return Center( ],
child: Text(
'No sections available',
style: AppTextStyles.greyTextStyle,
), ),
); );
} else { }
return ListView.builder(
// Tampilkan sections atau pesan jika kosong
if (sectionProvider.sections.isEmpty) {
return RefreshIndicator(
onRefresh: _refreshSections,
child: ListView(
children: [
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.library_books_outlined,
size: 80,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'No sections available',
style: AppTextStyles.greyTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refreshSections,
child: const Text('Refresh'),
)
],
),
),
],
),
);
}
// Tampilkan daftar sections
return RefreshIndicator(
onRefresh: _refreshSections,
child: ListView.builder(
itemCount: sectionProvider.sections.length, itemCount: sectionProvider.sections.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final section = sectionProvider.sections[index]; final section = sectionProvider.sections[index];
@ -114,8 +195,8 @@ class _LearningScreenState extends State<LearningScreen> {
), ),
); );
}, },
),
); );
}
}, },
), ),
), ),

View File

@ -3,15 +3,18 @@ import 'package:english_learning/core/widgets/loading/shimmer_loading_widget.dar
import 'package:english_learning/features/learning/modules/model/section_model.dart'; import 'package:english_learning/features/learning/modules/model/section_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart'; import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:cached_network_image/cached_network_image.dart';
class SectionCard extends StatefulWidget { class SectionCard extends StatefulWidget {
final Section section; final Section? section;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool isLoading;
const SectionCard({ const SectionCard({
super.key, super.key,
required this.section, this.section,
this.onTap, this.onTap,
this.isLoading = false,
}); });
@override @override
@ -19,7 +22,7 @@ class SectionCard extends StatefulWidget {
} }
class _SectionCardState extends State<SectionCard> class _SectionCardState extends State<SectionCard>
with SingleTickerProviderStateMixin { with AutomaticKeepAliveClientMixin {
String _getFullImageUrl(String thumbnail) { String _getFullImageUrl(String thumbnail) {
if (thumbnail.startsWith('http')) { if (thumbnail.startsWith('http')) {
return thumbnail; return thumbnail;
@ -28,9 +31,73 @@ class _SectionCardState extends State<SectionCard>
} }
} }
@override
bool get wantKeepAlive => true;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( super.build(context);
if (widget.isLoading) {
return Card(
color: AppColors.whiteColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 1,
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShimmerLoadingWidget(
width: 90,
height: 104,
borderRadius: BorderRadius.circular(8),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShimmerLoadingWidget(
width: MediaQuery.of(context).size.width * 0.4,
height: 20,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 12),
Column(
children: List.generate(
3,
(index) => Padding(
padding:
EdgeInsets.only(bottom: index != 2 ? 6.0 : 0),
child: ShimmerLoadingWidget(
width: MediaQuery.of(context).size.width *
(0.8 - (index * 0.1)),
height: 12,
borderRadius: BorderRadius.circular(4),
),
),
),
),
],
),
),
],
),
),
);
}
return Hero(
tag: 'section_${widget.section?.id}',
child: Material(
type: MaterialType.transparency,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: Card( child: Card(
color: AppColors.whiteColor, color: AppColors.whiteColor,
@ -46,39 +113,53 @@ class _SectionCardState extends State<SectionCard>
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Image.network( child: CachedNetworkImage(
_getFullImageUrl(widget.section.thumbnail ?? ''), imageUrl:
_getFullImageUrl(widget.section?.thumbnail ?? ''),
width: 90, width: 90,
height: 104, height: 104,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) { placeholder: (context, url) => ShimmerLoadingWidget(
print('Error loading image: $error');
return Container(
width: 90,
height: 104,
color: Colors.grey[300],
child: const Icon(
Icons.image_not_supported,
color: Colors.grey,
),
);
},
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return ShimmerLoadingWidget(
width: 90, width: 90,
height: 104, height: 104,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
); ),
}, errorWidget: (context, url, error) => Container(
)), width: 90,
height: 104,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.image_not_supported,
color: Colors.grey[400],
size: 24,
),
const SizedBox(height: 4),
Text(
'Image not\navailable',
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
widget.section.name, widget.section?.name ?? '',
style: AppTextStyles.blackTextStyle.copyWith( style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
@ -86,7 +167,7 @@ class _SectionCardState extends State<SectionCard>
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.section.description, widget.section?.description ?? '',
style: AppTextStyles.disableTextStyle.copyWith( style: AppTextStyles.disableTextStyle.copyWith(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -101,6 +182,9 @@ class _SectionCardState extends State<SectionCard>
), ),
), ),
), ),
),
),
),
); );
} }
} }

View File

@ -1,3 +1,4 @@
// section_card_loading.dart
import 'package:english_learning/core/widgets/loading/shimmer_loading_widget.dart'; import 'package:english_learning/core/widgets/loading/shimmer_loading_widget.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -28,32 +29,32 @@ class SectionCardLoading extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Title shimmer
ShimmerLoadingWidget( ShimmerLoadingWidget(
width: 200, width: MediaQuery.of(context).size.width * 0.4,
height: 16, height: 20,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
//description
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, children: List.generate(
children: [ 3,
for (int i = 0; i < 3; i++) ...[ (index) => Padding(
const ShimmerLoadingWidget( padding: EdgeInsets.only(bottom: index != 2 ? 6.0 : 0),
width: double.infinity, child: ShimmerLoadingWidget(
width: MediaQuery.of(context).size.width *
(0.8 - (index * 0.1)),
height: 12, height: 12,
borderRadius: BorderRadius.all(Radius.circular(4)), borderRadius: BorderRadius.circular(4),
),
),
), ),
const SizedBox(height: 6),
],
],
), ),
], ],
), ),
), ),
], ],
), ),
)); ),
);
} }
} }

View File

@ -53,7 +53,7 @@ class _SplashScreenState extends State<SplashScreen> {
} else { } else {
// Jika sudah pernah install tetapi belum login, arahkan ke SigninScreen // Jika sudah pernah install tetapi belum login, arahkan ke SigninScreen
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => SigninScreen()), MaterialPageRoute(builder: (_) => const SigninScreen()),
); );
} }
}); });

View File

@ -83,7 +83,7 @@ class WelcomeScreen extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => SigninScreen()), builder: (context) => const SigninScreen()),
); );
}, },
child: Text( child: Text(