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 {
try {
final response = await _dioClient.getCompletedTopics(token);
// Tambahkan pengecekan status code dan payload
if (response.statusCode == 200) {
final List<dynamic> topicsData = response.data['payload'];
return topicsData.map((data) => CompletedTopic.fromJson(data)).toList();
// Cek apakah payload null atau bukan list
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 {
return []; // Kembalikan list kosong jika payload bukan list
}
} else {
throw Exception(
'Failed to load completed topics: ${response.statusMessage}');
// Tangani status code selain 200
return [];
}
} 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) {
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 bool isRequired;
final bool isEnabled;
final Function(String?)? onFieldSubmitted;
const CustomFieldWidget({
super.key,
@ -40,6 +41,7 @@ class CustomFieldWidget extends StatefulWidget {
this.onSuffixIconTap,
this.isRequired = false,
this.isEnabled = true,
this.onFieldSubmitted,
});
@override
@ -52,7 +54,8 @@ class _CustomFieldWidgetState extends State<CustomFieldWidget> {
@override
void initState() {
super.initState();
_controller = TextEditingController();
_controller = widget.controller ?? TextEditingController();
context
.read<ValidatorProvider>()
.setController(widget.fieldName, _controller);
@ -61,7 +64,9 @@ class _CustomFieldWidgetState extends State<CustomFieldWidget> {
@override
void dispose() {
context.read<ValidatorProvider>().removeController(widget.fieldName);
_controller.dispose();
if (widget.controller == null) {
_controller.dispose();
}
super.dispose();
}
@ -100,6 +105,7 @@ class _CustomFieldWidgetState extends State<CustomFieldWidget> {
obscureText: widget.obscureText,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
onFieldSubmitted: widget.onFieldSubmitted,
enabled: widget.isEnabled,
decoration: InputDecoration(
hintText: widget.hintText,

View File

@ -11,216 +11,298 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.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 _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
Widget build(BuildContext context) {
final userProvider = Provider.of<UserProvider>(context, listen: false);
Provider.of<ValidatorProvider>(context, listen: false);
return Scaffold(
backgroundColor: AppColors.whiteColor,
body: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: SingleChildScrollView(
child: Consumer<ValidatorProvider>(
builder: (context, validatorProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 32),
Text(
'Welcome Back!',
style: AppTextStyles.blueTextStyle.copyWith(
fontSize: 30,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
'Login to continue your personalized learning journey.',
style: AppTextStyles.greyTextStyle.copyWith(
fontSize: 14,
),
),
const SizedBox(height: 26),
Center(
child: SvgPicture.asset(
'lib/features/auth/assets/images/login_illustration.svg',
width: 200,
),
),
const SizedBox(height: 30),
CustomFieldWidget(
fieldName: 'email',
controller: _emailController,
isRequired: true,
textInputAction: TextInputAction.next,
labelText: 'Email Address',
hintText: 'Enter Your Email Address',
keyboardType: TextInputType.emailAddress,
validator: validatorProvider.emailValidator,
onChanged: (value) {
validatorProvider.validateField(
'email',
value,
validator: validatorProvider.emailValidator,
);
},
errorText: validatorProvider.getError('email'),
),
const SizedBox(height: 14),
CustomFieldWidget(
fieldName: 'password',
controller: _passwordController,
isRequired: true,
textInputAction: TextInputAction.next,
labelText: 'Password',
hintText: 'Create a Strong Password',
obscureText: validatorProvider.isObscure('password'),
keyboardType: TextInputType.visiblePassword,
validator: validatorProvider.passwordValidator,
onChanged: (value) {
validatorProvider.validateField(
'password',
value,
validator: validatorProvider.passwordValidator,
);
},
onSuffixIconTap: () =>
validatorProvider.toggleVisibility('password'),
errorText: validatorProvider.getError('password'),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ForgotPasswordScreen()),
);
},
child: ShaderMask(
shaderCallback: (bounds) =>
AppColors.gradientTheme.createShader(
Rect.fromLTWH(0, 0, bounds.width, bounds.height),
),
child: const Text(
'Forgot Password?',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
backgroundColor: AppColors.whiteColor,
body: SafeArea(
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Consumer<ValidatorProvider>(
builder: (context, validatorProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Hero(
tag: 'welcome_text',
child: Material(
color: Colors.transparent,
child: Text(
'Welcome Back!',
style: AppTextStyles.blueTextStyle.copyWith(
fontSize: 30,
fontWeight: FontWeight.w900,
),
),
),
),
),
],
),
const SizedBox(height: 24),
GlobalButton(
text: 'Login',
isLoading: userProvider.isLoading,
onPressed: userProvider.isLoading
? null
: () async {
// 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),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Haven\'t joined us yet? ',
style: AppTextStyles.blackTextStyle
.copyWith(fontSize: 14),
),
GestureDetector(
onTap: () {
context.read<ValidatorProvider>().resetFields();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SignupScreen()),
);
},
child: Text(
'Sign Up Here',
style: AppTextStyles.blueTextStyle.copyWith(
const SizedBox(height: 4),
Text(
'Login to continue your personalized learning journey.',
style: AppTextStyles.greyTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
);
},
const SizedBox(height: 26),
Center(
child: Hero(
tag: 'login_illustration',
child: SvgPicture.asset(
'lib/features/auth/assets/images/login_illustration.svg',
width: 200,
),
),
),
const SizedBox(height: 30),
CustomFieldWidget(
fieldName: 'email',
controller: _emailController,
focusNode: _emailFocus,
isRequired: true,
textInputAction: TextInputAction.next,
labelText: 'Email Address',
hintText: 'Enter Your Email Address',
keyboardType: TextInputType.emailAddress,
validator: validatorProvider.emailValidator,
onChanged: (value) =>
validatorProvider.validateField(
'email',
value?.trim(),
validator: validatorProvider.emailValidator,
),
onFieldSubmitted: (_) {
FocusScope.of(context)
.requestFocus(_passwordFocus);
},
errorText: validatorProvider.getError('email'),
),
const SizedBox(height: 14),
CustomFieldWidget(
fieldName: 'password',
controller: _passwordController,
focusNode: _passwordFocus,
isRequired: true,
textInputAction: TextInputAction.done,
labelText: 'Password',
hintText: 'Enter Your Password',
obscureText:
validatorProvider.isObscure('password'),
keyboardType: TextInputType.visiblePassword,
validator: validatorProvider.passwordValidator,
onChanged: (value) =>
validatorProvider.validateField(
'password',
value,
validator: validatorProvider.passwordValidator,
),
onFieldSubmitted: (_) => _handleLogin(context),
onSuffixIconTap: () =>
validatorProvider.toggleVisibility('password'),
errorText: validatorProvider.getError('password'),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ForgotPasswordScreen(),
),
);
},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: ShaderMask(
shaderCallback: (bounds) =>
AppColors.gradientTheme.createShader(
Rect.fromLTWH(
0, 0, bounds.width, bounds.height),
),
child: const Text(
'Forgot Password?',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
),
const SizedBox(height: 24),
Consumer<UserProvider>(
builder: (context, userProvider, _) {
return GlobalButton(
text: 'Login',
isLoading: userProvider.isLoading,
onPressed: userProvider.isLoading
? null
: () => _handleLogin(context),
);
},
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Haven\'t joined us yet? ',
style: AppTextStyles.blackTextStyle
.copyWith(fontSize: 14),
),
TextButton(
onPressed: () {
context
.read<ValidatorProvider>()
.resetFields();
Navigator.push(
context,
PageRouteBuilder(
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(
'Sign Up Here',
style: AppTextStyles.blueTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
],
);
},
),
),
),
),
),
),

View File

@ -19,22 +19,53 @@ class HistoryScreen extends StatefulWidget {
}
class _HistoryScreenState extends State<HistoryScreen> {
bool _isInitialLoading = true;
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
// Memuat data saat HistoryScreen diinisialisasi
WidgetsBinding.instance.addPostFrameCallback((_) {
final historyProvider =
Provider.of<HistoryProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false);
historyProvider.fetchLearningHistory(userProvider.jwtToken!);
});
_initializeHistory();
}
Future<void> _initializeHistory() 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) {
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
Widget _buildShimmerLoading() {
return ListView.builder(
itemCount: 5, // Jumlah item shimmer
itemCount: 5,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
@ -43,10 +74,17 @@ class _HistoryScreenState extends State<HistoryScreen> {
highlightColor: Colors.grey[100]!,
child: Container(
width: double.infinity,
height: 100, // Sesuaikan dengan tinggi card history
height: 100,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
Colors.grey[200]!,
Colors.grey[100]!,
Colors.grey[200]!,
],
),
),
),
),
@ -93,37 +131,46 @@ class _HistoryScreenState extends State<HistoryScreen> {
}
Widget _buildContent(BuildContext context, HistoryProvider historyProvider) {
if (historyProvider.isLoading) {
// Prioritaskan initial loading
if (_isInitialLoading || historyProvider.isLoading) {
return _buildShimmerLoading();
}
// Tangani error
if (historyProvider.error != null) {
return isNotFoundError(historyProvider.error!)
? _buildEmptyState(context)
: _buildErrorState(context, historyProvider.error!);
}
// Tampilkan empty state jika tidak ada history
if (historyProvider.learningHistory.isEmpty) {
return _buildEmptyState(context);
}
// Tampilkan daftar history dengan refresh indicator
return RefreshIndicator(
onRefresh: () async {
final userProvider = Provider.of<UserProvider>(context, listen: false);
await historyProvider.fetchLearningHistory(userProvider.jwtToken!);
},
child: ListView.builder(
itemCount: historyProvider.learningHistory.length,
itemBuilder: (context, index) {
return Column(
children: [
ExerciseHistoryCard(
exercise: historyProvider.learningHistory[index],
onRefresh: _refreshHistory,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: ListView.builder(
key: ValueKey(historyProvider.learningHistory.length),
itemCount: historyProvider.learningHistory.length,
itemBuilder: (context, index) {
return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(milliseconds: 500),
child: Column(
children: [
ExerciseHistoryCard(
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
// ignore_for_file: unnecessary_null_comparison
import 'package:english_learning/core/services/repositories/completed_topics_repository.dart';
import 'package:english_learning/features/home/models/completed_topics_model.dart';
import 'package:flutter/foundation.dart';
@ -30,8 +32,16 @@ class CompletedTopicsProvider with ChangeNotifier {
try {
final result = await _repository.getCompletedTopics(token);
_completedTopics = result;
_error = null;
// 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;
_error = null;
}
} catch (e) {
// Tangani error
_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/provider/completed_topics_provider.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/learning/screens/learning_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:google_nav_bar/google_nav_bar.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@ -158,16 +158,78 @@ class _HomeContentState extends State<HomeContent> {
@override
void initState() {
super.initState();
// Memanggil fetchCompletedTopics saat HomeContent diinisialisasi
WidgetsBinding.instance.addPostFrameCallback((_) {
final userProvider = Provider.of<UserProvider>(context, listen: false);
final completedTopicsProvider =
Provider.of<CompletedTopicsProvider>(context, listen: false);
completedTopicsProvider.resetData();
completedTopicsProvider.fetchCompletedTopics(userProvider.jwtToken!);
_initializeData();
});
}
Future<void> _initializeData() async {
final userProvider = Provider.of<UserProvider>(context, listen: false);
final completedTopicsProvider =
Provider.of<CompletedTopicsProvider>(context, listen: false);
try {
// Reset data sebelum fetch
completedTopicsProvider.resetData();
// 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() {
return Padding(
padding: const EdgeInsets.all(12.0),
@ -203,228 +265,307 @@ 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) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: ListView.builder(
key: ValueKey<int>(provider.completedTopics.length),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
itemCount: 1,
itemBuilder: (context, index) {
return ProgressCard(
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),
),
),
],
),
),
],
),
],
),
),
itemCount: 1,
itemBuilder: (context, index) {
return ProgressCard(
completedTopic: provider.completedTopics,
);
},
);
}
@override
Widget build(BuildContext context) {
return Consumer2<UserProvider, CompletedTopicsProvider>(builder: (
context,
authProvider,
completedTopicsProvider,
child,
) {
final userName = authProvider.getUserName() ?? 'Guest';
return RefreshIndicator(
onRefresh: _initializeData,
child: Consumer2<UserProvider, CompletedTopicsProvider>(
builder: (context, authProvider, completedTopicsProvider, child) {
final userName = authProvider.getUserName() ?? 'Guest';
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: AppColors.gradientTheme,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: AppColors.gradientTheme,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(24),
bottomRight: Radius.circular(24),
),
),
),
child: Padding(
padding: const EdgeInsets.only(
top: 60.0,
left: 18.34,
right: 16.0,
bottom: 34.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
SvgPicture.asset(
'lib/features/home/assets/images/Logo.svg',
width: 31,
),
const SizedBox(width: 4.34),
Text(
'SEALS',
style:
AppTextStyles.logoTextStyle.copyWith(
fontSize: 28,
fontWeight: FontWeight.w700,
child: Padding(
padding: const EdgeInsets.only(
top: 60.0,
left: 18.34,
right: 16.0,
bottom: 34.0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
SvgPicture.asset(
'lib/features/home/assets/images/Logo.svg',
width: 31,
),
),
],
),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const EditProfileScreen(),
const SizedBox(width: 4.34),
Text(
'SEALS',
style: AppTextStyles.logoTextStyle
.copyWith(
fontSize: 28,
fontWeight: FontWeight.w700,
),
),
);
},
child: const Icon(
BootstrapIcons.person_circle,
size: 28,
color: AppColors.whiteColor,
],
),
),
],
),
const SizedBox(height: 17),
RichText(
text: TextSpan(
text: 'Hi, ',
style: AppTextStyles.whiteTextStyle.copyWith(
fontWeight: FontWeight.w700,
fontSize: 18,
),
children: <TextSpan>[
TextSpan(
text: userName,
style:
AppTextStyles.yellowTextStyle.copyWith(
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
TextSpan(
text: '!',
style:
AppTextStyles.whiteTextStyle.copyWith(
fontWeight: FontWeight.w700,
fontSize: 18,
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const EditProfileScreen(),
),
);
},
child: const Icon(
BootstrapIcons.person_circle,
size: 28,
color: AppColors.whiteColor,
),
),
],
),
const SizedBox(height: 17),
RichText(
text: TextSpan(
text: 'Hi, ',
style: AppTextStyles.whiteTextStyle.copyWith(
fontWeight: FontWeight.w700,
fontSize: 18,
),
children: <TextSpan>[
TextSpan(
text: userName,
style: AppTextStyles.yellowTextStyle
.copyWith(
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
TextSpan(
text: '!',
style:
AppTextStyles.whiteTextStyle.copyWith(
fontWeight: FontWeight.w700,
fontSize: 18,
),
),
],
),
),
const SizedBox(height: 8),
Text(
'Let\'s evolve together',
style: AppTextStyles.whiteTextStyle.copyWith(
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
],
),
],
),
const SizedBox(height: 16),
CarouselSlider.builder(
itemCount: cardData.cardData.length,
itemBuilder: (context, index, realIndex) {
return WelcomeCard(cardModel: cardData.cardData[index]);
},
options: CarouselOptions(
height: 168,
viewportFraction: 0.9,
enlargeCenterPage: true,
autoPlay: true,
autoPlayInterval: const Duration(seconds: 3),
autoPlayAnimationDuration: const Duration(milliseconds: 800),
autoPlayCurve: Curves.fastOutSlowIn,
onPageChanged: (index, reason) {
setState(
() {
_currentPage = index;
},
);
},
),
),
const SizedBox(height: 16),
SliderWidget(
currentPage: _currentPage,
itemCount: cardData.cardData.length,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
left: 24.0,
right: 24.0,
bottom: 47.0,
),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(
BootstrapIcons.info_circle,
color: AppColors.tetriaryColor,
size: 16,
),
const SizedBox(height: 8),
const SizedBox(width: 8),
Text(
'Let\'s evolve together',
style: AppTextStyles.whiteTextStyle.copyWith(
'Your Last Journey.',
style: AppTextStyles.tetriaryTextStyle.copyWith(
fontSize: 12,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w800,
),
),
],
),
),
),
],
),
],
),
const SizedBox(height: 16),
CarouselSlider.builder(
itemCount: cardData.cardData.length,
itemBuilder: (context, index, realIndex) {
return WelcomeCard(cardModel: cardData.cardData[index]);
},
options: CarouselOptions(
height: 168,
viewportFraction: 0.9,
enlargeCenterPage: true,
autoPlay: true,
autoPlayInterval: const Duration(seconds: 3),
autoPlayAnimationDuration: const Duration(milliseconds: 800),
autoPlayCurve: Curves.fastOutSlowIn,
onPageChanged: (index, reason) {
setState(
() {
_currentPage = index;
},
);
},
),
),
const SizedBox(height: 16),
SliderWidget(
currentPage: _currentPage,
itemCount: cardData.cardData.length,
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(
top: 8.0,
left: 24.0,
right: 24.0,
bottom: 47.0,
),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.2),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(
BootstrapIcons.info_circle,
color: AppColors.tetriaryColor,
size: 16,
),
const SizedBox(width: 8),
Text(
'Your Last Journey.',
style: AppTextStyles.tetriaryTextStyle.copyWith(
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
],
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: completedTopicsProvider.isLoading
? _buildLoadingWidget()
: _buildProgressCard(completedTopicsProvider),
),
),
completedTopicsProvider.isLoading
? const ProgressCardLoading()
: completedTopicsProvider.completedTopics.isEmpty
? _buildNoDataWidget()
: _buildCompletedTopicsContent(
completedTopicsProvider),
],
],
),
),
),
),
],
),
);
});
],
),
);
}),
);
}
}

View File

@ -1,4 +1,5 @@
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/learning/modules/level/screens/level_list_screen.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) {
if (loadingProgress == null) return child;
return Container(
width: 90,
height: 104,
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
return const ShimmerLoadingWidget(
height: 140,
width: double.infinity,
);
},
),

View File

@ -8,37 +8,74 @@ import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:provider/provider.dart';
class LearningScreen extends StatefulWidget {
const LearningScreen({
super.key,
});
const LearningScreen({super.key});
@override
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
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_fetchSections();
});
_initializeSections();
}
Future<void> _fetchSections() async {
Future<void> _initializeSections() async {
final userProvider = Provider.of<UserProvider>(context, listen: false);
final token = await userProvider.getValidToken();
final sectionProvider =
Provider.of<SectionProvider>(context, listen: false);
if (token != null) {
await Provider.of<SectionProvider>(context, listen: false)
.fetchSections(token);
// Cek apakah sections sudah ada
if (sectionProvider.sections.isEmpty) {
try {
final token = await userProvider.getValidToken();
if (token != null) {
await sectionProvider.fetchSections(token);
}
} catch (e) {
print('Error initializing sections: $e');
} finally {
setState(() {
_isInitialLoading = false;
});
}
} 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
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: AppColors.bgSoftColor,
body: SafeArea(
@ -66,39 +103,83 @@ class _LearningScreenState extends State<LearningScreen> {
Expanded(
child: Consumer<SectionProvider>(
builder: (context, sectionProvider, _) {
if (sectionProvider.isLoading) {
// Prioritaskan loading state
if (_isInitialLoading || sectionProvider.isLoading) {
return ListView.builder(
padding: const EdgeInsets.only(top: 8),
itemCount: 6,
itemCount: 4,
itemBuilder: (context, index) =>
const SectionCardLoading(),
);
} else if (sectionProvider.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Error: ${sectionProvider.error}',
style: AppTextStyles.greyTextStyle,
),
SizedBox(height: 16),
// Tambahkan tombol retry jika diperlukan
ElevatedButton(
onPressed: _fetchSections,
child: Text('Retry'),
)
],
));
} else if (sectionProvider.sections.isEmpty) {
return Center(
child: Text(
'No sections available',
style: AppTextStyles.greyTextStyle,
}
// Tampilkan error jika ada
if (sectionProvider.error != null) {
return RefreshIndicator(
onRefresh: _refreshSections,
child: ListView(
children: [
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Error: ${sectionProvider.error}',
style: AppTextStyles.greyTextStyle,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refreshSections,
child: const Text('Retry'),
)
],
),
),
],
),
);
} 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,
itemBuilder: (context, 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:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:cached_network_image/cached_network_image.dart';
class SectionCard extends StatefulWidget {
final Section section;
final Section? section;
final VoidCallback? onTap;
final bool isLoading;
const SectionCard({
super.key,
required this.section,
this.section,
this.onTap,
this.isLoading = false,
});
@override
@ -19,7 +22,7 @@ class SectionCard extends StatefulWidget {
}
class _SectionCardState extends State<SectionCard>
with SingleTickerProviderStateMixin {
with AutomaticKeepAliveClientMixin {
String _getFullImageUrl(String thumbnail) {
if (thumbnail.startsWith('http')) {
return thumbnail;
@ -28,71 +31,56 @@ class _SectionCardState extends State<SectionCard>
}
}
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
child: Card(
super.build(context);
if (widget.isLoading) {
return Card(
color: AppColors.whiteColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 1,
margin: const EdgeInsets.symmetric(vertical: 6.0),
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
_getFullImageUrl(widget.section.thumbnail ?? ''),
width: 90,
height: 104,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
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,
height: 104,
borderRadius: BorderRadius.circular(8),
);
},
)),
ShimmerLoadingWidget(
width: 90,
height: 104,
borderRadius: BorderRadius.circular(8),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.section.name,
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w900,
),
ShimmerLoadingWidget(
width: MediaQuery.of(context).size.width * 0.4,
height: 20,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 4),
Text(
widget.section.description,
style: AppTextStyles.disableTextStyle.copyWith(
fontSize: 13,
fontWeight: FontWeight.w500,
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),
),
),
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
@ -100,6 +88,102 @@ class _SectionCardState extends State<SectionCard>
],
),
),
);
}
return Hero(
tag: 'section_${widget.section?.id}',
child: Material(
type: MaterialType.transparency,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: GestureDetector(
onTap: widget.onTap,
child: Card(
color: AppColors.whiteColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 1,
margin: const EdgeInsets.symmetric(vertical: 6.0),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl:
_getFullImageUrl(widget.section?.thumbnail ?? ''),
width: 90,
height: 104,
fit: BoxFit.cover,
placeholder: (context, url) => ShimmerLoadingWidget(
width: 90,
height: 104,
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),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.section?.name ?? '',
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
widget.section?.description ?? '',
style: AppTextStyles.disableTextStyle.copyWith(
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
),
),
),
);
}

View File

@ -1,3 +1,4 @@
// section_card_loading.dart
import 'package:english_learning/core/widgets/loading/shimmer_loading_widget.dart';
import 'package:flutter/material.dart';
@ -7,53 +8,53 @@ class SectionCardLoading extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
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),
),
),
),
),
],
),
),
],
),
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: [
// Title shimmer
ShimmerLoadingWidget(
width: 200,
height: 16,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 12),
//description
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < 3; i++) ...[
const ShimmerLoadingWidget(
width: double.infinity,
height: 12,
borderRadius: BorderRadius.all(Radius.circular(4)),
),
const SizedBox(height: 6),
],
],
),
],
),
),
],
),
));
),
);
}
}

View File

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

View File

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