From eb206d728e5f25994f1c10bacb58a879991d1e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Tue, 24 Mar 2026 16:04:22 +0700 Subject: [PATCH 01/28] feat(core): add auth layout template, custom textfield and colors --- src/lib/core/theme/app_colors.dart | 11 +- src/lib/core/theme/auth_layout_template.dart | 226 +++++++++++++++++++ src/lib/core/theme/custom_text_field.dart | 96 ++++++++ 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 src/lib/core/theme/auth_layout_template.dart create mode 100644 src/lib/core/theme/custom_text_field.dart diff --git a/src/lib/core/theme/app_colors.dart b/src/lib/core/theme/app_colors.dart index b865e04..5c817b1 100644 --- a/src/lib/core/theme/app_colors.dart +++ b/src/lib/core/theme/app_colors.dart @@ -7,4 +7,13 @@ class AppColors { static const Color taskCardBg = Colors.white; static const Color timeBoxBg = Color(0xFF2C3E50); static const Color grayText = Color(0xFF757575); -} \ No newline at end of file + static const Color primary = Color(0xFF5A8DF3); + static const Color background = Color(0xFFF4F6F9); + static const Color textDark = Color(0xFF2D3440); + static const Color textSecondary = Colors.grey; + static const Color inputBackground = Color(0xFFF8FAFC); + static const Color border = Color(0xFFE2E8F0); + static const Color white = Colors.white; + static const Color error = Colors.redAccent; + static const Color success = Colors.green; +} diff --git a/src/lib/core/theme/auth_layout_template.dart b/src/lib/core/theme/auth_layout_template.dart new file mode 100644 index 0000000..8e7f620 --- /dev/null +++ b/src/lib/core/theme/auth_layout_template.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +/// The master layout template for all authentication screens. +/// Handles the UI skeleton, loading states, backgrounds, and responsive scrolling. +class AuthLayoutTemplate extends StatelessWidget { + final String title; + final String subtitle; + final Widget formContent; + final String submitText; + final VoidCallback onSubmit; + final bool isLoading; + final bool showSocial; + final bool useCard; + final Widget? customHeaderIcon; + final Widget? footerContent; + + const AuthLayoutTemplate({ + super.key, + required this.title, + required this.subtitle, + required this.formContent, + required this.submitText, + required this.onSubmit, + this.isLoading = false, + this.showSocial = false, + this.useCard = true, + this.customHeaderIcon, + this.footerContent, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: Navigator.canPop(context) + ? IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.primary), + onPressed: () => Navigator.pop(context), + ) + : null, + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24.0, 16.0, 24.0, 48.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeader(), + const SizedBox(height: 32), + useCard ? _buildCardContainer() : _buildTransparentContainer(), + const SizedBox(height: 32), + if (footerContent != null) footerContent!, + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + customHeaderIcon ?? + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: const Center( + child: Icon(Icons.task_alt, size: 48, color: AppColors.primary), + ), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: AppColors.textDark, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildCardContainer() { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(32), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.08), + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: _buildFormElements(), + ); + } + + Widget _buildTransparentContainer() => _buildFormElements(); + + Widget _buildFormElements() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + formContent, + const SizedBox(height: 16), + ElevatedButton( + // Disable button if loading + onPressed: isLoading ? null : onSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + disabledBackgroundColor: AppColors.primary.withOpacity(0.6), + padding: const EdgeInsets.symmetric(vertical: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: isLoading ? 0 : 4, + ), + child: isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: AppColors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + submitText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.white, + ), + ), + const SizedBox(width: 8), + const Icon( + Icons.arrow_forward, + color: AppColors.white, + size: 20, + ), + ], + ), + ), + if (showSocial) ...[ + const SizedBox(height: 32), + const Row( + children: [ + Expanded(child: Divider(color: AppColors.border)), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'OR', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppColors.textSecondary, + ), + ), + ), + Expanded(child: Divider(color: AppColors.border)), + ], + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon( + Icons.g_mobiledata, + color: Colors.red, + size: 28, + ), + label: const Text('Google'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.facebook, color: Color(0xFF1877F2)), + label: const Text('Facebook'), + ), + ), + ], + ), + ], + ], + ); + } +} diff --git a/src/lib/core/theme/custom_text_field.dart b/src/lib/core/theme/custom_text_field.dart new file mode 100644 index 0000000..7e98bcf --- /dev/null +++ b/src/lib/core/theme/custom_text_field.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +/// A highly reusable text input field component. +class CustomTextField extends StatelessWidget { + final String label; + final String hint; + final IconData icon; + final TextEditingController controller; + final bool isPassword; + final bool obscureText; + final VoidCallback? onToggleVisibility; + + const CustomTextField({ + super.key, + required this.label, + required this.hint, + required this.icon, + required this.controller, + this.isPassword = false, + this.obscureText = false, + this.onToggleVisibility, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + label.toUpperCase(), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textDark.withOpacity(0.6), + letterSpacing: 1, + ), + ), + ), + TextFormField( + controller: controller, + obscureText: obscureText, + style: const TextStyle( + color: AppColors.textDark, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + color: AppColors.textDark.withOpacity(0.3), + fontWeight: FontWeight.w400, + fontSize: 16, + ), + filled: true, + fillColor: AppColors.inputBackground, + prefixIcon: Icon(icon, color: AppColors.primary), + suffixIcon: isPassword + ? IconButton( + icon: Icon( + obscureText ? Icons.visibility_off : Icons.visibility, + color: Colors.grey, + ), + onPressed: onToggleVisibility, + ) + : null, + contentPadding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: AppColors.primary, + width: 2, + ), + ), + ), + ), + ], + ), + ); + } +} From 63f3e9e9f040241617850d68eba39a75093d39e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Tue, 24 Mar 2026 16:04:30 +0700 Subject: [PATCH 02/28] feat(auth): implement viewmodels for auth flow (MVVM) --- src/lib/viewmodels/auth_viewmodels.dart | 89 +++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/lib/viewmodels/auth_viewmodels.dart diff --git a/src/lib/viewmodels/auth_viewmodels.dart b/src/lib/viewmodels/auth_viewmodels.dart new file mode 100644 index 0000000..9d97852 --- /dev/null +++ b/src/lib/viewmodels/auth_viewmodels.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +/// Base class to handle shared loading state +class BaseViewModel extends ChangeNotifier { + bool _isLoading = false; + bool get isLoading => _isLoading; + + void setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } +} + +class LoginViewModel extends BaseViewModel { + final emailCtrl = TextEditingController(); + final passCtrl = TextEditingController(); + bool obscurePass = true; + + void togglePass() { obscurePass = !obscurePass; notifyListeners(); } + + Future login() async { + if (emailCtrl.text.isEmpty || passCtrl.text.isEmpty) return false; + setLoading(true); + // Mock API call + await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + setLoading(false); + return true; // Assume success + } +} + +class RegisterViewModel extends BaseViewModel { + final nameCtrl = TextEditingController(); + final emailCtrl = TextEditingController(); + final passCtrl = TextEditingController(); + final confirmPassCtrl = TextEditingController(); + bool obscurePass = true; + + void togglePass() { obscurePass = !obscurePass; notifyListeners(); } + + Future register() async { + if (passCtrl.text != confirmPassCtrl.text) return false; + setLoading(true); + await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + setLoading(false); + return true; + } +} + +class ForgotPassViewModel extends BaseViewModel { + final emailCtrl = TextEditingController(); + + Future sendCode() async { + if (emailCtrl.text.isEmpty) return false; + setLoading(true); + await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + setLoading(false); + return true; + } +} + +class OtpViewModel extends BaseViewModel { + List digits = List.filled(6, ""); + + void updateDigit(int idx, String val) { digits[idx] = val; notifyListeners(); } + + Future verify() async { + if (digits.join().length < 6) return false; + setLoading(true); + await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + setLoading(false); + return true; + } +} + +class NewPassViewModel extends BaseViewModel { + final passCtrl = TextEditingController(); + final confirmPassCtrl = TextEditingController(); + bool obscurePass = true; + + void togglePass() { obscurePass = !obscurePass; notifyListeners(); } + + Future updatePassword() async { + if (passCtrl.text != confirmPassCtrl.text) return false; + setLoading(true); + await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + setLoading(false); + return true; + } +} \ No newline at end of file From 18d3e8249205d8d3e264187162eccde2cbc74d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Tue, 24 Mar 2026 16:04:39 +0700 Subject: [PATCH 03/28] feat(auth): build complete auth UI screens (Login, Register, OTP, Passwords) --- .../features/auth/forgot_password_view.dart | 86 +++++++++ src/lib/features/auth/login_view.dart | 124 +++++++++++++ src/lib/features/auth/new_password_view.dart | 98 ++++++++++ .../features/auth/otp_verification_view.dart | 167 ++++++++++++++++++ src/lib/features/auth/register_view.dart | 105 +++++++++++ 5 files changed, 580 insertions(+) create mode 100644 src/lib/features/auth/forgot_password_view.dart create mode 100644 src/lib/features/auth/login_view.dart create mode 100644 src/lib/features/auth/new_password_view.dart create mode 100644 src/lib/features/auth/otp_verification_view.dart create mode 100644 src/lib/features/auth/register_view.dart diff --git a/src/lib/features/auth/forgot_password_view.dart b/src/lib/features/auth/forgot_password_view.dart new file mode 100644 index 0000000..5c220de --- /dev/null +++ b/src/lib/features/auth/forgot_password_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/auth_layout_template.dart'; +import '../../core/theme/custom_text_field.dart'; +import '../../viewmodels/auth_viewmodels.dart'; +import 'otp_verification_view.dart'; + +class ForgotPasswordView extends StatefulWidget { + const ForgotPasswordView({super.key}); + + @override + State createState() => _ForgotPasswordViewState(); +} + +class _ForgotPasswordViewState extends State { + final _vm = ForgotPassViewModel(); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _vm, + builder: (context, _) => AuthLayoutTemplate( + title: 'Quên mật khẩu?', + subtitle: 'Nhập email của bạn để nhận mã xác thực', + submitText: 'Gửi mã', + useCard: false, + isLoading: _vm.isLoading, + customHeaderIcon: Container( + width: 120, + height: 120, + decoration: const BoxDecoration( + color: AppColors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0xFFD1D9E6), blurRadius: 16)], + ), + child: const Icon( + Icons.lock_reset, + size: 64, + color: AppColors.primary, + ), + ), + onSubmit: () async { + FocusScope.of(context).unfocus(); + final success = await _vm.sendCode(); + if (!context.mounted) return; + + if (success) { + // Jump to Step 2: OTP + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const OtpVerificationView()), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vui lòng nhập email hợp lệ!'), + backgroundColor: AppColors.error, + ), + ); + } + }, + formContent: CustomTextField( + label: 'Địa chỉ Email', + hint: 'example@gmail.com', + icon: Icons.mail, + controller: _vm.emailCtrl, + ), + footerContent: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.help, size: 16, color: AppColors.primary), + SizedBox(width: 4), + Text( + 'Cần hỗ trợ? Liên hệ CSKH', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } +} diff --git a/src/lib/features/auth/login_view.dart b/src/lib/features/auth/login_view.dart new file mode 100644 index 0000000..a952819 --- /dev/null +++ b/src/lib/features/auth/login_view.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/auth_layout_template.dart'; +import '../../core/theme/custom_text_field.dart'; +import '../../viewmodels/auth_viewmodels.dart'; +import 'register_view.dart'; +import 'forgot_password_view.dart'; + +class LoginView extends StatefulWidget { + const LoginView({super.key}); + + @override + State createState() => _LoginViewState(); +} + +class _LoginViewState extends State { + final _vm = LoginViewModel(); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _vm, + builder: (context, _) => + AuthLayoutTemplate( + title: 'Task Management', + subtitle: 'Chào mừng trở lại!', + submitText: 'Đăng nhập', + isLoading: _vm.isLoading, + showSocial: true, + onSubmit: () async { + FocusScope.of(context).unfocus(); // Hide keyboard + final success = await _vm.login(); + if (!context.mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đăng nhập thành công!'), + backgroundColor: AppColors.success, + ), + ); + // TODO: Navigator.pushReplacementNamed(context, '/home'); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vui lòng điền đầy đủ thông tin!'), + backgroundColor: AppColors.error, + ), + ); + } + }, + formContent: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + CustomTextField( + label: 'Email', + hint: 'example@gmail.com', + icon: Icons.mail, + controller: _vm.emailCtrl, + ), + CustomTextField( + label: 'Mật khẩu', + hint: '••••••••', + icon: Icons.lock, + controller: _vm.passCtrl, + isPassword: true, + obscureText: _vm.obscurePass, + onToggleVisibility: _vm.togglePass, + ), + TextButton( + onPressed: () => + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ForgotPasswordView()), + ), + child: const Text( + 'Quên mật khẩu?', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + footerContent: _buildFooter( + 'Chưa có tài khoản? ', 'Đăng ký ngay', () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const RegisterView()), + ); + }), + ), + ); + } + + Widget _buildFooter(String text, String action, VoidCallback onTap) { + return Padding( + // Position this block 24dp above the bottom of the screen + padding: const EdgeInsets.only(bottom: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(text, style: const TextStyle(color: Colors.grey, fontSize: 16)), + + TextButton( + onPressed: onTap, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + minimumSize: const Size(50, 48), + ), + child: Text( + action, + style: const TextStyle(color: Color(0xFF5A8DF3), + fontWeight: FontWeight.bold, + fontSize: 16), + ), + ), + ], + ), + ); + } +} diff --git a/src/lib/features/auth/new_password_view.dart b/src/lib/features/auth/new_password_view.dart new file mode 100644 index 0000000..bae74cd --- /dev/null +++ b/src/lib/features/auth/new_password_view.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/auth_layout_template.dart'; +import '../../core/theme/custom_text_field.dart'; +import '../../viewmodels/auth_viewmodels.dart'; + +class NewPasswordView extends StatefulWidget { + const NewPasswordView({super.key}); + + @override + State createState() => _NewPasswordViewState(); +} + +class _NewPasswordViewState extends State { + final _vm = NewPassViewModel(); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _vm, + builder: (context, _) => AuthLayoutTemplate( + title: 'Tạo mật khẩu mới', + subtitle: 'Mật khẩu mới phải khác với mật khẩu cũ', + submitText: 'Cập nhật', + isLoading: _vm.isLoading, + customHeaderIcon: const CircleAvatar( + radius: 40, + backgroundColor: Color(0xFFEBF2FF), + child: Icon(Icons.lock_reset, size: 40, color: AppColors.primary), + ), + onSubmit: () async { + FocusScope.of(context).unfocus(); + final success = await _vm.updatePassword(); + if (!context.mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đổi mật khẩu thành công!'), + backgroundColor: AppColors.success, + ), + ); + // Complete Flow: Pop everything and return to Login Screen + Navigator.popUntil(context, (route) => route.isFirst); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mật khẩu không khớp!'), + backgroundColor: AppColors.error, + ), + ); + } + }, + formContent: Column( + children: [ + CustomTextField( + label: 'Mật khẩu mới', + hint: '••••••••', + icon: Icons.lock, + controller: _vm.passCtrl, + isPassword: true, + obscureText: _vm.obscurePass, + onToggleVisibility: _vm.togglePass, + ), + CustomTextField( + label: 'Xác nhận mật khẩu mới', + hint: '••••••••', + icon: Icons.lock, + controller: _vm.confirmPassCtrl, + isPassword: true, + obscureText: _vm.obscurePass, + onToggleVisibility: _vm.togglePass, + ), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFEBF2FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + children: [ + Icon(Icons.info, color: AppColors.primary, size: 16), + SizedBox(width: 8), + Expanded( + child: Text( + 'Mật khẩu tối thiểu 8 ký tự để đảm bảo an toàn.', + style: TextStyle(fontSize: 12, color: AppColors.primary), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/src/lib/features/auth/otp_verification_view.dart b/src/lib/features/auth/otp_verification_view.dart new file mode 100644 index 0000000..cbb2d7c --- /dev/null +++ b/src/lib/features/auth/otp_verification_view.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../core/theme/app_colors.dart'; +import '../../viewmodels/auth_viewmodels.dart'; +import 'new_password_view.dart'; + +class OtpVerificationView extends StatefulWidget { + const OtpVerificationView({super.key}); + + @override + State createState() => _OtpVerificationViewState(); +} + +class _OtpVerificationViewState extends State { + final _vm = OtpViewModel(); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.primary), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Xác thực OTP', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + centerTitle: true, + ), + body: AnimatedBuilder( + animation: _vm, + builder: (context, child) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.mark_email_read, + size: 80, + color: AppColors.primary, + ), + const SizedBox(height: 32), + const Text( + 'Nhập mã 6 số', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Mã đã được gửi đến email của bạn.', + style: TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 40), + + // 6 Box OTP + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + 6, + (index) => _buildOtpBox(index, context), + ), + ), + const SizedBox(height: 40), + + ElevatedButton( + onPressed: _vm.isLoading + ? null + : () async { + FocusScope.of(context).unfocus(); + final success = await _vm.verify(); + if (!context.mounted) return; + if (success) { + // Jump to Step 3: New Password + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const NewPasswordView(), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Mã OTP chưa đủ 6 số hoặc sai!', + ), + backgroundColor: AppColors.error, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _vm.isLoading + ? const CircularProgressIndicator( + color: AppColors.white, + ) + : const Text( + 'XÁC NHẬN', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.white, + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildOtpBox(int index, BuildContext context) { + return Container( + width: 45, + height: 55, + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: TextField( + onChanged: (value) { + _vm.updateDigit(index, value); + if (value.isNotEmpty && index < 5) FocusScope.of(context).nextFocus(); + if (value.isEmpty && index > 0) + FocusScope.of(context).previousFocus(); + }, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textDark, + ), + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + inputFormatters: [ + LengthLimitingTextInputFormatter(1), + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration( + border: InputBorder.none, + counterText: '', + ), + ), + ); + } +} diff --git a/src/lib/features/auth/register_view.dart b/src/lib/features/auth/register_view.dart new file mode 100644 index 0000000..5234f35 --- /dev/null +++ b/src/lib/features/auth/register_view.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/auth_layout_template.dart'; +import '../../core/theme/custom_text_field.dart'; +import '../../viewmodels/auth_viewmodels.dart'; + +class RegisterView extends StatefulWidget { + const RegisterView({super.key}); + + @override + State createState() => _RegisterViewState(); +} + +class _RegisterViewState extends State { + final _vm = RegisterViewModel(); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _vm, + builder: (context, _) => AuthLayoutTemplate( + title: 'Tạo tài khoản mới', + subtitle: 'Bắt đầu quản lý công việc khoa học', + submitText: 'Đăng ký', + isLoading: _vm.isLoading, + showSocial: true, + onSubmit: () async { + FocusScope.of(context).unfocus(); + final success = await _vm.register(); + if (!context.mounted) return; + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đăng ký thành công!'), + backgroundColor: AppColors.success, + ), + ); + Navigator.pop(context); // Go back to login + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mật khẩu không khớp hoặc thiếu thông tin!'), + backgroundColor: AppColors.error, + ), + ); + } + }, + formContent: Column( + children: [ + CustomTextField( + label: 'Họ tên', + hint: 'Nhập họ và tên', + icon: Icons.person, + controller: _vm.nameCtrl, + ), + CustomTextField( + label: 'Email', + hint: 'example@gmail.com', + icon: Icons.mail, + controller: _vm.emailCtrl, + ), + CustomTextField( + label: 'Mật khẩu', + hint: '••••••••', + icon: Icons.lock, + controller: _vm.passCtrl, + isPassword: true, + obscureText: _vm.obscurePass, + onToggleVisibility: _vm.togglePass, + ), + CustomTextField( + label: 'Xác nhận mật khẩu', + hint: '••••••••', + icon: Icons.shield, + controller: _vm.confirmPassCtrl, + isPassword: true, + obscureText: _vm.obscurePass, + onToggleVisibility: _vm.togglePass, + ), + ], + ), + footerContent: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Đã có tài khoản? ', + style: TextStyle(color: AppColors.textSecondary), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Text( + 'Đăng nhập', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + } +} From 5ebec14e3382df0eabf8ed4bce0b9ed781e8205c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Tue, 24 Mar 2026 16:04:59 +0700 Subject: [PATCH 04/28] chore(main): set LoginView as initial route --- src/lib/main.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/main.dart b/src/lib/main.dart index cea4410..f6d93ce 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'core/theme/app_colors.dart'; -import 'features/main/view/screens/main_screen.dart'; -import 'features/tasks/view/screens/home_screen.dart'; +import 'features/auth/login_view.dart'; void main() { SystemChrome.setSystemUIOverlayStyle( @@ -29,7 +28,7 @@ class TaskApp extends StatelessWidget { labelLarge: TextStyle(fontSize: 16, color: AppColors.primaryBlue), ), ), - home: const MainScreen(), + home: const LoginView(), debugShowCheckedModeBanner: false, ); } From 911444c168f49c5644dd4758f31a39ea43f8516e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Tue, 24 Mar 2026 16:05:44 +0700 Subject: [PATCH 05/28] refactor(auth) : delete .gitkeep --- src/lib/features/auth/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/lib/features/auth/.gitkeep diff --git a/src/lib/features/auth/.gitkeep b/src/lib/features/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 From f8ce39c29f94b22d3afdc0f81cc1354b763b7d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:06:07 +0700 Subject: [PATCH 06/28] chore: update dependencies and pubspec.lock --- src/lib/features/auth/data/auth_helper.dart | 0 src/lib/features/auth/models/user_model.dart | 0 .../view}/forgot_password_view.dart | 10 +- .../{ => presentation/view}/login_view.dart | 4 +- .../view}/new_password_view.dart | 2 +- .../view}/otp_verification_view.dart | 2 +- .../view}/register_view.dart | 2 +- .../viewmodels/auth_viewmodels.dart | 0 src/pubspec.lock | 511 +++++++++++++++++- src/pubspec.yaml | 5 +- 10 files changed, 524 insertions(+), 12 deletions(-) create mode 100644 src/lib/features/auth/data/auth_helper.dart create mode 100644 src/lib/features/auth/models/user_model.dart rename src/lib/features/auth/{ => presentation/view}/forgot_password_view.dart (89%) rename src/lib/features/auth/{ => presentation/view}/login_view.dart (97%) rename src/lib/features/auth/{ => presentation/view}/new_password_view.dart (98%) rename src/lib/features/auth/{ => presentation/view}/otp_verification_view.dart (98%) rename src/lib/features/auth/{ => presentation/view}/register_view.dart (98%) rename src/lib/{ => features/auth/presentation}/viewmodels/auth_viewmodels.dart (100%) diff --git a/src/lib/features/auth/data/auth_helper.dart b/src/lib/features/auth/data/auth_helper.dart new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/features/auth/models/user_model.dart b/src/lib/features/auth/models/user_model.dart new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/features/auth/forgot_password_view.dart b/src/lib/features/auth/presentation/view/forgot_password_view.dart similarity index 89% rename from src/lib/features/auth/forgot_password_view.dart rename to src/lib/features/auth/presentation/view/forgot_password_view.dart index 5c220de..cfa9c9b 100644 --- a/src/lib/features/auth/forgot_password_view.dart +++ b/src/lib/features/auth/presentation/view/forgot_password_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import '../../core/theme/app_colors.dart'; -import '../../core/theme/auth_layout_template.dart'; -import '../../core/theme/custom_text_field.dart'; -import '../../viewmodels/auth_viewmodels.dart'; -import 'otp_verification_view.dart'; +import '../../../../../../core/theme/app_colors.dart'; +import '../../../../../../core/theme/auth_layout_template.dart'; +import '../../../../../../core/theme/custom_text_field.dart'; +import '../auth/presentation/viewmodels/auth_viewmodels.dart' +import '../../otp_verification_view.dart'; class ForgotPasswordView extends StatefulWidget { const ForgotPasswordView({super.key}); diff --git a/src/lib/features/auth/login_view.dart b/src/lib/features/auth/presentation/view/login_view.dart similarity index 97% rename from src/lib/features/auth/login_view.dart rename to src/lib/features/auth/presentation/view/login_view.dart index a952819..f214724 100644 --- a/src/lib/features/auth/login_view.dart +++ b/src/lib/features/auth/presentation/view/login_view.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/auth_layout_template.dart'; import '../../core/theme/custom_text_field.dart'; -import '../../viewmodels/auth_viewmodels.dart'; +import '../auth/presentation/viewmodels/auth_viewmodels.dart'; import 'register_view.dart'; -import 'forgot_password_view.dart'; +import 'presentation/view /forgot_password_view.dart'; class LoginView extends StatefulWidget { const LoginView({super.key}); diff --git a/src/lib/features/auth/new_password_view.dart b/src/lib/features/auth/presentation/view/new_password_view.dart similarity index 98% rename from src/lib/features/auth/new_password_view.dart rename to src/lib/features/auth/presentation/view/new_password_view.dart index bae74cd..4b15fed 100644 --- a/src/lib/features/auth/new_password_view.dart +++ b/src/lib/features/auth/presentation/view/new_password_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/auth_layout_template.dart'; import '../../core/theme/custom_text_field.dart'; -import '../../viewmodels/auth_viewmodels.dart'; +import '../auth/presentation/viewmodels/auth_viewmodels.dart'; class NewPasswordView extends StatefulWidget { const NewPasswordView({super.key}); diff --git a/src/lib/features/auth/otp_verification_view.dart b/src/lib/features/auth/presentation/view/otp_verification_view.dart similarity index 98% rename from src/lib/features/auth/otp_verification_view.dart rename to src/lib/features/auth/presentation/view/otp_verification_view.dart index cbb2d7c..28241b4 100644 --- a/src/lib/features/auth/otp_verification_view.dart +++ b/src/lib/features/auth/presentation/view/otp_verification_view.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../core/theme/app_colors.dart'; -import '../../viewmodels/auth_viewmodels.dart'; +import '../auth/presentation/viewmodels/auth_viewmodels.dart'; import 'new_password_view.dart'; class OtpVerificationView extends StatefulWidget { diff --git a/src/lib/features/auth/register_view.dart b/src/lib/features/auth/presentation/view/register_view.dart similarity index 98% rename from src/lib/features/auth/register_view.dart rename to src/lib/features/auth/presentation/view/register_view.dart index 5234f35..099ea95 100644 --- a/src/lib/features/auth/register_view.dart +++ b/src/lib/features/auth/presentation/view/register_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/auth_layout_template.dart'; import '../../core/theme/custom_text_field.dart'; -import '../../viewmodels/auth_viewmodels.dart'; +import '../auth/presentation/viewmodels/auth_viewmodels.dart'; class RegisterView extends StatefulWidget { const RegisterView({super.key}); diff --git a/src/lib/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart similarity index 100% rename from src/lib/viewmodels/auth_viewmodels.dart rename to src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart diff --git a/src/pubspec.lock b/src/pubspec.lock index e9706b9..54b18f4 100644 --- a/src/pubspec.lock +++ b/src/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + app_links: + dependency: transitive + description: + name: app_links + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" async: dependency: transitive description: @@ -33,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -41,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -49,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: cb79ed79baa02b4f59a597bf365873cbd83f9bb15273d63f7803802d21717c7d + url: "https://pub.dev" + source: hosted + version: "3.4.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -57,6 +129,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -83,6 +171,75 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2 + url: "https://pub.dev" + source: hosted + version: "5.0.2" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: ecdf3fa3ef8c5f886390ba0056d00d29138c02c39984e9caa8194dffd8a73ef7 + url: "https://pub.dev" + source: hosted + version: "2.19.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" intl: dependency: "direct main" description: @@ -91,6 +248,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" leak_tracker: dependency: transitive description: @@ -123,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -147,6 +320,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" path: dependency: transitive description: @@ -155,6 +352,174 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: ee8e71af7a834e960f5b2f494f398117488036fbdb11f422611f7287fbf40562 + url: "https://pub.dev" + source: hosted + version: "2.7.1" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -176,6 +541,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "4ab4b433ddda1b05a6852f451a74a770028c6ad60ea349c0f0082c6f19be69ad" + url: "https://pub.dev" + source: hosted + version: "2.5.0" stream_channel: dependency: transitive description: @@ -192,6 +565,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: "638d48cd84ad30e731c44895a90c045cb0a34051b22485627fcac35d9a32b914" + url: "https://pub.dev" + source: hosted + version: "2.10.3" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "270d73ec8802fb676a8e4e825d0a2d42a466a7f023dc6f15864bb4011f878917" + url: "https://pub.dev" + source: hosted + version: "2.12.1" term_glyph: dependency: transitive description: @@ -208,6 +597,78 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -224,6 +685,54 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.38.4" diff --git a/src/pubspec.yaml b/src/pubspec.yaml index b9b86a1..e8aacab 100644 --- a/src/pubspec.yaml +++ b/src/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: cupertino_icons: ^1.0.8 flutter_dotenv: ^6.0.0 intl: ^0.19.0 + supabase_flutter: ^2.4.3 + flutter_timezone: ^5.0.2 dev_dependencies: flutter_test: @@ -58,7 +60,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true - + assets: + - .env # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg From 7dd868069d7f047c89bfcab41abf0bf2152f036f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:06:24 +0700 Subject: [PATCH 07/28] refactor(auth): optimize registration logic, timezone handling, and form validation --- src/lib/features/auth/data/auth_helper.dart | 109 ++++++++ src/lib/features/auth/models/user_model.dart | 28 +++ .../viewmodels/auth_viewmodels.dart | 232 +++++++++++++++--- 3 files changed, 339 insertions(+), 30 deletions(-) diff --git a/src/lib/features/auth/data/auth_helper.dart b/src/lib/features/auth/data/auth_helper.dart index e69de29..fecaf2c 100644 --- a/src/lib/features/auth/data/auth_helper.dart +++ b/src/lib/features/auth/data/auth_helper.dart @@ -0,0 +1,109 @@ +// data/helpers/auth_helper.dart + +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_timezone/flutter_timezone.dart'; +import '../models/user_model.dart'; +import 'package:task_management_app/main.dart'; + +class AuthHelper { + + + Future login(String email, String password) async { + try { + final response = await supabase.auth.signInWithPassword( + email: email, + password: password, + ); + + if (response.user != null) { + final userId = response.user!.id; + + final timezoneObj = await FlutterTimezone.getLocalTimezone(); + final String currentTimezone = timezoneObj.toString(); + + final profileData = await supabase + .from('profile') + .update({'timezone': currentTimezone}) + .eq('id', userId) + .select() + .single(); + + return UserModel.fromJson(profileData, email); + } + return null; + } on AuthException catch (e) { + print('Supabase Auth Error: ${e.message}'); + rethrow; + } catch (e) { + print('Unknown Error: $e'); + rethrow; + } + } + + Future register(String email, String password, String username) async { + try { + final timezoneObj = await FlutterTimezone.getLocalTimezone(); + final String currentTimezone = timezoneObj.toString(); + final response = await supabase.auth.signUp( + email: email, + password: password, + data: { + 'username': username, + 'timezone': currentTimezone, // Data is already a safe String + } + ); + + if (response.user != null) { + final userId = response.user!.id; + + // Step 2: Insert directly into the 'profile' table + final profileData = await supabase + .from('profile') + .select() + .eq('id', userId) + .single(); + + return UserModel.fromJson(profileData, email); + } + return null; + } on AuthException catch (e) { + print('Supabase Sign Up Error: ${e.message}'); + rethrow; + } + } + + Future sendPasswordReset(String email) async { + try { + await supabase.auth.resetPasswordForEmail(email); + } on AuthException catch (e) { + print('Lỗi Gửi OTP: ${e.message}'); + rethrow; + } + } + + Future verifyOTP(String email, String otpCode) async { + try { + // Authenticate OTP for recovery type + final response = await supabase.auth.verifyOTP( + email: email, + token: otpCode, + type: OtpType.recovery, + ); + // Returns true if authentication is successful and session is created + return response.session != null; + } on AuthException catch (e) { + print('Lỗi Xác thực OTP: ${e.message}'); + return false; + } + } + Future updatePassword(String newPassword) async { + try { + // After successful verifyOTP, user is automatically logged in + // At this point, just call updateUser to change the password + await supabase.auth.updateUser(UserAttributes(password: newPassword)); + } on AuthException catch (e) { + print('Lỗi Đổi Pass: ${e.message}'); + rethrow; + } + } +} \ No newline at end of file diff --git a/src/lib/features/auth/models/user_model.dart b/src/lib/features/auth/models/user_model.dart index e69de29..b906b47 100644 --- a/src/lib/features/auth/models/user_model.dart +++ b/src/lib/features/auth/models/user_model.dart @@ -0,0 +1,28 @@ +class UserModel { + final String id; + final String email; + final String username; + final int? age; + final String? avatar; + final String? timezone; + + UserModel({ + required this.id, + required this.email, + required this.username, + this.age, + this.avatar, + this.timezone, + }); + + factory UserModel.fromJson(Map json, String email) { + return UserModel( + id: json['id'] ?? '', + email: email, + username: json['username'] ?? 'No Name', + age: json['age'], + avatar: json['avatar'], + timezone: json['timezone'], + ); + } +} \ No newline at end of file diff --git a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart index 9d97852..4738249 100644 --- a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart +++ b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart @@ -1,6 +1,12 @@ +// viewmodels/auth_viewmodels.dart + import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../data/auth_helper.dart'; -/// Base class to handle shared loading state +// ========================================== +// BASE VIEWMODEL (Handles Loading, Validation & Errors) +// ========================================== class BaseViewModel extends ChangeNotifier { bool _isLoading = false; bool get isLoading => _isLoading; @@ -9,8 +15,56 @@ class BaseViewModel extends ChangeNotifier { _isLoading = value; notifyListeners(); } + + // --- FRONTEND VALIDATION HELPERS --- + + // Check valid email format using RegEx + bool isValidEmail(String email) { + final emailRegex = RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9-]+\.[a-zA-Z]+"); + return emailRegex.hasMatch(email); + } + + // Check if password has at least 1 uppercase, 1 lowercase, 1 number, and 1 special character + bool isStrongPassword(String password) { + final passRegex = RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{6,}$'); + return passRegex.hasMatch(password); + } + + // --- BACKEND ERROR TRANSLATOR --- + // Vietnamese translator machine for Supabase exceptions + String handleError(dynamic e) { + if (e is! AuthException) { + return 'Sever lỗi, vui lòng thử lại!'; + } + + final msg = e.message.toLowerCase(); + + // Dictionary error map (Backend errors only, frontend catches the rest) + final errorDictionary = { + 'already registered': 'Email này đã được đăng ký!', + 'already exists': 'Email này đã được đăng ký!', + 'invalid login credentials': 'Email hoặc mật khẩu không chính xác!', + 'rate limit': 'Bạn thao tác quá nhanh, vui lòng thử lại sau!', + 'over_email_send_rate_limit': 'Bạn thao tác quá nhanh, vui lòng thử lại sau!', + 'token has expired or is invalid': 'Mã OTP không hợp lệ hoặc đã hết hạn!', + }; + + for (final entry in errorDictionary.entries) { + if (msg.contains(entry.key)) { + return entry.value; + } + } + + return 'Lỗi xác thực: ${e.message}'; + } } +// Global AuthHelper instance for all ViewModels +final _authHelper = AuthHelper(); + +// ========================================== +// 1. LOGIN VIEWMODEL +// ========================================== class LoginViewModel extends BaseViewModel { final emailCtrl = TextEditingController(); final passCtrl = TextEditingController(); @@ -18,18 +72,36 @@ class LoginViewModel extends BaseViewModel { void togglePass() { obscurePass = !obscurePass; notifyListeners(); } - Future login() async { - if (emailCtrl.text.isEmpty || passCtrl.text.isEmpty) return false; + Future login() async { + final email = emailCtrl.text.trim(); + final pass = passCtrl.text; + + // FRONTEND VALIDATION: Empty fields & Email format + if (email.isEmpty || pass.isEmpty) { + return 'Vui lòng nhập đầy đủ email và mật khẩu!'; + } + if (!isValidEmail(email)) { + return 'Định dạng email không hợp lệ!'; + } + setLoading(true); - // Mock API call - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); - setLoading(false); - return true; // Assume success + try { + final user = await _authHelper.login(email, pass); + if (user != null) return null; // Success + return 'Không thể lấy thông tin người dùng!'; + } catch (e) { + return handleError(e); // Backend errors + } finally { + setLoading(false); + } } } +// ========================================== +// 2. REGISTER VIEWMODEL +// ========================================== class RegisterViewModel extends BaseViewModel { - final nameCtrl = TextEditingController(); + final usernameCtrl = TextEditingController(); final emailCtrl = TextEditingController(); final passCtrl = TextEditingController(); final confirmPassCtrl = TextEditingController(); @@ -37,41 +109,120 @@ class RegisterViewModel extends BaseViewModel { void togglePass() { obscurePass = !obscurePass; notifyListeners(); } - Future register() async { - if (passCtrl.text != confirmPassCtrl.text) return false; + Future register() async { + final username = usernameCtrl.text.trim(); + final email = emailCtrl.text.trim(); + final pass = passCtrl.text; + final confirmPass = confirmPassCtrl.text; + + // FRONTEND VALIDATION: Strict client-side checks + if (username.isEmpty || email.isEmpty) { + return 'Vui lòng điền đủ thông tin!'; + } + if (!isValidEmail(email)) { + return 'Định dạng email không hợp lệ!'; // Blocked at FE + } + if (pass.length < 8) { + return 'Mật khẩu phải từ 8 ký tự trở lên!'; + } + if (!isStrongPassword(pass)) { + return 'Mật khẩu phải chứa ít nhất 1 chữ hoa, thường, số và ký tự đặc biệt!'; // Blocked at FE + } + if (pass != confirmPass) { + return 'Mật khẩu xác nhận không khớp!'; + } + setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); - setLoading(false); - return true; + try { + final user = await _authHelper.register(email, pass, username); + if (user != null) return null; // Success + return 'Đăng ký thất bại, không có dữ liệu trả về!'; + } catch (e) { + return handleError(e); // Backend errors (e.g., email already exists) + } finally { + setLoading(false); + } } } +// ========================================== +// 3. FORGOT PASSWORD VIEWMODEL (Step 1) +// ========================================== class ForgotPassViewModel extends BaseViewModel { final emailCtrl = TextEditingController(); - Future sendCode() async { - if (emailCtrl.text.isEmpty) return false; + // Static variable to temporarily hold email for OTP screen + static String recoveryEmail = ''; + + Future sendCode() async { + final email = emailCtrl.text.trim(); + + // FRONTEND VALIDATION + if (email.isEmpty) return 'Vui lòng nhập email của bạn!'; + if (!isValidEmail(email)) return 'Định dạng email không hợp lệ!'; + setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); - setLoading(false); - return true; + try { + recoveryEmail = email; + await _authHelper.sendPasswordReset(recoveryEmail); + return null; // Success + } catch (e) { + return handleError(e); + } finally { + setLoading(false); + } } } +// ========================================== +// 4. OTP VERIFICATION VIEWMODEL (Step 2) +// ========================================== class OtpViewModel extends BaseViewModel { List digits = List.filled(6, ""); - void updateDigit(int idx, String val) { digits[idx] = val; notifyListeners(); } + void updateDigit(int idx, String val) { + digits[idx] = val; + notifyListeners(); + } + + Future verify() async { + final otpCode = digits.join(); + + // FRONTEND VALIDATION + if (otpCode.length < 6) return 'Vui lòng nhập đủ 6 số OTP!'; - Future verify() async { - if (digits.join().length < 6) return false; setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); - setLoading(false); - return true; + try { + final email = ForgotPassViewModel.recoveryEmail; + final success = await _authHelper.verifyOTP(email, otpCode); + if (success) return null; + return 'Mã OTP không hợp lệ!'; + } catch (e) { + return handleError(e); + } finally { + setLoading(false); + } + } + + Future resend() async { + final email = ForgotPassViewModel.recoveryEmail; + if (email.isEmpty) return 'Không tìm thấy email, vui lòng quay lại bước trước!'; + + setLoading(true); + try { + await _authHelper.sendPasswordReset(email); + return null; + } catch (e) { + return handleError(e); + } finally { + setLoading(false); + } } } +// ========================================== +// 5. NEW PASSWORD VIEWMODEL (Step 3) +// ========================================== class NewPassViewModel extends BaseViewModel { final passCtrl = TextEditingController(); final confirmPassCtrl = TextEditingController(); @@ -79,11 +230,32 @@ class NewPassViewModel extends BaseViewModel { void togglePass() { obscurePass = !obscurePass; notifyListeners(); } - Future updatePassword() async { - if (passCtrl.text != confirmPassCtrl.text) return false; + Future updatePassword() async { + final pass = passCtrl.text; + final confirmPass = confirmPassCtrl.text; + + // FRONTEND VALIDATION: Strict checks before updating + if (pass.isEmpty || confirmPass.isEmpty) { + return 'Vui lòng nhập mật khẩu mới!'; + } + if (pass.length < 6) { + return 'Mật khẩu phải từ 6 ký tự trở lên!'; + } + if (!isStrongPassword(pass)) { + return 'Mật khẩu phải chứa ít nhất 1 chữ hoa, thường, số và ký tự đặc biệt!'; + } + if (pass != confirmPass) { + return 'Mật khẩu xác nhận không khớp!'; + } + setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); - setLoading(false); - return true; + try { + await _authHelper.updatePassword(pass); + return null; // Success + } catch (e) { + return handleError(e); + } finally { + setLoading(false); + } } -} \ No newline at end of file +} From 14e37b6805ac9737cbcaa34dd0b628d8e0437f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:06:29 +0700 Subject: [PATCH 08/28] feat(auth): update UI for login, registration, and forgot password screens --- .../view/forgot_password_view.dart | 69 +++--- .../auth/presentation/view/login_view.dart | 132 ++++-------- .../presentation/view/new_password_view.dart | 60 ++---- .../view/otp_verification_view.dart | 198 ++++++++---------- .../auth/presentation/view/register_view.dart | 92 +++----- 5 files changed, 211 insertions(+), 340 deletions(-) diff --git a/src/lib/features/auth/presentation/view/forgot_password_view.dart b/src/lib/features/auth/presentation/view/forgot_password_view.dart index cfa9c9b..bc105ca 100644 --- a/src/lib/features/auth/presentation/view/forgot_password_view.dart +++ b/src/lib/features/auth/presentation/view/forgot_password_view.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import '../../../../../../core/theme/app_colors.dart'; -import '../../../../../../core/theme/auth_layout_template.dart'; -import '../../../../../../core/theme/custom_text_field.dart'; -import '../auth/presentation/viewmodels/auth_viewmodels.dart' -import '../../otp_verification_view.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/auth_layout_template.dart'; +import '../../../../core/theme/custom_text_field.dart'; +import '../viewmodels/auth_viewmodels.dart'; +import 'otp_verification_view.dart'; class ForgotPasswordView extends StatefulWidget { const ForgotPasswordView({super.key}); - @override State createState() => _ForgotPasswordViewState(); } @@ -26,37 +25,27 @@ class _ForgotPasswordViewState extends State { useCard: false, isLoading: _vm.isLoading, customHeaderIcon: Container( - width: 120, - height: 120, + width: 120, height: 120, decoration: const BoxDecoration( color: AppColors.white, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Color(0xFFD1D9E6), blurRadius: 16)], ), - child: const Icon( - Icons.lock_reset, - size: 64, - color: AppColors.primary, - ), + child: const Icon(Icons.lock_reset, size: 64, color: AppColors.primary), ), onSubmit: () async { - FocusScope.of(context).unfocus(); - final success = await _vm.sendCode(); + FocusScope.of(context).unfocus(); // Đóng bàn phím + + // Lấy câu chửi từ ViewModel (nếu có lỗi) + final errorMessage = await _vm.sendCode(); if (!context.mounted) return; - if (success) { - // Jump to Step 2: OTP - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const OtpVerificationView()), - ); + if (errorMessage == null) { + // Thành công (null) -> Bay qua màn hình điền OTP 6 số + Navigator.push(context, MaterialPageRoute(builder: (_) => const OtpVerificationView())); } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Vui lòng nhập email hợp lệ!'), - backgroundColor: AppColors.error, - ), - ); + // Có lỗi (như nhập sai định dạng, spam nút) -> Vã cái lỗi màu đỏ ra + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error)); } }, formContent: CustomTextField( @@ -65,22 +54,18 @@ class _ForgotPasswordViewState extends State { icon: Icons.mail, controller: _vm.emailCtrl, ), - footerContent: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.help, size: 16, color: AppColors.primary), - SizedBox(width: 4), - Text( - 'Cần hỗ trợ? Liên hệ CSKH', - style: TextStyle( - color: AppColors.textSecondary, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], + footerContent: const Padding( + padding: EdgeInsets.only(bottom: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.help, size: 16, color: AppColors.primary), + SizedBox(width: 4), + Text('Cần hỗ trợ? Liên hệ CSKH', style: TextStyle(color: AppColors.textSecondary, fontSize: 14, fontWeight: FontWeight.bold)), + ], + ), ), ), ); } -} +} \ No newline at end of file diff --git a/src/lib/features/auth/presentation/view/login_view.dart b/src/lib/features/auth/presentation/view/login_view.dart index f214724..6dfa6a5 100644 --- a/src/lib/features/auth/presentation/view/login_view.dart +++ b/src/lib/features/auth/presentation/view/login_view.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; -import '../../core/theme/app_colors.dart'; -import '../../core/theme/auth_layout_template.dart'; -import '../../core/theme/custom_text_field.dart'; -import '../auth/presentation/viewmodels/auth_viewmodels.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/auth_layout_template.dart'; +import '../../../../core/theme/custom_text_field.dart'; +import '../viewmodels/auth_viewmodels.dart'; import 'register_view.dart'; -import 'presentation/view /forgot_password_view.dart'; +import 'forgot_password_view.dart'; class LoginView extends StatefulWidget { const LoginView({super.key}); - @override State createState() => _LoginViewState(); } @@ -20,105 +19,58 @@ class _LoginViewState extends State { Widget build(BuildContext context) { return AnimatedBuilder( animation: _vm, - builder: (context, _) => - AuthLayoutTemplate( - title: 'Task Management', - subtitle: 'Chào mừng trở lại!', - submitText: 'Đăng nhập', - isLoading: _vm.isLoading, - showSocial: true, - onSubmit: () async { - FocusScope.of(context).unfocus(); // Hide keyboard - final success = await _vm.login(); - if (!context.mounted) return; + builder: (context, _) => AuthLayoutTemplate( + title: 'To-Do List', + subtitle: 'Chào mừng trở lại!', + submitText: 'Đăng nhập', + isLoading: _vm.isLoading, + showSocial: true, + onSubmit: () async { + FocusScope.of(context).unfocus(); + final errorMessage = await _vm.login(); + if (!context.mounted) return; - if (success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Đăng nhập thành công!'), - backgroundColor: AppColors.success, - ), - ); - // TODO: Navigator.pushReplacementNamed(context, '/home'); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Vui lòng điền đầy đủ thông tin!'), - backgroundColor: AppColors.error, - ), - ); - } - }, - formContent: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - CustomTextField( - label: 'Email', - hint: 'example@gmail.com', - icon: Icons.mail, - controller: _vm.emailCtrl, - ), - CustomTextField( - label: 'Mật khẩu', - hint: '••••••••', - icon: Icons.lock, - controller: _vm.passCtrl, - isPassword: true, - obscureText: _vm.obscurePass, - onToggleVisibility: _vm.togglePass, - ), - TextButton( - onPressed: () => - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const ForgotPasswordView()), - ), - child: const Text( - 'Quên mật khẩu?', - style: TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - ], + if (errorMessage == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Đăng nhập thành công!'), backgroundColor: AppColors.success)); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error)); + } + }, + formContent: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + CustomTextField(label: 'Email', hint: 'example@gmail.com', icon: Icons.mail, controller: _vm.emailCtrl), + CustomTextField( + label: 'Mật khẩu', hint: '••••••••', icon: Icons.lock, controller: _vm.passCtrl, + isPassword: true, obscureText: _vm.obscurePass, onToggleVisibility: _vm.togglePass, ), - footerContent: _buildFooter( - 'Chưa có tài khoản? ', 'Đăng ký ngay', () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const RegisterView()), - ); - }), - ), + TextButton( + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordView())), + child: const Text('Quên mật khẩu?', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold)), + ), + ], + ), + footerContent: _buildFooter('Chưa có tài khoản? ', 'Đăng ký ngay', () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const RegisterView())); + }), + ), ); } Widget _buildFooter(String text, String action, VoidCallback onTap) { return Padding( - // Position this block 24dp above the bottom of the screen padding: const EdgeInsets.only(bottom: 24.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(text, style: const TextStyle(color: Colors.grey, fontSize: 16)), - + Text(text, style: const TextStyle(color: AppColors.textSecondary, fontSize: 16)), TextButton( onPressed: onTap, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - minimumSize: const Size(50, 48), - ), - child: Text( - action, - style: const TextStyle(color: Color(0xFF5A8DF3), - fontWeight: FontWeight.bold, - fontSize: 16), - ), + style: TextButton.styleFrom(minimumSize: const Size(50, 48)), + child: Text(action, style: const TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 16)), ), ], ), ); } -} +} \ No newline at end of file diff --git a/src/lib/features/auth/presentation/view/new_password_view.dart b/src/lib/features/auth/presentation/view/new_password_view.dart index 4b15fed..dbe9bfc 100644 --- a/src/lib/features/auth/presentation/view/new_password_view.dart +++ b/src/lib/features/auth/presentation/view/new_password_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import '../../core/theme/app_colors.dart'; -import '../../core/theme/auth_layout_template.dart'; -import '../../core/theme/custom_text_field.dart'; -import '../auth/presentation/viewmodels/auth_viewmodels.dart'; + +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/auth_layout_template.dart'; +import '../../../../core/theme/custom_text_field.dart'; +import '../viewmodels/auth_viewmodels.dart'; class NewPasswordView extends StatefulWidget { const NewPasswordView({super.key}); - @override State createState() => _NewPasswordViewState(); } @@ -30,47 +30,32 @@ class _NewPasswordViewState extends State { ), onSubmit: () async { FocusScope.of(context).unfocus(); - final success = await _vm.updatePassword(); + + // Hứng lỗi (nếu có) + final errorMessage = await _vm.updatePassword(); if (!context.mounted) return; - if (success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Đổi mật khẩu thành công!'), - backgroundColor: AppColors.success, - ), - ); - // Complete Flow: Pop everything and return to Login Screen + if (errorMessage == null) { + // Null -> Thành công -> Báo xanh mướt + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Đổi mật khẩu thành công!'), backgroundColor: AppColors.success)); + // Cú đá chót: Xóa hết lịch sử trang, đá thẳng mặt về trang Login (isFirst) Navigator.popUntil(context, (route) => route.isFirst); } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mật khẩu không khớp!'), - backgroundColor: AppColors.error, - ), - ); + // Nếu lỗi do User nhập lệch pass -> Chửi + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error)); } }, formContent: Column( children: [ CustomTextField( - label: 'Mật khẩu mới', - hint: '••••••••', - icon: Icons.lock, - controller: _vm.passCtrl, - isPassword: true, - obscureText: _vm.obscurePass, - onToggleVisibility: _vm.togglePass, + label: 'Mật khẩu mới', hint: '••••••••', icon: Icons.lock, controller: _vm.passCtrl, + isPassword: true, obscureText: _vm.obscurePass, onToggleVisibility: _vm.togglePass, ), CustomTextField( - label: 'Xác nhận mật khẩu mới', - hint: '••••••••', - icon: Icons.lock, - controller: _vm.confirmPassCtrl, - isPassword: true, - obscureText: _vm.obscurePass, - onToggleVisibility: _vm.togglePass, + label: 'Xác nhận mật khẩu mới', hint: '••••••••', icon: Icons.lock, controller: _vm.confirmPassCtrl, + isPassword: true, obscureText: _vm.obscurePass, onToggleVisibility: _vm.togglePass, ), + // Cục Info hướng dẫn Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -82,10 +67,7 @@ class _NewPasswordViewState extends State { Icon(Icons.info, color: AppColors.primary, size: 16), SizedBox(width: 8), Expanded( - child: Text( - 'Mật khẩu tối thiểu 8 ký tự để đảm bảo an toàn.', - style: TextStyle(fontSize: 12, color: AppColors.primary), - ), + child: Text('Mật khẩu tối thiểu 6 ký tự để đảm bảo an toàn.', style: TextStyle(fontSize: 12, color: AppColors.primary)), ), ], ), @@ -95,4 +77,4 @@ class _NewPasswordViewState extends State { ), ); } -} +} \ No newline at end of file diff --git a/src/lib/features/auth/presentation/view/otp_verification_view.dart b/src/lib/features/auth/presentation/view/otp_verification_view.dart index 28241b4..b8bb7b7 100644 --- a/src/lib/features/auth/presentation/view/otp_verification_view.dart +++ b/src/lib/features/auth/presentation/view/otp_verification_view.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../core/theme/app_colors.dart'; -import '../auth/presentation/viewmodels/auth_viewmodels.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../viewmodels/auth_viewmodels.dart'; import 'new_password_view.dart'; class OtpVerificationView extends StatefulWidget { const OtpVerificationView({super.key}); - @override State createState() => _OtpVerificationViewState(); } @@ -27,141 +26,126 @@ class _OtpVerificationViewState extends State { ), title: const Text( 'Xác thực OTP', - style: TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.bold, - fontSize: 18, - ), + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 18), ), centerTitle: true, ), body: AnimatedBuilder( - animation: _vm, - builder: (context, child) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.mark_email_read, - size: 80, - color: AppColors.primary, - ), - const SizedBox(height: 32), - const Text( - 'Nhập mã 6 số', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: AppColors.textDark, + animation: _vm, + builder: (context, child) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.mark_email_read, size: 80, color: AppColors.primary), + const SizedBox(height: 32), + const Text( + 'Nhập mã 8 số', // Sửa chữ thành 8 số + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: AppColors.textDark), + ), + const SizedBox(height: 8), + const Text( + 'Mã đã được gửi đến email của bạn.', + style: TextStyle(color: AppColors.textSecondary), ), - ), - const SizedBox(height: 8), - const Text( - 'Mã đã được gửi đến email của bạn.', - style: TextStyle(color: AppColors.textSecondary), - ), - const SizedBox(height: 40), + const SizedBox(height: 40), - // 6 Box OTP - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - 6, - (index) => _buildOtpBox(index, context), + // Tạo ra 8 ô OTP thay vì 6 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(6, (index) => _buildOtpBox(index, context)), ), - ), - const SizedBox(height: 40), + const SizedBox(height: 40), + + ElevatedButton( + onPressed: _vm.isLoading ? null : () async { + FocusScope.of(context).unfocus(); + + final errorMessage = await _vm.verify(); + if (!context.mounted) return; - ElevatedButton( - onPressed: _vm.isLoading - ? null - : () async { - FocusScope.of(context).unfocus(); - final success = await _vm.verify(); - if (!context.mounted) return; - if (success) { - // Jump to Step 3: New Password - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => const NewPasswordView(), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Mã OTP chưa đủ 6 số hoặc sai!', - ), - backgroundColor: AppColors.error, - ), - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - minimumSize: const Size(double.infinity, 56), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + if (errorMessage == null) { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const NewPasswordView()), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + minimumSize: const Size(double.infinity, 56), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: _vm.isLoading + ? const CircularProgressIndicator(color: AppColors.white) + : const Text( + 'XÁC NHẬN', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.white), ), ), - child: _vm.isLoading - ? const CircularProgressIndicator( - color: AppColors.white, - ) - : const Text( - 'XÁC NHẬN', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColors.white, - ), - ), - ), - ], + const SizedBox(height: 16), + + TextButton.icon( + onPressed: _vm.isLoading ? null : () async { + final errorMessage = await _vm.resend(); + if (!context.mounted) return; + + if (errorMessage == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Đã gửi lại mã OTP!'), backgroundColor: AppColors.success), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error), + ); + } + }, + icon: const Icon(Icons.refresh, size: 18, color: AppColors.primary), + label: const Text( + 'Gửi lại mã', + style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold), + ), + ) + ], + ), ), - ), - ); - }, + ); + } ), ); } Widget _buildOtpBox(int index, BuildContext context) { return Container( - width: 45, - height: 55, + width: 35, height: 48, // Thu nhỏ kích thước ô lại để nhét vừa 8 ô trên 1 dòng decoration: BoxDecoration( color: AppColors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), border: Border.all(color: AppColors.border), ), child: TextField( onChanged: (value) { _vm.updateDigit(index, value); - if (value.isNotEmpty && index < 5) FocusScope.of(context).nextFocus(); - if (value.isEmpty && index > 0) - FocusScope.of(context).previousFocus(); + + // Sửa lại logic nhảy focus: bé hơn 7 thì nhảy tới, lớn hơn 0 thì nhảy lùi + if (value.isNotEmpty && index < 7) FocusScope.of(context).nextFocus(); + if (value.isEmpty && index > 0) FocusScope.of(context).previousFocus(); }, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: AppColors.textDark, - ), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.textDark), keyboardType: TextInputType.number, textAlign: TextAlign.center, inputFormatters: [ LengthLimitingTextInputFormatter(1), FilteringTextInputFormatter.digitsOnly, ], - decoration: const InputDecoration( - border: InputBorder.none, - counterText: '', - ), + decoration: const InputDecoration(border: InputBorder.none, counterText: ''), ), ); } -} +} \ No newline at end of file diff --git a/src/lib/features/auth/presentation/view/register_view.dart b/src/lib/features/auth/presentation/view/register_view.dart index 099ea95..bfb4bc2 100644 --- a/src/lib/features/auth/presentation/view/register_view.dart +++ b/src/lib/features/auth/presentation/view/register_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; -import '../../core/theme/app_colors.dart'; -import '../../core/theme/auth_layout_template.dart'; -import '../../core/theme/custom_text_field.dart'; -import '../auth/presentation/viewmodels/auth_viewmodels.dart'; +import 'package:task_management_app/features/auth/presentation/view/login_view.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/auth_layout_template.dart'; +import '../../../../core/theme/custom_text_field.dart'; +import '../viewmodels/auth_viewmodels.dart'; class RegisterView extends StatefulWidget { const RegisterView({super.key}); - @override State createState() => _RegisterViewState(); } @@ -26,78 +26,46 @@ class _RegisterViewState extends State { showSocial: true, onSubmit: () async { FocusScope.of(context).unfocus(); - final success = await _vm.register(); + final errorMessage = await _vm.register(); if (!context.mounted) return; - if (success) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Đăng ký thành công!'), - backgroundColor: AppColors.success, - ), + if (errorMessage == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Đăng ký thành công!'), backgroundColor: AppColors.success)); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => const LoginView()), ); - Navigator.pop(context); // Go back to login } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mật khẩu không khớp hoặc thiếu thông tin!'), - backgroundColor: AppColors.error, - ), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error)); } }, formContent: Column( children: [ + CustomTextField(label: 'Họ tên', hint: 'Nhập họ và tên', icon: Icons.person, controller: _vm.usernameCtrl), + CustomTextField(label: 'Email', hint: 'example@gmail.com', icon: Icons.mail, controller: _vm.emailCtrl), CustomTextField( - label: 'Họ tên', - hint: 'Nhập họ và tên', - icon: Icons.person, - controller: _vm.nameCtrl, - ), - CustomTextField( - label: 'Email', - hint: 'example@gmail.com', - icon: Icons.mail, - controller: _vm.emailCtrl, + label: 'Mật khẩu', hint: '••••••••', icon: Icons.lock, controller: _vm.passCtrl, + isPassword: true, obscureText: _vm.obscurePass, onToggleVisibility: _vm.togglePass, ), CustomTextField( - label: 'Mật khẩu', - hint: '••••••••', - icon: Icons.lock, - controller: _vm.passCtrl, - isPassword: true, - obscureText: _vm.obscurePass, - onToggleVisibility: _vm.togglePass, - ), - CustomTextField( - label: 'Xác nhận mật khẩu', - hint: '••••••••', - icon: Icons.shield, - controller: _vm.confirmPassCtrl, - isPassword: true, - obscureText: _vm.obscurePass, - onToggleVisibility: _vm.togglePass, + label: 'Xác nhận mật khẩu', hint: '••••••••', icon: Icons.shield, controller: _vm.confirmPassCtrl, + isPassword: true, obscureText: _vm.obscurePass, onToggleVisibility: _vm.togglePass, ), ], ), - footerContent: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'Đã có tài khoản? ', - style: TextStyle(color: AppColors.textSecondary), - ), - GestureDetector( - onTap: () => Navigator.pop(context), - child: const Text( - 'Đăng nhập', - style: TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.bold, - ), + footerContent: Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Đã có tài khoản? ', style: TextStyle(color: AppColors.textSecondary, fontSize: 16)), + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom(minimumSize: const Size(50, 48)), + child: const Text('Đăng nhập', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold, fontSize: 16)), ), - ), - ], + ], + ), ), ), ); From b6dada7c2d45250dda9ed18120ad7a56ace77871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:06:33 +0700 Subject: [PATCH 09/28] feat(tasks): update task management UI and statistics screen --- .../statistics/view/screens/statistics_screen.dart | 8 ++++---- src/lib/features/tasks/view/screens/home_screen.dart | 2 +- .../features/tasks/view/screens/task_detail_screen.dart | 4 ++-- src/lib/features/tasks/view/widgets/task_widgets.dart | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/lib/features/statistics/view/screens/statistics_screen.dart b/src/lib/features/statistics/view/screens/statistics_screen.dart index 7e3bf3c..e6f2695 100644 --- a/src/lib/features/statistics/view/screens/statistics_screen.dart +++ b/src/lib/features/statistics/view/screens/statistics_screen.dart @@ -11,16 +11,16 @@ class StatisticsScreen extends StatefulWidget { } class _StatisticsScreenState extends State { - // Biến lưu trữ ngày đang được chọn trên biểu đồ (0 = T2, 1 = T3, 2 = T4...) - int _selectedDayIndex = 2; // Mặc định chọn T4 (Index 2) giống trong thiết kế + // Variable to store the currently selected day on the chart (0 = Mon, 1 = Tue, 2 = Wed...) + int _selectedDayIndex = 2; // Default select Wed (Index 2) like in the design - // Dữ liệu giả lập phân loại theo ngày (0 đến 6) + // Mock data categorized by day (0 to 6) late Map> _tasksByDay; @override void initState() { super.initState(); - // Tạo mock data cho một vài ngày để test + // Create mock data for a few days for testing _tasksByDay = { 0: [ // Thứ 2 TaskModel(id: 'stat_t2_1', title: 'Họp team đầu tuần', description: 'Lên kế hoạch Sprint mới.', category: 'Development', startTime: const TimeOfDay(hour: 9, minute: 0), endTime: const TimeOfDay(hour: 10, minute: 0), date: DateTime.now()), diff --git a/src/lib/features/tasks/view/screens/home_screen.dart b/src/lib/features/tasks/view/screens/home_screen.dart index 2b53316..c30697a 100644 --- a/src/lib/features/tasks/view/screens/home_screen.dart +++ b/src/lib/features/tasks/view/screens/home_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../../../core/theme/app_colors.dart'; -import '../../model/task_model.dart'; // Đừng quên import TaskModel +import '../../model/task_model.dart'; import '../widgets/task_widgets.dart'; import 'create_task_screen.dart'; diff --git a/src/lib/features/tasks/view/screens/task_detail_screen.dart b/src/lib/features/tasks/view/screens/task_detail_screen.dart index 5ede774..34e04ab 100644 --- a/src/lib/features/tasks/view/screens/task_detail_screen.dart +++ b/src/lib/features/tasks/view/screens/task_detail_screen.dart @@ -3,7 +3,7 @@ import 'package:intl/intl.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/custom_input_field.dart'; import '../../model/task_model.dart'; -import '../widgets/task_widgets.dart'; // Nơi chứa TimePickerWidget +import '../widgets/task_widgets.dart'; // Contains TimePickerWidget class TaskDetailScreen extends StatefulWidget { final TaskModel task; @@ -24,7 +24,7 @@ class _TaskDetailScreenState extends State { @override void initState() { super.initState(); - // Khởi tạo data ban đầu từ task được truyền vào + // Initialize initial data from the passed task _titleController = TextEditingController(text: widget.task.title); _descController = TextEditingController(text: widget.task.description); _startTime = widget.task.startTime; diff --git a/src/lib/features/tasks/view/widgets/task_widgets.dart b/src/lib/features/tasks/view/widgets/task_widgets.dart index 6273827..20b453b 100644 --- a/src/lib/features/tasks/view/widgets/task_widgets.dart +++ b/src/lib/features/tasks/view/widgets/task_widgets.dart @@ -4,7 +4,7 @@ import '../../../../core/theme/app_colors.dart'; import '../../model/task_model.dart'; import '../screens/task_detail_screen.dart'; -// --- Clipper cho dải uốn lượn màu xanh --- +// --- Clipper for the blue wavy strip --- class TopWaveClipper extends CustomClipper { @override Path getClip(Size size) { @@ -29,7 +29,7 @@ class TopWaveClipper extends CustomClipper { bool shouldReclip(CustomClipper oldClipper) => false; } -// --- Widget cho ô ngày trong Timeline --- +// --- Widget for date box in Timeline --- class DateBox extends StatelessWidget { final DateTime date; final bool isSelected; From a19a4ccb3c2397000f5ce7e4a42162bc46790d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:06:37 +0700 Subject: [PATCH 10/28] chore: update main entry point and fix widget tests --- src/lib/main.dart | 20 +++++++++++++++++--- src/test/widget_test.dart | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/lib/main.dart b/src/lib/main.dart index f6d93ce..7d552a5 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -1,14 +1,28 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'core/theme/app_colors.dart'; -import 'features/auth/login_view.dart'; +import 'features/auth/presentation/view/login_view.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; -void main() { +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await dotenv.load(fileName: ".env"); + + await Supabase.initialize( + url: dotenv.env['SUPABASE_URL']!, + anonKey: dotenv.env['SUPABASE_ANON_KEY']!, + ); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle(statusBarColor: Colors.transparent)); runApp(const TaskApp()); } +// 4. Create a global variable for ViewModel to call API quickly +final supabase = Supabase.instance.client; + + + class TaskApp extends StatelessWidget { const TaskApp({super.key}); @@ -32,4 +46,4 @@ class TaskApp extends StatelessWidget { debugShowCheckedModeBanner: false, ); } -} \ No newline at end of file +} diff --git a/src/test/widget_test.dart b/src/test/widget_test.dart index c5a4f8d..7ffd2a2 100644 --- a/src/test/widget_test.dart +++ b/src/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:task_management_app/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const TaskApp()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); From ee1d86f0ce4a2c6a0cb7202918dabf7bb1b1106c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:09:08 +0700 Subject: [PATCH 11/28] chore: ignore devtools_options.yaml --- src/devtools_options.yaml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/devtools_options.yaml diff --git a/src/devtools_options.yaml b/src/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/src/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: From d9f46b8d5d9ee3c477ed640054cdad65e5d8e57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:09:27 +0700 Subject: [PATCH 12/28] chore: ignore devtools_options.yaml --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5bb9333..e3596ee 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ app.*.map.json **/supabase/.env **/supabase/pooler/ +# DevTools config +devtools_options.yaml \ No newline at end of file From 7c734b25e49ec9c867581d783f8154c36e7ec0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 25 Mar 2026 20:14:02 +0700 Subject: [PATCH 13/28] style(login) : rewrite title for login view --- src/lib/features/auth/presentation/view/login_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/features/auth/presentation/view/login_view.dart b/src/lib/features/auth/presentation/view/login_view.dart index 6dfa6a5..c00604f 100644 --- a/src/lib/features/auth/presentation/view/login_view.dart +++ b/src/lib/features/auth/presentation/view/login_view.dart @@ -20,7 +20,7 @@ class _LoginViewState extends State { return AnimatedBuilder( animation: _vm, builder: (context, _) => AuthLayoutTemplate( - title: 'To-Do List', + title: 'Task Management', subtitle: 'Chào mừng trở lại!', submitText: 'Đăng nhập', isLoading: _vm.isLoading, From 654f4f5b803ae988998880076fc4cf071fa51c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 26 Mar 2026 23:18:27 +0700 Subject: [PATCH 14/28] feat(auth): configure android deep link for supabase oauth --- src/android/app/src/main/AndroidManifest.xml | 8 ++++++++ src/lib/features/auth/presentation/view/auth_gate.dart | 0 2 files changed, 8 insertions(+) create mode 100644 src/lib/features/auth/presentation/view/auth_gate.dart diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 675ffff..e8e00f8 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,14 @@ the Android process has started. This theme is visible to the user while the Flutter UI initializes. After that, this theme continues to determine the Window background behind the Flutter UI. --> + + + + + + Date: Thu, 26 Mar 2026 23:19:33 +0700 Subject: [PATCH 15/28] refactor(ui): add social login callbacks to auth layout template --- src/lib/core/theme/auth_layout_template.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/core/theme/auth_layout_template.dart b/src/lib/core/theme/auth_layout_template.dart index 8e7f620..88e796d 100644 --- a/src/lib/core/theme/auth_layout_template.dart +++ b/src/lib/core/theme/auth_layout_template.dart @@ -14,6 +14,8 @@ class AuthLayoutTemplate extends StatelessWidget { final bool useCard; final Widget? customHeaderIcon; final Widget? footerContent; + final VoidCallback? onGoogleTap; // Login with Google + final VoidCallback? onFacebookTap; // Login with Facebook const AuthLayoutTemplate({ super.key, @@ -27,6 +29,8 @@ class AuthLayoutTemplate extends StatelessWidget { this.useCard = true, this.customHeaderIcon, this.footerContent, + this.onGoogleTap, + this.onFacebookTap, }); @override @@ -200,7 +204,7 @@ class AuthLayoutTemplate extends StatelessWidget { children: [ Expanded( child: OutlinedButton.icon( - onPressed: () {}, + onPressed: onGoogleTap, icon: const Icon( Icons.g_mobiledata, color: Colors.red, @@ -212,7 +216,7 @@ class AuthLayoutTemplate extends StatelessWidget { const SizedBox(width: 16), Expanded( child: OutlinedButton.icon( - onPressed: () {}, + onPressed: onFacebookTap, icon: const Icon(Icons.facebook, color: Color(0xFF1877F2)), label: const Text('Facebook'), ), From d4fdfe66bafa35bbf2c53c071b4b49850ecab832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 26 Mar 2026 23:20:13 +0700 Subject: [PATCH 16/28] feat(auth): update oauth methods with redirect url and signout --- src/lib/features/auth/data/auth_helper.dart | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/lib/features/auth/data/auth_helper.dart b/src/lib/features/auth/data/auth_helper.dart index fecaf2c..71f7594 100644 --- a/src/lib/features/auth/data/auth_helper.dart +++ b/src/lib/features/auth/data/auth_helper.dart @@ -40,6 +40,30 @@ class AuthHelper { } } + Future loginWithGoogle() async { + try { + return await supabase.auth.signInWithOAuth( + OAuthProvider.google, + redirectTo: 'taskapp://login-callback', // back to app + ); + } on AuthException catch (e) { + print('Lỗi đăng nhập Google: ${e.message}'); + return false; + } + } + + Future loginWithFacebook() async { + try { + return await supabase.auth.signInWithOAuth( + OAuthProvider.facebook, + redirectTo: 'taskapp://login-callback', // back to app + ); + } on AuthException catch (e) { + print('Lỗi đăng nhập Facebook: ${e.message}'); + return false; + } + } + Future register(String email, String password, String username) async { try { final timezoneObj = await FlutterTimezone.getLocalTimezone(); @@ -106,4 +130,14 @@ class AuthHelper { rethrow; } } +} + +// SignOut session +Future signOut() async { + try { + await supabase.auth.signOut(); + } catch (e) { + print('Lỗi đăng xuất: $e'); + rethrow; + } } \ No newline at end of file From a0be5d00993b94f72861b2ac8342d9d29af4c580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 26 Mar 2026 23:20:38 +0700 Subject: [PATCH 17/28] feat(auth): implement AuthGate using StreamBuilder for session tracking --- .../auth/presentation/view/auth_gate.dart | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/lib/features/auth/presentation/view/auth_gate.dart b/src/lib/features/auth/presentation/view/auth_gate.dart index e69de29..97438ea 100644 --- a/src/lib/features/auth/presentation/view/auth_gate.dart +++ b/src/lib/features/auth/presentation/view/auth_gate.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'login_view.dart'; +import '../../../main/view/screens/main_screen.dart'; + +class AuthGate extends StatelessWidget { + const AuthGate({super.key}); + + @override + Widget build(BuildContext context) { + // StreamBuilder continously checks the auth state + return StreamBuilder( + stream: Supabase.instance.client.auth.onAuthStateChange, + builder: (context, snapshot) { + // Wait response from Supabase + if (snapshot.connectionState == ConnectionState.waiting) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + // Check if there is an active session ( user logged in ) + final session = snapshot.data?.session; + + // if session exists -> Navigate to MainScreen + if (session != null) { + return const MainScreen(); + } + + // if session not exists -> Navigate to LoginView + return const LoginView(); + }, + ); + } +} \ No newline at end of file From 336610ba2a813ec34fbda908361d51d1c28ca9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 26 Mar 2026 23:21:18 +0700 Subject: [PATCH 18/28] feat(viewmodel): add oauth logic and improve provider lifecycle --- .../viewmodels/auth_viewmodels.dart | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart index 4738249..4af1ee1 100644 --- a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart +++ b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart @@ -95,8 +95,37 @@ class LoginViewModel extends BaseViewModel { setLoading(false); } } + // 1.1 LOGIN WITH GOOGLE + Future loginWithGoogle() async { + setLoading(true); + try { + final success = await _authHelper.loginWithGoogle(); + if (success) return null; // Thành công (thường Supabase sẽ tự văng ra web browser) + return 'Lỗi khi mở cổng đăng nhập Google!'; + } catch (e) { + return handleError(e); + } finally { + setLoading(false); + } + } + +// 1.2 LOGIN WITH FACEBOOK + + Future loginWithFacebook() async { + setLoading(true); + try { + final success = await _authHelper.loginWithFacebook(); + if (success) return null; + return 'Lỗi khi mở cổng đăng nhập Facebook!'; + } catch (e) { + return handleError(e); + } finally { + setLoading(false); + } + } } + // ========================================== // 2. REGISTER VIEWMODEL // ========================================== From 2e3b21283603fd079597701af41108a770d4d48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 26 Mar 2026 23:23:06 +0700 Subject: [PATCH 19/28] refactor(ui): migrate LoginView to Provider pattern --- .../features/auth/presentation/view/login_view.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/lib/features/auth/presentation/view/login_view.dart b/src/lib/features/auth/presentation/view/login_view.dart index c00604f..6bf1e9c 100644 --- a/src/lib/features/auth/presentation/view/login_view.dart +++ b/src/lib/features/auth/presentation/view/login_view.dart @@ -25,6 +25,18 @@ class _LoginViewState extends State { submitText: 'Đăng nhập', isLoading: _vm.isLoading, showSocial: true, + onGoogleTap: () async { + final error = await _vm.loginWithGoogle(); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error), backgroundColor: AppColors.error)); + } + }, + onFacebookTap: () async { + final error = await _vm.loginWithFacebook(); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error), backgroundColor: AppColors.error)); + } + }, onSubmit: () async { FocusScope.of(context).unfocus(); final errorMessage = await _vm.login(); From 4e4e1207c8f3eac229e0c2089346440314d64bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 26 Mar 2026 23:23:36 +0700 Subject: [PATCH 20/28] chore(main): set AuthGate as initial route and setup provider --- src/lib/main.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/main.dart b/src/lib/main.dart index 7d552a5..a129460 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'core/theme/app_colors.dart'; import 'features/auth/presentation/view/login_view.dart'; +import 'features/auth/presentation/view/auth_gate.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; + Future main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: ".env"); @@ -42,7 +44,7 @@ class TaskApp extends StatelessWidget { labelLarge: TextStyle(fontSize: 16, color: AppColors.primaryBlue), ), ), - home: const LoginView(), + home: const AuthGate(), debugShowCheckedModeBanner: false, ); } From f96dcc603b1381f42f1a05ea3e11834cabcac3a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 8 Apr 2026 09:41:39 +0700 Subject: [PATCH 21/28] feat: implement full Focus feature set - Added Pomodoro timer with Start/Reset/Skip logic. - Integrated local Quick Notes with Pin/Delete functionality. - Supported image attachments in notes using image_picker. - Added Focus settings: time duration, vibration, and ringtones. --- src/android/app/src/main/AndroidManifest.xml | 1 - .../features/auth/otp_verification_view.dart | 56 +-- .../viewmodels/auth_viewmodels.dart | 2 +- .../auth/{data => services}/auth_helper.dart | 2 +- .../auth/viewmodels/auth_viewmodels.dart | 61 +-- .../main/view/screens/main_screen.dart | 7 +- src/lib/features/note/model/note_model.dart | 13 + src/lib/features/note/view/focus_screen.dart | 139 +++++++ src/lib/features/note/view/focus_widget.dart | 356 ++++++++++++++++++ .../note/viewmodel/focus_viewmodel.dart | 176 +++++++++ .../view/screens/task_detail_screen.dart | 2 +- src/pubspec.lock | 138 ++++++- src/pubspec.yaml | 2 + 13 files changed, 890 insertions(+), 65 deletions(-) rename src/lib/features/auth/{data => services}/auth_helper.dart (99%) create mode 100644 src/lib/features/note/model/note_model.dart create mode 100644 src/lib/features/note/view/focus_screen.dart create mode 100644 src/lib/features/note/view/focus_widget.dart create mode 100644 src/lib/features/note/viewmodel/focus_viewmodel.dart diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index e8e00f8..f46e6ba 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -33,7 +33,6 @@ - { - // Bật chế độ 'chờ' cho ViewModel xử lý logic 8 số + // Set ViewModel to 'waiting' mode for 8-digit logic processing final _vm = OtpViewModel(); @override @@ -27,7 +27,7 @@ class _OtpVerificationViewState extends State { onPressed: () => Navigator.pop(context), ), title: const Text( - 'Xác thực OTP', + 'OTP Verification', style: TextStyle( color: AppColors.primary, fontWeight: FontWeight.bold, @@ -40,7 +40,7 @@ class _OtpVerificationViewState extends State { animation: _vm, builder: (context, child) { return SafeArea( - child: SingleChildScrollView( // Bọc lại đề phòng keyboard hiện lên làm tràn màn hình + child: SingleChildScrollView( // Wrap to prevent keyboard overflow child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 40), child: Column( @@ -53,7 +53,7 @@ class _OtpVerificationViewState extends State { ), const SizedBox(height: 32), const Text( - 'Nhập mã 8 số', // Hiển thị đúng 8 số + 'Enter 8-digit code', // Show correct 8-digit count style: TextStyle( fontSize: 24, fontWeight: FontWeight.bold, @@ -62,28 +62,28 @@ class _OtpVerificationViewState extends State { ), const SizedBox(height: 8), const Text( - 'Mã đã được gửi đến email của bạn.', + 'The code has been sent to your email.', style: TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 40), - // --- KHU VỰC 8 Ô OTP (ĐÃ SỬA LỖI) --- - // Dùng LayoutBuilder để tự tính toán kích thước ô cho vừa mọi màn hình + // --- 8 OTP BOXES AREA (FIXED) --- + // Use LayoutBuilder to calculate box size to fit any screen LayoutBuilder( builder: (context, constraints) { - // Tính toán độ rộng của ô dựa trên màn hình thật, trừ đi khoảng cách giữa các ô + // Calculate box width based on actual screen, subtracting space between boxes double availableWidth = constraints.maxWidth; - double spaceBetweenBoxes = 6.0; // Khoảng cách giữa các ô - double totalSpace = spaceBetweenBoxes * 7; // Có 7 khoảng trống giữa 8 ô - double boxWidth = (availableWidth - totalSpace) / 8; // Độ rộng tối đa mỗi ô + double spaceBetweenBoxes = 6.0; // Space between boxes + double totalSpace = spaceBetweenBoxes * 7; // 7 gaps between 8 boxes + double boxWidth = (availableWidth - totalSpace) / 8; // Max width per box - // Khống chế độ rộng ô không quá to để nhìn cho art (max 35-40) + // Constrain box width to look artistic (max 35-40) double finalBoxWidth = boxWidth > 38 ? 38 : boxWidth; return Row( - mainAxisAlignment: MainAxisAlignment.center, // Căn giữa hàng 8 ô + mainAxisAlignment: MainAxisAlignment.center, // Center the row of 8 boxes children: List.generate( - 8, // SỬA: Đã tạo đúng 8 ô ở đây + 8, // FIX: Created 8 boxes here (index) => Padding( padding: EdgeInsets.symmetric(horizontal: spaceBetweenBoxes / 2), child: _buildOtpBox(index, context, finalBoxWidth), @@ -94,18 +94,18 @@ class _OtpVerificationViewState extends State { ), const SizedBox(height: 40), - // Nút xác nhận xịn sò (Nhận lỗi cụ thể từ Server) + // Confirmation button (Handles specific Server errors) ElevatedButton( onPressed: _vm.isLoading ? null : () async { FocusScope.of(context).unfocus(); - // Gọi hàm verify(), nó trả về String? errorMessage + // Call verify(), it returns String? errorMessage final errorMessage = await _vm.verify(); if (!context.mounted) return; if (errorMessage == null) { - // Thành công: Nhảy sang bước 3 (Đổi mật khẩu mới) + // Success: Navigate to step 3 (Reset new password) Navigator.pushReplacement( context, MaterialPageRoute( @@ -113,7 +113,7 @@ class _OtpVerificationViewState extends State { ), ); } else { - // Thất bại: Hiện thông báo lỗi cụ thể (ví dụ: "Mã OTP hết hạn") + // Failure: Show specific error message (e.g., "OTP code expired") ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(errorMessage), @@ -134,7 +134,7 @@ class _OtpVerificationViewState extends State { color: AppColors.white, ) : const Text( - 'XÁC NHẬN', + 'CONFIRM', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -144,7 +144,7 @@ class _OtpVerificationViewState extends State { ), const SizedBox(height: 16), - // --- NÚT GỬI LẠI MÃ (CHỈ CÓ Ở BẢN XỊN) --- + // --- RESEND CODE BUTTON --- TextButton.icon( onPressed: _vm.isLoading ? null @@ -155,7 +155,7 @@ class _OtpVerificationViewState extends State { if (errorMessage == null) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Đã gửi lại mã OTP!'), + content: Text('OTP code resent!'), backgroundColor: AppColors.success, ), ); @@ -170,7 +170,7 @@ class _OtpVerificationViewState extends State { }, icon: const Icon(Icons.refresh, size: 18, color: AppColors.primary), label: const Text( - 'Gửi lại mã', + 'Resend code', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold), ), ) @@ -184,10 +184,10 @@ class _OtpVerificationViewState extends State { ); } - // SỬA: Nhận thêm 'boxWidth' để tự động co dãn cho vừa 8 ô + // FIX: Added 'boxWidth' to automatically scale for 8 boxes Widget _buildOtpBox(int index, BuildContext context, double boxWidth) { return Container( - width: boxWidth, // Sử dụng độ rộng đã tính toán + width: boxWidth, // Use calculated width height: 48, decoration: BoxDecoration( color: AppColors.white, @@ -197,7 +197,7 @@ class _OtpVerificationViewState extends State { child: TextField( onChanged: (value) { _vm.updateDigit(index, value); - // SỬA: Logic nhảy focus chuẩn cho 8 ô (index chạy từ 0 đến 7) + // FIX: Correct focus jump logic for 8 boxes (index from 0 to 7) if (value.isNotEmpty && index < 7) { FocusScope.of(context).nextFocus(); } @@ -223,4 +223,4 @@ class _OtpVerificationViewState extends State { ), ); } -} \ No newline at end of file +} diff --git a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart index 4af1ee1..e3ba24b 100644 --- a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart +++ b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import '../../data/auth_helper.dart'; +import '../../services/auth_helper.dart'; // ========================================== // BASE VIEWMODEL (Handles Loading, Validation & Errors) diff --git a/src/lib/features/auth/data/auth_helper.dart b/src/lib/features/auth/services/auth_helper.dart similarity index 99% rename from src/lib/features/auth/data/auth_helper.dart rename to src/lib/features/auth/services/auth_helper.dart index 71f7594..19ba933 100644 --- a/src/lib/features/auth/data/auth_helper.dart +++ b/src/lib/features/auth/services/auth_helper.dart @@ -1,4 +1,4 @@ -// data/helpers/auth_helper.dart +// services/helpers/auth_helper.dart import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; diff --git a/src/lib/features/auth/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/viewmodels/auth_viewmodels.dart index 9d97852..68a1a47 100644 --- a/src/lib/features/auth/viewmodels/auth_viewmodels.dart +++ b/src/lib/features/auth/viewmodels/auth_viewmodels.dart @@ -18,13 +18,13 @@ class LoginViewModel extends BaseViewModel { void togglePass() { obscurePass = !obscurePass; notifyListeners(); } - Future login() async { - if (emailCtrl.text.isEmpty || passCtrl.text.isEmpty) return false; + Future login() async { + if (emailCtrl.text.isEmpty || passCtrl.text.isEmpty) return "Please enter all fields"; setLoading(true); // Mock API call - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + await Future.delayed(const Duration(seconds: 1)); setLoading(false); - return true; // Assume success + return null; // Success } } @@ -37,38 +37,53 @@ class RegisterViewModel extends BaseViewModel { void togglePass() { obscurePass = !obscurePass; notifyListeners(); } - Future register() async { - if (passCtrl.text != confirmPassCtrl.text) return false; + Future register() async { + if (passCtrl.text != confirmPassCtrl.text) return "Passwords do not match"; setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + await Future.delayed(const Duration(seconds: 1)); setLoading(false); - return true; + return null; } } class ForgotPassViewModel extends BaseViewModel { final emailCtrl = TextEditingController(); - Future sendCode() async { - if (emailCtrl.text.isEmpty) return false; + Future sendCode() async { + if (emailCtrl.text.isEmpty) return "Please enter email"; setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + await Future.delayed(const Duration(seconds: 1)); setLoading(false); - return true; + return null; } } class OtpViewModel extends BaseViewModel { - List digits = List.filled(6, ""); + // Updated to 8 digits to match UI + List digits = List.filled(8, ""); + + void updateDigit(int idx, String val) { + if (idx < digits.length) { + digits[idx] = val; + notifyListeners(); + } + } - void updateDigit(int idx, String val) { digits[idx] = val; notifyListeners(); } + Future verify() async { + String code = digits.join(); + if (code.length < 8) return "Please enter 8-digit OTP"; + setLoading(true); + await Future.delayed(const Duration(seconds: 1)); + setLoading(false); + return null; // Success + } - Future verify() async { - if (digits.join().length < 6) return false; + // Added resend method to fix error in UI + Future resend() async { setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + await Future.delayed(const Duration(seconds: 1)); setLoading(false); - return true; + return null; // Success } } @@ -79,11 +94,11 @@ class NewPassViewModel extends BaseViewModel { void togglePass() { obscurePass = !obscurePass; notifyListeners(); } - Future updatePassword() async { - if (passCtrl.text != confirmPassCtrl.text) return false; + Future updatePassword() async { + if (passCtrl.text != confirmPassCtrl.text) return "Passwords do not match"; setLoading(true); - await Future.delayed(const Duration(seconds: 1, milliseconds: 500)); + await Future.delayed(const Duration(seconds: 1)); setLoading(false); - return true; + return null; } -} \ No newline at end of file +} diff --git a/src/lib/features/main/view/screens/main_screen.dart b/src/lib/features/main/view/screens/main_screen.dart index e6f3b16..89a477a 100644 --- a/src/lib/features/main/view/screens/main_screen.dart +++ b/src/lib/features/main/view/screens/main_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:task_management_app/features/statistics/viewmodel/statistics_viewmodel.dart'; import 'package:task_management_app/features/tasks/view/screens/home_screen.dart'; import '../../../../core/theme/app_colors.dart'; +import '../../../note/view/focus_screen.dart'; +import '../../../note/viewmodel/focus_viewmodel.dart'; import '../../../statistics/view/screens/statistics_screen.dart'; // import '../../../tasks/view/screens/home_screen.dart'; import 'package:provider/provider.dart'; @@ -19,7 +21,10 @@ class _MainScreenState extends State { final List _screens = [ const Center(child: HomeScreen()), const Center(child: Text('Màn hình Lịch')), - const Center(child: Text('Màn hình Tập trung')), + ChangeNotifierProvider( + create: (_) => FocusViewModel(), + child: const FocusScreen(), + ), ChangeNotifierProvider( create: (_) => StatisticsViewmodel(), child: const StatisticsScreen(), diff --git a/src/lib/features/note/model/note_model.dart b/src/lib/features/note/model/note_model.dart new file mode 100644 index 0000000..43cc3ba --- /dev/null +++ b/src/lib/features/note/model/note_model.dart @@ -0,0 +1,13 @@ +class NoteModel { + final String id; + final String content; + final bool pinned; + final String? imagePath; + + NoteModel({ + required this.id, + required this.content, + this.pinned = false, + this.imagePath, + }); +} diff --git a/src/lib/features/note/view/focus_screen.dart b/src/lib/features/note/view/focus_screen.dart new file mode 100644 index 0000000..26e2318 --- /dev/null +++ b/src/lib/features/note/view/focus_screen.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../viewmodel/focus_viewmodel.dart'; +import '../view/focus_widget.dart'; + +class FocusScreen extends StatelessWidget { + const FocusScreen({super.key}); + + // Shows the configuration dialog for timer and notification settings + void _showSettingsDialog(BuildContext context) { + final vm = context.read(); + + double currentPomodoro = (vm.pomodoroTime / 60).toDouble(); + double currentBreak = (vm.shortBreakTime / 60).toDouble(); + bool currentVibrate = vm.isVibrationEnabled; + int currentRingtone = vm.ringtoneType; + + showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setStateDialog) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text('Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Pomodoro', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text('${currentPomodoro.toInt()} min', style: const TextStyle(color: AppColors.primaryBlue, fontWeight: FontWeight.bold)), + ], + ), + Slider(value: currentPomodoro, min: 5, max: 60, divisions: 55, activeColor: AppColors.primaryBlue, onChanged: (val) => setStateDialog(() => currentPomodoro = val)), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Short Break', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text('${currentBreak.toInt()} min', style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold)), + ], + ), + Slider(value: currentBreak, min: 1, max: 30, divisions: 29, activeColor: Colors.orange, onChanged: (val) => setStateDialog(() => currentBreak = val)), + const Divider(height: 30), + SwitchListTile( + title: const Text('Vibrate', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + value: currentVibrate, activeColor: AppColors.primaryBlue, contentPadding: EdgeInsets.zero, + onChanged: (val) => setStateDialog(() => currentVibrate = val), + ), + const SizedBox(height: 10), + const Text('Sound', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric(horizontal: 15), + decoration: BoxDecoration(color: const Color(0xFFF4F6F9), borderRadius: BorderRadius.circular(15)), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: currentRingtone, + items: const [ + DropdownMenuItem(value: 1, child: Text('Alarm (Loud)')), + DropdownMenuItem(value: 2, child: Text('Notification (Soft)')), + DropdownMenuItem(value: 3, child: Text('Ringtone')), + ], + onChanged: (val) { + if (val != null) setStateDialog(() => currentRingtone = val); + }, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel', style: TextStyle(color: Colors.grey))), + ElevatedButton( + onPressed: () { + vm.updateSettings(newPomodoroMinutes: currentPomodoro.toInt(), newBreakMinutes: currentBreak.toInt(), vibrate: currentVibrate, ringtone: currentRingtone); + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryBlue, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), + child: const Text('Save', style: TextStyle(color: Colors.white)), + ), + ], + ); + }, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF4F6F9), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + CircleAvatar(radius: 22, backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=a042581f4e29026704d')), + SizedBox(width: 15), + Text('Focus', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), + ], + ), + GestureDetector( + onTap: () => _showSettingsDialog(context), + child: Container(padding: const EdgeInsets.all(10), decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), child: const Icon(Icons.settings_outlined, color: AppColors.primaryBlue)), + ), + ], + ), + const SizedBox(height: 30), + const FocusTabSelector(), + const SizedBox(height: 30), + const TimerDisplayWidget(), + const SizedBox(height: 40), + const TimerControlsWidget(), + const SizedBox(height: 40), + const QuickNoteCard(), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/src/lib/features/note/view/focus_widget.dart b/src/lib/features/note/view/focus_widget.dart new file mode 100644 index 0000000..eaa48dd --- /dev/null +++ b/src/lib/features/note/view/focus_widget.dart @@ -0,0 +1,356 @@ +import 'dart:io'; // Import to display image files +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../viewmodel/focus_viewmodel.dart'; + +// --- Tab Selector Widget --- +class FocusTabSelector extends StatelessWidget { + const FocusTabSelector({super.key}); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 10)], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTabBtn('Pomodoro', vm.isPomodoroMode, () => vm.setMode(true)), + _buildTabBtn('Nghỉ ngắn', !vm.isPomodoroMode, () => vm.setMode(false)), + ], + ), + ); + } + + Widget _buildTabBtn(String label, bool isSelected, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? AppColors.primaryBlue : Colors.transparent, + borderRadius: BorderRadius.circular(15), + ), + child: Text( + label, + style: TextStyle( + color: isSelected ? Colors.white : const Color(0xFF757575), + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ); + } +} + +// --- Timer Display Widget --- +class TimerDisplayWidget extends StatelessWidget { + const TimerDisplayWidget({super.key}); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + return Stack( + alignment: Alignment.center, + children: [ + Container( + width: 280, + height: 280, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 14)), + ), + SizedBox( + width: 280, + height: 280, + child: CircularProgressIndicator( + value: vm.progress, + strokeWidth: 14, + color: AppColors.primaryBlue, + backgroundColor: Colors.transparent, + strokeCap: StrokeCap.round, + ), + ), + Container( + width: 210, + height: 210, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 20, offset: const Offset(0, 10))], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + vm.timeString, + style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w900, color: Color(0xFF2C3E50), letterSpacing: -2), + ), + const SizedBox(height: 4), + Text( + vm.isPomodoroMode ? 'TẬP TRUNG' : 'NGHỈ NGƠI', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.primaryBlue.withOpacity(0.8), letterSpacing: 2), + ), + ], + ), + ), + ], + ); + } +} + +// --- Timer Controls Widget --- +class TimerControlsWidget extends StatelessWidget { + const TimerControlsWidget({super.key}); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildControlBtn( + icon: Icons.replay_rounded, + bgColor: Colors.white, + iconColor: const Color(0xFF757575), + size: 60, + onTap: vm.resetTimer, + ), + const SizedBox(width: 30), + _buildControlBtn( + icon: vm.isRunning ? Icons.pause_rounded : Icons.play_arrow_rounded, + bgColor: AppColors.primaryBlue, + iconColor: Colors.white, + size: 85, + hasShadow: true, + onTap: vm.toggleTimer, + ), + const SizedBox(width: 30), + _buildControlBtn( + icon: Icons.skip_next_rounded, + bgColor: Colors.white, + iconColor: const Color(0xFF757575), + size: 60, + onTap: vm.skipTimer, + ), + ], + ); + } + + Widget _buildControlBtn({ + required IconData icon, + required Color bgColor, + required Color iconColor, + required double size, + bool hasShadow = false, + required VoidCallback onTap, + }) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: bgColor, + shape: BoxShape.circle, + boxShadow: [ + if (hasShadow) BoxShadow(color: AppColors.primaryBlue.withOpacity(0.4), blurRadius: 20, offset: const Offset(0, 10)), + if (!hasShadow) BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 10, offset: const Offset(0, 5)), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(100), + onTap: onTap, + child: Icon(icon, color: iconColor, size: size * 0.45), + ), + ), + ); + } +} + +// --- Quick Note Card Widget --- +class QuickNoteCard extends StatelessWidget { + const QuickNoteCard({super.key}); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(25), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(30)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('Ghi chú nhanh', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), + Text('Đang thực hiện', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.grayText)), + ], + ), + const SizedBox(height: 15), + + // TEXT INPUT AND IMAGE PREVIEW AREA + Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF4F6F9), + borderRadius: BorderRadius.circular(15), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: vm.noteController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Thêm ý tưởng, tiến độ, hình ảnh...', + hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: const TextStyle(fontSize: 14, color: Color(0xFF2C3E50)), + ), + + // SHOW IMAGE PREVIEW IF ANY + if (vm.selectedImagePath != null) ...[ + const SizedBox(height: 10), + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(File(vm.selectedImagePath!), height: 80, width: 80, fit: BoxFit.cover), + ), + Positioned( + top: -10, + right: -10, + child: IconButton( + icon: const Icon(Icons.cancel, color: Colors.redAccent), + onPressed: vm.removeSelectedImage, + ), + ), + ], + ), + ], + ], + ), + ), + const SizedBox(height: 15), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.attach_file_rounded, color: Colors.grey.shade400, size: 22), + const SizedBox(width: 10), + // IMAGE PICKER BUTTON + GestureDetector( + onTap: () => vm.pickImage(), + child: Icon(Icons.image_outlined, color: AppColors.primaryBlue, size: 22), + ), + ], + ), + ElevatedButton( + onPressed: () { + FocusScope.of(context).unfocus(); + context.read().addNote(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + elevation: 0, + ), + child: const Text('Lưu ghi chú', style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)), + ), + ], + ), + + // --- LOCAL NOTE LIST WITH IMAGE SUPPORT --- + if (vm.notes.isNotEmpty) ...[ + const Padding(padding: EdgeInsets.symmetric(vertical: 15), child: Divider(color: Color(0xFFE2E8F0), height: 1)), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: vm.notes.length, + itemBuilder: (context, index) { + final note = vm.notes[index]; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: note.pinned ? const Color(0xFFFFF8E1) : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(15), + border: Border.all(color: note.pinned ? Colors.amber.shade200 : Colors.transparent), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // CLICK TO REMOVE + GestureDetector( + onTap: () => vm.removeNote(note.id), + child: Container( + margin: const EdgeInsets.only(top: 2, right: 12), + width: 22, + height: 22, + decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: AppColors.primaryBlue, width: 2)), + ), + ), + // CONTENT (TEXT AND IMAGE) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (note.content.isNotEmpty) + Text(note.content, style: const TextStyle(fontSize: 14, color: Color(0xFF2C3E50), height: 1.4)), + + // DISPLAY IMAGE IN NOTE + if (note.imagePath != null) ...[ + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.file( + File(note.imagePath!), + width: double.infinity, + height: 150, + fit: BoxFit.cover, + ), + ), + ], + ], + ), + ), + // PIN BUTTON + GestureDetector( + onTap: () => vm.togglePin(note.id), + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + note.pinned ? Icons.push_pin_rounded : Icons.push_pin_outlined, + color: note.pinned ? Colors.orange : Colors.grey.shade400, + size: 20, + ), + ), + ), + ], + ), + ); + }, + ), + ], + ], + ), + ); + } +} diff --git a/src/lib/features/note/viewmodel/focus_viewmodel.dart b/src/lib/features/note/viewmodel/focus_viewmodel.dart new file mode 100644 index 0000000..bb2b067 --- /dev/null +++ b/src/lib/features/note/viewmodel/focus_viewmodel.dart @@ -0,0 +1,176 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_ringtone_player/flutter_ringtone_player.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../model/note_model.dart'; + +class FocusViewModel extends ChangeNotifier { + // ========================================== + // 1. STATE FOR NOTES (LOCAL UI ONLY) + // ========================================== + final TextEditingController noteController = TextEditingController(); + + // Temporary variable for the selected image + String? selectedImagePath; + final ImagePicker _picker = ImagePicker(); + + // Mock initial data to populate UI + List notes = [ + NoteModel(id: '1', content: 'Hoàn thiện các component cho màn hình Pomodoro, tập trung vào các nút bấm Soft UI.', pinned: true), + NoteModel(id: '2', content: 'Thiết kế hiệu ứng vòng tròn đếm ngược.'), + ]; + + // Open gallery to pick an image + Future pickImage() async { + try { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image != null) { + selectedImagePath = image.path; // Save local file path + notifyListeners(); + } + } catch (e) { + debugPrint("Error picking image: $e"); + } + } + + // Clear selected image + void removeSelectedImage() { + selectedImagePath = null; + notifyListeners(); + } + + // Add note (optionally with an image) instantly to the UI + void addNote() { + final text = noteController.text.trim(); + if (text.isEmpty && selectedImagePath == null) return; // Skip if both text and image are empty + + notes.insert(0, NoteModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + content: text, + pinned: false, + imagePath: selectedImagePath, // Store image in model + )); + + // Sort to keep pinned notes at the top + _sortNotes(); + noteController.clear(); + selectedImagePath = null; // Clear temporary image after saving + notifyListeners(); + } + + // Remove note instantly + void removeNote(String id) { + notes.removeWhere((note) => note.id == id); + notifyListeners(); + } + + // Pin/unpin note + void togglePin(String id) { + final index = notes.indexWhere((n) => n.id == id); + if (index != -1) { + notes[index] = NoteModel( + id: notes[index].id, + content: notes[index].content, + pinned: !notes[index].pinned, + imagePath: notes[index].imagePath // Keep image when pinning + ); + _sortNotes(); + notifyListeners(); + } + } + + void _sortNotes() { + notes.sort((a, b) { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return b.id.compareTo(a.id); + }); + } + + // ========================================== + // 2. POMODORO TIMER STATE + // ========================================== + int pomodoroTime = 25 * 60; + int shortBreakTime = 5 * 60; + + bool isVibrationEnabled = true; + int ringtoneType = 1; + + bool isPomodoroMode = true; + late int totalTime = pomodoroTime; + late int timeRemaining = pomodoroTime; + bool isRunning = false; + Timer? _timer; + + String get timeString { + String minutes = (timeRemaining ~/ 60).toString().padLeft(2, '0'); + String seconds = (timeRemaining % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } + + double get progress => timeRemaining / totalTime; + + void toggleTimer() { + if (isRunning) { + _timer?.cancel(); + isRunning = false; + } else { + isRunning = true; + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (timeRemaining > 0) { + timeRemaining--; + } else { + _timer?.cancel(); + isRunning = false; + + if (isVibrationEnabled) HapticFeedback.heavyImpact(); + if (ringtoneType == 1) FlutterRingtonePlayer().playAlarm(); + else if (ringtoneType == 2) FlutterRingtonePlayer().playNotification(); + else if (ringtoneType == 3) FlutterRingtonePlayer().playRingtone(); + } + notifyListeners(); + }); + } + notifyListeners(); + } + + void resetTimer() { + _timer?.cancel(); + isRunning = false; + timeRemaining = totalTime; + notifyListeners(); + } + + void setMode(bool isPomodoro) { + _timer?.cancel(); + isRunning = false; + isPomodoroMode = isPomodoro; + totalTime = isPomodoro ? pomodoroTime : shortBreakTime; + timeRemaining = totalTime; + notifyListeners(); + } + + void skipTimer() => setMode(!isPomodoroMode); + + void updateSettings({required int newPomodoroMinutes, required int newBreakMinutes, required bool vibrate, required int ringtone}) { + pomodoroTime = newPomodoroMinutes * 60; + shortBreakTime = newBreakMinutes * 60; + isVibrationEnabled = vibrate; + ringtoneType = ringtone; + + _timer?.cancel(); + isRunning = false; + totalTime = isPomodoroMode ? pomodoroTime : shortBreakTime; + timeRemaining = totalTime; + + notifyListeners(); + } + + @override + void dispose() { + _timer?.cancel(); + noteController.dispose(); + super.dispose(); + } +} diff --git a/src/lib/features/tasks/view/screens/task_detail_screen.dart b/src/lib/features/tasks/view/screens/task_detail_screen.dart index 6afd045..6297293 100644 --- a/src/lib/features/tasks/view/screens/task_detail_screen.dart +++ b/src/lib/features/tasks/view/screens/task_detail_screen.dart @@ -24,7 +24,7 @@ class _TaskDetailScreenState extends State { @override void initState() { super.initState(); - // Initialize state variables with data from the passed task object + // Initialize state variables with services from the passed task object _titleController = TextEditingController(text: widget.task.title); _descController = TextEditingController(text: widget.task.description); _startTime = widget.task.startTime; diff --git a/src/pubspec.lock b/src/pubspec.lock index 8cfed75..efabe5b 100644 --- a/src/pubspec.lock +++ b/src/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" clock: dependency: transitive description: @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -145,6 +153,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -166,6 +206,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_ringtone_player: + dependency: "direct main" + description: + name: flutter_ringtone_player + sha256: ae4a2caab2cfd14902f3ee5fe0307c454b396f0077fa34700238d6c4bd99cfec + url: "https://pub.dev" + source: hosted + version: "4.0.0+4" flutter_test: dependency: "direct dev" description: flutter @@ -240,6 +296,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "66810af8e99b2657ee98e5c6f02064f69bb63f7a70e343937f70946c5f8c6622" + url: "https://pub.dev" + source: hosted + version: "0.8.13+16" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -300,18 +420,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -441,7 +561,7 @@ packages: source: hosted version: "2.6.0" provider: - dependency: transitive + dependency: "direct main" description: name: provider sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" @@ -609,10 +729,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/src/pubspec.yaml b/src/pubspec.yaml index e4310e1..738f97b 100644 --- a/src/pubspec.yaml +++ b/src/pubspec.yaml @@ -39,6 +39,8 @@ dependencies: supabase_flutter: ^2.12.2 flutter_timezone: ^5.0.2 provider: ^6.1.5+1 + flutter_ringtone_player: ^4.0.0+4 + image_picker: ^1.2.1 dev_dependencies: flutter_test: From 3c68ce249868368a71b9ecec0d965da51893a7cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 8 Apr 2026 13:55:59 +0700 Subject: [PATCH 22/28] fix (auth) : dispose TextEditingControllers to prevent memory leaks --- .../viewmodels/auth_viewmodels.dart | 25 +++++++++++++++++++ src/lib/features/note/view/focus_screen.dart | 24 +++++++++--------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart index e3ba24b..5808490 100644 --- a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart +++ b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart @@ -123,6 +123,12 @@ class LoginViewModel extends BaseViewModel { setLoading(false); } } + @override + void dispose() { + emailCtrl.dispose(); + passCtrl.dispose(); + super.dispose(); + } } @@ -172,6 +178,14 @@ class RegisterViewModel extends BaseViewModel { setLoading(false); } } + @override + void dispose() { + usernameCtrl.dispose(); + emailCtrl.dispose(); + passCtrl.dispose(); + confirmPassCtrl.dispose(); + super.dispose(); + } } // ========================================== @@ -201,6 +215,11 @@ class ForgotPassViewModel extends BaseViewModel { setLoading(false); } } + @override + void dispose() { + emailCtrl.dispose(); + super.dispose(); + } } // ========================================== @@ -287,4 +306,10 @@ class NewPassViewModel extends BaseViewModel { setLoading(false); } } + @override + void dispose() { + passCtrl.dispose(); + confirmPassCtrl.dispose(); + super.dispose(); + } } diff --git a/src/lib/features/note/view/focus_screen.dart b/src/lib/features/note/view/focus_screen.dart index 26e2318..d7e29be 100644 --- a/src/lib/features/note/view/focus_screen.dart +++ b/src/lib/features/note/view/focus_screen.dart @@ -24,7 +24,7 @@ class FocusScreen extends StatelessWidget { return AlertDialog( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text('Settings', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), + title: const Text('Cài đặt', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, @@ -34,7 +34,7 @@ class FocusScreen extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Pomodoro', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - Text('${currentPomodoro.toInt()} min', style: const TextStyle(color: AppColors.primaryBlue, fontWeight: FontWeight.bold)), + Text('${currentPomodoro.toInt()} phút', style: const TextStyle(color: AppColors.primaryBlue, fontWeight: FontWeight.bold)), ], ), Slider(value: currentPomodoro, min: 5, max: 60, divisions: 55, activeColor: AppColors.primaryBlue, onChanged: (val) => setStateDialog(() => currentPomodoro = val)), @@ -42,19 +42,19 @@ class FocusScreen extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Short Break', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), - Text('${currentBreak.toInt()} min', style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold)), + const Text('Nghỉ ngắn', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + Text('${currentBreak.toInt()} phút', style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold)), ], ), Slider(value: currentBreak, min: 1, max: 30, divisions: 29, activeColor: Colors.orange, onChanged: (val) => setStateDialog(() => currentBreak = val)), const Divider(height: 30), SwitchListTile( - title: const Text('Vibrate', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + title: const Text('Rung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), value: currentVibrate, activeColor: AppColors.primaryBlue, contentPadding: EdgeInsets.zero, onChanged: (val) => setStateDialog(() => currentVibrate = val), ), const SizedBox(height: 10), - const Text('Sound', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + const Text('Âm thanh', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), const SizedBox(height: 10), Container( padding: const EdgeInsets.symmetric(horizontal: 15), @@ -64,9 +64,9 @@ class FocusScreen extends StatelessWidget { isExpanded: true, value: currentRingtone, items: const [ - DropdownMenuItem(value: 1, child: Text('Alarm (Loud)')), - DropdownMenuItem(value: 2, child: Text('Notification (Soft)')), - DropdownMenuItem(value: 3, child: Text('Ringtone')), + DropdownMenuItem(value: 1, child: Text('Chuông (Lớn)')), + DropdownMenuItem(value: 2, child: Text('Thông báo (Nhỏ)')), + DropdownMenuItem(value: 3, child: Text('Nhạc chuông')), ], onChanged: (val) { if (val != null) setStateDialog(() => currentRingtone = val); @@ -78,14 +78,14 @@ class FocusScreen extends StatelessWidget { ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel', style: TextStyle(color: Colors.grey))), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Hủy', style: TextStyle(color: Colors.grey))), ElevatedButton( onPressed: () { vm.updateSettings(newPomodoroMinutes: currentPomodoro.toInt(), newBreakMinutes: currentBreak.toInt(), vibrate: currentVibrate, ringtone: currentRingtone); Navigator.pop(context); }, style: ElevatedButton.styleFrom(backgroundColor: AppColors.primaryBlue, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10))), - child: const Text('Save', style: TextStyle(color: Colors.white)), + child: const Text('Lưu', style: TextStyle(color: Colors.white)), ), ], ); @@ -112,7 +112,7 @@ class FocusScreen extends StatelessWidget { children: [ CircleAvatar(radius: 22, backgroundImage: NetworkImage('https://i.pravatar.cc/150?u=a042581f4e29026704d')), SizedBox(width: 15), - Text('Focus', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), + Text('Tập trung', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), ], ), GestureDetector( From 0e4ad09b9a7d3ded64885bda5c7f38d3dbb9fcb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 8 Apr 2026 14:10:22 +0700 Subject: [PATCH 23/28] refactor (alarm ) : create off alarm button when time out --- src/lib/features/note/model/note_model.dart | 30 ++++++- src/lib/features/note/view/focus_screen.dart | 12 ++- src/lib/features/note/view/focus_widget.dart | 23 +++-- .../note/viewmodel/focus_viewmodel.dart | 87 ++++++++++++++++--- 4 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/lib/features/note/model/note_model.dart b/src/lib/features/note/model/note_model.dart index 43cc3ba..c0c0a02 100644 --- a/src/lib/features/note/model/note_model.dart +++ b/src/lib/features/note/model/note_model.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + class NoteModel { final String id; final String content; @@ -10,4 +12,30 @@ class NoteModel { this.pinned = false, this.imagePath, }); -} + + // --- LOCAL STORAGE HELPERS --- + + // Convert Object to Map for local storage + Map toMap() { + return { + 'id': id, + 'content': content, + 'pinned': pinned, + 'imagePath': imagePath, + }; + } + + // Create Object from Map + factory NoteModel.fromMap(Map map) { + return NoteModel( + id: map['id'], + content: map['content'], + pinned: map['pinned'] ?? false, + imagePath: map['imagePath'], + ); + } + + // JSON Helpers for SharedPreferences + String toJson() => json.encode(toMap()); + factory NoteModel.fromJson(String source) => NoteModel.fromMap(json.decode(source)); +} \ No newline at end of file diff --git a/src/lib/features/note/view/focus_screen.dart b/src/lib/features/note/view/focus_screen.dart index d7e29be..9349b49 100644 --- a/src/lib/features/note/view/focus_screen.dart +++ b/src/lib/features/note/view/focus_screen.dart @@ -30,6 +30,7 @@ class FocusScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // --- Time Settings --- Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -48,6 +49,8 @@ class FocusScreen extends StatelessWidget { ), Slider(value: currentBreak, min: 1, max: 30, divisions: 29, activeColor: Colors.orange, onChanged: (val) => setStateDialog(() => currentBreak = val)), const Divider(height: 30), + + // --- Hardware Settings --- SwitchListTile( title: const Text('Rung', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), value: currentVibrate, activeColor: AppColors.primaryBlue, contentPadding: EdgeInsets.zero, @@ -81,6 +84,7 @@ class FocusScreen extends StatelessWidget { TextButton(onPressed: () => Navigator.pop(context), child: const Text('Hủy', style: TextStyle(color: Colors.grey))), ElevatedButton( onPressed: () { + // Update settings in ViewModel vm.updateSettings(newPomodoroMinutes: currentPomodoro.toInt(), newBreakMinutes: currentBreak.toInt(), vibrate: currentVibrate, ringtone: currentRingtone); Navigator.pop(context); }, @@ -105,6 +109,7 @@ class FocusScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ + // --- Header --- Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -115,6 +120,7 @@ class FocusScreen extends StatelessWidget { Text('Tập trung', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Color(0xFF2C3E50))), ], ), + // Settings Icon GestureDetector( onTap: () => _showSettingsDialog(context), child: Container(padding: const EdgeInsets.all(10), decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle), child: const Icon(Icons.settings_outlined, color: AppColors.primaryBlue)), @@ -122,6 +128,8 @@ class FocusScreen extends StatelessWidget { ], ), const SizedBox(height: 30), + + // --- Main Content Widgets --- const FocusTabSelector(), const SizedBox(height: 30), const TimerDisplayWidget(), @@ -129,11 +137,11 @@ class FocusScreen extends StatelessWidget { const TimerControlsWidget(), const SizedBox(height: 40), const QuickNoteCard(), - const SizedBox(height: 80), + const SizedBox(height: 80), // Padding for bottom nav bar ], ), ), ), ); } -} +} \ No newline at end of file diff --git a/src/lib/features/note/view/focus_widget.dart b/src/lib/features/note/view/focus_widget.dart index eaa48dd..caf3e05 100644 --- a/src/lib/features/note/view/focus_widget.dart +++ b/src/lib/features/note/view/focus_widget.dart @@ -124,14 +124,19 @@ class TimerControlsWidget extends StatelessWidget { onTap: vm.resetTimer, ), const SizedBox(width: 30), + + // MAIN BUTTON: CHANGES TO RED WHEN RINGING TO STOP ALARM _buildControlBtn( - icon: vm.isRunning ? Icons.pause_rounded : Icons.play_arrow_rounded, - bgColor: AppColors.primaryBlue, + icon: vm.isRinging + ? Icons.notifications_off_rounded // Muted bell icon + : (vm.isRunning ? Icons.pause_rounded : Icons.play_arrow_rounded), + bgColor: vm.isRinging ? Colors.redAccent : AppColors.primaryBlue, iconColor: Colors.white, size: 85, hasShadow: true, - onTap: vm.toggleTimer, + onTap: vm.toggleTimer, // Stops alarm if ringing, otherwise toggles timer ), + const SizedBox(width: 30), _buildControlBtn( icon: Icons.skip_next_rounded, @@ -159,7 +164,7 @@ class TimerControlsWidget extends StatelessWidget { color: bgColor, shape: BoxShape.circle, boxShadow: [ - if (hasShadow) BoxShadow(color: AppColors.primaryBlue.withOpacity(0.4), blurRadius: 20, offset: const Offset(0, 10)), + if (hasShadow) BoxShadow(color: bgColor.withOpacity(0.4), blurRadius: 20, offset: const Offset(0, 10)), if (!hasShadow) BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 10, offset: const Offset(0, 5)), ], ), @@ -222,7 +227,7 @@ class QuickNoteCard extends StatelessWidget { style: const TextStyle(fontSize: 14, color: Color(0xFF2C3E50)), ), - // SHOW IMAGE PREVIEW IF ANY + // SHOW IMAGE PREVIEW IF SELECTED if (vm.selectedImagePath != null) ...[ const SizedBox(height: 10), Stack( @@ -263,7 +268,7 @@ class QuickNoteCard extends StatelessWidget { ), ElevatedButton( onPressed: () { - FocusScope.of(context).unfocus(); + FocusScope.of(context).unfocus(); // Dismiss keyboard context.read().addNote(); }, style: ElevatedButton.styleFrom( @@ -297,7 +302,7 @@ class QuickNoteCard extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // CLICK TO REMOVE + // CLICK TO REMOVE BUTTON GestureDetector( onTap: () => vm.removeNote(note.id), child: Container( @@ -315,7 +320,7 @@ class QuickNoteCard extends StatelessWidget { if (note.content.isNotEmpty) Text(note.content, style: const TextStyle(fontSize: 14, color: Color(0xFF2C3E50), height: 1.4)), - // DISPLAY IMAGE IN NOTE + // DISPLAY ATTACHED IMAGE IN NOTE if (note.imagePath != null) ...[ const SizedBox(height: 8), ClipRRect( @@ -353,4 +358,4 @@ class QuickNoteCard extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/src/lib/features/note/viewmodel/focus_viewmodel.dart b/src/lib/features/note/viewmodel/focus_viewmodel.dart index bb2b067..c4f0cc0 100644 --- a/src/lib/features/note/viewmodel/focus_viewmodel.dart +++ b/src/lib/features/note/viewmodel/focus_viewmodel.dart @@ -1,13 +1,15 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/services.dart'; import 'package:flutter_ringtone_player/flutter_ringtone_player.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../model/note_model.dart'; class FocusViewModel extends ChangeNotifier { // ========================================== - // 1. STATE FOR NOTES (LOCAL UI ONLY) + // 1. STATE FOR NOTES (LOCAL STORAGE & UI) // ========================================== final TextEditingController noteController = TextEditingController(); @@ -15,11 +17,34 @@ class FocusViewModel extends ChangeNotifier { String? selectedImagePath; final ImagePicker _picker = ImagePicker(); - // Mock initial data to populate UI - List notes = [ - NoteModel(id: '1', content: 'Hoàn thiện các component cho màn hình Pomodoro, tập trung vào các nút bấm Soft UI.', pinned: true), - NoteModel(id: '2', content: 'Thiết kế hiệu ứng vòng tròn đếm ngược.'), - ]; + // List to hold the notes + List notes = []; + + // Constructor: Load saved notes when the ViewModel is initialized + FocusViewModel() { + loadNotesFromDisk(); + } + + // --- LOCAL STORAGE LOGIC --- + + // Save current notes list to device storage + Future saveNotesToDisk() async { + final prefs = await SharedPreferences.getInstance(); + List noteStrings = notes.map((n) => n.toJson()).toList(); + await prefs.setStringList('saved_notes', noteStrings); + } + + // Load saved notes from device storage + Future loadNotesFromDisk() async { + final prefs = await SharedPreferences.getInstance(); + List? noteStrings = prefs.getStringList('saved_notes'); + if (noteStrings != null) { + notes = noteStrings.map((s) => NoteModel.fromJson(s)).toList(); + notifyListeners(); + } + } + + // --- NOTE OPERATIONS --- // Open gallery to pick an image Future pickImage() async { @@ -34,13 +59,13 @@ class FocusViewModel extends ChangeNotifier { } } - // Clear selected image + // Clear selected image before saving void removeSelectedImage() { selectedImagePath = null; notifyListeners(); } - // Add note (optionally with an image) instantly to the UI + // Add note (optionally with an image) instantly to the UI and save to disk void addNote() { final text = noteController.text.trim(); if (text.isEmpty && selectedImagePath == null) return; // Skip if both text and image are empty @@ -52,20 +77,21 @@ class FocusViewModel extends ChangeNotifier { imagePath: selectedImagePath, // Store image in model )); - // Sort to keep pinned notes at the top _sortNotes(); + saveNotesToDisk(); // Persist data noteController.clear(); selectedImagePath = null; // Clear temporary image after saving notifyListeners(); } - // Remove note instantly + // Remove note instantly and update storage void removeNote(String id) { notes.removeWhere((note) => note.id == id); + saveNotesToDisk(); // Persist data notifyListeners(); } - // Pin/unpin note + // Pin/unpin note and update storage void togglePin(String id) { final index = notes.indexWhere((n) => n.id == id); if (index != -1) { @@ -76,10 +102,12 @@ class FocusViewModel extends ChangeNotifier { imagePath: notes[index].imagePath // Keep image when pinning ); _sortNotes(); + saveNotesToDisk(); // Persist data notifyListeners(); } } + // Sort notes: Pinned items at the top, then by ID (newest first) void _sortNotes() { notes.sort((a, b) { if (a.pinned && !b.pinned) return -1; @@ -89,29 +117,50 @@ class FocusViewModel extends ChangeNotifier { } // ========================================== - // 2. POMODORO TIMER STATE + // 2. POMODORO TIMER STATE & LOGIC // ========================================== int pomodoroTime = 25 * 60; int shortBreakTime = 5 * 60; + // Hardware settings bool isVibrationEnabled = true; int ringtoneType = 1; + // Timer states bool isPomodoroMode = true; late int totalTime = pomodoroTime; late int timeRemaining = pomodoroTime; bool isRunning = false; + bool isRinging = false; // Flag to check if alarm is currently ringing Timer? _timer; + // Format time to MM:SS String get timeString { String minutes = (timeRemaining ~/ 60).toString().padLeft(2, '0'); String seconds = (timeRemaining % 60).toString().padLeft(2, '0'); return '$minutes:$seconds'; } + // Calculate progress for the circular indicator double get progress => timeRemaining / totalTime; + // --- TIMER OPERATIONS --- + + // Stop the alarm sound and reset the ringing flag + void stopAlarm() { + FlutterRingtonePlayer().stop(); + isRinging = false; + notifyListeners(); + } + + // Start, pause, or handle alarm state void toggleTimer() { + // If alarm is ringing, clicking the main button should only stop the alarm + if (isRinging) { + stopAlarm(); + return; + } + if (isRunning) { _timer?.cancel(); isRunning = false; @@ -121,9 +170,12 @@ class FocusViewModel extends ChangeNotifier { if (timeRemaining > 0) { timeRemaining--; } else { + // Time is up _timer?.cancel(); isRunning = false; + isRinging = true; // Set flag to change UI button state + // Trigger hardware feedback if (isVibrationEnabled) HapticFeedback.heavyImpact(); if (ringtoneType == 1) FlutterRingtonePlayer().playAlarm(); else if (ringtoneType == 2) FlutterRingtonePlayer().playNotification(); @@ -135,14 +187,18 @@ class FocusViewModel extends ChangeNotifier { notifyListeners(); } + // Reset the current timer back to full duration void resetTimer() { + stopAlarm(); // Stop alarm if resetting _timer?.cancel(); isRunning = false; timeRemaining = totalTime; notifyListeners(); } + // Switch between Pomodoro and Short Break void setMode(bool isPomodoro) { + stopAlarm(); // Stop alarm if switching modes _timer?.cancel(); isRunning = false; isPomodoroMode = isPomodoro; @@ -151,9 +207,12 @@ class FocusViewModel extends ChangeNotifier { notifyListeners(); } + // Skip current session void skipTimer() => setMode(!isPomodoroMode); + // Update preferences from the settings dialog void updateSettings({required int newPomodoroMinutes, required int newBreakMinutes, required bool vibrate, required int ringtone}) { + stopAlarm(); // Stop alarm if opening settings pomodoroTime = newPomodoroMinutes * 60; shortBreakTime = newBreakMinutes * 60; isVibrationEnabled = vibrate; @@ -167,10 +226,12 @@ class FocusViewModel extends ChangeNotifier { notifyListeners(); } + // Prevent memory leaks when the ViewModel is destroyed @override void dispose() { + stopAlarm(); // Ensure alarm doesn't keep ringing in the background _timer?.cancel(); noteController.dispose(); super.dispose(); } -} +} \ No newline at end of file From a4a6a8347272897eb207b4ee1fe6d9c132192bce Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:20:20 +0000 Subject: [PATCH 24/28] fix: apply CodeRabbit auto-fixes Fixed 3 file(s) based on 4 unresolved review comments. Co-authored-by: CodeRabbit --- src/lib/features/note/view/focus_widget.dart | 52 ++++++++++++++++++- .../note/viewmodel/focus_viewmodel.dart | 23 +++++--- src/pubspec.yaml | 3 +- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/lib/features/note/view/focus_widget.dart b/src/lib/features/note/view/focus_widget.dart index caf3e05..8037b4a 100644 --- a/src/lib/features/note/view/focus_widget.dart +++ b/src/lib/features/note/view/focus_widget.dart @@ -234,7 +234,27 @@ class QuickNoteCard extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.file(File(vm.selectedImagePath!), height: 80, width: 80, fit: BoxFit.cover), + child: Image.file( + File(vm.selectedImagePath!), + height: 80, + width: 80, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 80, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.broken_image_outlined, + color: Colors.grey.shade600, + size: 40, + ), + ); + }, + ), ), Positioned( top: -10, @@ -321,7 +341,7 @@ class QuickNoteCard extends StatelessWidget { Text(note.content, style: const TextStyle(fontSize: 14, color: Color(0xFF2C3E50), height: 1.4)), // DISPLAY ATTACHED IMAGE IN NOTE - if (note.imagePath != null) ...[ + if (note.imagePath != null && note.imagePath!.isNotEmpty) ...[ const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(10), @@ -330,6 +350,34 @@ class QuickNoteCard extends StatelessWidget { width: double.infinity, height: 150, fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: double.infinity, + height: 150, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.broken_image_outlined, + color: Colors.grey.shade600, + size: 50, + ), + const SizedBox(height: 8), + Text( + 'Image not available', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ], + ), + ); + }, ), ), ], diff --git a/src/lib/features/note/viewmodel/focus_viewmodel.dart b/src/lib/features/note/viewmodel/focus_viewmodel.dart index c4f0cc0..50f0e5d 100644 --- a/src/lib/features/note/viewmodel/focus_viewmodel.dart +++ b/src/lib/features/note/viewmodel/focus_viewmodel.dart @@ -39,7 +39,16 @@ class FocusViewModel extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); List? noteStrings = prefs.getStringList('saved_notes'); if (noteStrings != null) { - notes = noteStrings.map((s) => NoteModel.fromJson(s)).toList(); + List loadedNotes = []; + for (String noteString in noteStrings) { + try { + loadedNotes.add(NoteModel.fromJson(noteString)); + } catch (e) { + debugPrint("Error parsing note JSON: $e"); + // Skip the malformed entry and continue + } + } + notes = loadedNotes; notifyListeners(); } } @@ -66,7 +75,7 @@ class FocusViewModel extends ChangeNotifier { } // Add note (optionally with an image) instantly to the UI and save to disk - void addNote() { + Future addNote() async { final text = noteController.text.trim(); if (text.isEmpty && selectedImagePath == null) return; // Skip if both text and image are empty @@ -78,21 +87,21 @@ class FocusViewModel extends ChangeNotifier { )); _sortNotes(); - saveNotesToDisk(); // Persist data + await saveNotesToDisk(); // Persist data noteController.clear(); selectedImagePath = null; // Clear temporary image after saving notifyListeners(); } // Remove note instantly and update storage - void removeNote(String id) { + Future removeNote(String id) async { notes.removeWhere((note) => note.id == id); - saveNotesToDisk(); // Persist data + await saveNotesToDisk(); // Persist data notifyListeners(); } // Pin/unpin note and update storage - void togglePin(String id) { + Future togglePin(String id) async { final index = notes.indexWhere((n) => n.id == id); if (index != -1) { notes[index] = NoteModel( @@ -102,7 +111,7 @@ class FocusViewModel extends ChangeNotifier { imagePath: notes[index].imagePath // Keep image when pinning ); _sortNotes(); - saveNotesToDisk(); // Persist data + await saveNotesToDisk(); // Persist data notifyListeners(); } } diff --git a/src/pubspec.yaml b/src/pubspec.yaml index 738f97b..65c8e9c 100644 --- a/src/pubspec.yaml +++ b/src/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: provider: ^6.1.5+1 flutter_ringtone_player: ^4.0.0+4 image_picker: ^1.2.1 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: @@ -90,4 +91,4 @@ flutter: # weight: 700 # # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + # see https://flutter.dev/to/font-from-package \ No newline at end of file From 3dcb2a38a0102af1870450d5aab6f0203bd5ba04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 8 Apr 2026 14:32:52 +0700 Subject: [PATCH 25/28] fix(timer): prevent division by zero in progress calculation and sanitize negative settings input --- src/lib/features/note/viewmodel/focus_viewmodel.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/features/note/viewmodel/focus_viewmodel.dart b/src/lib/features/note/viewmodel/focus_viewmodel.dart index c4f0cc0..8839e7a 100644 --- a/src/lib/features/note/viewmodel/focus_viewmodel.dart +++ b/src/lib/features/note/viewmodel/focus_viewmodel.dart @@ -142,7 +142,7 @@ class FocusViewModel extends ChangeNotifier { } // Calculate progress for the circular indicator - double get progress => timeRemaining / totalTime; + double get progress => totalTime <= 0 ? 0.0 : (timeRemaining / totalTime).clamp(0.0, 1.0); // --- TIMER OPERATIONS --- @@ -212,6 +212,11 @@ class FocusViewModel extends ChangeNotifier { // Update preferences from the settings dialog void updateSettings({required int newPomodoroMinutes, required int newBreakMinutes, required bool vibrate, required int ringtone}) { + if (newPomodoroMinutes <= 0 || newBreakMinutes <= 0) { + debugPrint('Lỗi: Thời gian cài đặt phải lớn hơn 0 phút. Đã tự động set về 1.'); + newPomodoroMinutes = newPomodoroMinutes <= 0 ? 1 : newPomodoroMinutes; + newBreakMinutes = newBreakMinutes <= 0 ? 1 : newBreakMinutes; + } stopAlarm(); // Stop alarm if opening settings pomodoroTime = newPomodoroMinutes * 60; shortBreakTime = newBreakMinutes * 60; From 1b8984e9a6137774b92495f9e1da41d0a57606a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Wed, 8 Apr 2026 14:39:12 +0700 Subject: [PATCH 26/28] fix(timer): prevent division by zero in progress calculation and sanitize negative settings input --- src/lib/features/note/viewmodel/focus_viewmodel.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/features/note/viewmodel/focus_viewmodel.dart b/src/lib/features/note/viewmodel/focus_viewmodel.dart index 50f0e5d..ec8a3c8 100644 --- a/src/lib/features/note/viewmodel/focus_viewmodel.dart +++ b/src/lib/features/note/viewmodel/focus_viewmodel.dart @@ -151,7 +151,7 @@ class FocusViewModel extends ChangeNotifier { } // Calculate progress for the circular indicator - double get progress => timeRemaining / totalTime; + double get progress => totalTime <= 0 ? 0.0 : (timeRemaining / totalTime).clamp(0.0, 1.0); // --- TIMER OPERATIONS --- @@ -221,6 +221,11 @@ class FocusViewModel extends ChangeNotifier { // Update preferences from the settings dialog void updateSettings({required int newPomodoroMinutes, required int newBreakMinutes, required bool vibrate, required int ringtone}) { + if (newPomodoroMinutes <= 0 || newBreakMinutes <= 0) { + debugPrint('Lỗi: Thời gian cài đặt phải lớn hơn 0 phút. Đã tự động set về 1.'); + newPomodoroMinutes = newPomodoroMinutes <= 0 ? 1 : newPomodoroMinutes; + newBreakMinutes = newBreakMinutes <= 0 ? 1 : newBreakMinutes; + } stopAlarm(); // Stop alarm if opening settings pomodoroTime = newPomodoroMinutes * 60; shortBreakTime = newBreakMinutes * 60; From f17dd003d25cffd546240c7033549a221a43105c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Thu, 9 Apr 2026 15:17:13 +0700 Subject: [PATCH 27/28] fix(auth): unblock new-user login and add settings logout --- .../auth/presentation/view/login_view.dart | 2 + .../auth/presentation/view/register_view.dart | 7 +-- .../viewmodels/auth_viewmodels.dart | 1 + .../features/auth/services/auth_helper.dart | 35 ++++++++---- .../main/view/screens/main_screen.dart | 3 +- .../main/view/screens/settings_screen.dart | 54 +++++++++++++++++++ src/pubspec.lock | 2 +- 7 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 src/lib/features/main/view/screens/settings_screen.dart diff --git a/src/lib/features/auth/presentation/view/login_view.dart b/src/lib/features/auth/presentation/view/login_view.dart index 6bf1e9c..8d39494 100644 --- a/src/lib/features/auth/presentation/view/login_view.dart +++ b/src/lib/features/auth/presentation/view/login_view.dart @@ -44,6 +44,8 @@ class _LoginViewState extends State { if (errorMessage == null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Đăng nhập thành công!'), backgroundColor: AppColors.success)); + // If LoginView was pushed on top of AuthGate, pop back to root so MainScreen is visible. + Navigator.of(context).popUntil((route) => route.isFirst); } else { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error)); } diff --git a/src/lib/features/auth/presentation/view/register_view.dart b/src/lib/features/auth/presentation/view/register_view.dart index bfb4bc2..17dfc88 100644 --- a/src/lib/features/auth/presentation/view/register_view.dart +++ b/src/lib/features/auth/presentation/view/register_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:task_management_app/features/auth/presentation/view/login_view.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/auth_layout_template.dart'; import '../../../../core/theme/custom_text_field.dart'; @@ -31,10 +30,8 @@ class _RegisterViewState extends State { if (errorMessage == null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Đăng ký thành công!'), backgroundColor: AppColors.success)); - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (context) => const LoginView()), - ); + // Return to the existing LoginView to avoid stacking duplicate login routes. + Navigator.pop(context); } else { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(errorMessage), backgroundColor: AppColors.error)); } diff --git a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart index 5808490..221baf5 100644 --- a/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart +++ b/src/lib/features/auth/presentation/viewmodels/auth_viewmodels.dart @@ -44,6 +44,7 @@ class BaseViewModel extends ChangeNotifier { 'already registered': 'Email này đã được đăng ký!', 'already exists': 'Email này đã được đăng ký!', 'invalid login credentials': 'Email hoặc mật khẩu không chính xác!', + 'email not confirmed': 'Email chưa được xác nhận. Vui lòng kiểm tra hộp thư!', 'rate limit': 'Bạn thao tác quá nhanh, vui lòng thử lại sau!', 'over_email_send_rate_limit': 'Bạn thao tác quá nhanh, vui lòng thử lại sau!', 'token has expired or is invalid': 'Mã OTP không hợp lệ hoặc đã hết hạn!', diff --git a/src/lib/features/auth/services/auth_helper.dart b/src/lib/features/auth/services/auth_helper.dart index 19ba933..ab064d2 100644 --- a/src/lib/features/auth/services/auth_helper.dart +++ b/src/lib/features/auth/services/auth_helper.dart @@ -15,16 +15,21 @@ class AuthHelper { password: password, ); - if (response.user != null) { - final userId = response.user!.id; - + final user = response.user; + if (user != null) { + final userId = user.id; + final userMetadata = user.userMetadata ?? {}; + final String? username = userMetadata['username']?.toString(); final timezoneObj = await FlutterTimezone.getLocalTimezone(); final String currentTimezone = timezoneObj.toString(); final profileData = await supabase .from('profile') - .update({'timezone': currentTimezone}) - .eq('id', userId) + .upsert({ + 'id': userId, + if (username != null && username.isNotEmpty) 'username': username, + 'timezone': currentTimezone, + }) .select() .single(); @@ -77,14 +82,26 @@ class AuthHelper { } ); - if (response.user != null) { - final userId = response.user!.id; + final user = response.user; + if (user != null) { + if (response.session == null) { + // Email confirmation may be required; skip profile write until login. + return UserModel( + id: user.id, + email: email, + username: username, + timezone: currentTimezone, + ); + } - // Step 2: Insert directly into the 'profile' table final profileData = await supabase .from('profile') + .upsert({ + 'id': user.id, + 'username': username, + 'timezone': currentTimezone, + }) .select() - .eq('id', userId) .single(); return UserModel.fromJson(profileData, email); diff --git a/src/lib/features/main/view/screens/main_screen.dart b/src/lib/features/main/view/screens/main_screen.dart index 89a477a..8c8cffe 100644 --- a/src/lib/features/main/view/screens/main_screen.dart +++ b/src/lib/features/main/view/screens/main_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:task_management_app/features/statistics/viewmodel/statistics_viewmodel.dart'; import 'package:task_management_app/features/tasks/view/screens/home_screen.dart'; +import 'settings_screen.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../note/view/focus_screen.dart'; import '../../../note/viewmodel/focus_viewmodel.dart'; @@ -29,7 +30,7 @@ class _MainScreenState extends State { create: (_) => StatisticsViewmodel(), child: const StatisticsScreen(), ), - const Center(child: Text('Màn hình Cài đặt')), + const SettingsScreen(), ]; @override diff --git a/src/lib/features/main/view/screens/settings_screen.dart b/src/lib/features/main/view/screens/settings_screen.dart new file mode 100644 index 0000000..113f4cf --- /dev/null +++ b/src/lib/features/main/view/screens/settings_screen.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../auth/services/auth_helper.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Cai dat'), + ), + body: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tai khoan', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: AppColors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () async { + try { + await signOut(); + } catch (error) { + if (!context.mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Dang xuat that bai: $error')), + ); + } + }, + icon: const Icon(Icons.logout_rounded), + label: const Text('Dang xuat'), + ), + ), + ], + ), + ), + ); + } +} + diff --git a/src/pubspec.lock b/src/pubspec.lock index efabe5b..1b845aa 100644 --- a/src/pubspec.lock +++ b/src/pubspec.lock @@ -601,7 +601,7 @@ packages: source: hosted version: "0.28.0" shared_preferences: - dependency: transitive + dependency: "direct main" description: name: shared_preferences sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" From c1075b1966a2bd0edd7c6fbace7fefd69f8d84cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20L=C3=AA=20Ho=C3=A0ng=20H=E1=BA=A3o?= Date: Sat, 11 Apr 2026 22:57:06 +0700 Subject: [PATCH 28/28] refactor(LoginScreen) : compact all items to fit in screen to help users interface easily --- src/lib/core/theme/auth_layout_template.dart | 108 ++++++++++-------- .../auth/presentation/view/login_view.dart | 14 ++- .../auth/presentation/view/register_view.dart | 9 +- 3 files changed, 71 insertions(+), 60 deletions(-) diff --git a/src/lib/core/theme/auth_layout_template.dart b/src/lib/core/theme/auth_layout_template.dart index e001784..3146fc2 100644 --- a/src/lib/core/theme/auth_layout_template.dart +++ b/src/lib/core/theme/auth_layout_template.dart @@ -15,6 +15,7 @@ class AuthLayoutTemplate extends StatelessWidget { final Widget? footerContent; final VoidCallback? onGoogleTap; // Login with Google final VoidCallback? onFacebookTap; // Login with Facebook + final bool compactMode; const AuthLayoutTemplate({ super.key, @@ -30,6 +31,7 @@ class AuthLayoutTemplate extends StatelessWidget { this.footerContent, this.onGoogleTap, this.onFacebookTap, + this.compactMode = false, }); @override @@ -61,48 +63,54 @@ class AuthLayoutTemplate extends StatelessWidget { ) : null, ), - body: Container( - decoration: BoxDecoration( - gradient: isDark - ? const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF08142D), Color(0xFF0B1A38), Color(0xFF0A1834)], - ) - : null, - ), - child: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(24.0, 16.0, 24.0, 48.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildHeader(context), - const SizedBox(height: 32), - useCard - ? _buildCardContainer(context) - : _buildTransparentContainer(context), - const SizedBox(height: 32), - if (footerContent != null) footerContent!, - ], + body: LayoutBuilder( + builder: (context, constraints) { + final isCompact = compactMode || constraints.maxHeight <= 780; + + return Container( + decoration: BoxDecoration( + gradient: isDark + ? const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF08142D), Color(0xFF0B1A38), Color(0xFF0A1834)], + ) + : null, + ), + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(20.0, isCompact ? 8.0 : 16.0, 20.0, isCompact ? 16.0 : 36.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildHeader(context, isCompact), + SizedBox(height: isCompact ? 16 : 28), + useCard + ? _buildCardContainer(context, isCompact) + : _buildTransparentContainer(context, isCompact), + SizedBox(height: isCompact ? 12 : 24), + if (footerContent != null) footerContent!, + ], + ), + ), ), ), - ), - ), + ); + }, ), ); } - Widget _buildHeader(BuildContext context) { + Widget _buildHeader(BuildContext context, bool isCompact) { final isDark = Theme.of(context).brightness == Brightness.dark; return Column( children: [ customHeaderIcon ?? Container( - width: 80, - height: 80, + width: isCompact ? 64 : 80, + height: isCompact ? 64 : 80, decoration: BoxDecoration( color: isDark ? const Color(0xFF1E2B47) : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(24), @@ -117,26 +125,26 @@ class AuthLayoutTemplate extends StatelessWidget { child: Center( child: Icon( Icons.task_alt, - size: 48, + size: isCompact ? 36 : 48, color: Theme.of(context).colorScheme.primary, ), ), ), - const SizedBox(height: 24), + SizedBox(height: isCompact ? 12 : 24), Text( title, style: TextStyle( - fontSize: 28, + fontSize: isCompact ? 24 : 28, fontWeight: FontWeight.w800, color: Theme.of(context).colorScheme.onSurface, letterSpacing: -0.5, ), ), - const SizedBox(height: 8), + SizedBox(height: isCompact ? 4 : 8), Text( subtitle, style: TextStyle( - fontSize: 14, + fontSize: isCompact ? 13 : 14, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurfaceVariant, ), @@ -146,11 +154,11 @@ class AuthLayoutTemplate extends StatelessWidget { ); } - Widget _buildCardContainer(BuildContext context) { + Widget _buildCardContainer(BuildContext context, bool isCompact) { final isDark = Theme.of(context).brightness == Brightness.dark; return Container( - padding: const EdgeInsets.all(32), + padding: EdgeInsets.all(isCompact ? 20 : 32), decoration: BoxDecoration( color: isDark ? const Color(0xFF1A2945) : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(32), @@ -163,21 +171,21 @@ class AuthLayoutTemplate extends StatelessWidget { ), ], ), - child: _buildFormElements(context), + child: _buildFormElements(context, isCompact), ); } - Widget _buildTransparentContainer(BuildContext context) => - _buildFormElements(context); + Widget _buildTransparentContainer(BuildContext context, bool isCompact) => + _buildFormElements(context, isCompact); - Widget _buildFormElements(BuildContext context) { + Widget _buildFormElements(BuildContext context, bool isCompact) { final isDark = Theme.of(context).brightness == Brightness.dark; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ formContent, - const SizedBox(height: 16), + SizedBox(height: isCompact ? 12 : 16), ElevatedButton( // Disable button if loading onPressed: isLoading ? null : onSubmit, @@ -185,7 +193,7 @@ class AuthLayoutTemplate extends StatelessWidget { backgroundColor: Theme.of(context).colorScheme.primary, disabledBackgroundColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.6), - padding: const EdgeInsets.symmetric(vertical: 20), + padding: EdgeInsets.symmetric(vertical: isCompact ? 14 : 20), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), @@ -209,7 +217,7 @@ class AuthLayoutTemplate extends StatelessWidget { Text( submitText, style: TextStyle( - fontSize: 16, + fontSize: isCompact ? 15 : 16, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.surface, ), @@ -224,14 +232,14 @@ class AuthLayoutTemplate extends StatelessWidget { ), ), if (showSocial) ...[ - const SizedBox(height: 32), + SizedBox(height: isCompact ? 16 : 32), Row( children: [ Expanded( child: Divider(color: Theme.of(context).colorScheme.outline), ), Padding( - padding: EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: isCompact ? 12 : 16), child: Text( 'OR', style: TextStyle( @@ -246,7 +254,7 @@ class AuthLayoutTemplate extends StatelessWidget { ), ], ), - const SizedBox(height: 24), + SizedBox(height: isCompact ? 12 : 24), Row( children: [ Expanded( @@ -259,7 +267,7 @@ class AuthLayoutTemplate extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - padding: const EdgeInsets.symmetric(vertical: 14), + padding: EdgeInsets.symmetric(vertical: isCompact ? 10 : 14), ), onPressed: onGoogleTap, icon: const Icon( @@ -270,7 +278,7 @@ class AuthLayoutTemplate extends StatelessWidget { label: const Text('Google'), ), ), - const SizedBox(width: 16), + SizedBox(width: isCompact ? 10 : 16), Expanded( child: OutlinedButton.icon( style: OutlinedButton.styleFrom( @@ -281,7 +289,7 @@ class AuthLayoutTemplate extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - padding: const EdgeInsets.symmetric(vertical: 14), + padding: EdgeInsets.symmetric(vertical: isCompact ? 10 : 14), ), onPressed: onFacebookTap, icon: const Icon(Icons.facebook, color: Color(0xFF1877F2)), diff --git a/src/lib/features/auth/presentation/view/login_view.dart b/src/lib/features/auth/presentation/view/login_view.dart index 7807b4f..c315fd9 100644 --- a/src/lib/features/auth/presentation/view/login_view.dart +++ b/src/lib/features/auth/presentation/view/login_view.dart @@ -22,6 +22,7 @@ class _LoginViewState extends State { title: 'Task Management', subtitle: 'Chào mừng trở lại!', submitText: 'Đăng nhập', + compactMode: true, isLoading: _vm.isLoading, showSocial: true, onGoogleTap: () async { @@ -79,6 +80,7 @@ class _LoginViewState extends State { ), TextButton( onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordView())), + style: TextButton.styleFrom(minimumSize: const Size(50, 40), padding: const EdgeInsets.symmetric(horizontal: 4)), child: Text( 'Quên mật khẩu?', style: TextStyle( @@ -94,8 +96,8 @@ class _LoginViewState extends State { 'Chưa có tài khoản? ', 'Đăng ký ngay', () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const RegisterView())); - }, + Navigator.push(context, MaterialPageRoute(builder: (_) => const RegisterView())); + }, ), ), ); @@ -108,7 +110,7 @@ class _LoginViewState extends State { VoidCallback onTap, ) { return Padding( - padding: const EdgeInsets.only(bottom: 24.0), + padding: const EdgeInsets.only(bottom: 14.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -116,18 +118,18 @@ class _LoginViewState extends State { text, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 16, + fontSize: 15, ), ), TextButton( onPressed: onTap, - style: TextButton.styleFrom(minimumSize: const Size(50, 48)), + style: TextButton.styleFrom(minimumSize: const Size(50, 40)), child: Text( action, style: TextStyle( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, - fontSize: 16, + fontSize: 15, ), ), ), diff --git a/src/lib/features/auth/presentation/view/register_view.dart b/src/lib/features/auth/presentation/view/register_view.dart index ddc3556..d5125d7 100644 --- a/src/lib/features/auth/presentation/view/register_view.dart +++ b/src/lib/features/auth/presentation/view/register_view.dart @@ -21,6 +21,7 @@ class _RegisterViewState extends State { title: 'Tạo tài khoản mới', subtitle: 'Bắt đầu quản lý công việc khoa học', submitText: 'Đăng ký', + compactMode: true, isLoading: _vm.isLoading, showSocial: true, onSubmit: () async { @@ -61,7 +62,7 @@ class _RegisterViewState extends State { ], ), footerContent: Padding( - padding: const EdgeInsets.only(bottom: 24.0), + padding: const EdgeInsets.only(bottom: 14.0), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -69,18 +70,18 @@ class _RegisterViewState extends State { 'Đã có tài khoản? ', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 16, + fontSize: 15, ), ), TextButton( onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom(minimumSize: const Size(50, 48)), + style: TextButton.styleFrom(minimumSize: const Size(50, 40)), child: Text( 'Đăng nhập', style: TextStyle( color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, - fontSize: 16, + fontSize: 15, ), ), ),