Skip to content

Commit 949d75a

Browse files
committed
fix conflict merge
2 parents 4e4e120 + 42a97b9 commit 949d75a

17 files changed

Lines changed: 905 additions & 173 deletions

documentation/architecture/database-schema.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
The database is built on **PostgreSQL** (hosted via Supabase) and is strictly secured using **Row Level Security (RLS)**.
44

5+
### Database ERD
6+
7+
<img width="1247" height="620" alt="image" src="https://github.com/user-attachments/assets/30b4850d-744e-4c80-bcc8-d225be763d16" />
58

69

710
### Core Database Dictionary
@@ -16,4 +19,4 @@ The database is built on **PostgreSQL** (hosted via Supabase) and is strictly se
1619
| **`reminder`** | Scheduling entity for push notifications. | `1:N` with `task`. |
1720

1821
### Security Architecture
19-
* **Row Level Security (RLS):** Every core table contains a `profile_id`. RLS policies are enforced at the database level so users can only `SELECT`, `INSERT`, `UPDATE`, or `DELETE` their own data.
22+
* **Row Level Security (RLS):** Every core table contains a `profile_id`. RLS policies are enforced at the database level so users can only `SELECT`, `INSERT`, `UPDATE`, or `DELETE` their own data.

src/lib/core/enum/TaskStatus.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
enum TaskStatus {
2+
toDo(0),
3+
completed(1),
4+
overDue(2);
5+
6+
final int value;
7+
const TaskStatus(this.value);
8+
9+
factory TaskStatus.fromInt(int dbValue){
10+
return TaskStatus.values.firstWhere(
11+
(status) => status.value == dbValue,
12+
orElse: () => TaskStatus.toDo,
13+
);
14+
}
15+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
import '../../core/theme/app_colors.dart';
4+
import '../viewmodels/auth_viewmodels.dart';
5+
import 'new_password_view.dart';
6+
7+
class OtpVerificationView extends StatefulWidget {
8+
const OtpVerificationView({super.key});
9+
10+
@override
11+
State<OtpVerificationView> createState() => _OtpVerificationViewState();
12+
}
13+
14+
class _OtpVerificationViewState extends State<OtpVerificationView> {
15+
// Bật chế độ 'chờ' cho ViewModel xử lý logic 8 số
16+
final _vm = OtpViewModel();
17+
18+
@override
19+
Widget build(BuildContext context) {
20+
return Scaffold(
21+
backgroundColor: AppColors.background,
22+
appBar: AppBar(
23+
backgroundColor: Colors.transparent,
24+
elevation: 0,
25+
leading: IconButton(
26+
icon: const Icon(Icons.arrow_back, color: AppColors.primary),
27+
onPressed: () => Navigator.pop(context),
28+
),
29+
title: const Text(
30+
'Xác thực OTP',
31+
style: TextStyle(
32+
color: AppColors.primary,
33+
fontWeight: FontWeight.bold,
34+
fontSize: 18,
35+
),
36+
),
37+
centerTitle: true,
38+
),
39+
body: AnimatedBuilder(
40+
animation: _vm,
41+
builder: (context, child) {
42+
return SafeArea(
43+
child: SingleChildScrollView( // Bọc lại đề phòng keyboard hiện lên làm tràn màn hình
44+
child: Padding(
45+
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 40),
46+
child: Column(
47+
mainAxisAlignment: MainAxisAlignment.center,
48+
children: [
49+
const Icon(
50+
Icons.mark_email_read,
51+
size: 80,
52+
color: AppColors.primary,
53+
),
54+
const SizedBox(height: 32),
55+
const Text(
56+
'Nhập mã 8 số', // Hiển thị đúng 8 số
57+
style: TextStyle(
58+
fontSize: 24,
59+
fontWeight: FontWeight.bold,
60+
color: AppColors.textDark,
61+
),
62+
),
63+
const SizedBox(height: 8),
64+
const Text(
65+
'Mã đã được gửi đến email của bạn.',
66+
style: TextStyle(color: AppColors.textSecondary),
67+
),
68+
const SizedBox(height: 40),
69+
70+
// --- KHU VỰC 8 Ô OTP (ĐÃ SỬA LỖI) ---
71+
// Dùng LayoutBuilder để tự tính toán kích thước ô cho vừa mọi màn hình
72+
LayoutBuilder(
73+
builder: (context, constraints) {
74+
// 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 ô
75+
double availableWidth = constraints.maxWidth;
76+
double spaceBetweenBoxes = 6.0; // Khoảng cách giữa các ô
77+
double totalSpace = spaceBetweenBoxes * 7; // Có 7 khoảng trống giữa 8 ô
78+
double boxWidth = (availableWidth - totalSpace) / 8; // Độ rộng tối đa mỗi ô
79+
80+
// Khống chế độ rộng ô không quá to để nhìn cho art (max 35-40)
81+
double finalBoxWidth = boxWidth > 38 ? 38 : boxWidth;
82+
83+
return Row(
84+
mainAxisAlignment: MainAxisAlignment.center, // Căn giữa hàng 8 ô
85+
children: List.generate(
86+
8, // SỬA: Đã tạo đúng 8 ô ở đây
87+
(index) => Padding(
88+
padding: EdgeInsets.symmetric(horizontal: spaceBetweenBoxes / 2),
89+
child: _buildOtpBox(index, context, finalBoxWidth),
90+
),
91+
),
92+
);
93+
}
94+
),
95+
const SizedBox(height: 40),
96+
97+
// Nút xác nhận xịn sò (Nhận lỗi cụ thể từ Server)
98+
ElevatedButton(
99+
onPressed: _vm.isLoading
100+
? null
101+
: () async {
102+
FocusScope.of(context).unfocus();
103+
// Gọi hàm verify(), nó trả về String? errorMessage
104+
final errorMessage = await _vm.verify();
105+
if (!context.mounted) return;
106+
107+
if (errorMessage == null) {
108+
// Thành công: Nhảy sang bước 3 (Đổi mật khẩu mới)
109+
Navigator.pushReplacement(
110+
context,
111+
MaterialPageRoute(
112+
builder: (_) => const NewPasswordView(),
113+
),
114+
);
115+
} else {
116+
// Thất bại: Hiện thông báo lỗi cụ thể (ví dụ: "Mã OTP hết hạn")
117+
ScaffoldMessenger.of(context).showSnackBar(
118+
SnackBar(
119+
content: Text(errorMessage),
120+
backgroundColor: AppColors.error,
121+
),
122+
);
123+
}
124+
},
125+
style: ElevatedButton.styleFrom(
126+
backgroundColor: AppColors.primary,
127+
minimumSize: const Size(double.infinity, 56),
128+
shape: RoundedRectangleBorder(
129+
borderRadius: BorderRadius.circular(16),
130+
),
131+
),
132+
child: _vm.isLoading
133+
? const CircularProgressIndicator(
134+
color: AppColors.white,
135+
)
136+
: const Text(
137+
'XÁC NHẬN',
138+
style: TextStyle(
139+
fontSize: 16,
140+
fontWeight: FontWeight.bold,
141+
color: AppColors.white,
142+
),
143+
),
144+
),
145+
const SizedBox(height: 16),
146+
147+
// --- NÚT GỬI LẠI MÃ (CHỈ CÓ Ở BẢN XỊN) ---
148+
TextButton.icon(
149+
onPressed: _vm.isLoading
150+
? null
151+
: () async {
152+
final errorMessage = await _vm.resend();
153+
if (!context.mounted) return;
154+
155+
if (errorMessage == null) {
156+
ScaffoldMessenger.of(context).showSnackBar(
157+
const SnackBar(
158+
content: Text('Đã gửi lại mã OTP!'),
159+
backgroundColor: AppColors.success,
160+
),
161+
);
162+
} else {
163+
ScaffoldMessenger.of(context).showSnackBar(
164+
SnackBar(
165+
content: Text(errorMessage),
166+
backgroundColor: AppColors.error,
167+
),
168+
);
169+
}
170+
},
171+
icon: const Icon(Icons.refresh, size: 18, color: AppColors.primary),
172+
label: const Text(
173+
'Gửi lại mã',
174+
style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold),
175+
),
176+
)
177+
],
178+
),
179+
),
180+
),
181+
);
182+
},
183+
),
184+
);
185+
}
186+
187+
// SỬA: Nhận thêm 'boxWidth' để tự động co dãn cho vừa 8 ô
188+
Widget _buildOtpBox(int index, BuildContext context, double boxWidth) {
189+
return Container(
190+
width: boxWidth, // Sử dụng độ rộng đã tính toán
191+
height: 48,
192+
decoration: BoxDecoration(
193+
color: AppColors.white,
194+
borderRadius: BorderRadius.circular(8),
195+
border: Border.all(color: AppColors.border),
196+
),
197+
child: TextField(
198+
onChanged: (value) {
199+
_vm.updateDigit(index, value);
200+
// SỬA: Logic nhảy focus chuẩn cho 8 ô (index chạy từ 0 đến 7)
201+
if (value.isNotEmpty && index < 7) {
202+
FocusScope.of(context).nextFocus();
203+
}
204+
if (value.isEmpty && index > 0) {
205+
FocusScope.of(context).previousFocus();
206+
}
207+
},
208+
style: const TextStyle(
209+
fontSize: 18,
210+
fontWeight: FontWeight.bold,
211+
color: AppColors.textDark,
212+
),
213+
keyboardType: TextInputType.number,
214+
textAlign: TextAlign.center,
215+
inputFormatters: [
216+
LengthLimitingTextInputFormatter(1),
217+
FilteringTextInputFormatter.digitsOnly,
218+
],
219+
decoration: const InputDecoration(
220+
border: InputBorder.none,
221+
counterText: '',
222+
),
223+
),
224+
);
225+
}
226+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import 'package:flutter/material.dart';
2+
3+
/// Base class to handle shared loading state
4+
class BaseViewModel extends ChangeNotifier {
5+
bool _isLoading = false;
6+
bool get isLoading => _isLoading;
7+
8+
void setLoading(bool value) {
9+
_isLoading = value;
10+
notifyListeners();
11+
}
12+
}
13+
14+
class LoginViewModel extends BaseViewModel {
15+
final emailCtrl = TextEditingController();
16+
final passCtrl = TextEditingController();
17+
bool obscurePass = true;
18+
19+
void togglePass() { obscurePass = !obscurePass; notifyListeners(); }
20+
21+
Future<bool> login() async {
22+
if (emailCtrl.text.isEmpty || passCtrl.text.isEmpty) return false;
23+
setLoading(true);
24+
// Mock API call
25+
await Future.delayed(const Duration(seconds: 1, milliseconds: 500));
26+
setLoading(false);
27+
return true; // Assume success
28+
}
29+
}
30+
31+
class RegisterViewModel extends BaseViewModel {
32+
final nameCtrl = TextEditingController();
33+
final emailCtrl = TextEditingController();
34+
final passCtrl = TextEditingController();
35+
final confirmPassCtrl = TextEditingController();
36+
bool obscurePass = true;
37+
38+
void togglePass() { obscurePass = !obscurePass; notifyListeners(); }
39+
40+
Future<bool> register() async {
41+
if (passCtrl.text != confirmPassCtrl.text) return false;
42+
setLoading(true);
43+
await Future.delayed(const Duration(seconds: 1, milliseconds: 500));
44+
setLoading(false);
45+
return true;
46+
}
47+
}
48+
49+
class ForgotPassViewModel extends BaseViewModel {
50+
final emailCtrl = TextEditingController();
51+
52+
Future<bool> sendCode() async {
53+
if (emailCtrl.text.isEmpty) return false;
54+
setLoading(true);
55+
await Future.delayed(const Duration(seconds: 1, milliseconds: 500));
56+
setLoading(false);
57+
return true;
58+
}
59+
}
60+
61+
class OtpViewModel extends BaseViewModel {
62+
List<String> digits = List.filled(6, "");
63+
64+
void updateDigit(int idx, String val) { digits[idx] = val; notifyListeners(); }
65+
66+
Future<bool> verify() async {
67+
if (digits.join().length < 6) return false;
68+
setLoading(true);
69+
await Future.delayed(const Duration(seconds: 1, milliseconds: 500));
70+
setLoading(false);
71+
return true;
72+
}
73+
}
74+
75+
class NewPassViewModel extends BaseViewModel {
76+
final passCtrl = TextEditingController();
77+
final confirmPassCtrl = TextEditingController();
78+
bool obscurePass = true;
79+
80+
void togglePass() { obscurePass = !obscurePass; notifyListeners(); }
81+
82+
Future<bool> updatePassword() async {
83+
if (passCtrl.text != confirmPassCtrl.text) return false;
84+
setLoading(true);
85+
await Future.delayed(const Duration(seconds: 1, milliseconds: 500));
86+
setLoading(false);
87+
return true;
88+
}
89+
}

0 commit comments

Comments
 (0)