fix: refactoring loading handling in various feature and improve performance
This commit is contained in:
parent
e8980afc60
commit
88ceaf7163
|
|
@ -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/';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
return []; // Kembalikan list kosong jika payload bukan list
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Exception(
|
// Tangani status code selain 200
|
||||||
'Failed to load completed topics: ${response.statusMessage}');
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
_controller.dispose();
|
if (widget.controller == null) {
|
||||||
|
_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,
|
||||||
|
|
|
||||||
|
|
@ -11,216 +11,298 @@ 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: SafeArea(
|
||||||
body: Center(
|
child: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Consumer<ValidatorProvider>(
|
child: Form(
|
||||||
builder: (context, validatorProvider, child) {
|
key: _formKey,
|
||||||
return Column(
|
child: Consumer<ValidatorProvider>(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
builder: (context, validatorProvider, child) {
|
||||||
children: [
|
return Column(
|
||||||
const SizedBox(height: 32),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
'Welcome Back!',
|
const SizedBox(height: 24),
|
||||||
style: AppTextStyles.blueTextStyle.copyWith(
|
Hero(
|
||||||
fontSize: 30,
|
tag: 'welcome_text',
|
||||||
fontWeight: FontWeight.w900,
|
child: Material(
|
||||||
),
|
color: Colors.transparent,
|
||||||
),
|
child: Text(
|
||||||
const SizedBox(height: 4),
|
'Welcome Back!',
|
||||||
Text(
|
style: AppTextStyles.blueTextStyle.copyWith(
|
||||||
'Login to continue your personalized learning journey.',
|
fontSize: 30,
|
||||||
style: AppTextStyles.greyTextStyle.copyWith(
|
fontWeight: FontWeight.w900,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
],
|
Text(
|
||||||
),
|
'Login to continue your personalized learning journey.',
|
||||||
const SizedBox(height: 24),
|
style: AppTextStyles.greyTextStyle.copyWith(
|
||||||
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(
|
|
||||||
fontSize: 14,
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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((_) {
|
}
|
||||||
final historyProvider =
|
|
||||||
Provider.of<HistoryProvider>(context, listen: false);
|
Future<void> _initializeHistory() async {
|
||||||
final userProvider = Provider.of<UserProvider>(context, listen: false);
|
final historyProvider =
|
||||||
historyProvider.fetchLearningHistory(userProvider.jwtToken!);
|
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
|
// 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,37 +131,46 @@ 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(
|
||||||
children: [
|
opacity: 1.0,
|
||||||
ExerciseHistoryCard(
|
duration: const Duration(milliseconds: 500),
|
||||||
exercise: historyProvider.learningHistory[index],
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ExerciseHistoryCard(
|
||||||
|
exercise: historyProvider.learningHistory[index],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8.0),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8.0),
|
);
|
||||||
],
|
},
|
||||||
);
|
),
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
_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) {
|
} catch (e) {
|
||||||
// Tangani error
|
// Tangani error
|
||||||
_completedTopics = [];
|
_completedTopics = [];
|
||||||
|
|
|
||||||
|
|
@ -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,16 +158,78 @@ 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((_) {
|
||||||
final userProvider = Provider.of<UserProvider>(context, listen: false);
|
_initializeData();
|
||||||
final completedTopicsProvider =
|
|
||||||
Provider.of<CompletedTopicsProvider>(context, listen: false);
|
|
||||||
completedTopicsProvider.resetData();
|
|
||||||
completedTopicsProvider.fetchCompletedTopics(userProvider.jwtToken!);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
Widget _buildNoDataWidget() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
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) {
|
Widget _buildCompletedTopicsContent(CompletedTopicsProvider provider) {
|
||||||
return ListView.builder(
|
return AnimatedSwitcher(
|
||||||
shrinkWrap: true,
|
duration: const Duration(milliseconds: 300),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(
|
key: ValueKey<int>(provider.completedTopics.length),
|
||||||
horizontal: 16.0,
|
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
|
@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(
|
||||||
child: Column(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Stack(
|
children: [
|
||||||
children: [
|
Stack(
|
||||||
Column(
|
children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
Container(
|
children: [
|
||||||
width: double.infinity,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: double.infinity,
|
||||||
gradient: AppColors.gradientTheme,
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.only(
|
gradient: AppColors.gradientTheme,
|
||||||
bottomLeft: Radius.circular(24),
|
borderRadius: const BorderRadius.only(
|
||||||
bottomRight: Radius.circular(24),
|
bottomLeft: Radius.circular(24),
|
||||||
|
bottomRight: Radius.circular(24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.only(
|
||||||
padding: const EdgeInsets.only(
|
top: 60.0,
|
||||||
top: 60.0,
|
left: 18.34,
|
||||||
left: 18.34,
|
right: 16.0,
|
||||||
right: 16.0,
|
bottom: 34.0,
|
||||||
bottom: 34.0,
|
),
|
||||||
),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
mainAxisAlignment:
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
SvgPicture.asset(
|
SvgPicture.asset(
|
||||||
'lib/features/home/assets/images/Logo.svg',
|
'lib/features/home/assets/images/Logo.svg',
|
||||||
width: 31,
|
width: 31,
|
||||||
),
|
|
||||||
const SizedBox(width: 4.34),
|
|
||||||
Text(
|
|
||||||
'SEALS',
|
|
||||||
style:
|
|
||||||
AppTextStyles.logoTextStyle.copyWith(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4.34),
|
||||||
],
|
Text(
|
||||||
),
|
'SEALS',
|
||||||
GestureDetector(
|
style: AppTextStyles.logoTextStyle
|
||||||
onTap: () {
|
.copyWith(
|
||||||
Navigator.push(
|
fontSize: 28,
|
||||||
context,
|
fontWeight: FontWeight.w700,
|
||||||
MaterialPageRoute(
|
),
|
||||||
builder: (context) =>
|
|
||||||
const EditProfileScreen(),
|
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
|
||||||
child: const Icon(
|
|
||||||
BootstrapIcons.person_circle,
|
|
||||||
size: 28,
|
|
||||||
color: AppColors.whiteColor,
|
|
||||||
),
|
),
|
||||||
),
|
GestureDetector(
|
||||||
],
|
onTap: () {
|
||||||
),
|
Navigator.push(
|
||||||
const SizedBox(height: 17),
|
context,
|
||||||
RichText(
|
MaterialPageRoute(
|
||||||
text: TextSpan(
|
builder: (context) =>
|
||||||
text: 'Hi, ',
|
const EditProfileScreen(),
|
||||||
style: AppTextStyles.whiteTextStyle.copyWith(
|
),
|
||||||
fontWeight: FontWeight.w700,
|
);
|
||||||
fontSize: 18,
|
},
|
||||||
),
|
child: const Icon(
|
||||||
children: <TextSpan>[
|
BootstrapIcons.person_circle,
|
||||||
TextSpan(
|
size: 28,
|
||||||
text: userName,
|
color: AppColors.whiteColor,
|
||||||
style:
|
|
||||||
AppTextStyles.yellowTextStyle.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: '!',
|
|
||||||
style:
|
|
||||||
AppTextStyles.whiteTextStyle.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
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(
|
Text(
|
||||||
'Let\'s evolve together',
|
'Your Last Journey.',
|
||||||
style: AppTextStyles.whiteTextStyle.copyWith(
|
style: AppTextStyles.tetriaryTextStyle.copyWith(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
AnimatedSwitcher(
|
||||||
],
|
duration: const Duration(milliseconds: 300),
|
||||||
),
|
child: completedTopicsProvider.isLoading
|
||||||
],
|
? _buildLoadingWidget()
|
||||||
),
|
: _buildProgressCard(completedTopicsProvider),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
completedTopicsProvider.isLoading
|
),
|
||||||
? const ProgressCardLoading()
|
|
||||||
: completedTopicsProvider.completedTopics.isEmpty
|
|
||||||
? _buildNoDataWidget()
|
|
||||||
: _buildCompletedTopicsContent(
|
|
||||||
completedTopicsProvider),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
if (token != null) {
|
// Cek apakah sections sudah ada
|
||||||
await Provider.of<SectionProvider>(context, listen: false)
|
if (sectionProvider.sections.isEmpty) {
|
||||||
.fetchSections(token);
|
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 {
|
} 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,39 +103,83 @@ 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(
|
|
||||||
child: Column(
|
// Tampilkan error jika ada
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
if (sectionProvider.error != null) {
|
||||||
children: [
|
return RefreshIndicator(
|
||||||
Text(
|
onRefresh: _refreshSections,
|
||||||
'Error: ${sectionProvider.error}',
|
child: ListView(
|
||||||
style: AppTextStyles.greyTextStyle,
|
children: [
|
||||||
),
|
Center(
|
||||||
SizedBox(height: 16),
|
child: Column(
|
||||||
// Tambahkan tombol retry jika diperlukan
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
ElevatedButton(
|
children: [
|
||||||
onPressed: _fetchSections,
|
Text(
|
||||||
child: Text('Retry'),
|
'Error: ${sectionProvider.error}',
|
||||||
)
|
style: AppTextStyles.greyTextStyle,
|
||||||
],
|
),
|
||||||
));
|
const SizedBox(height: 16),
|
||||||
} else if (sectionProvider.sections.isEmpty) {
|
ElevatedButton(
|
||||||
return Center(
|
onPressed: _refreshSections,
|
||||||
child: Text(
|
child: const Text('Retry'),
|
||||||
'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> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,71 +31,56 @@ 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);
|
||||||
onTap: widget.onTap,
|
|
||||||
child: Card(
|
if (widget.isLoading) {
|
||||||
|
return Card(
|
||||||
color: AppColors.whiteColor,
|
color: AppColors.whiteColor,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ShimmerLoadingWidget(
|
||||||
borderRadius: BorderRadius.circular(8),
|
width: 90,
|
||||||
child: Image.network(
|
height: 104,
|
||||||
_getFullImageUrl(widget.section.thumbnail ?? ''),
|
borderRadius: BorderRadius.circular(8),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
ShimmerLoadingWidget(
|
||||||
widget.section.name,
|
width: MediaQuery.of(context).size.width * 0.4,
|
||||||
style: AppTextStyles.blackTextStyle.copyWith(
|
height: 20,
|
||||||
fontSize: 16,
|
borderRadius: BorderRadius.circular(4),
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Column(
|
||||||
widget.section.description,
|
children: List.generate(
|
||||||
style: AppTextStyles.disableTextStyle.copyWith(
|
3,
|
||||||
fontSize: 13,
|
(index) => Padding(
|
||||||
fontWeight: FontWeight.w500,
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
@ -7,53 +8,53 @@ class SectionCardLoading extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Card(
|
return Card(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
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),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user