fborj commited on
Commit
2a9d4e4
Β·
2 Parent(s): 047e937f7569d9

Merge branch 'main' of https://github.com/fborj/FactCheckThesis

Browse files
Files changed (39) hide show
  1. Icons/648286034_929277936354011_247561233351299912_n.jpg +3 -0
  2. Icons/648528435_1489135242834045_123474419086154405_n.jpg +3 -0
  3. api/email_utils.py +139 -0
  4. api/main.py +227 -0
  5. check_app/android/app/src/main/AndroidManifest.xml +1 -1
  6. check_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +2 -2
  7. check_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +2 -2
  8. check_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +2 -2
  9. check_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +2 -2
  10. check_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +2 -2
  11. check_app/assets/images/app_logo_dark.jpg +3 -0
  12. check_app/assets/images/app_logo_light.jpg +3 -0
  13. check_app/ios/Runner/Info.plist +2 -2
  14. check_app/lib/main.dart +22 -3
  15. check_app/lib/screens/history_screen.dart +1 -1
  16. check_app/lib/screens/home_screen.dart +97 -30
  17. check_app/lib/screens/link_check_screen.dart +2 -2
  18. check_app/lib/screens/login_screen.dart +146 -2
  19. check_app/lib/screens/proofread_screen.dart +2 -2
  20. check_app/lib/screens/register_screen.dart +435 -11
  21. check_app/lib/screens/settings_screen.dart +1003 -0
  22. check_app/lib/screens/verify_email_screen.dart +343 -0
  23. check_app/lib/services/auth_service.dart +112 -3
  24. check_app/lib/services/localization.dart +334 -0
  25. check_app/lib/widgets/animated_loader.dart +9 -1
  26. check_app/lib/widgets/captcha_slider.dart +302 -0
  27. check_app/pubspec.yaml +3 -4
  28. check_app/web/favicon.png +2 -2
  29. check_app/web/icons/Icon-192.png +2 -2
  30. check_app/web/icons/Icon-512.png +2 -2
  31. check_app/web/icons/Icon-maskable-192.png +2 -2
  32. check_app/web/icons/Icon-maskable-512.png +2 -2
  33. check_app/web/index.html +3 -3
  34. check_app/web/manifest.json +3 -3
  35. check_app/windows/runner/main.cpp +1 -1
  36. check_app/windows/runner/resources/app_icon.ico +2 -2
  37. checker/external/core.py +68 -8
  38. checker/external/web_search.py +83 -1
  39. db/database.py +150 -2
Icons/648286034_929277936354011_247561233351299912_n.jpg ADDED

Git LFS Details

  • SHA256: 4c2e8098e7cb2fdc84850b50ae340efbf3007088e27e10c179252c3fa0ef3ebe
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
Icons/648528435_1489135242834045_123474419086154405_n.jpg ADDED

Git LFS Details

  • SHA256: bf55d5dfb9d83aa6a6e3e27363818414368117136ebaa47ae035b7a0e97e1a32
  • Pointer size: 131 Bytes
  • Size of remote file: 191 kB
api/email_utils.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email utilities β€” send verification codes via SMTP.
3
+ """
4
+
5
+ import os
6
+ import random
7
+ import string
8
+ import smtplib
9
+ import logging
10
+ import threading
11
+ from email.mime.text import MIMEText
12
+ from email.mime.multipart import MIMEMultipart
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ── SMTP Configuration (via environment variables) ───────────────────
17
+ SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
18
+ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
19
+ SMTP_USER = os.getenv("SMTP_USER", "")
20
+ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
21
+ SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "BantayPahayag")
22
+ SMTP_TIMEOUT = int(os.getenv("SMTP_TIMEOUT", "10")) # seconds
23
+
24
+
25
+ def generate_verification_code(length: int = 6) -> str:
26
+ """Generate a random numeric verification code."""
27
+ return "".join(random.choices(string.digits, k=length))
28
+
29
+
30
+ def _send_email_sync(to_email: str, code: str, username: str = "") -> bool:
31
+ """
32
+ Internal: send a verification code email (blocking).
33
+ Returns True if sent successfully, False otherwise.
34
+ """
35
+ if not SMTP_USER or not SMTP_PASSWORD:
36
+ logger.warning(
37
+ "SMTP credentials not configured (SMTP_USER / SMTP_PASSWORD). "
38
+ "Cannot send verification email to %s. Code: %s",
39
+ to_email, code,
40
+ )
41
+ return False
42
+
43
+ subject = f"BantayPahayag \u2014 Your Verification Code: {code}"
44
+
45
+ html_body = f"""
46
+ <div style="font-family: 'Segoe UI', Arial, sans-serif; max-width: 480px; margin: 0 auto;
47
+ padding: 32px 24px; background: #ffffff; border-radius: 12px;
48
+ border: 1px solid #e2e8f0;">
49
+ <div style="text-align: center; margin-bottom: 24px;">
50
+ <div style="display: inline-block; background: linear-gradient(135deg, #1E3A8A, #3B82F6);
51
+ color: white; font-size: 20px; font-weight: 800; padding: 12px 24px;
52
+ border-radius: 10px; letter-spacing: 0.5px;">
53
+ BantayPahayag
54
+ </div>
55
+ </div>
56
+
57
+ <h2 style="color: #0F172A; font-size: 22px; margin: 0 0 8px 0; text-align: center;">
58
+ Verify Your Email
59
+ </h2>
60
+
61
+ <p style="color: #64748B; font-size: 14px; line-height: 1.6; text-align: center; margin: 0 0 24px 0;">
62
+ Hi{' ' + username if username else ''}! Use the code below to verify your email address
63
+ and activate your BantayPahayag account.
64
+ </p>
65
+
66
+ <div style="background: #F0F4F8; border: 2px dashed #3B82F6; border-radius: 10px;
67
+ padding: 20px; text-align: center; margin: 0 0 24px 0;">
68
+ <div style="font-size: 36px; font-weight: 800; letter-spacing: 8px; color: #1E3A8A;
69
+ font-family: 'Courier New', monospace;">
70
+ {code}
71
+ </div>
72
+ </div>
73
+
74
+ <p style="color: #94A3B8; font-size: 12px; text-align: center; margin: 0 0 8px 0;">
75
+ This code expires in <strong>10 minutes</strong>.
76
+ </p>
77
+ <p style="color: #94A3B8; font-size: 12px; text-align: center; margin: 0;">
78
+ If you didn't create a BantayPahayag account, you can safely ignore this email.
79
+ </p>
80
+
81
+ <hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0 16px 0;" />
82
+
83
+ <p style="color: #CBD5E1; font-size: 11px; text-align: center; margin: 0;">
84
+ BantayPahayag \u2014 News Verification System<br/>
85
+ Cavite State University Thesis Project
86
+ </p>
87
+ </div>
88
+ """
89
+
90
+ text_body = (
91
+ f"BantayPahayag \u2014 Email Verification\n\n"
92
+ f"Hi{' ' + username if username else ''}!\n\n"
93
+ f"Your verification code is: {code}\n\n"
94
+ f"This code expires in 10 minutes.\n\n"
95
+ f"If you didn't create a BantayPahayag account, ignore this email."
96
+ )
97
+
98
+ try:
99
+ msg = MIMEMultipart("alternative")
100
+ msg["Subject"] = subject
101
+ msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_USER}>"
102
+ msg["To"] = to_email
103
+
104
+ msg.attach(MIMEText(text_body, "plain"))
105
+ msg.attach(MIMEText(html_body, "html"))
106
+
107
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=SMTP_TIMEOUT) as server:
108
+ server.starttls()
109
+ server.login(SMTP_USER, SMTP_PASSWORD)
110
+ server.sendmail(SMTP_USER, to_email, msg.as_string())
111
+
112
+ logger.info("Verification email sent to %s", to_email)
113
+ return True
114
+
115
+ except Exception as exc:
116
+ logger.error("Failed to send verification email to %s: %s", to_email, exc)
117
+ return False
118
+
119
+
120
+ def send_verification_email(to_email: str, code: str, username: str = "") -> bool:
121
+ """
122
+ Send verification email in a background thread so the API response
123
+ is not blocked by slow SMTP connections.
124
+ Always returns True (fire-and-forget). Check server logs for errors.
125
+ """
126
+ if not SMTP_USER or not SMTP_PASSWORD:
127
+ logger.warning(
128
+ "SMTP not configured. Verification code for %s: %s", to_email, code
129
+ )
130
+ return False
131
+
132
+ thread = threading.Thread(
133
+ target=_send_email_sync,
134
+ args=(to_email, code, username),
135
+ daemon=True,
136
+ )
137
+ thread.start()
138
+ logger.info("Verification email queued for %s", to_email)
139
+ return True
api/main.py CHANGED
@@ -31,9 +31,18 @@ from db.database import (
31
  create_user,
32
  get_user_by_email,
33
  get_user_by_id,
 
 
 
 
 
 
 
34
  save_check_history,
35
  get_user_history,
36
  )
 
 
37
 
38
 
39
  # ── Lazy FactChecker singleton ───────────────────────────────────────
@@ -82,6 +91,31 @@ class LoginRequest(BaseModel):
82
  password: str
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  # ── App ──────────────────────────────────────────────────────────────
86
  app = FastAPI(
87
  title="FactCheckThesis API",
@@ -108,6 +142,23 @@ async def on_startup():
108
  except Exception as exc:
109
  logger.warning("Could not initialize DB tables: %s", exc)
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  port = os.environ.get("PORT", "10000")
112
  logger.info("πŸš€ FastAPI server is UP on port %s", port)
113
 
@@ -129,6 +180,7 @@ async def health() -> Dict[str, str]:
129
  async def register(payload: RegisterRequest) -> Dict[str, Any]:
130
  """
131
  Create a new user account.
 
132
  Returns the user info + an access token on success.
133
  """
134
  if len(payload.password) < 6:
@@ -137,12 +189,28 @@ async def register(payload: RegisterRequest) -> Dict[str, Any]:
137
  if not payload.email or not payload.username:
138
  raise HTTPException(status_code=400, detail="Email and username are required.")
139
 
 
 
 
 
 
140
  hashed = hash_password(payload.password)
141
  user = create_user(payload.email.lower().strip(), payload.username.strip(), hashed)
142
 
143
  if user is None:
144
  raise HTTPException(status_code=409, detail="An account with this email already exists.")
145
 
 
 
 
 
 
 
 
 
 
 
 
146
  token = create_access_token(user["id"], user["email"])
147
  return {
148
  "token": token,
@@ -150,7 +218,9 @@ async def register(payload: RegisterRequest) -> Dict[str, Any]:
150
  "id": user["id"],
151
  "email": user["email"],
152
  "username": user["username"],
 
153
  },
 
154
  }
155
 
156
 
@@ -159,11 +229,14 @@ async def login(payload: LoginRequest) -> Dict[str, Any]:
159
  """
160
  Authenticate a user with email + password.
161
  Returns user info + access token on success.
 
162
  """
163
  user = get_user_by_email(payload.email.lower().strip())
164
  if user is None or not verify_password(payload.password, user["password_hash"]):
165
  raise HTTPException(status_code=401, detail="Invalid email or password.")
166
 
 
 
167
  token = create_access_token(user["id"], user["email"])
168
  return {
169
  "token": token,
@@ -171,6 +244,7 @@ async def login(payload: LoginRequest) -> Dict[str, Any]:
171
  "id": user["id"],
172
  "email": user["email"],
173
  "username": user["username"],
 
174
  },
175
  }
176
 
@@ -186,6 +260,61 @@ async def me(current_user: dict = Depends(require_current_user)) -> Dict[str, An
186
  return {"user": user}
187
 
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  # ── History endpoint ─────────────────────────────────────────────────
190
  @app.get("/api/history")
191
  async def history(current_user: dict = Depends(require_current_user)) -> Dict[str, Any]:
@@ -198,6 +327,104 @@ async def history(current_user: dict = Depends(require_current_user)) -> Dict[st
198
  return {"history": rows}
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  # ── Fact-check endpoint ──────────────────────────────────────────────
202
  @app.post("/api/check")
203
  async def check_article(
 
31
  create_user,
32
  get_user_by_email,
33
  get_user_by_id,
34
+ get_user_by_username,
35
+ update_username,
36
+ update_user_password,
37
+ delete_user,
38
+ set_verification_code,
39
+ verify_email_code,
40
+ is_email_verified,
41
  save_check_history,
42
  get_user_history,
43
  )
44
+ from api.email_utils import generate_verification_code, send_verification_email
45
+ from datetime import datetime, timedelta
46
 
47
 
48
  # ── Lazy FactChecker singleton ───────────────────────────────────────
 
91
  password: str
92
 
93
 
94
+ class UpdateProfileRequest(BaseModel):
95
+ username: str
96
+
97
+
98
+ class ChangePasswordRequest(BaseModel):
99
+ current_password: str
100
+ new_password: str
101
+
102
+
103
+ class DeleteAccountRequest(BaseModel):
104
+ password: str
105
+
106
+
107
+ class CheckUsernameRequest(BaseModel):
108
+ username: str
109
+
110
+
111
+ class VerifyEmailRequest(BaseModel):
112
+ code: str
113
+
114
+
115
+ class ResendVerificationRequest(BaseModel):
116
+ pass
117
+
118
+
119
  # ── App ──────────────────────────────────────────────────────────────
120
  app = FastAPI(
121
  title="FactCheckThesis API",
 
142
  except Exception as exc:
143
  logger.warning("Could not initialize DB tables: %s", exc)
144
 
145
+ # One-time migration: mark pre-existing accounts as email verified
146
+ try:
147
+ from db.database import get_connection
148
+ conn = get_connection()
149
+ cursor = conn.cursor()
150
+ cursor.execute(
151
+ "UPDATE users SET email_verified = TRUE "
152
+ "WHERE verification_code IS NULL AND email_verified = FALSE"
153
+ )
154
+ affected = cursor.rowcount
155
+ conn.commit()
156
+ conn.close()
157
+ if affected > 0:
158
+ logger.info("Migration: marked %d pre-existing account(s) as email_verified", affected)
159
+ except Exception as exc:
160
+ logger.warning("Migration (email_verified) skipped: %s", exc)
161
+
162
  port = os.environ.get("PORT", "10000")
163
  logger.info("πŸš€ FastAPI server is UP on port %s", port)
164
 
 
180
  async def register(payload: RegisterRequest) -> Dict[str, Any]:
181
  """
182
  Create a new user account.
183
+ Generates a verification code and sends it to the user's email.
184
  Returns the user info + an access token on success.
185
  """
186
  if len(payload.password) < 6:
 
189
  if not payload.email or not payload.username:
190
  raise HTTPException(status_code=400, detail="Email and username are required.")
191
 
192
+ # Check username uniqueness first
193
+ existing_username = get_user_by_username(payload.username.strip())
194
+ if existing_username is not None:
195
+ raise HTTPException(status_code=409, detail="This username is already taken. Please choose another.")
196
+
197
  hashed = hash_password(payload.password)
198
  user = create_user(payload.email.lower().strip(), payload.username.strip(), hashed)
199
 
200
  if user is None:
201
  raise HTTPException(status_code=409, detail="An account with this email already exists.")
202
 
203
+ # Generate and send verification code
204
+ code = generate_verification_code()
205
+ expires_at = datetime.now() + timedelta(minutes=10)
206
+ set_verification_code(user["id"], code, expires_at)
207
+
208
+ email_sent = send_verification_email(
209
+ to_email=payload.email.lower().strip(),
210
+ code=code,
211
+ username=payload.username.strip(),
212
+ )
213
+
214
  token = create_access_token(user["id"], user["email"])
215
  return {
216
  "token": token,
 
218
  "id": user["id"],
219
  "email": user["email"],
220
  "username": user["username"],
221
+ "email_verified": False,
222
  },
223
+ "verification_email_sent": email_sent,
224
  }
225
 
226
 
 
229
  """
230
  Authenticate a user with email + password.
231
  Returns user info + access token on success.
232
+ Includes email_verified status so the app can redirect unverified users.
233
  """
234
  user = get_user_by_email(payload.email.lower().strip())
235
  if user is None or not verify_password(payload.password, user["password_hash"]):
236
  raise HTTPException(status_code=401, detail="Invalid email or password.")
237
 
238
+ verified = is_email_verified(user["id"])
239
+
240
  token = create_access_token(user["id"], user["email"])
241
  return {
242
  "token": token,
 
244
  "id": user["id"],
245
  "email": user["email"],
246
  "username": user["username"],
247
+ "email_verified": verified,
248
  },
249
  }
250
 
 
260
  return {"user": user}
261
 
262
 
263
+ # ── Email verification endpoints ─────────────────────────────────────
264
+ @app.post("/api/verify-email")
265
+ async def verify_email(
266
+ payload: VerifyEmailRequest,
267
+ current_user: dict = Depends(require_current_user),
268
+ ) -> Dict[str, Any]:
269
+ """Verify the user's email with a 6-digit code."""
270
+ user_id = int(current_user["sub"])
271
+ code = payload.code.strip()
272
+
273
+ if not code or len(code) != 6:
274
+ raise HTTPException(status_code=400, detail="Please enter a valid 6-digit code.")
275
+
276
+ result = verify_email_code(user_id, code)
277
+
278
+ if result == "ok":
279
+ return {"success": True, "message": "Email verified successfully!"}
280
+ elif result == "expired":
281
+ raise HTTPException(status_code=410, detail="Verification code has expired. Please request a new one.")
282
+ else:
283
+ raise HTTPException(status_code=400, detail="Invalid verification code. Please try again.")
284
+
285
+
286
+ @app.post("/api/resend-verification")
287
+ async def resend_verification(
288
+ current_user: dict = Depends(require_current_user),
289
+ ) -> Dict[str, Any]:
290
+ """Resend the verification code email."""
291
+ user_id = int(current_user["sub"])
292
+ user = get_user_by_id(user_id)
293
+ if user is None:
294
+ raise HTTPException(status_code=404, detail="User not found.")
295
+
296
+ if user.get("email_verified"):
297
+ return {"success": True, "message": "Email is already verified."}
298
+
299
+ code = generate_verification_code()
300
+ expires_at = datetime.now() + timedelta(minutes=10)
301
+ set_verification_code(user_id, code, expires_at)
302
+
303
+ email_sent = send_verification_email(
304
+ to_email=user["email"],
305
+ code=code,
306
+ username=user["username"],
307
+ )
308
+
309
+ if email_sent:
310
+ return {"success": True, "message": "Verification code sent to your email."}
311
+ else:
312
+ raise HTTPException(
313
+ status_code=500,
314
+ detail="Could not send verification email. Please check that your email address is correct and try again.",
315
+ )
316
+
317
+
318
  # ── History endpoint ─────────────────────────────────────────────────
319
  @app.get("/api/history")
320
  async def history(current_user: dict = Depends(require_current_user)) -> Dict[str, Any]:
 
327
  return {"history": rows}
328
 
329
 
330
+ # ── Check username availability ───────────────────────────────────────
331
+ @app.post("/api/check-username")
332
+ async def check_username(payload: CheckUsernameRequest) -> Dict[str, Any]:
333
+ """Check if a username is available."""
334
+ username = payload.username.strip()
335
+ if not username or len(username) < 3:
336
+ return {"available": False, "reason": "Username must be at least 3 characters."}
337
+ if len(username) > 30:
338
+ return {"available": False, "reason": "Username must be at most 30 characters."}
339
+
340
+ existing = get_user_by_username(username)
341
+ if existing is not None:
342
+ return {"available": False, "reason": "This username is already taken."}
343
+ return {"available": True}
344
+
345
+
346
+ # ── Update profile (username) ─────────────────────────────────────────
347
+ @app.put("/api/update-profile")
348
+ async def update_profile(
349
+ payload: UpdateProfileRequest,
350
+ current_user: dict = Depends(require_current_user),
351
+ ) -> Dict[str, Any]:
352
+ """Update the current user's username."""
353
+ username = payload.username.strip()
354
+ if not username or len(username) < 3:
355
+ raise HTTPException(status_code=400, detail="Username must be at least 3 characters.")
356
+ if len(username) > 30:
357
+ raise HTTPException(status_code=400, detail="Username must be at most 30 characters.")
358
+
359
+ user_id = int(current_user["sub"])
360
+ updated = update_username(user_id, username)
361
+ if updated is None:
362
+ raise HTTPException(status_code=409, detail="This username is already taken.")
363
+
364
+ return {
365
+ "success": True,
366
+ "user": {
367
+ "id": updated["id"],
368
+ "email": updated["email"],
369
+ "username": updated["username"],
370
+ },
371
+ }
372
+
373
+
374
+ # ── Change password ──────────────────────────────────────────────────
375
+ @app.put("/api/change-password")
376
+ async def change_password(
377
+ payload: ChangePasswordRequest,
378
+ current_user: dict = Depends(require_current_user),
379
+ ) -> Dict[str, Any]:
380
+ """Change the current user's password."""
381
+ user_id = int(current_user["sub"])
382
+ user = get_user_by_id(user_id)
383
+ if user is None:
384
+ raise HTTPException(status_code=404, detail="User not found.")
385
+
386
+ # Verify current password
387
+ full_user = get_user_by_email(user["email"])
388
+ if full_user is None or not verify_password(payload.current_password, full_user["password_hash"]):
389
+ raise HTTPException(status_code=401, detail="Current password is incorrect.")
390
+
391
+ # Validate new password strength
392
+ new_pw = payload.new_password
393
+ if len(new_pw) < 8:
394
+ raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
395
+
396
+ new_hash = hash_password(new_pw)
397
+ success = update_user_password(user_id, new_hash)
398
+ if not success:
399
+ raise HTTPException(status_code=500, detail="Failed to update password.")
400
+
401
+ return {"success": True, "message": "Password updated successfully."}
402
+
403
+
404
+ # ── Delete account ───────────────────────────────────────────────────
405
+ @app.delete("/api/delete-account")
406
+ async def delete_account(
407
+ payload: DeleteAccountRequest,
408
+ current_user: dict = Depends(require_current_user),
409
+ ) -> Dict[str, Any]:
410
+ """Permanently delete the current user's account and all data."""
411
+ user_id = int(current_user["sub"])
412
+ user = get_user_by_id(user_id)
413
+ if user is None:
414
+ raise HTTPException(status_code=404, detail="User not found.")
415
+
416
+ # Verify password before deletion
417
+ full_user = get_user_by_email(user["email"])
418
+ if full_user is None or not verify_password(payload.password, full_user["password_hash"]):
419
+ raise HTTPException(status_code=401, detail="Incorrect password. Account was not deleted.")
420
+
421
+ success = delete_user(user_id)
422
+ if not success:
423
+ raise HTTPException(status_code=500, detail="Failed to delete account.")
424
+
425
+ return {"success": True, "message": "Account deleted successfully."}
426
+
427
+
428
  # ── Fact-check endpoint ──────────────────────────────────────────────
429
  @app.post("/api/check")
430
  async def check_article(
check_app/android/app/src/main/AndroidManifest.xml CHANGED
@@ -1,6 +1,6 @@
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
  <application
3
- android:label="check_app"
4
  android:name="${applicationName}"
5
  android:icon="@mipmap/ic_launcher">
6
  <activity
 
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
  <application
3
+ android:label="BantayPahayag"
4
  android:name="${applicationName}"
5
  android:icon="@mipmap/ic_launcher">
6
  <activity
check_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png CHANGED

Git LFS Details

  • SHA256: 6a7c8f0d703e3682108f9662f813302236240d3f8f638bb391e32bfb96055fef
  • Pointer size: 128 Bytes
  • Size of remote file: 544 Bytes

Git LFS Details

  • SHA256: a7051b317caa190966c81cfa2336b3ae9d67955a572baed11b039fa6d8e45937
  • Pointer size: 129 Bytes
  • Size of remote file: 6.92 kB
check_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png CHANGED

Git LFS Details

  • SHA256: c7c0c0189145e4e32a401c61c9bdc615754b0264e7afae24e834bb81049eaf81
  • Pointer size: 128 Bytes
  • Size of remote file: 442 Bytes

Git LFS Details

  • SHA256: 4abde8b06c42e65a16b3d7616ecdc6f5642259b0dbd5883effd6d4245bd3dd96
  • Pointer size: 129 Bytes
  • Size of remote file: 3.25 kB
check_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png CHANGED

Git LFS Details

  • SHA256: e14aa40904929bf313fded22cf7e7ffcbf1d1aac4263b5ef1be8bfce650397aa
  • Pointer size: 128 Bytes
  • Size of remote file: 721 Bytes

Git LFS Details

  • SHA256: e642bbec7789aba7a4c83a29b47a8384e83ff2f23cb6073df173105f4d266e19
  • Pointer size: 130 Bytes
  • Size of remote file: 11.4 kB
check_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png CHANGED

Git LFS Details

  • SHA256: 4d470bf22d5c17d84edc5f82516d1ba8a1c09559cd761cefb792f86d9f52b540
  • Pointer size: 129 Bytes
  • Size of remote file: 1.03 kB

Git LFS Details

  • SHA256: 1e222f2c379f0bbdf7f11d491bfd3097f868aef521b4b61d92ed64409dcf484b
  • Pointer size: 130 Bytes
  • Size of remote file: 21.7 kB
check_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png CHANGED

Git LFS Details

  • SHA256: 3c34e1f298d0c9ea3455d46db6b7759c8211a49e9ec6e44b635fc5c87dfb4180
  • Pointer size: 129 Bytes
  • Size of remote file: 1.44 kB

Git LFS Details

  • SHA256: 7a86b08ba523eaba971ebaeef5105fa8341c6c5b7f450683302b7224554a3bab
  • Pointer size: 130 Bytes
  • Size of remote file: 33.6 kB
check_app/assets/images/app_logo_dark.jpg ADDED

Git LFS Details

  • SHA256: bf55d5dfb9d83aa6a6e3e27363818414368117136ebaa47ae035b7a0e97e1a32
  • Pointer size: 131 Bytes
  • Size of remote file: 191 kB
check_app/assets/images/app_logo_light.jpg ADDED

Git LFS Details

  • SHA256: 4c2e8098e7cb2fdc84850b50ae340efbf3007088e27e10c179252c3fa0ef3ebe
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
check_app/ios/Runner/Info.plist CHANGED
@@ -7,7 +7,7 @@
7
  <key>CFBundleDevelopmentRegion</key>
8
  <string>$(DEVELOPMENT_LANGUAGE)</string>
9
  <key>CFBundleDisplayName</key>
10
- <string>Check App</string>
11
  <key>CFBundleExecutable</key>
12
  <string>$(EXECUTABLE_NAME)</string>
13
  <key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
15
  <key>CFBundleInfoDictionaryVersion</key>
16
  <string>6.0</string>
17
  <key>CFBundleName</key>
18
- <string>check_app</string>
19
  <key>CFBundlePackageType</key>
20
  <string>APPL</string>
21
  <key>CFBundleShortVersionString</key>
 
7
  <key>CFBundleDevelopmentRegion</key>
8
  <string>$(DEVELOPMENT_LANGUAGE)</string>
9
  <key>CFBundleDisplayName</key>
10
+ <string>BantayPahayag</string>
11
  <key>CFBundleExecutable</key>
12
  <string>$(EXECUTABLE_NAME)</string>
13
  <key>CFBundleIdentifier</key>
 
15
  <key>CFBundleInfoDictionaryVersion</key>
16
  <string>6.0</string>
17
  <key>CFBundleName</key>
18
+ <string>BantayPahayag</string>
19
  <key>CFBundlePackageType</key>
20
  <string>APPL</string>
21
  <key>CFBundleShortVersionString</key>
check_app/lib/main.dart CHANGED
@@ -1,11 +1,13 @@
1
  import 'package:flutter/material.dart';
2
  import 'package:shared_preferences/shared_preferences.dart';
 
3
  import 'services/auth_service.dart';
4
  import 'screens/login_screen.dart';
5
  import 'screens/home_screen.dart';
6
  import 'screens/link_check_screen.dart';
7
  import 'screens/proofread_screen.dart';
8
  import 'screens/history_screen.dart';
 
9
 
10
  // ── API Config ──────────────────────────────────────────────────────
11
  const String apiBaseUrl = 'https://abpthesisgroup-thesisproject.hf.space';
@@ -27,6 +29,7 @@ Future<void> saveThemePreference(bool isDark) async {
27
  void main() async {
28
  WidgetsFlutterBinding.ensureInitialized();
29
  await loadThemePreference();
 
30
  runApp(const BantayPahayagApp());
31
  }
32
 
@@ -111,6 +114,9 @@ class BantayPahayagApp extends StatelessWidget {
111
  return ValueListenableBuilder<ThemeMode>(
112
  valueListenable: themeNotifier,
113
  builder: (context, mode, _) {
 
 
 
114
  return MaterialApp(
115
  title: 'BantayPahayag',
116
  debugShowCheckedModeBanner: false,
@@ -161,6 +167,7 @@ class BantayPahayagApp extends StatelessWidget {
161
  '/check-link': const LinkCheckScreen(),
162
  '/proofread': const ProofreadScreen(),
163
  '/history': const HistoryScreen(),
 
164
  };
165
 
166
  final page = routes[settings.name];
@@ -168,13 +175,16 @@ class BantayPahayagApp extends StatelessWidget {
168
 
169
  if (settings.name == '/check-link' ||
170
  settings.name == '/proofread' ||
171
- settings.name == '/history') {
 
172
  return SmoothPageRoute(page: page);
173
  }
174
 
175
  return MaterialPageRoute(builder: (_) => page, settings: settings);
176
  },
177
  );
 
 
178
  },
179
  );
180
  }
@@ -251,12 +261,21 @@ class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMix
251
  scale: _scaleAnim,
252
  child: Container(
253
  width: 90, height: 90,
 
254
  decoration: BoxDecoration(
255
  color: Colors.white.withValues(alpha: 0.15),
256
  borderRadius: BorderRadius.circular(24),
257
  border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1.5),
258
- ),
259
- child: const Icon(Icons.shield_outlined, color: Colors.white, size: 48),
 
 
 
 
 
 
 
 
260
  ),
261
  ),
262
  const SizedBox(height: 20),
 
1
  import 'package:flutter/material.dart';
2
  import 'package:shared_preferences/shared_preferences.dart';
3
+ import 'services/localization.dart';
4
  import 'services/auth_service.dart';
5
  import 'screens/login_screen.dart';
6
  import 'screens/home_screen.dart';
7
  import 'screens/link_check_screen.dart';
8
  import 'screens/proofread_screen.dart';
9
  import 'screens/history_screen.dart';
10
+ import 'screens/settings_screen.dart';
11
 
12
  // ── API Config ──────────────────────────────────────────────────────
13
  const String apiBaseUrl = 'https://abpthesisgroup-thesisproject.hf.space';
 
29
  void main() async {
30
  WidgetsFlutterBinding.ensureInitialized();
31
  await loadThemePreference();
32
+ await loadLanguagePreference();
33
  runApp(const BantayPahayagApp());
34
  }
35
 
 
114
  return ValueListenableBuilder<ThemeMode>(
115
  valueListenable: themeNotifier,
116
  builder: (context, mode, _) {
117
+ return ValueListenableBuilder<AppLanguage>(
118
+ valueListenable: languageNotifier,
119
+ builder: (context, lang, _) {
120
  return MaterialApp(
121
  title: 'BantayPahayag',
122
  debugShowCheckedModeBanner: false,
 
167
  '/check-link': const LinkCheckScreen(),
168
  '/proofread': const ProofreadScreen(),
169
  '/history': const HistoryScreen(),
170
+ '/settings': const SettingsScreen(),
171
  };
172
 
173
  final page = routes[settings.name];
 
175
 
176
  if (settings.name == '/check-link' ||
177
  settings.name == '/proofread' ||
178
+ settings.name == '/history' ||
179
+ settings.name == '/settings') {
180
  return SmoothPageRoute(page: page);
181
  }
182
 
183
  return MaterialPageRoute(builder: (_) => page, settings: settings);
184
  },
185
  );
186
+ },
187
+ );
188
  },
189
  );
190
  }
 
261
  scale: _scaleAnim,
262
  child: Container(
263
  width: 90, height: 90,
264
+ clipBehavior: Clip.antiAlias,
265
  decoration: BoxDecoration(
266
  color: Colors.white.withValues(alpha: 0.15),
267
  borderRadius: BorderRadius.circular(24),
268
  border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1.5),
269
+ ),
270
+ child: ClipRRect(
271
+ borderRadius: BorderRadius.circular(18),
272
+ child: Image.asset(
273
+ 'assets/images/app_logo_dark.jpg',
274
+ width: 90,
275
+ height: 90,
276
+ fit: BoxFit.cover,
277
+ ),
278
+ ),
279
  ),
280
  ),
281
  const SizedBox(height: 20),
check_app/lib/screens/history_screen.dart CHANGED
@@ -40,7 +40,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
40
  switch (verdict) {
41
  case 'VERIFIED': return AppColors.success;
42
  case 'LIKELY FAKE': return AppColors.danger;
43
- case 'COVERAGE FOUND ONLINE': return AppColors.warning;
44
  case 'WEAK MATCH': return const Color(0xFF9333EA);
45
  default: return AppColors.textSecondary;
46
  }
 
40
  switch (verdict) {
41
  case 'VERIFIED': return AppColors.success;
42
  case 'LIKELY FAKE': return AppColors.danger;
43
+ case 'COVERAGE FOUND ONLINE': return AppColors.primaryLight;
44
  case 'WEAK MATCH': return const Color(0xFF9333EA);
45
  default: return AppColors.textSecondary;
46
  }
check_app/lib/screens/home_screen.dart CHANGED
@@ -1,6 +1,7 @@
1
  import 'package:flutter/material.dart';
2
  import '../main.dart';
3
  import '../services/auth_service.dart';
 
4
 
5
  class HomeScreen extends StatefulWidget {
6
  const HomeScreen({super.key});
@@ -23,6 +24,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
23
  void initState() {
24
  super.initState();
25
  themeNotifier.addListener(_onThemeChange);
 
26
 
27
  _staggerCtrl = AnimationController(
28
  duration: const Duration(milliseconds: 1200),
@@ -51,6 +53,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
51
  @override
52
  void dispose() {
53
  themeNotifier.removeListener(_onThemeChange);
 
54
  _staggerCtrl.dispose();
55
  super.dispose();
56
  }
@@ -71,13 +74,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
71
 
72
  return Scaffold(
73
  appBar: AppBar(
74
- title: const Row(
75
- children: [
76
- Icon(Icons.shield_outlined, size: 22),
77
- SizedBox(width: 10),
78
- Text('BantayPahayag', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18, letterSpacing: 0.3)),
79
- ],
80
- ),
81
  actions: [
82
  IconButton(
83
  icon: Icon(
@@ -94,7 +91,35 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
94
  saveThemePreference(newMode == ThemeMode.dark);
95
  },
96
  ),
97
- const SizedBox(width: 4),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  ],
99
  flexibleSpace: Container(
100
  decoration: BoxDecoration(
@@ -113,7 +138,28 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
113
  ),
114
  ),
115
  drawer: _buildDrawer(username),
116
- body: SingleChildScrollView(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
118
  child: Column(
119
  crossAxisAlignment: CrossAxisAlignment.start,
@@ -152,13 +198,13 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
152
  const SizedBox(width: 14),
153
  Expanded(
154
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
155
- Text('Welcome back,', style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13)),
156
  Text(username, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w700)),
157
  ]),
158
  ),
159
  ]),
160
  const SizedBox(height: 14),
161
- Text('Choose a tool below to start verifying information.',
162
  style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13, height: 1.4)),
163
  ],
164
  ),
@@ -172,7 +218,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
172
  position: _card1Slide,
173
  child: FadeTransition(
174
  opacity: _card1Fade,
175
- child: Text('Verification Tools', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
176
  ),
177
  ),
178
  const SizedBox(height: 16),
@@ -185,10 +231,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
185
  child: _HoverFeatureCard(
186
  icon: Icons.link,
187
  iconGradient: const [Color(0xFF1E3A8A), Color(0xFF3B82F6)],
188
- title: 'Check Link',
189
- subtitle: 'Verify a news article by URL',
190
- description: 'Paste a link to any news article and our system will extract the content, '
191
- 'analyze it with ML models, and cross-reference it with trusted sources.',
192
  onTap: () => Navigator.pushNamed(context, '/check-link'),
193
  ),
194
  ),
@@ -203,10 +248,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
203
  child: _HoverFeatureCard(
204
  icon: Icons.edit_note,
205
  iconGradient: const [Color(0xFF2563EB), Color(0xFF60A5FA)],
206
- title: 'Proofread Article',
207
- subtitle: 'Fact-check text you are writing',
208
- description: 'Paste or type article text directly. The system will analyze the content '
209
- 'for misinformation, bias, and verify claims against known sources.',
210
  onTap: () => Navigator.pushNamed(context, '/proofread'),
211
  ),
212
  ),
@@ -231,16 +275,16 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
231
  Row(children: [
232
  Icon(Icons.auto_awesome, color: AppColors.primaryLight, size: 18),
233
  const SizedBox(width: 8),
234
- Text('How It Works', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
235
  ]),
236
  const SizedBox(height: 16),
237
- _howItWorksStep('1', 'Choose a verification method above'),
238
  const SizedBox(height: 12),
239
- _howItWorksStep('2', 'Provide a URL or paste your article text'),
240
  const SizedBox(height: 12),
241
- _howItWorksStep('3', 'ML model + source cross-referencing analyzes the content'),
242
  const SizedBox(height: 12),
243
- _howItWorksStep('4', 'Get a detailed verdict with explanations'),
244
  ]),
245
  ),
246
  ),
@@ -249,6 +293,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
249
  ],
250
  ),
251
  ),
 
 
252
  );
253
  }
254
 
@@ -293,15 +339,36 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
293
  Text(AuthService.user?['email'] ?? '', style: TextStyle(color: Colors.white.withValues(alpha: 0.75), fontSize: 13)),
294
  ]),
295
  ),
296
- ListTile(leading: const Icon(Icons.home_outlined, color: AppColors.primaryLight), title: Text('Home', style: TextStyle(color: AppColors.textPrimary)), onTap: () => Navigator.pop(context)),
297
- ListTile(leading: const Icon(Icons.link, color: AppColors.primaryLight), title: Text('Check Link', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); Navigator.pushNamed(context, '/check-link'); }),
298
- ListTile(leading: const Icon(Icons.edit_note, color: AppColors.primaryLight), title: Text('Proofread Article', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); Navigator.pushNamed(context, '/proofread'); }),
299
  ListTile(leading: const Icon(Icons.history, color: AppColors.primaryLight), title: Text('History', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); Navigator.pushNamed(context, '/history'); }),
300
- Divider(color: AppColors.divider),
301
- ListTile(leading: const Icon(Icons.logout, color: AppColors.danger), title: Text('Sign Out', style: TextStyle(color: AppColors.danger)), onTap: _logout),
 
 
302
  ]),
303
  );
304
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  }
306
 
307
  // ── Hover Feature Card with highlight + scale + glow ─────────────────
 
1
  import 'package:flutter/material.dart';
2
  import '../main.dart';
3
  import '../services/auth_service.dart';
4
+ import '../services/localization.dart';
5
 
6
  class HomeScreen extends StatefulWidget {
7
  const HomeScreen({super.key});
 
24
  void initState() {
25
  super.initState();
26
  themeNotifier.addListener(_onThemeChange);
27
+ languageNotifier.addListener(_onThemeChange);
28
 
29
  _staggerCtrl = AnimationController(
30
  duration: const Duration(milliseconds: 1200),
 
53
  @override
54
  void dispose() {
55
  themeNotifier.removeListener(_onThemeChange);
56
+ languageNotifier.removeListener(_onThemeChange);
57
  _staggerCtrl.dispose();
58
  super.dispose();
59
  }
 
74
 
75
  return Scaffold(
76
  appBar: AppBar(
77
+ title: const Text('BantayPahayag', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18, letterSpacing: 0.3)),
 
 
 
 
 
 
78
  actions: [
79
  IconButton(
80
  icon: Icon(
 
91
  saveThemePreference(newMode == ThemeMode.dark);
92
  },
93
  ),
94
+ PopupMenuButton<AppLanguage>(
95
+ icon: Icon(Icons.translate, color: Colors.white.withValues(alpha: 0.9), size: 22),
96
+ tooltip: tr('language'),
97
+ onSelected: (lang) {
98
+ languageNotifier.value = lang;
99
+ saveLanguagePreference(lang);
100
+ },
101
+ itemBuilder: (_) => AppLanguage.values.map((lang) {
102
+ final isSelected = languageNotifier.value == lang;
103
+ return PopupMenuItem(
104
+ value: lang,
105
+ child: Row(children: [
106
+ Text(languageFlag(lang), style: const TextStyle(fontSize: 18)),
107
+ const SizedBox(width: 10),
108
+ Text(
109
+ languageDisplayName(lang),
110
+ style: TextStyle(
111
+ fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400,
112
+ color: isSelected ? AppColors.primaryLight : null,
113
+ ),
114
+ ),
115
+ if (isSelected) ...[
116
+ const Spacer(),
117
+ const Icon(Icons.check, size: 18, color: AppColors.primaryLight),
118
+ ],
119
+ ]),
120
+ );
121
+ }).toList(),
122
+ ),
123
  ],
124
  flexibleSpace: Container(
125
  decoration: BoxDecoration(
 
138
  ),
139
  ),
140
  drawer: _buildDrawer(username),
141
+ body: Stack(
142
+ children: [
143
+ // ── Background watermark logo ────────────────────
144
+ Positioned.fill(
145
+ child: Center(
146
+ child: Opacity(
147
+ opacity: AppColors.isDark ? 0.04 : 0.06,
148
+ child: Image.asset(
149
+ AppColors.isDark
150
+ ? 'assets/images/app_logo_dark.jpg'
151
+ : 'assets/images/app_logo_light.jpg',
152
+ width: 320,
153
+ height: 320,
154
+ fit: BoxFit.contain,
155
+ color: AppColors.isDark ? const Color(0xFF0A1628) : null,
156
+ colorBlendMode: AppColors.isDark ? BlendMode.darken : null,
157
+ ),
158
+ ),
159
+ ),
160
+ ),
161
+ // ── Scrollable content ──────────────────────────
162
+ SingleChildScrollView(
163
  padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
164
  child: Column(
165
  crossAxisAlignment: CrossAxisAlignment.start,
 
198
  const SizedBox(width: 14),
199
  Expanded(
200
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
201
+ Text(tr('welcome_back'), style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13)),
202
  Text(username, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w700)),
203
  ]),
204
  ),
205
  ]),
206
  const SizedBox(height: 14),
207
+ Text(tr('verify_news_today'),
208
  style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 13, height: 1.4)),
209
  ],
210
  ),
 
218
  position: _card1Slide,
219
  child: FadeTransition(
220
  opacity: _card1Fade,
221
+ child: Text(tr('verification_tools'), style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
222
  ),
223
  ),
224
  const SizedBox(height: 16),
 
231
  child: _HoverFeatureCard(
232
  icon: Icons.link,
233
  iconGradient: const [Color(0xFF1E3A8A), Color(0xFF3B82F6)],
234
+ title: tr('check_link'),
235
+ subtitle: tr('check_link_subtitle'),
236
+ description: tr('check_link_desc'),
 
237
  onTap: () => Navigator.pushNamed(context, '/check-link'),
238
  ),
239
  ),
 
248
  child: _HoverFeatureCard(
249
  icon: Icons.edit_note,
250
  iconGradient: const [Color(0xFF2563EB), Color(0xFF60A5FA)],
251
+ title: tr('proofread_article'),
252
+ subtitle: tr('proofread_subtitle'),
253
+ description: tr('proofread_desc'),
 
254
  onTap: () => Navigator.pushNamed(context, '/proofread'),
255
  ),
256
  ),
 
275
  Row(children: [
276
  Icon(Icons.auto_awesome, color: AppColors.primaryLight, size: 18),
277
  const SizedBox(width: 8),
278
+ Text(tr('how_it_works'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
279
  ]),
280
  const SizedBox(height: 16),
281
+ _howItWorksStep('1', tr('how_step_1')),
282
  const SizedBox(height: 12),
283
+ _howItWorksStep('2', tr('how_step_2')),
284
  const SizedBox(height: 12),
285
+ _howItWorksStep('3', tr('how_step_3')),
286
  const SizedBox(height: 12),
287
+ _howItWorksStep('4', tr('how_step_4')),
288
  ]),
289
  ),
290
  ),
 
293
  ],
294
  ),
295
  ),
296
+ ], // Stack children
297
+ ), // Stack
298
  );
299
  }
300
 
 
339
  Text(AuthService.user?['email'] ?? '', style: TextStyle(color: Colors.white.withValues(alpha: 0.75), fontSize: 13)),
340
  ]),
341
  ),
 
 
 
342
  ListTile(leading: const Icon(Icons.history, color: AppColors.primaryLight), title: Text('History', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); Navigator.pushNamed(context, '/history'); }),
343
+ ListTile(leading: const Icon(Icons.settings_outlined, color: AppColors.primaryLight), title: Text('Settings', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); Navigator.pushNamed(context, '/settings'); }),
344
+ Divider(color: AppColors.divider, height: 1),
345
+ ListTile(leading: const Icon(Icons.info_outline, color: AppColors.primaryLight), title: Text('About Us', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); _showInProgressDialog('About Us'); }),
346
+ ListTile(leading: const Icon(Icons.mail_outline, color: AppColors.primaryLight), title: Text('Contact Us', style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); _showInProgressDialog('Contact Us'); }),
347
  ]),
348
  );
349
  }
350
+
351
+ void _showInProgressDialog(String title) {
352
+ showDialog(
353
+ context: context,
354
+ builder: (_) => AlertDialog(
355
+ backgroundColor: AppColors.cardBg,
356
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
357
+ title: Row(children: [
358
+ Icon(Icons.construction, color: AppColors.primaryLight, size: 22),
359
+ const SizedBox(width: 10),
360
+ Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
361
+ ]),
362
+ content: Text('This section is currently in progress.\nStay tuned for updates!', style: TextStyle(color: AppColors.textSecondary, fontSize: 14, height: 1.5)),
363
+ actions: [
364
+ TextButton(
365
+ onPressed: () => Navigator.pop(context),
366
+ child: Text('OK', style: TextStyle(color: AppColors.primaryLight, fontWeight: FontWeight.w600)),
367
+ ),
368
+ ],
369
+ ),
370
+ );
371
+ }
372
  }
373
 
374
  // ── Hover Feature Card with highlight + scale + glow ─────────────────
check_app/lib/screens/link_check_screen.dart CHANGED
@@ -276,7 +276,7 @@ class _LinkCheckScreenState extends State<LinkCheckScreen> with TickerProviderSt
276
  switch (verdict) {
277
  case 'VERIFIED': accentColor = AppColors.success; bgColor = AppColors.successBg; icon = Icons.check_circle_outline; break;
278
  case 'LIKELY FAKE': accentColor = AppColors.danger; bgColor = AppColors.dangerBg; icon = Icons.cancel_outlined; break;
279
- case 'COVERAGE FOUND ONLINE': accentColor = AppColors.warning; bgColor = AppColors.warningBg; icon = Icons.language; break;
280
  case 'WEAK MATCH': accentColor = const Color(0xFF9333EA); bgColor = AppColors.purpleBg; icon = Icons.help_outline; break;
281
  default: accentColor = AppColors.textSecondary; bgColor = AppColors.infoBg; icon = Icons.info_outline;
282
  }
@@ -422,7 +422,7 @@ class _LinkCheckScreenState extends State<LinkCheckScreen> with TickerProviderSt
422
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
423
  _sectionHeader(Icons.public, 'Related News Sources'),
424
  const SizedBox(height: 14),
425
- ...webResults.take(5).map((r) {
426
  final link = r['link'] ?? r['url'] ?? '';
427
  final hasLink = link.toString().startsWith('http');
428
  return GestureDetector(
 
276
  switch (verdict) {
277
  case 'VERIFIED': accentColor = AppColors.success; bgColor = AppColors.successBg; icon = Icons.check_circle_outline; break;
278
  case 'LIKELY FAKE': accentColor = AppColors.danger; bgColor = AppColors.dangerBg; icon = Icons.cancel_outlined; break;
279
+ case 'COVERAGE FOUND ONLINE': accentColor = AppColors.primaryLight; bgColor = AppColors.infoBg; icon = Icons.language; break;
280
  case 'WEAK MATCH': accentColor = const Color(0xFF9333EA); bgColor = AppColors.purpleBg; icon = Icons.help_outline; break;
281
  default: accentColor = AppColors.textSecondary; bgColor = AppColors.infoBg; icon = Icons.info_outline;
282
  }
 
422
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
423
  _sectionHeader(Icons.public, 'Related News Sources'),
424
  const SizedBox(height: 14),
425
+ ...webResults.map((r) {
426
  final link = r['link'] ?? r['url'] ?? '';
427
  final hasLink = link.toString().startsWith('http');
428
  return GestureDetector(
check_app/lib/screens/login_screen.dart CHANGED
@@ -96,12 +96,21 @@ class _LoginScreenState extends State<LoginScreen> with SingleTickerProviderStat
96
  children: [
97
  Container(
98
  width: 80, height: 80,
 
99
  decoration: BoxDecoration(
100
  color: Colors.white.withValues(alpha: 0.15),
101
  borderRadius: BorderRadius.circular(22),
102
  border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1.5),
103
  ),
104
- child: const Icon(Icons.shield_outlined, color: Colors.white, size: 42),
 
 
 
 
 
 
 
 
105
  ),
106
  const SizedBox(height: 16),
107
  const Text('BantayPahayag', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: Colors.white)),
@@ -230,7 +239,6 @@ class _LoginScreenState extends State<LoginScreen> with SingleTickerProviderStat
230
  ),
231
  const SizedBox(height: 24),
232
 
233
- // Register link
234
  FadeTransition(
235
  opacity: _formFade,
236
  child: Row(
@@ -244,6 +252,30 @@ class _LoginScreenState extends State<LoginScreen> with SingleTickerProviderStat
244
  ],
245
  ),
246
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  const SizedBox(height: 30),
248
  ],
249
  ),
@@ -267,4 +299,116 @@ class _LoginScreenState extends State<LoginScreen> with SingleTickerProviderStat
267
  focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.primaryLight, width: 1.5)),
268
  );
269
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  children: [
97
  Container(
98
  width: 80, height: 80,
99
+ clipBehavior: Clip.antiAlias,
100
  decoration: BoxDecoration(
101
  color: Colors.white.withValues(alpha: 0.15),
102
  borderRadius: BorderRadius.circular(22),
103
  border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1.5),
104
  ),
105
+ child: ClipRRect(
106
+ borderRadius: BorderRadius.circular(16),
107
+ child: Image.asset(
108
+ 'assets/images/app_logo_dark.jpg',
109
+ width: 80,
110
+ height: 80,
111
+ fit: BoxFit.cover,
112
+ ),
113
+ ),
114
  ),
115
  const SizedBox(height: 16),
116
  const Text('BantayPahayag', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800, color: Colors.white)),
 
239
  ),
240
  const SizedBox(height: 24),
241
 
 
242
  FadeTransition(
243
  opacity: _formFade,
244
  child: Row(
 
252
  ],
253
  ),
254
  ),
255
+ const SizedBox(height: 16),
256
+
257
+ // Privacy policy link
258
+ FadeTransition(
259
+ opacity: _formFade,
260
+ child: GestureDetector(
261
+ onTap: _showPrivacyPolicy,
262
+ child: Row(
263
+ mainAxisAlignment: MainAxisAlignment.center,
264
+ children: [
265
+ Icon(Icons.privacy_tip_outlined, color: AppColors.textSecondary.withValues(alpha: 0.7), size: 14),
266
+ const SizedBox(width: 6),
267
+ Text(
268
+ 'Privacy Policy',
269
+ style: TextStyle(
270
+ color: AppColors.textSecondary.withValues(alpha: 0.7),
271
+ fontSize: 12,
272
+ decoration: TextDecoration.underline,
273
+ ),
274
+ ),
275
+ ],
276
+ ),
277
+ ),
278
+ ),
279
  const SizedBox(height: 30),
280
  ],
281
  ),
 
299
  focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.primaryLight, width: 1.5)),
300
  );
301
  }
302
+
303
+ void _showPrivacyPolicy() {
304
+ showDialog(
305
+ context: context,
306
+ builder: (ctx) => AlertDialog(
307
+ backgroundColor: AppColors.cardBg,
308
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
309
+ title: Row(
310
+ children: [
311
+ Icon(Icons.privacy_tip_outlined, color: AppColors.primaryLight, size: 22),
312
+ const SizedBox(width: 10),
313
+ Text('Privacy Policy', style: TextStyle(
314
+ fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary,
315
+ )),
316
+ ],
317
+ ),
318
+ content: SizedBox(
319
+ width: double.maxFinite,
320
+ height: 400,
321
+ child: SingleChildScrollView(
322
+ child: Text(
323
+ _privacyPolicyText,
324
+ style: TextStyle(fontSize: 13, color: AppColors.textSecondary, height: 1.6),
325
+ ),
326
+ ),
327
+ ),
328
+ actions: [
329
+ TextButton(
330
+ onPressed: () => Navigator.of(ctx).pop(),
331
+ child: Text('Close', style: TextStyle(color: AppColors.primaryLight, fontWeight: FontWeight.w600)),
332
+ ),
333
+ ],
334
+ ),
335
+ );
336
+ }
337
  }
338
+
339
+ const String _privacyPolicyText = '''
340
+ BantayPahayag Privacy Policy
341
+
342
+ Last updated: March 2026
343
+
344
+ 1. INFORMATION WE COLLECT
345
+
346
+ When you create an account, we collect:
347
+ \u2022 Email address \u2014 used for account identification and communication.
348
+ \u2022 Username \u2014 a publicly visible display name within the app.
349
+ \u2022 Password \u2014 securely hashed and stored; we never store plaintext passwords.
350
+
351
+ When you use our fact-checking services, we may store:
352
+ \u2022 The text or URLs you submit for analysis.
353
+ \u2022 The results of our fact-check analysis.
354
+ \u2022 Timestamps of your activity.
355
+
356
+ 2. HOW WE USE YOUR INFORMATION
357
+
358
+ We use the collected information to:
359
+ \u2022 Provide and maintain the BantayPahayag fact-checking service.
360
+ \u2022 Store your fact-check history for your convenience.
361
+ \u2022 Improve our machine learning models and accuracy.
362
+ \u2022 Communicate important service updates.
363
+
364
+ 3. DATA STORAGE AND SECURITY
365
+
366
+ \u2022 All passwords are cryptographically hashed with random salts.
367
+ \u2022 We use industry-standard encryption for data transmission (HTTPS/TLS).
368
+ \u2022 Your data is stored on secure, access-controlled servers.
369
+ \u2022 We conduct regular security reviews and updates.
370
+
371
+ 4. DATA SHARING
372
+
373
+ We do NOT:
374
+ \u2022 Sell your personal data to third parties.
375
+ \u2022 Share your data with advertisers.
376
+ \u2022 Use your data for targeted advertising.
377
+
378
+ We may share anonymized, aggregated data for research purposes related to combating misinformation.
379
+
380
+ 5. YOUR RIGHTS
381
+
382
+ You have the right to:
383
+ \u2022 Access your personal data stored in our system.
384
+ \u2022 Update your profile information (username, password).
385
+ \u2022 Delete your account and all associated data permanently.
386
+ \u2022 Request a copy of your data.
387
+
388
+ 6. DATA RETENTION
389
+
390
+ \u2022 Account data is retained until you delete your account.
391
+ \u2022 Fact-check history is retained for your reference until account deletion.
392
+ \u2022 Upon account deletion, all personal data is permanently removed.
393
+
394
+ 7. COOKIES AND TRACKING
395
+
396
+ BantayPahayag uses minimal local storage (shared preferences) for:
397
+ \u2022 Maintaining your login session.
398
+ \u2022 Storing your theme preference (light/dark mode).
399
+ We do NOT use third-party tracking or analytics cookies.
400
+
401
+ 8. CHILDREN'S PRIVACY
402
+
403
+ BantayPahayag is not intended for children under the age of 13. We do not knowingly collect personal data from children.
404
+
405
+ 9. CHANGES TO THIS POLICY
406
+
407
+ We may update this privacy policy from time to time. We will notify users of significant changes through the app.
408
+
409
+ 10. CONTACT
410
+
411
+ For questions or concerns about this privacy policy, please contact the BantayPahayag development team.
412
+
413
+ This application was developed as part of an academic thesis project at Cavite State University.
414
+ ''';
check_app/lib/screens/proofread_screen.dart CHANGED
@@ -363,7 +363,7 @@ class _ProofreadScreenState extends State<ProofreadScreen> with TickerProviderSt
363
  switch (verdict) {
364
  case 'VERIFIED': accentColor = AppColors.success; bgColor = AppColors.successBg; icon = Icons.check_circle_outline; break;
365
  case 'LIKELY FAKE': accentColor = AppColors.danger; bgColor = AppColors.dangerBg; icon = Icons.cancel_outlined; break;
366
- case 'COVERAGE FOUND ONLINE': accentColor = AppColors.warning; bgColor = AppColors.warningBg; icon = Icons.language; break;
367
  case 'WEAK MATCH': accentColor = const Color(0xFF9333EA); bgColor = AppColors.purpleBg; icon = Icons.help_outline; break;
368
  default: accentColor = AppColors.textSecondary; bgColor = AppColors.infoBg; icon = Icons.info_outline;
369
  }
@@ -508,7 +508,7 @@ class _ProofreadScreenState extends State<ProofreadScreen> with TickerProviderSt
508
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
509
  _sectionHeader(Icons.public, 'Related News Sources'),
510
  const SizedBox(height: 14),
511
- ...webResults.take(5).map((r) {
512
  final link = r['link'] ?? r['url'] ?? '';
513
  final hasLink = link.toString().startsWith('http');
514
  return GestureDetector(
 
363
  switch (verdict) {
364
  case 'VERIFIED': accentColor = AppColors.success; bgColor = AppColors.successBg; icon = Icons.check_circle_outline; break;
365
  case 'LIKELY FAKE': accentColor = AppColors.danger; bgColor = AppColors.dangerBg; icon = Icons.cancel_outlined; break;
366
+ case 'COVERAGE FOUND ONLINE': accentColor = AppColors.primaryLight; bgColor = AppColors.infoBg; icon = Icons.language; break;
367
  case 'WEAK MATCH': accentColor = const Color(0xFF9333EA); bgColor = AppColors.purpleBg; icon = Icons.help_outline; break;
368
  default: accentColor = AppColors.textSecondary; bgColor = AppColors.infoBg; icon = Icons.info_outline;
369
  }
 
508
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
509
  _sectionHeader(Icons.public, 'Related News Sources'),
510
  const SizedBox(height: 14),
511
+ ...webResults.map((r) {
512
  final link = r['link'] ?? r['url'] ?? '';
513
  final hasLink = link.toString().startsWith('http');
514
  return GestureDetector(
check_app/lib/screens/register_screen.dart CHANGED
@@ -1,5 +1,7 @@
1
  import 'package:flutter/material.dart';
 
2
  import '../services/auth_service.dart';
 
3
  import '../main.dart';
4
 
5
  class RegisterScreen extends StatefulWidget {
@@ -15,7 +17,20 @@ class _RegisterScreenState extends State<RegisterScreen> with SingleTickerProvid
15
  final _confirmCtrl = TextEditingController();
16
  bool _loading = false;
17
  String? _error;
18
- bool _obscure = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  late final AnimationController _animCtrl;
21
  late final Animation<double> _logoFade;
@@ -31,6 +46,9 @@ class _RegisterScreenState extends State<RegisterScreen> with SingleTickerProvid
31
  .animate(CurvedAnimation(parent: _animCtrl, curve: const Interval(0.25, 0.75, curve: Curves.easeOutCubic)));
32
  _formFade = CurvedAnimation(parent: _animCtrl, curve: const Interval(0.25, 0.75, curve: Curves.easeOut));
33
  _animCtrl.forward();
 
 
 
34
  }
35
 
36
  @override
@@ -43,20 +61,140 @@ class _RegisterScreenState extends State<RegisterScreen> with SingleTickerProvid
43
  super.dispose();
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  Future<void> _register() async {
47
  if (_emailCtrl.text.trim().isEmpty || _usernameCtrl.text.trim().isEmpty ||
48
  _passwordCtrl.text.isEmpty || _confirmCtrl.text.isEmpty) {
49
  setState(() => _error = 'Please fill in all fields.');
50
  return;
51
  }
52
- if (_passwordCtrl.text.length < 6) {
53
- setState(() => _error = 'Password must be at least 6 characters.');
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  return;
55
  }
 
 
 
 
 
 
56
  if (_passwordCtrl.text != _confirmCtrl.text) {
57
  setState(() => _error = 'Passwords do not match.');
58
  return;
59
  }
 
 
 
 
 
 
 
 
 
 
 
60
  setState(() { _loading = true; _error = null; });
61
  try {
62
  final result = await AuthService.register(
@@ -74,6 +212,41 @@ class _RegisterScreenState extends State<RegisterScreen> with SingleTickerProvid
74
  }
75
  }
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  InputDecoration _inputDecoration(String hint, IconData icon) {
78
  return InputDecoration(
79
  hintText: hint,
@@ -154,22 +327,147 @@ class _RegisterScreenState extends State<RegisterScreen> with SingleTickerProvid
154
  ],
155
  ),
156
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
157
- TextField(controller: _usernameCtrl, decoration: _inputDecoration('Username', Icons.person_outline)),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  const SizedBox(height: 14),
159
- TextField(controller: _emailCtrl, keyboardType: TextInputType.emailAddress, decoration: _inputDecoration('Email', Icons.email_outlined)),
 
 
160
  const SizedBox(height: 14),
 
 
161
  TextField(
162
- controller: _passwordCtrl, obscureText: _obscure,
163
- decoration: _inputDecoration('Password', Icons.lock_outline).copyWith(
164
  suffixIcon: IconButton(
165
- icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 20),
166
- onPressed: () => setState(() => _obscure = !_obscure),
167
  ),
168
  ),
169
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  const SizedBox(height: 14),
171
- TextField(controller: _confirmCtrl, obscureText: true, decoration: _inputDecoration('Confirm Password', Icons.lock_outline)),
172
- const SizedBox(height: 22),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
  if (_error != null)
175
  Container(
@@ -227,4 +525,130 @@ class _RegisterScreenState extends State<RegisterScreen> with SingleTickerProvid
227
  ),
228
  );
229
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import 'package:flutter/material.dart';
2
+ import 'package:flutter/gestures.dart';
3
  import '../services/auth_service.dart';
4
+ import '../widgets/captcha_slider.dart';
5
  import '../main.dart';
6
 
7
  class RegisterScreen extends StatefulWidget {
 
17
  final _confirmCtrl = TextEditingController();
18
  bool _loading = false;
19
  String? _error;
20
+ bool _obscurePassword = true;
21
+ bool _obscureConfirm = true;
22
+ bool _agreedToPrivacy = false;
23
+ bool _captchaVerified = false;
24
+
25
+ // Password strength tracking
26
+ double _passwordStrength = 0;
27
+ String _passwordStrengthLabel = '';
28
+ Color _passwordStrengthColor = Colors.transparent;
29
+
30
+ // Username availability
31
+ bool _checkingUsername = false;
32
+ bool? _usernameAvailable;
33
+ String? _usernameMessage;
34
 
35
  late final AnimationController _animCtrl;
36
  late final Animation<double> _logoFade;
 
46
  .animate(CurvedAnimation(parent: _animCtrl, curve: const Interval(0.25, 0.75, curve: Curves.easeOutCubic)));
47
  _formFade = CurvedAnimation(parent: _animCtrl, curve: const Interval(0.25, 0.75, curve: Curves.easeOut));
48
  _animCtrl.forward();
49
+
50
+ _passwordCtrl.addListener(_evaluatePasswordStrength);
51
+ _usernameCtrl.addListener(_onUsernameChanged);
52
  }
53
 
54
  @override
 
61
  super.dispose();
62
  }
63
 
64
+ void _evaluatePasswordStrength() {
65
+ final pw = _passwordCtrl.text;
66
+ if (pw.isEmpty) {
67
+ setState(() {
68
+ _passwordStrength = 0;
69
+ _passwordStrengthLabel = '';
70
+ _passwordStrengthColor = Colors.transparent;
71
+ });
72
+ return;
73
+ }
74
+
75
+ double strength = 0;
76
+ // Length checks
77
+ if (pw.length >= 6) strength += 0.15;
78
+ if (pw.length >= 8) strength += 0.15;
79
+ if (pw.length >= 12) strength += 0.1;
80
+
81
+ // Character type checks
82
+ if (RegExp(r'[a-z]').hasMatch(pw)) strength += 0.15;
83
+ if (RegExp(r'[A-Z]').hasMatch(pw)) strength += 0.15;
84
+ if (RegExp(r'[0-9]').hasMatch(pw)) strength += 0.15;
85
+ if (RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(pw)) strength += 0.15;
86
+
87
+ strength = strength.clamp(0.0, 1.0);
88
+
89
+ String label;
90
+ Color color;
91
+ if (strength < 0.3) {
92
+ label = 'Weak';
93
+ color = AppColors.danger;
94
+ } else if (strength < 0.6) {
95
+ label = 'Fair';
96
+ color = AppColors.warning;
97
+ } else if (strength < 0.85) {
98
+ label = 'Good';
99
+ color = const Color(0xFF2196F3);
100
+ } else {
101
+ label = 'Strong';
102
+ color = AppColors.success;
103
+ }
104
+
105
+ setState(() {
106
+ _passwordStrength = strength;
107
+ _passwordStrengthLabel = label;
108
+ _passwordStrengthColor = color;
109
+ });
110
+ }
111
+
112
+ int _usernameDebounce = 0;
113
+
114
+ void _onUsernameChanged() {
115
+ final username = _usernameCtrl.text.trim();
116
+ _usernameDebounce++;
117
+ final currentDebounce = _usernameDebounce;
118
+
119
+ if (username.length < 3) {
120
+ setState(() {
121
+ _usernameAvailable = null;
122
+ _usernameMessage = username.isNotEmpty ? 'At least 3 characters' : null;
123
+ _checkingUsername = false;
124
+ });
125
+ return;
126
+ }
127
+
128
+ setState(() => _checkingUsername = true);
129
+
130
+ // Debounce: wait 500ms before checking
131
+ Future.delayed(const Duration(milliseconds: 500), () async {
132
+ if (currentDebounce != _usernameDebounce || !mounted) return;
133
+ try {
134
+ final result = await AuthService.checkUsername(username);
135
+ if (!mounted || currentDebounce != _usernameDebounce) return;
136
+ setState(() {
137
+ _checkingUsername = false;
138
+ _usernameAvailable = result['available'] == true;
139
+ _usernameMessage = result['available'] == true
140
+ ? 'Username is available!'
141
+ : (result['reason'] ?? 'Username is taken.');
142
+ });
143
+ } catch (_) {
144
+ if (!mounted) return;
145
+ setState(() {
146
+ _checkingUsername = false;
147
+ _usernameAvailable = null;
148
+ _usernameMessage = null;
149
+ });
150
+ }
151
+ });
152
+ }
153
+
154
  Future<void> _register() async {
155
  if (_emailCtrl.text.trim().isEmpty || _usernameCtrl.text.trim().isEmpty ||
156
  _passwordCtrl.text.isEmpty || _confirmCtrl.text.isEmpty) {
157
  setState(() => _error = 'Please fill in all fields.');
158
  return;
159
  }
160
+
161
+ // Email format validation
162
+ final emailRegex = RegExp(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$');
163
+ if (!emailRegex.hasMatch(_emailCtrl.text.trim())) {
164
+ setState(() => _error = 'Please enter a valid email address.');
165
+ return;
166
+ }
167
+
168
+ if (_usernameCtrl.text.trim().length < 3) {
169
+ setState(() => _error = 'Username must be at least 3 characters.');
170
+ return;
171
+ }
172
+
173
+ if (_passwordCtrl.text.length < 8) {
174
+ setState(() => _error = 'Password must be at least 8 characters.');
175
  return;
176
  }
177
+
178
+ if (_passwordStrength < 0.3) {
179
+ setState(() => _error = 'Password is too weak. Include uppercase, lowercase, numbers, and symbols.');
180
+ return;
181
+ }
182
+
183
  if (_passwordCtrl.text != _confirmCtrl.text) {
184
  setState(() => _error = 'Passwords do not match.');
185
  return;
186
  }
187
+
188
+ if (!_agreedToPrivacy) {
189
+ setState(() => _error = 'You must agree to the Privacy Policy to continue.');
190
+ return;
191
+ }
192
+
193
+ if (!_captchaVerified) {
194
+ setState(() => _error = 'Please complete the verification slider.');
195
+ return;
196
+ }
197
+
198
  setState(() { _loading = true; _error = null; });
199
  try {
200
  final result = await AuthService.register(
 
212
  }
213
  }
214
 
215
+ void _showPrivacyPolicy() {
216
+ showDialog(
217
+ context: context,
218
+ builder: (ctx) => AlertDialog(
219
+ backgroundColor: AppColors.cardBg,
220
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
221
+ title: Row(
222
+ children: [
223
+ Icon(Icons.privacy_tip_outlined, color: AppColors.primaryLight, size: 22),
224
+ const SizedBox(width: 10),
225
+ Text('Privacy Policy', style: TextStyle(
226
+ fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary,
227
+ )),
228
+ ],
229
+ ),
230
+ content: SizedBox(
231
+ width: double.maxFinite,
232
+ height: 400,
233
+ child: SingleChildScrollView(
234
+ child: Text(
235
+ _privacyPolicyText,
236
+ style: TextStyle(fontSize: 13, color: AppColors.textSecondary, height: 1.6),
237
+ ),
238
+ ),
239
+ ),
240
+ actions: [
241
+ TextButton(
242
+ onPressed: () => Navigator.of(ctx).pop(),
243
+ child: Text('Close', style: TextStyle(color: AppColors.primaryLight, fontWeight: FontWeight.w600)),
244
+ ),
245
+ ],
246
+ ),
247
+ );
248
+ }
249
+
250
  InputDecoration _inputDecoration(String hint, IconData icon) {
251
  return InputDecoration(
252
  hintText: hint,
 
327
  ],
328
  ),
329
  child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
330
+ // Username field with availability indicator
331
+ TextField(
332
+ controller: _usernameCtrl,
333
+ decoration: _inputDecoration('Choose a username', Icons.alternate_email).copyWith(
334
+ suffixIcon: _checkingUsername
335
+ ? const Padding(
336
+ padding: EdgeInsets.all(14),
337
+ child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
338
+ )
339
+ : _usernameAvailable != null
340
+ ? Icon(
341
+ _usernameAvailable! ? Icons.check_circle : Icons.cancel,
342
+ color: _usernameAvailable! ? AppColors.success : AppColors.danger,
343
+ size: 20,
344
+ )
345
+ : null,
346
+ ),
347
+ ),
348
+ if (_usernameMessage != null)
349
+ Padding(
350
+ padding: const EdgeInsets.only(top: 6, left: 4),
351
+ child: Text(
352
+ _usernameMessage!,
353
+ style: TextStyle(
354
+ fontSize: 11,
355
+ color: _usernameAvailable == true ? AppColors.success : AppColors.danger,
356
+ ),
357
+ ),
358
+ ),
359
  const SizedBox(height: 14),
360
+
361
+ // Email field
362
+ TextField(controller: _emailCtrl, keyboardType: TextInputType.emailAddress, decoration: _inputDecoration('Email address', Icons.email_outlined)),
363
  const SizedBox(height: 14),
364
+
365
+ // Password field
366
  TextField(
367
+ controller: _passwordCtrl, obscureText: _obscurePassword,
368
+ decoration: _inputDecoration('Create password', Icons.lock_outline).copyWith(
369
  suffixIcon: IconButton(
370
+ icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 20),
371
+ onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
372
  ),
373
  ),
374
  ),
375
+
376
+ // Password strength indicator
377
+ if (_passwordCtrl.text.isNotEmpty) ...[
378
+ const SizedBox(height: 10),
379
+ Row(
380
+ children: [
381
+ Expanded(
382
+ child: ClipRRect(
383
+ borderRadius: BorderRadius.circular(4),
384
+ child: LinearProgressIndicator(
385
+ value: _passwordStrength,
386
+ backgroundColor: AppColors.divider,
387
+ valueColor: AlwaysStoppedAnimation<Color>(_passwordStrengthColor),
388
+ minHeight: 4,
389
+ ),
390
+ ),
391
+ ),
392
+ const SizedBox(width: 10),
393
+ Text(
394
+ _passwordStrengthLabel,
395
+ style: TextStyle(
396
+ fontSize: 11,
397
+ fontWeight: FontWeight.w600,
398
+ color: _passwordStrengthColor,
399
+ ),
400
+ ),
401
+ ],
402
+ ),
403
+ const SizedBox(height: 6),
404
+ _buildPasswordHints(),
405
+ ],
406
  const SizedBox(height: 14),
407
+
408
+ // Confirm password field
409
+ TextField(
410
+ controller: _confirmCtrl, obscureText: _obscureConfirm,
411
+ decoration: _inputDecoration('Confirm password', Icons.lock_outline).copyWith(
412
+ suffixIcon: IconButton(
413
+ icon: Icon(_obscureConfirm ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 20),
414
+ onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
415
+ ),
416
+ ),
417
+ ),
418
+ const SizedBox(height: 18),
419
+
420
+ // Privacy policy checkbox
421
+ GestureDetector(
422
+ onTap: () => setState(() => _agreedToPrivacy = !_agreedToPrivacy),
423
+ child: Row(
424
+ crossAxisAlignment: CrossAxisAlignment.start,
425
+ children: [
426
+ SizedBox(
427
+ width: 22, height: 22,
428
+ child: Checkbox(
429
+ value: _agreedToPrivacy,
430
+ onChanged: (v) => setState(() => _agreedToPrivacy = v ?? false),
431
+ activeColor: AppColors.primaryLight,
432
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
433
+ side: BorderSide(color: AppColors.divider, width: 1.5),
434
+ materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
435
+ visualDensity: VisualDensity.compact,
436
+ ),
437
+ ),
438
+ const SizedBox(width: 8),
439
+ Expanded(
440
+ child: RichText(
441
+ text: TextSpan(
442
+ style: TextStyle(fontSize: 12, color: AppColors.textSecondary, height: 1.4),
443
+ children: [
444
+ const TextSpan(text: 'I agree to the '),
445
+ TextSpan(
446
+ text: 'Privacy Policy',
447
+ style: TextStyle(
448
+ color: AppColors.primaryLight,
449
+ fontWeight: FontWeight.w600,
450
+ decoration: TextDecoration.underline,
451
+ ),
452
+ recognizer: TapGestureRecognizer()..onTap = _showPrivacyPolicy,
453
+ ),
454
+ const TextSpan(text: ' and Terms of Service'),
455
+ ],
456
+ ),
457
+ ),
458
+ ),
459
+ ],
460
+ ),
461
+ ),
462
+ const SizedBox(height: 18),
463
+
464
+ // CAPTCHA slider
465
+ CaptchaSlider(
466
+ onVerified: (verified) {
467
+ setState(() => _captchaVerified = verified);
468
+ },
469
+ ),
470
+ const SizedBox(height: 18),
471
 
472
  if (_error != null)
473
  Container(
 
525
  ),
526
  );
527
  }
528
+
529
+ Widget _buildPasswordHints() {
530
+ final pw = _passwordCtrl.text;
531
+ return Wrap(
532
+ spacing: 8,
533
+ runSpacing: 4,
534
+ children: [
535
+ _passwordHintChip('8+ chars', pw.length >= 8),
536
+ _passwordHintChip('A-Z', RegExp(r'[A-Z]').hasMatch(pw)),
537
+ _passwordHintChip('a-z', RegExp(r'[a-z]').hasMatch(pw)),
538
+ _passwordHintChip('0-9', RegExp(r'[0-9]').hasMatch(pw)),
539
+ _passwordHintChip('!@#\$', RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(pw)),
540
+ ],
541
+ );
542
+ }
543
+
544
+ Widget _passwordHintChip(String label, bool met) {
545
+ return Container(
546
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
547
+ decoration: BoxDecoration(
548
+ color: met
549
+ ? AppColors.success.withValues(alpha: 0.1)
550
+ : AppColors.divider.withValues(alpha: 0.5),
551
+ borderRadius: BorderRadius.circular(6),
552
+ border: Border.all(
553
+ color: met ? AppColors.success.withValues(alpha: 0.3) : Colors.transparent,
554
+ ),
555
+ ),
556
+ child: Row(
557
+ mainAxisSize: MainAxisSize.min,
558
+ children: [
559
+ Icon(
560
+ met ? Icons.check_circle : Icons.circle_outlined,
561
+ size: 12,
562
+ color: met ? AppColors.success : AppColors.textSecondary.withValues(alpha: 0.5),
563
+ ),
564
+ const SizedBox(width: 4),
565
+ Text(
566
+ label,
567
+ style: TextStyle(
568
+ fontSize: 10,
569
+ fontWeight: FontWeight.w500,
570
+ color: met ? AppColors.success : AppColors.textSecondary.withValues(alpha: 0.6),
571
+ ),
572
+ ),
573
+ ],
574
+ ),
575
+ );
576
+ }
577
  }
578
+
579
+ const String _privacyPolicyText = '''
580
+ BantayPahayag Privacy Policy
581
+
582
+ Last updated: March 2026
583
+
584
+ 1. INFORMATION WE COLLECT
585
+
586
+ When you create an account, we collect:
587
+ β€’ Email address β€” used for account identification and communication.
588
+ β€’ Username β€” a publicly visible display name within the app.
589
+ β€’ Password β€” securely hashed and stored; we never store plaintext passwords.
590
+
591
+ When you use our fact-checking services, we may store:
592
+ β€’ The text or URLs you submit for analysis.
593
+ β€’ The results of our fact-check analysis.
594
+ β€’ Timestamps of your activity.
595
+
596
+ 2. HOW WE USE YOUR INFORMATION
597
+
598
+ We use the collected information to:
599
+ β€’ Provide and maintain the BantayPahayag fact-checking service.
600
+ β€’ Store your fact-check history for your convenience.
601
+ β€’ Improve our machine learning models and accuracy.
602
+ β€’ Communicate important service updates.
603
+
604
+ 3. DATA STORAGE AND SECURITY
605
+
606
+ β€’ All passwords are cryptographically hashed with random salts.
607
+ β€’ We use industry-standard encryption for data transmission (HTTPS/TLS).
608
+ β€’ Your data is stored on secure, access-controlled servers.
609
+ β€’ We conduct regular security reviews and updates.
610
+
611
+ 4. DATA SHARING
612
+
613
+ We do NOT:
614
+ β€’ Sell your personal data to third parties.
615
+ β€’ Share your data with advertisers.
616
+ β€’ Use your data for targeted advertising.
617
+
618
+ We may share anonymized, aggregated data for research purposes related to combating misinformation.
619
+
620
+ 5. YOUR RIGHTS
621
+
622
+ You have the right to:
623
+ β€’ Access your personal data stored in our system.
624
+ β€’ Update your profile information (username, password).
625
+ β€’ Delete your account and all associated data permanently.
626
+ β€’ Request a copy of your data.
627
+
628
+ 6. DATA RETENTION
629
+
630
+ β€’ Account data is retained until you delete your account.
631
+ β€’ Fact-check history is retained for your reference until account deletion.
632
+ β€’ Upon account deletion, all personal data is permanently removed.
633
+
634
+ 7. COOKIES AND TRACKING
635
+
636
+ BantayPahayag uses minimal local storage (shared preferences) for:
637
+ β€’ Maintaining your login session.
638
+ β€’ Storing your theme preference (light/dark mode).
639
+ We do NOT use third-party tracking or analytics cookies.
640
+
641
+ 8. CHILDREN'S PRIVACY
642
+
643
+ BantayPahayag is not intended for children under the age of 13. We do not knowingly collect personal data from children.
644
+
645
+ 9. CHANGES TO THIS POLICY
646
+
647
+ We may update this privacy policy from time to time. We will notify users of significant changes through the app.
648
+
649
+ 10. CONTACT
650
+
651
+ For questions or concerns about this privacy policy, please contact the BantayPahayag development team.
652
+
653
+ This application was developed as part of an academic thesis project at Cavite State University.
654
+ ''';
check_app/lib/screens/settings_screen.dart ADDED
@@ -0,0 +1,1003 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import '../main.dart';
3
+ import '../services/auth_service.dart';
4
+
5
+ class SettingsScreen extends StatefulWidget {
6
+ const SettingsScreen({super.key});
7
+ @override
8
+ State<SettingsScreen> createState() => _SettingsScreenState();
9
+ }
10
+
11
+ class _SettingsScreenState extends State<SettingsScreen> with SingleTickerProviderStateMixin {
12
+ late final AnimationController _animCtrl;
13
+ late final Animation<double> _fadeAnim;
14
+
15
+ @override
16
+ void initState() {
17
+ super.initState();
18
+ themeNotifier.addListener(_onThemeChange);
19
+ _animCtrl = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
20
+ _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
21
+ _animCtrl.forward();
22
+ }
23
+
24
+ @override
25
+ void dispose() {
26
+ themeNotifier.removeListener(_onThemeChange);
27
+ _animCtrl.dispose();
28
+ super.dispose();
29
+ }
30
+
31
+ void _onThemeChange() {
32
+ if (mounted) setState(() {});
33
+ }
34
+
35
+ @override
36
+ Widget build(BuildContext context) {
37
+ final user = AuthService.user;
38
+ final username = user?['username'] ?? 'User';
39
+ final email = user?['email'] ?? '';
40
+ final createdAt = user?['created_at'] ?? '';
41
+
42
+ return Scaffold(
43
+ appBar: AppBar(
44
+ title: const Text('Settings', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18, letterSpacing: 0.3)),
45
+ leading: IconButton(
46
+ icon: const Icon(Icons.arrow_back_ios_new, size: 20),
47
+ onPressed: () => Navigator.pop(context),
48
+ ),
49
+ flexibleSpace: Container(
50
+ decoration: BoxDecoration(
51
+ gradient: LinearGradient(
52
+ colors: AppColors.appBarGradient,
53
+ begin: Alignment.centerLeft,
54
+ end: Alignment.centerRight,
55
+ ),
56
+ ),
57
+ ),
58
+ ),
59
+ backgroundColor: AppColors.background,
60
+ body: FadeTransition(
61
+ opacity: _fadeAnim,
62
+ child: SingleChildScrollView(
63
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
64
+ child: Column(
65
+ crossAxisAlignment: CrossAxisAlignment.start,
66
+ children: [
67
+ // ── Profile Card ────────────────────────────────────
68
+ Container(
69
+ width: double.infinity,
70
+ padding: const EdgeInsets.all(22),
71
+ decoration: BoxDecoration(
72
+ gradient: const LinearGradient(
73
+ colors: [Color(0xFF1E3A8A), Color(0xFF2563EB), Color(0xFF3B82F6)],
74
+ begin: Alignment.topLeft,
75
+ end: Alignment.bottomRight,
76
+ ),
77
+ borderRadius: BorderRadius.circular(16),
78
+ boxShadow: [
79
+ BoxShadow(color: const Color(0xFF1E3A8A).withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 6)),
80
+ ],
81
+ ),
82
+ child: Row(
83
+ children: [
84
+ Container(
85
+ width: 60, height: 60,
86
+ decoration: BoxDecoration(
87
+ color: Colors.white.withValues(alpha: 0.2),
88
+ borderRadius: BorderRadius.circular(16),
89
+ border: Border.all(color: Colors.white.withValues(alpha: 0.3)),
90
+ ),
91
+ child: Center(
92
+ child: Text(
93
+ username.isNotEmpty ? username[0].toUpperCase() : 'U',
94
+ style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w800, color: Colors.white),
95
+ ),
96
+ ),
97
+ ),
98
+ const SizedBox(width: 16),
99
+ Expanded(
100
+ child: Column(
101
+ crossAxisAlignment: CrossAxisAlignment.start,
102
+ children: [
103
+ Text(username, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white)),
104
+ const SizedBox(height: 4),
105
+ Text(email, style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.8))),
106
+ if (createdAt.isNotEmpty) ...[
107
+ const SizedBox(height: 4),
108
+ Text(
109
+ 'Member since ${_formatDate(createdAt)}',
110
+ style: TextStyle(fontSize: 11, color: Colors.white.withValues(alpha: 0.6)),
111
+ ),
112
+ ],
113
+ ],
114
+ ),
115
+ ),
116
+ ],
117
+ ),
118
+ ),
119
+ const SizedBox(height: 28),
120
+
121
+ // ── Account Section ─────────────────────────────────
122
+ _buildSectionHeader('Account', Icons.person_outline),
123
+ const SizedBox(height: 12),
124
+ _buildSettingsCard([
125
+ _SettingsTile(
126
+ icon: Icons.alternate_email,
127
+ iconColor: AppColors.primaryLight,
128
+ title: 'Change Username',
129
+ subtitle: 'Currently: $username',
130
+ onTap: () => _showChangeUsernameDialog(),
131
+ ),
132
+ _SettingsTile(
133
+ icon: Icons.lock_outline,
134
+ iconColor: const Color(0xFF8B5CF6),
135
+ title: 'Change Password',
136
+ subtitle: 'Update your password',
137
+ onTap: () => _showChangePasswordDialog(),
138
+ ),
139
+ ]),
140
+ const SizedBox(height: 24),
141
+
142
+ // ── Appearance Section ──────────────────────────────
143
+ _buildSectionHeader('Appearance', Icons.palette_outlined),
144
+ const SizedBox(height: 12),
145
+ _buildSettingsCard([
146
+ _SettingsTile(
147
+ icon: AppColors.isDark ? Icons.light_mode : Icons.dark_mode,
148
+ iconColor: AppColors.warning,
149
+ title: 'Dark Mode',
150
+ subtitle: AppColors.isDark ? 'Currently enabled' : 'Currently disabled',
151
+ trailing: Switch(
152
+ value: AppColors.isDark,
153
+ onChanged: (v) {
154
+ themeNotifier.value = v ? ThemeMode.dark : ThemeMode.light;
155
+ saveThemePreference(v);
156
+ },
157
+ activeThumbColor: AppColors.primaryLight,
158
+ ),
159
+ ),
160
+ ]),
161
+ const SizedBox(height: 24),
162
+
163
+ // ── Legal Section ────────────────────────────────────
164
+ _buildSectionHeader('Legal & Privacy', Icons.gavel_outlined),
165
+ const SizedBox(height: 12),
166
+ _buildSettingsCard([
167
+ _SettingsTile(
168
+ icon: Icons.privacy_tip_outlined,
169
+ iconColor: AppColors.success,
170
+ title: 'Privacy Policy',
171
+ subtitle: 'How we handle your data',
172
+ onTap: () => _showPrivacyPolicy(),
173
+ ),
174
+ ]),
175
+ const SizedBox(height: 24),
176
+
177
+ // ── Danger Zone ──────────────────────────────────────
178
+ _buildSectionHeader('Danger Zone', Icons.warning_amber_rounded, color: AppColors.danger),
179
+ const SizedBox(height: 12),
180
+ Container(
181
+ width: double.infinity,
182
+ decoration: BoxDecoration(
183
+ color: AppColors.cardBg,
184
+ borderRadius: BorderRadius.circular(14),
185
+ border: Border.all(color: AppColors.dangerBorder),
186
+ boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
187
+ ),
188
+ child: _SettingsTile(
189
+ icon: Icons.delete_forever,
190
+ iconColor: AppColors.danger,
191
+ title: 'Delete Account',
192
+ subtitle: 'Permanently delete your account and all data',
193
+ titleColor: AppColors.danger,
194
+ onTap: () => _showDeleteAccountDialog(),
195
+ showDivider: false,
196
+ ),
197
+ ),
198
+ const SizedBox(height: 32),
199
+
200
+ // ── Sign Out ─────────────────────────────────────────
201
+ SizedBox(
202
+ width: double.infinity,
203
+ height: 50,
204
+ child: OutlinedButton.icon(
205
+ onPressed: _logout,
206
+ icon: const Icon(Icons.logout, size: 20),
207
+ label: const Text('Sign Out', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
208
+ style: OutlinedButton.styleFrom(
209
+ foregroundColor: AppColors.danger,
210
+ side: BorderSide(color: AppColors.danger.withValues(alpha: 0.5)),
211
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
212
+ ),
213
+ ),
214
+ ),
215
+ const SizedBox(height: 24),
216
+
217
+ // App version
218
+ Center(
219
+ child: Text(
220
+ 'BantayPahayag v1.0.0',
221
+ style: TextStyle(fontSize: 11, color: AppColors.textSecondary.withValues(alpha: 0.5)),
222
+ ),
223
+ ),
224
+ const SizedBox(height: 16),
225
+ ],
226
+ ),
227
+ ),
228
+ ),
229
+ );
230
+ }
231
+
232
+ Widget _buildSectionHeader(String title, IconData icon, {Color? color}) {
233
+ return Row(
234
+ children: [
235
+ Icon(icon, size: 18, color: color ?? AppColors.textSecondary),
236
+ const SizedBox(width: 8),
237
+ Text(title, style: TextStyle(
238
+ fontSize: 14,
239
+ fontWeight: FontWeight.w700,
240
+ color: color ?? AppColors.textSecondary,
241
+ letterSpacing: 0.5,
242
+ )),
243
+ ],
244
+ );
245
+ }
246
+
247
+ Widget _buildSettingsCard(List<_SettingsTile> tiles) {
248
+ return Container(
249
+ decoration: BoxDecoration(
250
+ color: AppColors.cardBg,
251
+ borderRadius: BorderRadius.circular(14),
252
+ border: Border.all(color: AppColors.divider),
253
+ boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2))],
254
+ ),
255
+ child: Column(
256
+ children: tiles.asMap().entries.map((entry) {
257
+ final tile = entry.value;
258
+ final isLast = entry.key == tiles.length - 1;
259
+ return tile.copyWith(showDivider: !isLast);
260
+ }).toList(),
261
+ ),
262
+ );
263
+ }
264
+
265
+ String _formatDate(String dateStr) {
266
+ try {
267
+ final date = DateTime.parse(dateStr);
268
+ final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
269
+ return '${months[date.month - 1]} ${date.year}';
270
+ } catch (_) {
271
+ return dateStr;
272
+ }
273
+ }
274
+
275
+ void _logout() async {
276
+ await AuthService.logout();
277
+ if (!mounted) return;
278
+ Navigator.pushReplacementNamed(context, '/login');
279
+ }
280
+
281
+ // ── Change Username Dialog ────────────────────────────────────────
282
+ void _showChangeUsernameDialog() {
283
+ final ctrl = TextEditingController(text: AuthService.user?['username'] ?? '');
284
+ bool loading = false;
285
+ String? errorMsg;
286
+ bool? available;
287
+ String? availabilityMsg;
288
+
289
+ showDialog(
290
+ context: context,
291
+ builder: (ctx) => StatefulBuilder(
292
+ builder: (ctx, setDialogState) => AlertDialog(
293
+ backgroundColor: AppColors.cardBg,
294
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
295
+ title: Row(
296
+ children: [
297
+ Container(
298
+ width: 38, height: 38,
299
+ decoration: BoxDecoration(
300
+ color: AppColors.primaryLight.withValues(alpha: 0.1),
301
+ borderRadius: BorderRadius.circular(10),
302
+ ),
303
+ child: Icon(Icons.alternate_email, color: AppColors.primaryLight, size: 20),
304
+ ),
305
+ const SizedBox(width: 12),
306
+ Text('Change Username', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
307
+ ],
308
+ ),
309
+ content: Column(
310
+ mainAxisSize: MainAxisSize.min,
311
+ crossAxisAlignment: CrossAxisAlignment.start,
312
+ children: [
313
+ Text('Choose a new unique username:', style: TextStyle(fontSize: 13, color: AppColors.textSecondary)),
314
+ const SizedBox(height: 14),
315
+ TextField(
316
+ controller: ctrl,
317
+ decoration: InputDecoration(
318
+ hintText: 'New username',
319
+ hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
320
+ prefixIcon: Icon(Icons.person_outline, color: AppColors.textSecondary, size: 20),
321
+ suffixIcon: available != null
322
+ ? Icon(available! ? Icons.check_circle : Icons.cancel, color: available! ? AppColors.success : AppColors.danger, size: 20)
323
+ : null,
324
+ filled: true,
325
+ fillColor: AppColors.inputBg,
326
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
327
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
328
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
329
+ focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.primaryLight, width: 1.5)),
330
+ ),
331
+ onChanged: (val) async {
332
+ if (val.trim().length < 3) {
333
+ setDialogState(() {
334
+ available = null;
335
+ availabilityMsg = val.trim().isNotEmpty ? 'At least 3 characters' : null;
336
+ });
337
+ return;
338
+ }
339
+ try {
340
+ final result = await AuthService.checkUsername(val.trim());
341
+ setDialogState(() {
342
+ available = result['available'] == true;
343
+ availabilityMsg = result['available'] == true ? 'Available!' : (result['reason'] ?? 'Taken');
344
+ });
345
+ } catch (_) {}
346
+ },
347
+ ),
348
+ if (availabilityMsg != null)
349
+ Padding(
350
+ padding: const EdgeInsets.only(top: 6, left: 4),
351
+ child: Text(availabilityMsg!, style: TextStyle(fontSize: 11, color: available == true ? AppColors.success : AppColors.danger)),
352
+ ),
353
+ if (errorMsg != null)
354
+ Padding(
355
+ padding: const EdgeInsets.only(top: 10),
356
+ child: Container(
357
+ padding: const EdgeInsets.all(10),
358
+ decoration: BoxDecoration(color: AppColors.dangerBg, borderRadius: BorderRadius.circular(8)),
359
+ child: Row(children: [
360
+ const Icon(Icons.error_outline, color: AppColors.danger, size: 14),
361
+ const SizedBox(width: 6),
362
+ Expanded(child: Text(errorMsg!, style: const TextStyle(color: AppColors.danger, fontSize: 12))),
363
+ ]),
364
+ ),
365
+ ),
366
+ ],
367
+ ),
368
+ actions: [
369
+ TextButton(
370
+ onPressed: () => Navigator.of(ctx).pop(),
371
+ child: Text('Cancel', style: TextStyle(color: AppColors.textSecondary)),
372
+ ),
373
+ ElevatedButton(
374
+ onPressed: loading ? null : () async {
375
+ final newName = ctrl.text.trim();
376
+ if (newName.length < 3) {
377
+ setDialogState(() => errorMsg = 'Username must be at least 3 characters.');
378
+ return;
379
+ }
380
+ setDialogState(() { loading = true; errorMsg = null; });
381
+ try {
382
+ final result = await AuthService.updateProfile(newName);
383
+ if (!ctx.mounted) return;
384
+ if (result['success'] == true) {
385
+ Navigator.of(ctx).pop();
386
+ if (mounted) setState(() {});
387
+ _showSuccessSnack('Username updated to "$newName"');
388
+ } else {
389
+ setDialogState(() { errorMsg = result['error']; loading = false; });
390
+ }
391
+ } catch (e) {
392
+ setDialogState(() { errorMsg = 'Connection error.'; loading = false; });
393
+ }
394
+ },
395
+ style: ElevatedButton.styleFrom(
396
+ backgroundColor: AppColors.primary,
397
+ foregroundColor: Colors.white,
398
+ elevation: 0,
399
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
400
+ ),
401
+ child: loading
402
+ ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
403
+ : const Text('Save', style: TextStyle(fontWeight: FontWeight.w600)),
404
+ ),
405
+ ],
406
+ ),
407
+ ),
408
+ );
409
+ }
410
+
411
+ // ── Change Password Dialog ────────────────────────────────────────
412
+ void _showChangePasswordDialog() {
413
+ final currentCtrl = TextEditingController();
414
+ final newCtrl = TextEditingController();
415
+ final confirmCtrl = TextEditingController();
416
+ bool loading = false;
417
+ String? errorMsg;
418
+ bool obscureCurrent = true;
419
+ bool obscureNew = true;
420
+ bool obscureConfirm = true;
421
+ double strength = 0;
422
+ String strengthLabel = '';
423
+ Color strengthColor = Colors.transparent;
424
+
425
+ void evaluateStrength(String pw, StateSetter setDialogState) {
426
+ if (pw.isEmpty) {
427
+ setDialogState(() { strength = 0; strengthLabel = ''; strengthColor = Colors.transparent; });
428
+ return;
429
+ }
430
+ double s = 0;
431
+ if (pw.length >= 6) s += 0.15;
432
+ if (pw.length >= 8) s += 0.15;
433
+ if (pw.length >= 12) s += 0.1;
434
+ if (RegExp(r'[a-z]').hasMatch(pw)) s += 0.15;
435
+ if (RegExp(r'[A-Z]').hasMatch(pw)) s += 0.15;
436
+ if (RegExp(r'[0-9]').hasMatch(pw)) s += 0.15;
437
+ if (RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(pw)) s += 0.15;
438
+ s = s.clamp(0.0, 1.0);
439
+
440
+ String label;
441
+ Color color;
442
+ if (s < 0.3) { label = 'Weak'; color = AppColors.danger; }
443
+ else if (s < 0.6) { label = 'Fair'; color = AppColors.warning; }
444
+ else if (s < 0.85) { label = 'Good'; color = const Color(0xFF2196F3); }
445
+ else { label = 'Strong'; color = AppColors.success; }
446
+
447
+ setDialogState(() { strength = s; strengthLabel = label; strengthColor = color; });
448
+ }
449
+
450
+ showDialog(
451
+ context: context,
452
+ builder: (ctx) => StatefulBuilder(
453
+ builder: (ctx, setDialogState) => AlertDialog(
454
+ backgroundColor: AppColors.cardBg,
455
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
456
+ title: Row(
457
+ children: [
458
+ Container(
459
+ width: 38, height: 38,
460
+ decoration: BoxDecoration(
461
+ color: const Color(0xFF8B5CF6).withValues(alpha: 0.1),
462
+ borderRadius: BorderRadius.circular(10),
463
+ ),
464
+ child: const Icon(Icons.lock_outline, color: Color(0xFF8B5CF6), size: 20),
465
+ ),
466
+ const SizedBox(width: 12),
467
+ Text('Change Password', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
468
+ ],
469
+ ),
470
+ content: SingleChildScrollView(
471
+ child: Column(
472
+ mainAxisSize: MainAxisSize.min,
473
+ crossAxisAlignment: CrossAxisAlignment.start,
474
+ children: [
475
+ // Current password
476
+ TextField(
477
+ controller: currentCtrl,
478
+ obscureText: obscureCurrent,
479
+ decoration: InputDecoration(
480
+ hintText: 'Current password',
481
+ hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
482
+ prefixIcon: Icon(Icons.lock_clock, color: AppColors.textSecondary, size: 20),
483
+ suffixIcon: IconButton(
484
+ icon: Icon(obscureCurrent ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 18),
485
+ onPressed: () => setDialogState(() => obscureCurrent = !obscureCurrent),
486
+ ),
487
+ filled: true,
488
+ fillColor: AppColors.inputBg,
489
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
490
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
491
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
492
+ focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.primaryLight, width: 1.5)),
493
+ ),
494
+ ),
495
+ const SizedBox(height: 14),
496
+
497
+ // New password
498
+ TextField(
499
+ controller: newCtrl,
500
+ obscureText: obscureNew,
501
+ onChanged: (val) => evaluateStrength(val, setDialogState),
502
+ decoration: InputDecoration(
503
+ hintText: 'New password',
504
+ hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
505
+ prefixIcon: Icon(Icons.lock_outline, color: AppColors.textSecondary, size: 20),
506
+ suffixIcon: IconButton(
507
+ icon: Icon(obscureNew ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 18),
508
+ onPressed: () => setDialogState(() => obscureNew = !obscureNew),
509
+ ),
510
+ filled: true,
511
+ fillColor: AppColors.inputBg,
512
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
513
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
514
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
515
+ focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.primaryLight, width: 1.5)),
516
+ ),
517
+ ),
518
+
519
+ // Strength bar
520
+ if (newCtrl.text.isNotEmpty) ...[
521
+ const SizedBox(height: 8),
522
+ Row(
523
+ children: [
524
+ Expanded(
525
+ child: ClipRRect(
526
+ borderRadius: BorderRadius.circular(4),
527
+ child: LinearProgressIndicator(
528
+ value: strength,
529
+ backgroundColor: AppColors.divider,
530
+ valueColor: AlwaysStoppedAnimation<Color>(strengthColor),
531
+ minHeight: 4,
532
+ ),
533
+ ),
534
+ ),
535
+ const SizedBox(width: 10),
536
+ Text(strengthLabel, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: strengthColor)),
537
+ ],
538
+ ),
539
+ ],
540
+ const SizedBox(height: 14),
541
+
542
+ // Confirm new password
543
+ TextField(
544
+ controller: confirmCtrl,
545
+ obscureText: obscureConfirm,
546
+ decoration: InputDecoration(
547
+ hintText: 'Confirm new password',
548
+ hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
549
+ prefixIcon: Icon(Icons.lock_outline, color: AppColors.textSecondary, size: 20),
550
+ suffixIcon: IconButton(
551
+ icon: Icon(obscureConfirm ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 18),
552
+ onPressed: () => setDialogState(() => obscureConfirm = !obscureConfirm),
553
+ ),
554
+ filled: true,
555
+ fillColor: AppColors.inputBg,
556
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
557
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
558
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.divider)),
559
+ focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.primaryLight, width: 1.5)),
560
+ ),
561
+ ),
562
+
563
+ if (errorMsg != null)
564
+ Padding(
565
+ padding: const EdgeInsets.only(top: 12),
566
+ child: Container(
567
+ padding: const EdgeInsets.all(10),
568
+ decoration: BoxDecoration(color: AppColors.dangerBg, borderRadius: BorderRadius.circular(8)),
569
+ child: Row(children: [
570
+ const Icon(Icons.error_outline, color: AppColors.danger, size: 14),
571
+ const SizedBox(width: 6),
572
+ Expanded(child: Text(errorMsg!, style: const TextStyle(color: AppColors.danger, fontSize: 12))),
573
+ ]),
574
+ ),
575
+ ),
576
+
577
+ const SizedBox(height: 8),
578
+ Container(
579
+ padding: const EdgeInsets.all(10),
580
+ decoration: BoxDecoration(
581
+ color: AppColors.infoBg,
582
+ borderRadius: BorderRadius.circular(8),
583
+ border: Border.all(color: AppColors.divider),
584
+ ),
585
+ child: Row(
586
+ children: [
587
+ Icon(Icons.info_outline, color: AppColors.primaryLight, size: 14),
588
+ const SizedBox(width: 8),
589
+ Expanded(
590
+ child: Text(
591
+ 'Use 8+ characters with uppercase, lowercase, numbers, and symbols.',
592
+ style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.3),
593
+ ),
594
+ ),
595
+ ],
596
+ ),
597
+ ),
598
+ ],
599
+ ),
600
+ ),
601
+ actions: [
602
+ TextButton(
603
+ onPressed: () => Navigator.of(ctx).pop(),
604
+ child: Text('Cancel', style: TextStyle(color: AppColors.textSecondary)),
605
+ ),
606
+ ElevatedButton(
607
+ onPressed: loading ? null : () async {
608
+ if (currentCtrl.text.isEmpty) {
609
+ setDialogState(() => errorMsg = 'Enter your current password.');
610
+ return;
611
+ }
612
+ if (newCtrl.text.length < 8) {
613
+ setDialogState(() => errorMsg = 'New password must be at least 8 characters.');
614
+ return;
615
+ }
616
+ if (strength < 0.3) {
617
+ setDialogState(() => errorMsg = 'New password is too weak.');
618
+ return;
619
+ }
620
+ if (newCtrl.text != confirmCtrl.text) {
621
+ setDialogState(() => errorMsg = 'New passwords do not match.');
622
+ return;
623
+ }
624
+ setDialogState(() { loading = true; errorMsg = null; });
625
+ try {
626
+ final result = await AuthService.changePassword(currentCtrl.text, newCtrl.text);
627
+ if (!ctx.mounted) return;
628
+ if (result['success'] == true) {
629
+ Navigator.of(ctx).pop();
630
+ _showSuccessSnack('Password updated successfully!');
631
+ } else {
632
+ setDialogState(() { errorMsg = result['error']; loading = false; });
633
+ }
634
+ } catch (e) {
635
+ setDialogState(() { errorMsg = 'Connection error.'; loading = false; });
636
+ }
637
+ },
638
+ style: ElevatedButton.styleFrom(
639
+ backgroundColor: AppColors.primary,
640
+ foregroundColor: Colors.white,
641
+ elevation: 0,
642
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
643
+ ),
644
+ child: loading
645
+ ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
646
+ : const Text('Update Password', style: TextStyle(fontWeight: FontWeight.w600)),
647
+ ),
648
+ ],
649
+ ),
650
+ ),
651
+ );
652
+ }
653
+
654
+ // ── Delete Account Dialog ─────────────────────────────────────────
655
+ void _showDeleteAccountDialog() {
656
+ final pwCtrl = TextEditingController();
657
+ bool loading = false;
658
+ String? errorMsg;
659
+ bool obscure = true;
660
+
661
+ showDialog(
662
+ context: context,
663
+ builder: (ctx) => StatefulBuilder(
664
+ builder: (ctx, setDialogState) => AlertDialog(
665
+ backgroundColor: AppColors.cardBg,
666
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
667
+ title: Row(
668
+ children: [
669
+ Container(
670
+ width: 38, height: 38,
671
+ decoration: BoxDecoration(
672
+ color: AppColors.danger.withValues(alpha: 0.1),
673
+ borderRadius: BorderRadius.circular(10),
674
+ ),
675
+ child: const Icon(Icons.delete_forever, color: AppColors.danger, size: 20),
676
+ ),
677
+ const SizedBox(width: 12),
678
+ Text('Delete Account', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.danger)),
679
+ ],
680
+ ),
681
+ content: Column(
682
+ mainAxisSize: MainAxisSize.min,
683
+ crossAxisAlignment: CrossAxisAlignment.start,
684
+ children: [
685
+ Container(
686
+ padding: const EdgeInsets.all(12),
687
+ decoration: BoxDecoration(
688
+ color: AppColors.dangerBg,
689
+ borderRadius: BorderRadius.circular(10),
690
+ border: Border.all(color: AppColors.dangerBorder),
691
+ ),
692
+ child: Row(
693
+ children: [
694
+ const Icon(Icons.warning_amber_rounded, color: AppColors.danger, size: 20),
695
+ const SizedBox(width: 10),
696
+ Expanded(
697
+ child: Text(
698
+ 'This action is permanent and cannot be undone. All your data, including fact-check history, will be permanently deleted.',
699
+ style: TextStyle(fontSize: 12, color: AppColors.danger, height: 1.4),
700
+ ),
701
+ ),
702
+ ],
703
+ ),
704
+ ),
705
+ const SizedBox(height: 18),
706
+ Text('Enter your password to confirm:', style: TextStyle(fontSize: 13, color: AppColors.textSecondary)),
707
+ const SizedBox(height: 10),
708
+ TextField(
709
+ controller: pwCtrl,
710
+ obscureText: obscure,
711
+ decoration: InputDecoration(
712
+ hintText: 'Your password',
713
+ hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
714
+ prefixIcon: Icon(Icons.lock_outline, color: AppColors.textSecondary, size: 20),
715
+ suffixIcon: IconButton(
716
+ icon: Icon(obscure ? Icons.visibility_off : Icons.visibility, color: AppColors.textSecondary, size: 18),
717
+ onPressed: () => setDialogState(() => obscure = !obscure),
718
+ ),
719
+ filled: true,
720
+ fillColor: AppColors.inputBg,
721
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
722
+ border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.dangerBorder)),
723
+ enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: AppColors.dangerBorder)),
724
+ focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: AppColors.danger, width: 1.5)),
725
+ ),
726
+ ),
727
+ if (errorMsg != null)
728
+ Padding(
729
+ padding: const EdgeInsets.only(top: 10),
730
+ child: Container(
731
+ padding: const EdgeInsets.all(10),
732
+ decoration: BoxDecoration(color: AppColors.dangerBg, borderRadius: BorderRadius.circular(8)),
733
+ child: Row(children: [
734
+ const Icon(Icons.error_outline, color: AppColors.danger, size: 14),
735
+ const SizedBox(width: 6),
736
+ Expanded(child: Text(errorMsg!, style: const TextStyle(color: AppColors.danger, fontSize: 12))),
737
+ ]),
738
+ ),
739
+ ),
740
+ ],
741
+ ),
742
+ actions: [
743
+ TextButton(
744
+ onPressed: () => Navigator.of(ctx).pop(),
745
+ child: Text('Cancel', style: TextStyle(color: AppColors.textSecondary)),
746
+ ),
747
+ ElevatedButton(
748
+ onPressed: loading ? null : () async {
749
+ if (pwCtrl.text.isEmpty) {
750
+ setDialogState(() => errorMsg = 'Please enter your password.');
751
+ return;
752
+ }
753
+ setDialogState(() { loading = true; errorMsg = null; });
754
+ try {
755
+ final result = await AuthService.deleteAccount(pwCtrl.text);
756
+ if (!ctx.mounted) return;
757
+ if (result['success'] == true) {
758
+ Navigator.of(ctx).pop();
759
+ if (mounted) {
760
+ Navigator.pushReplacementNamed(context, '/login');
761
+ }
762
+ } else {
763
+ setDialogState(() { errorMsg = result['error']; loading = false; });
764
+ }
765
+ } catch (e) {
766
+ setDialogState(() { errorMsg = 'Connection error.'; loading = false; });
767
+ }
768
+ },
769
+ style: ElevatedButton.styleFrom(
770
+ backgroundColor: AppColors.danger,
771
+ foregroundColor: Colors.white,
772
+ elevation: 0,
773
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
774
+ ),
775
+ child: loading
776
+ ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
777
+ : const Text('Delete My Account', style: TextStyle(fontWeight: FontWeight.w600)),
778
+ ),
779
+ ],
780
+ ),
781
+ ),
782
+ );
783
+ }
784
+
785
+ // ── Privacy Policy Dialog ─────────────────────────────────────────
786
+ void _showPrivacyPolicy() {
787
+ showDialog(
788
+ context: context,
789
+ builder: (ctx) => AlertDialog(
790
+ backgroundColor: AppColors.cardBg,
791
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
792
+ title: Row(
793
+ children: [
794
+ Icon(Icons.privacy_tip_outlined, color: AppColors.primaryLight, size: 22),
795
+ const SizedBox(width: 10),
796
+ Text('Privacy Policy', style: TextStyle(
797
+ fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary,
798
+ )),
799
+ ],
800
+ ),
801
+ content: SizedBox(
802
+ width: double.maxFinite,
803
+ height: 400,
804
+ child: SingleChildScrollView(
805
+ child: Text(
806
+ _privacyPolicyText,
807
+ style: TextStyle(fontSize: 13, color: AppColors.textSecondary, height: 1.6),
808
+ ),
809
+ ),
810
+ ),
811
+ actions: [
812
+ TextButton(
813
+ onPressed: () => Navigator.of(ctx).pop(),
814
+ child: Text('Close', style: TextStyle(color: AppColors.primaryLight, fontWeight: FontWeight.w600)),
815
+ ),
816
+ ],
817
+ ),
818
+ );
819
+ }
820
+
821
+ void _showSuccessSnack(String message) {
822
+ ScaffoldMessenger.of(context).showSnackBar(
823
+ SnackBar(
824
+ content: Row(
825
+ children: [
826
+ const Icon(Icons.check_circle, color: Colors.white, size: 18),
827
+ const SizedBox(width: 10),
828
+ Expanded(child: Text(message, style: const TextStyle(color: Colors.white, fontSize: 13))),
829
+ ],
830
+ ),
831
+ backgroundColor: AppColors.success,
832
+ behavior: SnackBarBehavior.floating,
833
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
834
+ margin: const EdgeInsets.all(16),
835
+ duration: const Duration(seconds: 3),
836
+ ),
837
+ );
838
+ }
839
+ }
840
+
841
+ // ── Settings Tile Widget ──────────────────────────────────────────────
842
+ class _SettingsTile extends StatelessWidget {
843
+ final IconData icon;
844
+ final Color iconColor;
845
+ final String title;
846
+ final String subtitle;
847
+ final Color? titleColor;
848
+ final Widget? trailing;
849
+ final VoidCallback? onTap;
850
+ final bool showDivider;
851
+
852
+ const _SettingsTile({
853
+ required this.icon,
854
+ required this.iconColor,
855
+ required this.title,
856
+ required this.subtitle,
857
+ this.titleColor,
858
+ this.trailing,
859
+ this.onTap,
860
+ this.showDivider = true,
861
+ });
862
+
863
+ _SettingsTile copyWith({bool? showDivider}) {
864
+ return _SettingsTile(
865
+ icon: icon,
866
+ iconColor: iconColor,
867
+ title: title,
868
+ subtitle: subtitle,
869
+ titleColor: titleColor,
870
+ trailing: trailing,
871
+ onTap: onTap,
872
+ showDivider: showDivider ?? this.showDivider,
873
+ );
874
+ }
875
+
876
+ @override
877
+ Widget build(BuildContext context) {
878
+ return Column(
879
+ children: [
880
+ InkWell(
881
+ onTap: onTap,
882
+ borderRadius: BorderRadius.circular(14),
883
+ child: Padding(
884
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
885
+ child: Row(
886
+ children: [
887
+ Container(
888
+ width: 38, height: 38,
889
+ decoration: BoxDecoration(
890
+ color: iconColor.withValues(alpha: 0.1),
891
+ borderRadius: BorderRadius.circular(10),
892
+ ),
893
+ child: Icon(icon, color: iconColor, size: 20),
894
+ ),
895
+ const SizedBox(width: 14),
896
+ Expanded(
897
+ child: Column(
898
+ crossAxisAlignment: CrossAxisAlignment.start,
899
+ children: [
900
+ Text(title, style: TextStyle(
901
+ fontSize: 14,
902
+ fontWeight: FontWeight.w600,
903
+ color: titleColor ?? AppColors.textPrimary,
904
+ )),
905
+ const SizedBox(height: 2),
906
+ Text(subtitle, style: TextStyle(fontSize: 11, color: AppColors.textSecondary)),
907
+ ],
908
+ ),
909
+ ),
910
+ if (trailing != null)
911
+ trailing!
912
+ else if (onTap != null)
913
+ Icon(Icons.chevron_right, color: AppColors.textSecondary.withValues(alpha: 0.5), size: 20),
914
+ ],
915
+ ),
916
+ ),
917
+ ),
918
+ if (showDivider)
919
+ Padding(
920
+ padding: const EdgeInsets.symmetric(horizontal: 16),
921
+ child: Divider(height: 1, color: AppColors.divider),
922
+ ),
923
+ ],
924
+ );
925
+ }
926
+ }
927
+
928
+ const String _privacyPolicyText = '''
929
+ BantayPahayag Privacy Policy
930
+
931
+ Last updated: March 2026
932
+
933
+ 1. INFORMATION WE COLLECT
934
+
935
+ When you create an account, we collect:
936
+ β€’ Email address β€” used for account identification and communication.
937
+ β€’ Username β€” a publicly visible display name within the app.
938
+ β€’ Password β€” securely hashed and stored; we never store plaintext passwords.
939
+
940
+ When you use our fact-checking services, we may store:
941
+ β€’ The text or URLs you submit for analysis.
942
+ β€’ The results of our fact-check analysis.
943
+ β€’ Timestamps of your activity.
944
+
945
+ 2. HOW WE USE YOUR INFORMATION
946
+
947
+ We use the collected information to:
948
+ β€’ Provide and maintain the BantayPahayag fact-checking service.
949
+ β€’ Store your fact-check history for your convenience.
950
+ β€’ Improve our machine learning models and accuracy.
951
+ β€’ Communicate important service updates.
952
+
953
+ 3. DATA STORAGE AND SECURITY
954
+
955
+ β€’ All passwords are cryptographically hashed with random salts.
956
+ β€’ We use industry-standard encryption for data transmission (HTTPS/TLS).
957
+ β€’ Your data is stored on secure, access-controlled servers.
958
+ β€’ We conduct regular security reviews and updates.
959
+
960
+ 4. DATA SHARING
961
+
962
+ We do NOT:
963
+ β€’ Sell your personal data to third parties.
964
+ β€’ Share your data with advertisers.
965
+ β€’ Use your data for targeted advertising.
966
+
967
+ We may share anonymized, aggregated data for research purposes related to combating misinformation.
968
+
969
+ 5. YOUR RIGHTS
970
+
971
+ You have the right to:
972
+ β€’ Access your personal data stored in our system.
973
+ β€’ Update your profile information (username, password).
974
+ β€’ Delete your account and all associated data permanently.
975
+ β€’ Request a copy of your data.
976
+
977
+ 6. DATA RETENTION
978
+
979
+ β€’ Account data is retained until you delete your account.
980
+ β€’ Fact-check history is retained for your reference until account deletion.
981
+ β€’ Upon account deletion, all personal data is permanently removed.
982
+
983
+ 7. COOKIES AND TRACKING
984
+
985
+ BantayPahayag uses minimal local storage (shared preferences) for:
986
+ β€’ Maintaining your login session.
987
+ β€’ Storing your theme preference (light/dark mode).
988
+ We do NOT use third-party tracking or analytics cookies.
989
+
990
+ 8. CHILDREN'S PRIVACY
991
+
992
+ BantayPahayag is not intended for children under the age of 13. We do not knowingly collect personal data from children.
993
+
994
+ 9. CHANGES TO THIS POLICY
995
+
996
+ We may update this privacy policy from time to time. We will notify users of significant changes through the app.
997
+
998
+ 10. CONTACT
999
+
1000
+ For questions or concerns about this privacy policy, please contact the BantayPahayag development team.
1001
+
1002
+ This application was developed as part of an academic thesis project at Cavite State University.
1003
+ ''';
check_app/lib/screens/verify_email_screen.dart ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter/services.dart';
3
+ import '../main.dart';
4
+ import '../services/auth_service.dart';
5
+
6
+ class VerifyEmailScreen extends StatefulWidget {
7
+ const VerifyEmailScreen({super.key});
8
+ @override
9
+ State<VerifyEmailScreen> createState() => _VerifyEmailScreenState();
10
+ }
11
+
12
+ class _VerifyEmailScreenState extends State<VerifyEmailScreen> with SingleTickerProviderStateMixin {
13
+ final List<TextEditingController> _codeControllers = List.generate(6, (_) => TextEditingController());
14
+ final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
15
+
16
+ bool _loading = false;
17
+ bool _resending = false;
18
+ String? _error;
19
+ String? _successMsg;
20
+ late final AnimationController _animCtrl;
21
+ late final Animation<double> _fadeAnim;
22
+
23
+ @override
24
+ void initState() {
25
+ super.initState();
26
+ _animCtrl = AnimationController(duration: const Duration(milliseconds: 700), vsync: this);
27
+ _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
28
+ _animCtrl.forward();
29
+ }
30
+
31
+ @override
32
+ void dispose() {
33
+ _animCtrl.dispose();
34
+ for (final c in _codeControllers) {
35
+ c.dispose();
36
+ }
37
+ for (final f in _focusNodes) {
38
+ f.dispose();
39
+ }
40
+ super.dispose();
41
+ }
42
+
43
+ String get _fullCode => _codeControllers.map((c) => c.text).join();
44
+
45
+ Future<void> _verify() async {
46
+ final code = _fullCode;
47
+ if (code.length != 6) {
48
+ setState(() => _error = 'Please enter the complete 6-digit code.');
49
+ return;
50
+ }
51
+
52
+ setState(() { _loading = true; _error = null; _successMsg = null; });
53
+ try {
54
+ final result = await AuthService.verifyEmail(code);
55
+ if (!mounted) return;
56
+ if (result['success'] == true) {
57
+ Navigator.pushReplacementNamed(context, '/home');
58
+ } else {
59
+ setState(() { _error = result['error']; _loading = false; });
60
+ // Clear the code fields on error
61
+ for (final c in _codeControllers) {
62
+ c.clear();
63
+ }
64
+ _focusNodes[0].requestFocus();
65
+ }
66
+ } catch (e) {
67
+ if (!mounted) return;
68
+ setState(() { _error = 'Connection error. Please try again.'; _loading = false; });
69
+ }
70
+ }
71
+
72
+ Future<void> _resend() async {
73
+ setState(() { _resending = true; _error = null; _successMsg = null; });
74
+ try {
75
+ final result = await AuthService.resendVerification();
76
+ if (!mounted) return;
77
+ if (result['success'] == true) {
78
+ setState(() { _successMsg = result['message'] ?? 'Verification code sent!'; _resending = false; });
79
+ } else {
80
+ setState(() { _error = result['error']; _resending = false; });
81
+ }
82
+ } catch (e) {
83
+ if (!mounted) return;
84
+ setState(() { _error = 'Connection error. Please try again.'; _resending = false; });
85
+ }
86
+ }
87
+
88
+ @override
89
+ Widget build(BuildContext context) {
90
+ final email = AuthService.user?['email'] ?? '';
91
+
92
+ return Scaffold(
93
+ backgroundColor: AppColors.background,
94
+ body: SafeArea(
95
+ child: FadeTransition(
96
+ opacity: _fadeAnim,
97
+ child: Center(
98
+ child: SingleChildScrollView(
99
+ padding: const EdgeInsets.symmetric(horizontal: 28),
100
+ child: Column(
101
+ mainAxisAlignment: MainAxisAlignment.center,
102
+ children: [
103
+ // ── Mail icon ─────────────────────────────────
104
+ Container(
105
+ width: 80, height: 80,
106
+ decoration: BoxDecoration(
107
+ gradient: const LinearGradient(
108
+ colors: [Color(0xFF1E3A8A), Color(0xFF3B82F6)],
109
+ begin: Alignment.topLeft,
110
+ end: Alignment.bottomRight,
111
+ ),
112
+ borderRadius: BorderRadius.circular(20),
113
+ boxShadow: [
114
+ BoxShadow(
115
+ color: const Color(0xFF1E3A8A).withValues(alpha: 0.3),
116
+ blurRadius: 20,
117
+ offset: const Offset(0, 8),
118
+ ),
119
+ ],
120
+ ),
121
+ child: const Icon(Icons.mark_email_read_outlined, color: Colors.white, size: 40),
122
+ ),
123
+ const SizedBox(height: 28),
124
+
125
+ // ── Title ─────────────────────────────────────
126
+ Text(
127
+ 'Verify Your Email',
128
+ style: TextStyle(
129
+ fontSize: 26,
130
+ fontWeight: FontWeight.w800,
131
+ color: AppColors.textPrimary,
132
+ letterSpacing: -0.5,
133
+ ),
134
+ ),
135
+ const SizedBox(height: 10),
136
+ Text(
137
+ 'We sent a 6-digit verification code to',
138
+ style: TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.4),
139
+ textAlign: TextAlign.center,
140
+ ),
141
+ const SizedBox(height: 4),
142
+ Text(
143
+ email,
144
+ style: TextStyle(
145
+ fontSize: 14,
146
+ fontWeight: FontWeight.w700,
147
+ color: AppColors.primaryLight,
148
+ ),
149
+ textAlign: TextAlign.center,
150
+ ),
151
+ const SizedBox(height: 32),
152
+
153
+ // ── Code input fields ─────────────────────────
154
+ Row(
155
+ mainAxisAlignment: MainAxisAlignment.center,
156
+ children: List.generate(6, (i) => _buildCodeField(i)),
157
+ ),
158
+ const SizedBox(height: 24),
159
+
160
+ // ── Error message ─────────────────────────────
161
+ if (_error != null)
162
+ Container(
163
+ width: double.infinity,
164
+ padding: const EdgeInsets.all(12),
165
+ margin: const EdgeInsets.only(bottom: 16),
166
+ decoration: BoxDecoration(
167
+ color: AppColors.dangerBg,
168
+ borderRadius: BorderRadius.circular(10),
169
+ border: Border.all(color: AppColors.dangerBorder),
170
+ ),
171
+ child: Row(
172
+ children: [
173
+ const Icon(Icons.error_outline, color: AppColors.danger, size: 16),
174
+ const SizedBox(width: 8),
175
+ Expanded(
176
+ child: Text(_error!, style: const TextStyle(color: AppColors.danger, fontSize: 13)),
177
+ ),
178
+ ],
179
+ ),
180
+ ),
181
+
182
+ // ── Success message ───────────────────────────
183
+ if (_successMsg != null)
184
+ Container(
185
+ width: double.infinity,
186
+ padding: const EdgeInsets.all(12),
187
+ margin: const EdgeInsets.only(bottom: 16),
188
+ decoration: BoxDecoration(
189
+ color: AppColors.success.withValues(alpha: 0.1),
190
+ borderRadius: BorderRadius.circular(10),
191
+ border: Border.all(color: AppColors.success.withValues(alpha: 0.3)),
192
+ ),
193
+ child: Row(
194
+ children: [
195
+ Icon(Icons.check_circle_outline, color: AppColors.success, size: 16),
196
+ const SizedBox(width: 8),
197
+ Expanded(
198
+ child: Text(_successMsg!, style: TextStyle(color: AppColors.success, fontSize: 13)),
199
+ ),
200
+ ],
201
+ ),
202
+ ),
203
+
204
+ // ── Verify button ─────────────────────────────
205
+ SizedBox(
206
+ width: double.infinity,
207
+ height: 52,
208
+ child: ElevatedButton(
209
+ onPressed: _loading ? null : _verify,
210
+ style: ElevatedButton.styleFrom(
211
+ backgroundColor: AppColors.primary,
212
+ foregroundColor: Colors.white,
213
+ elevation: 0,
214
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
215
+ ),
216
+ child: _loading
217
+ ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white))
218
+ : const Text('Verify Email', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
219
+ ),
220
+ ),
221
+ const SizedBox(height: 20),
222
+
223
+ // ── Resend code ───────────────────────────────
224
+ Row(
225
+ mainAxisAlignment: MainAxisAlignment.center,
226
+ children: [
227
+ Text("Didn't receive the code? ", style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
228
+ GestureDetector(
229
+ onTap: _resending ? null : _resend,
230
+ child: _resending
231
+ ? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primaryLight))
232
+ : Text(
233
+ 'Resend Code',
234
+ style: TextStyle(
235
+ color: AppColors.primaryLight,
236
+ fontWeight: FontWeight.w600,
237
+ fontSize: 13,
238
+ ),
239
+ ),
240
+ ),
241
+ ],
242
+ ),
243
+ const SizedBox(height: 16),
244
+
245
+ // ── Info banner ────────────────────────────────
246
+ Container(
247
+ width: double.infinity,
248
+ padding: const EdgeInsets.all(14),
249
+ decoration: BoxDecoration(
250
+ color: AppColors.infoBg,
251
+ borderRadius: BorderRadius.circular(10),
252
+ border: Border.all(color: AppColors.divider),
253
+ ),
254
+ child: Row(
255
+ children: [
256
+ Icon(Icons.access_time_outlined, color: AppColors.primaryLight, size: 16),
257
+ const SizedBox(width: 10),
258
+ Expanded(
259
+ child: Text(
260
+ 'The verification code expires in 10 minutes. Check your spam folder if you don\'t see it.',
261
+ style: TextStyle(fontSize: 11, color: AppColors.textSecondary, height: 1.4),
262
+ ),
263
+ ),
264
+ ],
265
+ ),
266
+ ),
267
+ const SizedBox(height: 24),
268
+
269
+ // ── Sign out link ─────────────────────────────
270
+ GestureDetector(
271
+ onTap: () async {
272
+ await AuthService.logout();
273
+ if (!mounted) return;
274
+ Navigator.pushReplacementNamed(context, '/login');
275
+ },
276
+ child: Text(
277
+ 'Use a different account',
278
+ style: TextStyle(
279
+ color: AppColors.textSecondary.withValues(alpha: 0.7),
280
+ fontSize: 12,
281
+ decoration: TextDecoration.underline,
282
+ ),
283
+ ),
284
+ ),
285
+ const SizedBox(height: 30),
286
+ ],
287
+ ),
288
+ ),
289
+ ),
290
+ ),
291
+ ),
292
+ );
293
+ }
294
+
295
+ Widget _buildCodeField(int index) {
296
+ return Container(
297
+ width: 46,
298
+ height: 56,
299
+ margin: EdgeInsets.only(left: index == 0 ? 0 : 8),
300
+ child: TextField(
301
+ controller: _codeControllers[index],
302
+ focusNode: _focusNodes[index],
303
+ keyboardType: TextInputType.number,
304
+ textAlign: TextAlign.center,
305
+ maxLength: 1,
306
+ style: TextStyle(
307
+ fontSize: 22,
308
+ fontWeight: FontWeight.w800,
309
+ color: AppColors.textPrimary,
310
+ letterSpacing: 0,
311
+ ),
312
+ decoration: InputDecoration(
313
+ counterText: '',
314
+ filled: true,
315
+ fillColor: AppColors.inputBg,
316
+ contentPadding: const EdgeInsets.symmetric(vertical: 14),
317
+ border: OutlineInputBorder(
318
+ borderRadius: BorderRadius.circular(12),
319
+ borderSide: BorderSide(color: AppColors.divider, width: 1.5),
320
+ ),
321
+ enabledBorder: OutlineInputBorder(
322
+ borderRadius: BorderRadius.circular(12),
323
+ borderSide: BorderSide(color: AppColors.divider, width: 1.5),
324
+ ),
325
+ focusedBorder: OutlineInputBorder(
326
+ borderRadius: BorderRadius.circular(12),
327
+ borderSide: const BorderSide(color: AppColors.primaryLight, width: 2),
328
+ ),
329
+ ),
330
+ inputFormatters: [FilteringTextInputFormatter.digitsOnly],
331
+ onChanged: (val) {
332
+ if (val.isNotEmpty && index < 5) {
333
+ _focusNodes[index + 1].requestFocus();
334
+ }
335
+ // Auto-submit when all 6 digits are filled
336
+ if (_fullCode.length == 6) {
337
+ _verify();
338
+ }
339
+ },
340
+ ),
341
+ );
342
+ }
343
+ }
check_app/lib/services/auth_service.dart CHANGED
@@ -10,6 +10,7 @@ class AuthService {
10
  static String? get token => _token;
11
  static Map<String, dynamic>? get user => _user;
12
  static bool get isLoggedIn => _token != null;
 
13
 
14
  /// Load saved token from disk on app start.
15
  static Future<bool> loadSavedSession() async {
@@ -26,6 +27,12 @@ class AuthService {
26
  headers: {'Authorization': 'Bearer $saved'},
27
  ).timeout(const Duration(seconds: 10));
28
  if (res.statusCode == 200) {
 
 
 
 
 
 
29
  return true;
30
  }
31
  // If 503 (space sleeping), keep the saved session and let user in
@@ -57,7 +64,11 @@ class AuthService {
57
  final body = jsonDecode(res.body);
58
  if (res.statusCode == 200) {
59
  await _saveSession(body['token'], body['user']);
60
- return {'success': true};
 
 
 
 
61
  }
62
  return {'success': false, 'error': body['detail'] ?? 'Registration failed.'};
63
  }
@@ -75,15 +86,80 @@ class AuthService {
75
  if (persist) {
76
  await _saveSession(body['token'], body['user']);
77
  } else {
78
- // Memory-only session β€” won't survive app restart
79
  _token = body['token'];
80
  _user = body['user'];
81
  }
82
- return {'success': true};
 
 
 
83
  }
84
  return {'success': false, 'error': body['detail'] ?? 'Login failed.'};
85
  }
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  /// Fetch the user's fact-check history.
88
  static Future<List<dynamic>> fetchHistory() async {
89
  if (_token == null) return [];
@@ -120,4 +196,37 @@ class AuthService {
120
  await prefs.remove('auth_token');
121
  await prefs.remove('auth_user');
122
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
 
10
  static String? get token => _token;
11
  static Map<String, dynamic>? get user => _user;
12
  static bool get isLoggedIn => _token != null;
13
+ static bool get isEmailVerified => _user?['email_verified'] == true;
14
 
15
  /// Load saved token from disk on app start.
16
  static Future<bool> loadSavedSession() async {
 
27
  headers: {'Authorization': 'Bearer $saved'},
28
  ).timeout(const Duration(seconds: 10));
29
  if (res.statusCode == 200) {
30
+ // Update user data from server
31
+ final body = jsonDecode(res.body);
32
+ if (body['user'] != null) {
33
+ _user = body['user'];
34
+ await prefs.setString('auth_user', jsonEncode(_user));
35
+ }
36
  return true;
37
  }
38
  // If 503 (space sleeping), keep the saved session and let user in
 
64
  final body = jsonDecode(res.body);
65
  if (res.statusCode == 200) {
66
  await _saveSession(body['token'], body['user']);
67
+ return {
68
+ 'success': true,
69
+ 'email_verified': body['user']?['email_verified'] ?? false,
70
+ 'verification_email_sent': body['verification_email_sent'] ?? false,
71
+ };
72
  }
73
  return {'success': false, 'error': body['detail'] ?? 'Registration failed.'};
74
  }
 
86
  if (persist) {
87
  await _saveSession(body['token'], body['user']);
88
  } else {
 
89
  _token = body['token'];
90
  _user = body['user'];
91
  }
92
+ return {
93
+ 'success': true,
94
+ 'email_verified': body['user']?['email_verified'] ?? false,
95
+ };
96
  }
97
  return {'success': false, 'error': body['detail'] ?? 'Login failed.'};
98
  }
99
 
100
+ /// Check if a username is available.
101
+ static Future<Map<String, dynamic>> checkUsername(String username) async {
102
+ final res = await http.post(
103
+ Uri.parse('$apiBaseUrl/api/check-username'),
104
+ headers: {'Content-Type': 'application/json'},
105
+ body: jsonEncode({'username': username}),
106
+ );
107
+ return jsonDecode(res.body);
108
+ }
109
+
110
+ /// Update the current user's username.
111
+ static Future<Map<String, dynamic>> updateProfile(String newUsername) async {
112
+ if (_token == null) return {'success': false, 'error': 'Not logged in.'};
113
+ final res = await http.put(
114
+ Uri.parse('$apiBaseUrl/api/update-profile'),
115
+ headers: authHeaders,
116
+ body: jsonEncode({'username': newUsername}),
117
+ );
118
+ final body = jsonDecode(res.body);
119
+ if (res.statusCode == 200 && body['success'] == true) {
120
+ // Update local user data
121
+ _user = body['user'];
122
+ final prefs = await SharedPreferences.getInstance();
123
+ await prefs.setString('auth_user', jsonEncode(_user));
124
+ return {'success': true, 'user': body['user']};
125
+ }
126
+ return {'success': false, 'error': body['detail'] ?? 'Failed to update profile.'};
127
+ }
128
+
129
+ /// Change the current user's password.
130
+ static Future<Map<String, dynamic>> changePassword(String currentPassword, String newPassword) async {
131
+ if (_token == null) return {'success': false, 'error': 'Not logged in.'};
132
+ final res = await http.put(
133
+ Uri.parse('$apiBaseUrl/api/change-password'),
134
+ headers: authHeaders,
135
+ body: jsonEncode({
136
+ 'current_password': currentPassword,
137
+ 'new_password': newPassword,
138
+ }),
139
+ );
140
+ final body = jsonDecode(res.body);
141
+ if (res.statusCode == 200 && body['success'] == true) {
142
+ return {'success': true, 'message': body['message']};
143
+ }
144
+ return {'success': false, 'error': body['detail'] ?? 'Failed to change password.'};
145
+ }
146
+
147
+ /// Delete the current user's account permanently.
148
+ static Future<Map<String, dynamic>> deleteAccount(String password) async {
149
+ if (_token == null) return {'success': false, 'error': 'Not logged in.'};
150
+ final res = await http.delete(
151
+ Uri.parse('$apiBaseUrl/api/delete-account'),
152
+ headers: authHeaders,
153
+ body: jsonEncode({'password': password}),
154
+ );
155
+ final body = jsonDecode(res.body);
156
+ if (res.statusCode == 200 && body['success'] == true) {
157
+ await logout();
158
+ return {'success': true};
159
+ }
160
+ return {'success': false, 'error': body['detail'] ?? 'Failed to delete account.'};
161
+ }
162
+
163
  /// Fetch the user's fact-check history.
164
  static Future<List<dynamic>> fetchHistory() async {
165
  if (_token == null) return [];
 
196
  await prefs.remove('auth_token');
197
  await prefs.remove('auth_user');
198
  }
199
+
200
+ /// Verify email with a 6-digit code.
201
+ static Future<Map<String, dynamic>> verifyEmail(String code) async {
202
+ if (_token == null) return {'success': false, 'error': 'Not logged in.'};
203
+ final res = await http.post(
204
+ Uri.parse('$apiBaseUrl/api/verify-email'),
205
+ headers: authHeaders,
206
+ body: jsonEncode({'code': code}),
207
+ );
208
+ final body = jsonDecode(res.body);
209
+ if (res.statusCode == 200 && body['success'] == true) {
210
+ // Update local user verified status
211
+ _user?['email_verified'] = true;
212
+ final prefs = await SharedPreferences.getInstance();
213
+ await prefs.setString('auth_user', jsonEncode(_user));
214
+ return {'success': true, 'message': body['message']};
215
+ }
216
+ return {'success': false, 'error': body['detail'] ?? 'Verification failed.'};
217
+ }
218
+
219
+ /// Resend the verification code email.
220
+ static Future<Map<String, dynamic>> resendVerification() async {
221
+ if (_token == null) return {'success': false, 'error': 'Not logged in.'};
222
+ final res = await http.post(
223
+ Uri.parse('$apiBaseUrl/api/resend-verification'),
224
+ headers: authHeaders,
225
+ );
226
+ final body = jsonDecode(res.body);
227
+ if (res.statusCode == 200 && body['success'] == true) {
228
+ return {'success': true, 'message': body['message']};
229
+ }
230
+ return {'success': false, 'error': body['detail'] ?? 'Could not resend verification email.'};
231
+ }
232
  }
check_app/lib/services/localization.dart ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import 'package:shared_preferences/shared_preferences.dart';
3
+
4
+ /// Supported languages
5
+ enum AppLanguage { en, fil, ceb }
6
+
7
+ /// Global language notifier
8
+ final ValueNotifier<AppLanguage> languageNotifier = ValueNotifier(AppLanguage.en);
9
+
10
+ Future<void> loadLanguagePreference() async {
11
+ final prefs = await SharedPreferences.getInstance();
12
+ final code = prefs.getString('app_language') ?? 'en';
13
+ languageNotifier.value = AppLanguage.values.firstWhere(
14
+ (l) => l.name == code,
15
+ orElse: () => AppLanguage.en,
16
+ );
17
+ }
18
+
19
+ Future<void> saveLanguagePreference(AppLanguage lang) async {
20
+ final prefs = await SharedPreferences.getInstance();
21
+ await prefs.setString('app_language', lang.name);
22
+ }
23
+
24
+ /// Get the display name for a language
25
+ String languageDisplayName(AppLanguage lang) {
26
+ switch (lang) {
27
+ case AppLanguage.en: return 'English';
28
+ case AppLanguage.fil: return 'Filipino';
29
+ case AppLanguage.ceb: return 'Cebuano';
30
+ }
31
+ }
32
+
33
+ /// Get the flag emoji for a language
34
+ String languageFlag(AppLanguage lang) {
35
+ switch (lang) {
36
+ case AppLanguage.en: return 'πŸ‡ΊπŸ‡Έ';
37
+ case AppLanguage.fil: return 'πŸ‡΅πŸ‡­';
38
+ case AppLanguage.ceb: return 'πŸ‡΅πŸ‡­';
39
+ }
40
+ }
41
+
42
+ /// Shorthand to get a translated string
43
+ String tr(String key) {
44
+ final lang = languageNotifier.value;
45
+ final map = _strings[key];
46
+ if (map == null) return key;
47
+ return map[lang] ?? map[AppLanguage.en] ?? key;
48
+ }
49
+
50
+ /// All UI strings organized by key
51
+ const Map<String, Map<AppLanguage, String>> _strings = {
52
+ // ── App-wide ──
53
+ 'app_name': {
54
+ AppLanguage.en: 'BantayPahayag',
55
+ AppLanguage.fil: 'BantayPahayag',
56
+ AppLanguage.ceb: 'BantayPahayag',
57
+ },
58
+ 'news_verification_system': {
59
+ AppLanguage.en: 'News Verification System',
60
+ AppLanguage.fil: 'Sistema sa Pag-verify ng Balita',
61
+ AppLanguage.ceb: 'Sistema sa Pag-verify sa Balita',
62
+ },
63
+
64
+ // ── Home Screen ──
65
+ 'welcome_back': {
66
+ AppLanguage.en: 'Welcome back,',
67
+ AppLanguage.fil: 'Mabuhay,',
68
+ AppLanguage.ceb: 'Maayong pag-abot,',
69
+ },
70
+ 'verification_tools': {
71
+ AppLanguage.en: 'Verification Tools',
72
+ AppLanguage.fil: 'Mga Tool sa Pag-verify',
73
+ AppLanguage.ceb: 'Mga Tool sa Pag-verify',
74
+ },
75
+ 'verify_news_today': {
76
+ AppLanguage.en: "Let's verify the news today.",
77
+ AppLanguage.fil: 'I-verify natin ang balita ngayon.',
78
+ AppLanguage.ceb: 'Atong i-verify ang balita karon.',
79
+ },
80
+ 'check_link': {
81
+ AppLanguage.en: 'Check Link',
82
+ AppLanguage.fil: 'I-check ang Link',
83
+ AppLanguage.ceb: 'I-check ang Link',
84
+ },
85
+ 'check_link_subtitle': {
86
+ AppLanguage.en: 'Verify any article URL',
87
+ AppLanguage.fil: 'I-verify ang URL ng artikulo',
88
+ AppLanguage.ceb: 'I-verify ang URL sa artikulo',
89
+ },
90
+ 'check_link_desc': {
91
+ AppLanguage.en: 'Paste any news article link to instantly verify its credibility through ML analysis and source cross-referencing.',
92
+ AppLanguage.fil: 'I-paste ang link ng balita para i-verify ang kredibilidad nito gamit ang ML analysis at cross-referencing ng pinagmulan.',
93
+ AppLanguage.ceb: 'I-paste ang link sa balita aron i-verify ang kredibilidad niini pinaagi sa ML analysis ug cross-referencing sa tinubdan.',
94
+ },
95
+ 'proofread_article': {
96
+ AppLanguage.en: 'Proofread Article',
97
+ AppLanguage.fil: 'I-proofread ang Artikulo',
98
+ AppLanguage.ceb: 'I-proofread ang Artikulo',
99
+ },
100
+ 'proofread_subtitle': {
101
+ AppLanguage.en: 'Paste text to analyze',
102
+ AppLanguage.fil: 'I-paste ang teksto para suriin',
103
+ AppLanguage.ceb: 'I-paste ang teksto aron susihon',
104
+ },
105
+ 'proofread_desc': {
106
+ AppLanguage.en: 'Paste the full article text for a deep ML-powered analysis with headline consistency check and bias detection.',
107
+ AppLanguage.fil: 'I-paste ang buong teksto ng artikulo para sa malalim na pagsusuri gamit ang ML, kasama ang pagtsek ng headline at pag-detect ng bias.',
108
+ AppLanguage.ceb: 'I-paste ang tibuok teksto sa artikulo alang sa lawom nga pagsusi gamit ang ML, lakip ang pagtsek sa headline ug pagdetektar sa bias.',
109
+ },
110
+ 'how_it_works': {
111
+ AppLanguage.en: 'How It Works',
112
+ AppLanguage.fil: 'Paano Ito Gumagana',
113
+ AppLanguage.ceb: 'Unsaon Kini Paggana',
114
+ },
115
+ 'how_step_1': {
116
+ AppLanguage.en: 'Submit a news article link or paste the full text.',
117
+ AppLanguage.fil: 'Mag-submit ng link ng balita o i-paste ang buong teksto.',
118
+ AppLanguage.ceb: 'Pag-submit og link sa balita o i-paste ang tibuok teksto.',
119
+ },
120
+ 'how_step_2': {
121
+ AppLanguage.en: 'Our ML model analyzes language patterns and credibility signals.',
122
+ AppLanguage.fil: 'Sinusuri ng aming ML model ang mga pattern ng wika at senyales ng kredibilidad.',
123
+ AppLanguage.ceb: 'Ang among ML model nag-analisar sa mga pattern sa pinulongan ug senyales sa kredibilidad.',
124
+ },
125
+ 'how_step_3': {
126
+ AppLanguage.en: 'Cross-reference with verified news databases and online sources.',
127
+ AppLanguage.fil: 'Ikukumpara sa mga verified na database ng balita at online sources.',
128
+ AppLanguage.ceb: 'I-cross-reference sa mga verified nga database sa balita ug online sources.',
129
+ },
130
+ 'how_step_4': {
131
+ AppLanguage.en: 'Get a clear verdict with detailed explanations.',
132
+ AppLanguage.fil: 'Makakuha ng malinaw na hatol kasama ang detalyadong paliwanag.',
133
+ AppLanguage.ceb: 'Makakuha og tin-aw nga resulta uban ang detalyadong pagpasabot.',
134
+ },
135
+
136
+ // ── Sidebar/Drawer ──
137
+ 'history': {
138
+ AppLanguage.en: 'History',
139
+ AppLanguage.fil: 'Kasaysayan',
140
+ AppLanguage.ceb: 'Kasaysayan',
141
+ },
142
+ 'settings': {
143
+ AppLanguage.en: 'Settings',
144
+ AppLanguage.fil: 'Mga Setting',
145
+ AppLanguage.ceb: 'Mga Setting',
146
+ },
147
+
148
+ // ── Check Link Screen ──
149
+ 'article_link_checker': {
150
+ AppLanguage.en: 'Article Link Checker',
151
+ AppLanguage.fil: 'Tagasuri ng Link ng Artikulo',
152
+ AppLanguage.ceb: 'Tagsusi sa Link sa Artikulo',
153
+ },
154
+ 'paste_link_hint': {
155
+ AppLanguage.en: 'Paste article URL here...',
156
+ AppLanguage.fil: 'I-paste ang URL ng artikulo dito...',
157
+ AppLanguage.ceb: 'I-paste ang URL sa artikulo dinhi...',
158
+ },
159
+ 'verify_article': {
160
+ AppLanguage.en: 'Verify Article',
161
+ AppLanguage.fil: 'I-verify ang Artikulo',
162
+ AppLanguage.ceb: 'I-verify ang Artikulo',
163
+ },
164
+
165
+ // ── Proofread Screen ──
166
+ 'article_proofreader': {
167
+ AppLanguage.en: 'Article Proofreader',
168
+ AppLanguage.fil: 'Tagasuri ng Artikulo',
169
+ AppLanguage.ceb: 'Tagsusi sa Artikulo',
170
+ },
171
+ 'separate_headline_body': {
172
+ AppLanguage.en: 'Separate the headline and body for better analysis',
173
+ AppLanguage.fil: 'Ihiwalay ang headline at nilalaman para sa mas magandang pagsusuri',
174
+ AppLanguage.ceb: 'Laina ang headline ug sulod alang sa mas maayong pagsusi',
175
+ },
176
+ 'headline': {
177
+ AppLanguage.en: 'Headline',
178
+ AppLanguage.fil: 'Headline',
179
+ AppLanguage.ceb: 'Headline',
180
+ },
181
+ 'article_body': {
182
+ AppLanguage.en: 'Article Body',
183
+ AppLanguage.fil: 'Nilalaman ng Artikulo',
184
+ AppLanguage.ceb: 'Sulod sa Artikulo',
185
+ },
186
+ 'enter_headline_hint': {
187
+ AppLanguage.en: 'Enter the article headline...',
188
+ AppLanguage.fil: 'Ilagay ang headline ng artikulo...',
189
+ AppLanguage.ceb: 'Ibutang ang headline sa artikulo...',
190
+ },
191
+ 'paste_body_hint': {
192
+ AppLanguage.en: 'Paste or type the article body text...',
193
+ AppLanguage.fil: 'I-paste o i-type ang nilalaman ng artikulo...',
194
+ AppLanguage.ceb: 'I-paste o i-type ang sulod sa artikulo...',
195
+ },
196
+ 'verify_text': {
197
+ AppLanguage.en: 'Verify Text',
198
+ AppLanguage.fil: 'I-verify ang Teksto',
199
+ AppLanguage.ceb: 'I-verify ang Teksto',
200
+ },
201
+ 'tip_headline': {
202
+ AppLanguage.en: 'Tip: Separating the headline helps detect clickbait and misleading titles. The body alone gives the ML model a cleaner signal.',
203
+ AppLanguage.fil: 'Tip: Ang paghihiwalay ng headline ay tumutulong sa pag-detect ng clickbait at mapanlinlang na mga titulo. Ang nilalaman lamang ang nagbibigay ng mas malinaw na signal sa ML model.',
204
+ AppLanguage.ceb: 'Tip: Ang paglain sa headline motabang sa pagdetektar sa clickbait ug mapanglimbong nga mga titulo. Ang sulod lang ang mohatag og mas tin-aw nga signal sa ML model.',
205
+ },
206
+
207
+ // ── Results ──
208
+ 'ml_model_analysis': {
209
+ AppLanguage.en: 'ML Model Analysis',
210
+ AppLanguage.fil: 'Pagsusuri ng ML Model',
211
+ AppLanguage.ceb: 'Pagsusi sa ML Model',
212
+ },
213
+ 'prediction': {
214
+ AppLanguage.en: 'Prediction',
215
+ AppLanguage.fil: 'Hula',
216
+ AppLanguage.ceb: 'Tagna',
217
+ },
218
+ 'related_news_sources': {
219
+ AppLanguage.en: 'Related News Sources',
220
+ AppLanguage.fil: 'Mga Kaugnay na Pinagmulan ng Balita',
221
+ AppLanguage.ceb: 'Mga May Kalabutan nga Tinubdan sa Balita',
222
+ },
223
+ 'explanation_lime': {
224
+ AppLanguage.en: 'Explanation (LIME)',
225
+ AppLanguage.fil: 'Paliwanag (LIME)',
226
+ AppLanguage.ceb: 'Pagpasabot (LIME)',
227
+ },
228
+ 'headline_consistency': {
229
+ AppLanguage.en: 'Headline Consistency',
230
+ AppLanguage.fil: 'Pagkakapare-pareho ng Headline',
231
+ AppLanguage.ceb: 'Pagkakonsistente sa Headline',
232
+ },
233
+ 'date_warnings': {
234
+ AppLanguage.en: 'Date Warnings',
235
+ AppLanguage.fil: 'Mga Babala sa Petsa',
236
+ AppLanguage.ceb: 'Mga Pasidaan sa Petsa',
237
+ },
238
+
239
+ // ── Login / Register ──
240
+ 'sign_in': {
241
+ AppLanguage.en: 'Sign In',
242
+ AppLanguage.fil: 'Mag-sign In',
243
+ AppLanguage.ceb: 'Mag-sign In',
244
+ },
245
+ 'sign_up': {
246
+ AppLanguage.en: 'Sign Up',
247
+ AppLanguage.fil: 'Mag-sign Up',
248
+ AppLanguage.ceb: 'Mag-sign Up',
249
+ },
250
+ 'create_account': {
251
+ AppLanguage.en: 'Create Account',
252
+ AppLanguage.fil: 'Gumawa ng Account',
253
+ AppLanguage.ceb: 'Paghimo og Account',
254
+ },
255
+ 'email': {
256
+ AppLanguage.en: 'Email',
257
+ AppLanguage.fil: 'Email',
258
+ AppLanguage.ceb: 'Email',
259
+ },
260
+ 'password': {
261
+ AppLanguage.en: 'Password',
262
+ AppLanguage.fil: 'Password',
263
+ AppLanguage.ceb: 'Password',
264
+ },
265
+ 'username': {
266
+ AppLanguage.en: 'Username',
267
+ AppLanguage.fil: 'Username',
268
+ AppLanguage.ceb: 'Username',
269
+ },
270
+
271
+ // ── Settings ──
272
+ 'sign_out': {
273
+ AppLanguage.en: 'Sign Out',
274
+ AppLanguage.fil: 'Mag-sign Out',
275
+ AppLanguage.ceb: 'Mag-sign Out',
276
+ },
277
+ 'delete_account': {
278
+ AppLanguage.en: 'Delete Account',
279
+ AppLanguage.fil: 'I-delete ang Account',
280
+ AppLanguage.ceb: 'I-delete ang Account',
281
+ },
282
+ 'change_password': {
283
+ AppLanguage.en: 'Change Password',
284
+ AppLanguage.fil: 'Palitan ang Password',
285
+ AppLanguage.ceb: 'Ilisan ang Password',
286
+ },
287
+ 'change_username': {
288
+ AppLanguage.en: 'Change Username',
289
+ AppLanguage.fil: 'Palitan ang Username',
290
+ AppLanguage.ceb: 'Ilisan ang Username',
291
+ },
292
+ 'privacy_policy': {
293
+ AppLanguage.en: 'Privacy Policy',
294
+ AppLanguage.fil: 'Patakaran sa Privacy',
295
+ AppLanguage.ceb: 'Palisiya sa Privacy',
296
+ },
297
+ 'language': {
298
+ AppLanguage.en: 'Language',
299
+ AppLanguage.fil: 'Wika',
300
+ AppLanguage.ceb: 'Pinulongan',
301
+ },
302
+
303
+ // ── Loader ──
304
+ 'analyzing_text': {
305
+ AppLanguage.en: 'Analyzing Text',
306
+ AppLanguage.fil: 'Sinusuri ang Teksto',
307
+ AppLanguage.ceb: 'Gisusi ang Teksto',
308
+ },
309
+ 'processing_text': {
310
+ AppLanguage.en: 'Processing text input...',
311
+ AppLanguage.fil: 'Pinoproseso ang teksto...',
312
+ AppLanguage.ceb: 'Giproseso ang teksto...',
313
+ },
314
+ 'running_ml': {
315
+ AppLanguage.en: 'Running ML classification...',
316
+ AppLanguage.fil: 'Pinapatakbo ang ML classification...',
317
+ AppLanguage.ceb: 'Gipadagan ang ML classification...',
318
+ },
319
+ 'analyzing_phrases': {
320
+ AppLanguage.en: 'Analyzing key phrases...',
321
+ AppLanguage.fil: 'Sinusuri ang mga pangunahing parirala...',
322
+ AppLanguage.ceb: 'Gisusi ang mga importante nga hugpong sa pulong...',
323
+ },
324
+ 'cross_referencing': {
325
+ AppLanguage.en: 'Cross-referencing sources...',
326
+ AppLanguage.fil: 'Ikina-cross-reference ang mga pinagmulan...',
327
+ AppLanguage.ceb: 'Gina-cross-reference ang mga tinubdan...',
328
+ },
329
+ 'generating_verdict': {
330
+ AppLanguage.en: 'Generating verdict...',
331
+ AppLanguage.fil: 'Ginagawa ang hatol...',
332
+ AppLanguage.ceb: 'Gihimo ang resulta...',
333
+ },
334
+ };
check_app/lib/widgets/animated_loader.dart CHANGED
@@ -103,7 +103,15 @@ class _AnimatedLoaderState extends State<AnimatedLoader> with TickerProviderStat
103
  ),
104
  ],
105
  ),
106
- child: const Icon(Icons.shield_outlined, color: Colors.white, size: 30),
 
 
 
 
 
 
 
 
107
  ),
108
  ),
109
  const SizedBox(height: 20),
 
103
  ),
104
  ],
105
  ),
106
+ child: ClipRRect(
107
+ borderRadius: BorderRadius.circular(14),
108
+ child: Image.asset(
109
+ 'assets/images/app_logo_dark.jpg',
110
+ width: 64,
111
+ height: 64,
112
+ fit: BoxFit.cover,
113
+ ),
114
+ ),
115
  ),
116
  ),
117
  const SizedBox(height: 20),
check_app/lib/widgets/captcha_slider.dart ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dart:math';
2
+ import 'package:flutter/material.dart';
3
+ import '../main.dart';
4
+
5
+ /// A modern slider-puzzle CAPTCHA widget.
6
+ /// User must drag the slider handle to the highlighted target zone to verify.
7
+ class CaptchaSlider extends StatefulWidget {
8
+ final ValueChanged<bool> onVerified;
9
+ const CaptchaSlider({super.key, required this.onVerified});
10
+
11
+ @override
12
+ State<CaptchaSlider> createState() => _CaptchaSliderState();
13
+ }
14
+
15
+ class _CaptchaSliderState extends State<CaptchaSlider> with SingleTickerProviderStateMixin {
16
+ static const double _trackHeight = 48;
17
+ static const double _handleSize = 44;
18
+ static const double _targetWidth = 54;
19
+ static const double _tolerance = 8;
20
+
21
+ double _targetPosition = 0; // 0..1 fraction along the track
22
+ double _handlePosition = 0; // 0..1 fraction
23
+ bool _verified = false;
24
+ bool _dragging = false;
25
+
26
+ late AnimationController _glowCtrl;
27
+ late Animation<double> _glowAnim;
28
+
29
+ @override
30
+ void initState() {
31
+ super.initState();
32
+ _randomizeTarget();
33
+ _glowCtrl = AnimationController(duration: const Duration(milliseconds: 800), vsync: this);
34
+ _glowAnim = CurvedAnimation(parent: _glowCtrl, curve: Curves.easeInOut);
35
+ _glowCtrl.repeat(reverse: true);
36
+ }
37
+
38
+ @override
39
+ void dispose() {
40
+ _glowCtrl.dispose();
41
+ super.dispose();
42
+ }
43
+
44
+ void _randomizeTarget() {
45
+ // Target between 30% and 85% of the track
46
+ _targetPosition = 0.30 + Random().nextDouble() * 0.55;
47
+ }
48
+
49
+ void _onDragUpdate(DragUpdateDetails details, double trackWidth) {
50
+ if (_verified) return;
51
+ final usable = trackWidth - _handleSize;
52
+ setState(() {
53
+ _handlePosition += details.delta.dx / usable;
54
+ _handlePosition = _handlePosition.clamp(0.0, 1.0);
55
+ _dragging = true;
56
+ });
57
+ }
58
+
59
+ void _onDragEnd(DragEndDetails details, double trackWidth) {
60
+ if (_verified) return;
61
+ final usable = trackWidth - _handleSize;
62
+ final handleCenter = _handlePosition * usable + _handleSize / 2;
63
+ final targetCenter = _targetPosition * (trackWidth - _targetWidth) + _targetWidth / 2;
64
+ final distance = (handleCenter - targetCenter).abs();
65
+
66
+ if (distance <= _tolerance + _targetWidth / 2) {
67
+ // Snap to target
68
+ setState(() {
69
+ _verified = true;
70
+ _handlePosition = (_targetPosition * (trackWidth - _targetWidth) + _targetWidth / 2 - _handleSize / 2) / usable;
71
+ _dragging = false;
72
+ });
73
+ _glowCtrl.stop();
74
+ widget.onVerified(true);
75
+ } else {
76
+ // Reset with animation
77
+ setState(() {
78
+ _handlePosition = 0;
79
+ _dragging = false;
80
+ });
81
+ }
82
+ }
83
+
84
+ @override
85
+ Widget build(BuildContext context) {
86
+ return Column(
87
+ crossAxisAlignment: CrossAxisAlignment.start,
88
+ children: [
89
+ // Label
90
+ Row(
91
+ children: [
92
+ Icon(
93
+ _verified ? Icons.verified_user : Icons.security,
94
+ size: 16,
95
+ color: _verified ? AppColors.success : AppColors.textSecondary,
96
+ ),
97
+ const SizedBox(width: 6),
98
+ Text(
99
+ _verified ? 'Verification Complete' : 'Slide to verify you\'re human',
100
+ style: TextStyle(
101
+ fontSize: 12,
102
+ fontWeight: FontWeight.w600,
103
+ color: _verified ? AppColors.success : AppColors.textSecondary,
104
+ ),
105
+ ),
106
+ ],
107
+ ),
108
+ const SizedBox(height: 8),
109
+
110
+ // Slider track
111
+ LayoutBuilder(
112
+ builder: (context, constraints) {
113
+ final trackWidth = constraints.maxWidth;
114
+ final usable = trackWidth - _handleSize;
115
+ final handleLeft = _handlePosition * usable;
116
+ final targetLeft = _targetPosition * (trackWidth - _targetWidth);
117
+
118
+ return GestureDetector(
119
+ onHorizontalDragUpdate: (d) => _onDragUpdate(d, trackWidth),
120
+ onHorizontalDragEnd: (d) => _onDragEnd(d, trackWidth),
121
+ child: AnimatedBuilder(
122
+ animation: _glowAnim,
123
+ builder: (context, child) {
124
+ return Container(
125
+ height: _trackHeight,
126
+ decoration: BoxDecoration(
127
+ color: _verified
128
+ ? AppColors.success.withValues(alpha: 0.1)
129
+ : AppColors.inputBg,
130
+ borderRadius: BorderRadius.circular(12),
131
+ border: Border.all(
132
+ color: _verified
133
+ ? AppColors.success.withValues(alpha: 0.5)
134
+ : AppColors.divider,
135
+ width: 1.5,
136
+ ),
137
+ ),
138
+ child: Stack(
139
+ children: [
140
+ // Progress fill
141
+ if (!_verified)
142
+ Positioned(
143
+ left: 0, top: 0, bottom: 0,
144
+ width: handleLeft + _handleSize / 2,
145
+ child: Container(
146
+ decoration: BoxDecoration(
147
+ gradient: LinearGradient(
148
+ colors: [
149
+ AppColors.primaryLight.withValues(alpha: 0.08),
150
+ AppColors.primaryLight.withValues(alpha: 0.15),
151
+ ],
152
+ ),
153
+ borderRadius: const BorderRadius.horizontal(left: Radius.circular(11)),
154
+ ),
155
+ ),
156
+ ),
157
+
158
+ // Target zone (pulsing glow)
159
+ if (!_verified)
160
+ Positioned(
161
+ left: targetLeft,
162
+ top: 3, bottom: 3,
163
+ width: _targetWidth,
164
+ child: Container(
165
+ decoration: BoxDecoration(
166
+ color: AppColors.primaryLight.withValues(alpha: 0.12 + _glowAnim.value * 0.12),
167
+ borderRadius: BorderRadius.circular(8),
168
+ border: Border.all(
169
+ color: AppColors.primaryLight.withValues(alpha: 0.3 + _glowAnim.value * 0.3),
170
+ width: 2,
171
+ ),
172
+ ),
173
+ child: Center(
174
+ child: Icon(
175
+ Icons.gps_fixed,
176
+ size: 18,
177
+ color: AppColors.primaryLight.withValues(alpha: 0.5 + _glowAnim.value * 0.3),
178
+ ),
179
+ ),
180
+ ),
181
+ ),
182
+
183
+ // Verified fill
184
+ if (_verified)
185
+ Positioned.fill(
186
+ child: Container(
187
+ decoration: BoxDecoration(
188
+ gradient: LinearGradient(
189
+ colors: [
190
+ AppColors.success.withValues(alpha: 0.05),
191
+ AppColors.success.withValues(alpha: 0.15),
192
+ ],
193
+ ),
194
+ borderRadius: BorderRadius.circular(11),
195
+ ),
196
+ child: const Center(
197
+ child: Row(
198
+ mainAxisSize: MainAxisSize.min,
199
+ children: [
200
+ Icon(Icons.check_circle, color: AppColors.success, size: 20),
201
+ SizedBox(width: 6),
202
+ Text(
203
+ 'Verified!',
204
+ style: TextStyle(
205
+ color: AppColors.success,
206
+ fontWeight: FontWeight.w700,
207
+ fontSize: 14,
208
+ ),
209
+ ),
210
+ ],
211
+ ),
212
+ ),
213
+ ),
214
+ ),
215
+
216
+ // Draggable handle
217
+ if (!_verified)
218
+ Positioned(
219
+ left: handleLeft,
220
+ top: (_trackHeight - _handleSize) / 2,
221
+ child: Container(
222
+ width: _handleSize,
223
+ height: _handleSize,
224
+ decoration: BoxDecoration(
225
+ color: _dragging ? AppColors.primaryLight : AppColors.cardBg,
226
+ borderRadius: BorderRadius.circular(10),
227
+ border: Border.all(
228
+ color: _dragging ? AppColors.primaryLight : AppColors.divider,
229
+ width: 2,
230
+ ),
231
+ boxShadow: [
232
+ BoxShadow(
233
+ color: (_dragging ? AppColors.primaryLight : Colors.black).withValues(alpha: 0.15),
234
+ blurRadius: 8,
235
+ offset: const Offset(0, 2),
236
+ ),
237
+ ],
238
+ ),
239
+ child: Icon(
240
+ Icons.chevron_right_rounded,
241
+ size: 24,
242
+ color: _dragging ? Colors.white : AppColors.textSecondary,
243
+ ),
244
+ ),
245
+ ),
246
+ ],
247
+ ),
248
+ );
249
+ },
250
+ ),
251
+ );
252
+ },
253
+ ),
254
+ ],
255
+ );
256
+ }
257
+ }
258
+
259
+ /// Wrapper: AnimatedBuilder = AnimatedWidget using builder pattern
260
+ class AnimatedBuilder extends StatelessWidget {
261
+ final Animation<double> animation;
262
+ final Widget Function(BuildContext, Widget?) builder;
263
+ const AnimatedBuilder({super.key, required this.animation, required this.builder});
264
+
265
+ @override
266
+ Widget build(BuildContext context) {
267
+ return AnimatedBuilder._internal(animation: animation, builder: builder);
268
+ }
269
+
270
+ // Use AnimatedBuilder from Flutter
271
+ static Widget _internal({required Animation<double> animation, required Widget Function(BuildContext, Widget?) builder}) {
272
+ return _AnimatedBuilderWidget(animation: animation, builder: builder);
273
+ }
274
+ }
275
+
276
+ class _AnimatedBuilderWidget extends StatefulWidget {
277
+ final Animation<double> animation;
278
+ final Widget Function(BuildContext, Widget?) builder;
279
+ const _AnimatedBuilderWidget({required this.animation, required this.builder});
280
+
281
+ @override
282
+ State<_AnimatedBuilderWidget> createState() => _AnimatedBuilderWidgetState();
283
+ }
284
+
285
+ class _AnimatedBuilderWidgetState extends State<_AnimatedBuilderWidget> {
286
+ @override
287
+ void initState() {
288
+ super.initState();
289
+ widget.animation.addListener(_onTick);
290
+ }
291
+
292
+ @override
293
+ void dispose() {
294
+ widget.animation.removeListener(_onTick);
295
+ super.dispose();
296
+ }
297
+
298
+ void _onTick() => setState(() {});
299
+
300
+ @override
301
+ Widget build(BuildContext context) => widget.builder(context, null);
302
+ }
check_app/pubspec.yaml CHANGED
@@ -60,10 +60,9 @@ flutter:
60
  # the material Icons class.
61
  uses-material-design: true
62
 
63
- # To add assets to your application, add an assets section, like this:
64
- # assets:
65
- # - images/a_dot_burr.jpeg
66
- # - images/a_dot_ham.jpeg
67
 
68
  # An image asset can refer to one or more resolution-specific "variants", see
69
  # https://flutter.dev/to/resolution-aware-images
 
60
  # the material Icons class.
61
  uses-material-design: true
62
 
63
+ assets:
64
+ - assets/images/app_logo_light.jpg
65
+ - assets/images/app_logo_dark.jpg
 
66
 
67
  # An image asset can refer to one or more resolution-specific "variants", see
68
  # https://flutter.dev/to/resolution-aware-images
check_app/web/favicon.png CHANGED

Git LFS Details

  • SHA256: 7ab2525f4b86b65d3e4c70358a17e5a1aaf6f437f99cbcc046dad73d59bb9015
  • Pointer size: 128 Bytes
  • Size of remote file: 917 Bytes

Git LFS Details

  • SHA256: 87d6c150f7d6ff1dba82fc98614441ccca17904236afc32d5efb76803dac821c
  • Pointer size: 129 Bytes
  • Size of remote file: 1.45 kB
check_app/web/icons/Icon-192.png CHANGED

Git LFS Details

  • SHA256: 3dce99077602f70421c1c6b2a240bc9b83d64d86681d45f2154143310c980be3
  • Pointer size: 129 Bytes
  • Size of remote file: 5.29 kB

Git LFS Details

  • SHA256: 7a86b08ba523eaba971ebaeef5105fa8341c6c5b7f450683302b7224554a3bab
  • Pointer size: 130 Bytes
  • Size of remote file: 33.6 kB
check_app/web/icons/Icon-512.png CHANGED

Git LFS Details

  • SHA256: baccb205ae45f0b421be1657259b4943ac40c95094ab877f3bcbe12cd544dcbe
  • Pointer size: 129 Bytes
  • Size of remote file: 8.25 kB

Git LFS Details

  • SHA256: ad4f72fedbd60a65e1e5f2ac4a0ebee4832d3a2ab8f3db701b05d99b42a9a02d
  • Pointer size: 131 Bytes
  • Size of remote file: 141 kB
check_app/web/icons/Icon-maskable-192.png CHANGED

Git LFS Details

  • SHA256: d2c842e22a9f4ec9d996b23373a905c88d9a203b220c5c151885ad621f974b5c
  • Pointer size: 129 Bytes
  • Size of remote file: 5.59 kB

Git LFS Details

  • SHA256: 7a86b08ba523eaba971ebaeef5105fa8341c6c5b7f450683302b7224554a3bab
  • Pointer size: 130 Bytes
  • Size of remote file: 33.6 kB
check_app/web/icons/Icon-maskable-512.png CHANGED

Git LFS Details

  • SHA256: 6aee06cdcab6b2aef74b1734c4778f4421d2da100b0ff9e52b21b55240202929
  • Pointer size: 130 Bytes
  • Size of remote file: 21 kB

Git LFS Details

  • SHA256: ad4f72fedbd60a65e1e5f2ac4a0ebee4832d3a2ab8f3db701b05d99b42a9a02d
  • Pointer size: 131 Bytes
  • Size of remote file: 141 kB
check_app/web/index.html CHANGED
@@ -18,18 +18,18 @@
18
 
19
  <meta charset="UTF-8">
20
  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
21
- <meta name="description" content="A new Flutter project.">
22
 
23
  <!-- iOS meta tags & icons -->
24
  <meta name="mobile-web-app-capable" content="yes">
25
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
26
- <meta name="apple-mobile-web-app-title" content="check_app">
27
  <link rel="apple-touch-icon" href="icons/Icon-192.png">
28
 
29
  <!-- Favicon -->
30
  <link rel="icon" type="image/png" href="favicon.png"/>
31
 
32
- <title>check_app</title>
33
  <link rel="manifest" href="manifest.json">
34
  </head>
35
  <body>
 
18
 
19
  <meta charset="UTF-8">
20
  <meta content="IE=Edge" http-equiv="X-UA-Compatible">
21
+ <meta name="description" content="BantayPahayag - News Verification System">
22
 
23
  <!-- iOS meta tags & icons -->
24
  <meta name="mobile-web-app-capable" content="yes">
25
  <meta name="apple-mobile-web-app-status-bar-style" content="black">
26
+ <meta name="apple-mobile-web-app-title" content="BantayPahayag">
27
  <link rel="apple-touch-icon" href="icons/Icon-192.png">
28
 
29
  <!-- Favicon -->
30
  <link rel="icon" type="image/png" href="favicon.png"/>
31
 
32
+ <title>BantayPahayag</title>
33
  <link rel="manifest" href="manifest.json">
34
  </head>
35
  <body>
check_app/web/manifest.json CHANGED
@@ -1,11 +1,11 @@
1
  {
2
- "name": "check_app",
3
- "short_name": "check_app",
4
  "start_url": ".",
5
  "display": "standalone",
6
  "background_color": "#0175C2",
7
  "theme_color": "#0175C2",
8
- "description": "A new Flutter project.",
9
  "orientation": "portrait-primary",
10
  "prefer_related_applications": false,
11
  "icons": [
 
1
  {
2
+ "name": "BantayPahayag",
3
+ "short_name": "BantayPahayag",
4
  "start_url": ".",
5
  "display": "standalone",
6
  "background_color": "#0175C2",
7
  "theme_color": "#0175C2",
8
+ "description": "News Verification System",
9
  "orientation": "portrait-primary",
10
  "prefer_related_applications": false,
11
  "icons": [
check_app/windows/runner/main.cpp CHANGED
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
27
  FlutterWindow window(project);
28
  Win32Window::Point origin(10, 10);
29
  Win32Window::Size size(1280, 720);
30
- if (!window.Create(L"check_app", origin, size)) {
31
  return EXIT_FAILURE;
32
  }
33
  window.SetQuitOnClose(true);
 
27
  FlutterWindow window(project);
28
  Win32Window::Point origin(10, 10);
29
  Win32Window::Size size(1280, 720);
30
+ if (!window.Create(L"BantayPahayag", origin, size)) {
31
  return EXIT_FAILURE;
32
  }
33
  window.SetQuitOnClose(true);
check_app/windows/runner/resources/app_icon.ico CHANGED

Git LFS Details

  • SHA256: c098d3fc85cacff98b8e69811b48e9f0d852fcee278132d794411d978869cbf8
  • Pointer size: 130 Bytes
  • Size of remote file: 33.8 kB

Git LFS Details

  • SHA256: 288bb45f2e0b4b57c4fc7cbb7e379bae0dbececb36d0f2a24fa20041e3114365
  • Pointer size: 128 Bytes
  • Size of remote file: 549 Bytes
checker/external/core.py CHANGED
@@ -1,5 +1,6 @@
1
  import sys
2
  import os
 
3
 
4
  # Add project root to path
5
  sys.path.insert(
@@ -7,28 +8,81 @@ sys.path.insert(
7
  )
8
 
9
  from checker.external.local_search import find_related_articles
10
- from checker.external.web_search import search_google_news, extract_search_query
 
 
 
11
  from checker.external.scraper import fetch_all_news
12
  from db.database import insert_articles, init_db
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  class ExternalChecker:
16
  def __init__(self):
17
  """Initialize the external checker and ensure database is ready."""
18
  init_db()
19
 
20
- def check_claim(self, claim_text):
21
  """
22
  Check a claim against the local database and external web search.
23
 
24
  Args:
25
- claim_text (str): The claim or article text to verify.
 
26
 
27
  Returns:
28
  dict: Structured results containing:
29
  - "db_results": List of dicts from local DB search.
30
  - "web_results": List of dicts from Google News search.
31
- - "verdict": Preliminary verdict string ("VERIFIED", "WEAK MATCH", etc).
32
  - "top_score": Highest semantic similarity score (float) or 0.0.
33
  """
34
  if not claim_text or not claim_text.strip():
@@ -39,14 +93,20 @@ class ExternalChecker:
39
  "top_score": 0.0,
40
  }
41
 
42
- # 1. Search local database
43
- db_results = find_related_articles(claim_text)
 
44
  top_score = db_results[0]["similarity"] if db_results else 0.0
45
  has_strong_db_match = top_score >= 0.55
46
 
47
- # 2. Search web (always run, but context depends on DB results)
48
  query = extract_search_query(claim_text)
49
- web_results = search_google_news(query, max_results=5)
 
 
 
 
 
50
 
51
  # 3. Determine verdict
52
  if has_strong_db_match:
 
1
  import sys
2
  import os
3
+ import logging
4
 
5
  # Add project root to path
6
  sys.path.insert(
 
8
  )
9
 
10
  from checker.external.local_search import find_related_articles
11
+ from checker.external.web_search import (
12
+ search_google_news_multilingual,
13
+ extract_search_query,
14
+ )
15
  from checker.external.scraper import fetch_all_news
16
  from db.database import insert_articles, init_db
17
 
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ── Language detection + translation ─────────────────────────────────
21
+ _translator = None
22
+ _detector_available = False
23
+
24
+ try:
25
+ from langdetect import detect as _detect_lang
26
+ _detector_available = True
27
+ except ImportError:
28
+ logger.warning("langdetect not installed β€” language detection disabled")
29
+ _detect_lang = None
30
+
31
+ try:
32
+ from deep_translator import GoogleTranslator
33
+ _translator_available = True
34
+ except ImportError:
35
+ logger.warning("deep-translator not installed β€” translation disabled")
36
+ GoogleTranslator = None
37
+ _translator_available = False
38
+
39
+
40
+ def detect_language(text: str) -> str:
41
+ """Detect the language of the input text. Returns ISO 639-1 code."""
42
+ if not _detector_available or not text or len(text.strip()) < 20:
43
+ return "en"
44
+ try:
45
+ lang = _detect_lang(text)
46
+ # langdetect returns 'tl' for Tagalog/Filipino, 'ceb' for Cebuano
47
+ return lang
48
+ except Exception:
49
+ return "en"
50
+
51
+
52
+ def translate_to_english(text: str, source_lang: str) -> str:
53
+ """Translate text to English for ML processing."""
54
+ if source_lang == "en" or not _translator_available or not GoogleTranslator:
55
+ return text
56
+ try:
57
+ # deep-translator uses 'auto' for auto-detection or specific codes
58
+ translated = GoogleTranslator(source=source_lang, target="en").translate(text)
59
+ if translated:
60
+ logger.info("Translated %s β†’ en (%d chars)", source_lang, len(text))
61
+ return translated
62
+ return text
63
+ except Exception as exc:
64
+ logger.warning("Translation failed (%s→en): %s", source_lang, exc)
65
+ return text
66
+
67
 
68
  class ExternalChecker:
69
  def __init__(self):
70
  """Initialize the external checker and ensure database is ready."""
71
  init_db()
72
 
73
+ def check_claim(self, claim_text, translated_text=None):
74
  """
75
  Check a claim against the local database and external web search.
76
 
77
  Args:
78
+ claim_text (str): The claim or article text to verify (original language).
79
+ translated_text (str, optional): English translation for search boost.
80
 
81
  Returns:
82
  dict: Structured results containing:
83
  - "db_results": List of dicts from local DB search.
84
  - "web_results": List of dicts from Google News search.
85
+ - "verdict": Preliminary verdict string.
86
  - "top_score": Highest semantic similarity score (float) or 0.0.
87
  """
88
  if not claim_text or not claim_text.strip():
 
93
  "top_score": 0.0,
94
  }
95
 
96
+ # 1. Search local database (use translated text if available for better matching)
97
+ search_text = translated_text if translated_text else claim_text
98
+ db_results = find_related_articles(search_text)
99
  top_score = db_results[0]["similarity"] if db_results else 0.0
100
  has_strong_db_match = top_score >= 0.55
101
 
102
+ # 2. Multilingual web search β€” searches in English + Filipino editions
103
  query = extract_search_query(claim_text)
104
+ translated_query = extract_search_query(translated_text) if translated_text else None
105
+ web_results = search_google_news_multilingual(
106
+ query,
107
+ translated_query=translated_query,
108
+ max_results=10,
109
+ )
110
 
111
  # 3. Determine verdict
112
  if has_strong_db_match:
checker/external/web_search.py CHANGED
@@ -101,7 +101,7 @@ def extract_search_query(text, max_words=10):
101
  return " ".join(keywords[:max_words])
102
 
103
 
104
- def search_google_news(query, max_results=5):
105
  """Search Google News RSS for articles matching the query.
106
 
107
  Returns a list of dicts with title, source, url, and published date.
@@ -138,3 +138,85 @@ def search_google_news(query, max_results=5):
138
  )
139
 
140
  return results
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  return " ".join(keywords[:max_words])
102
 
103
 
104
+ def search_google_news(query, max_results=10):
105
  """Search Google News RSS for articles matching the query.
106
 
107
  Returns a list of dicts with title, source, url, and published date.
 
138
  )
139
 
140
  return results
141
+
142
+
143
+ def search_google_news_multilingual(query, translated_query=None, max_results=10):
144
+ """Search Google News in multiple language editions and merge results.
145
+
146
+ Searches in:
147
+ - English (Philippines edition)
148
+ - Filipino edition
149
+ - Cebuano edition (if available)
150
+
151
+ Results are deduplicated by title similarity.
152
+ """
153
+ # Language editions to search
154
+ editions = [
155
+ ("en-PH", "PH", "PH:en"), # English - Philippines
156
+ ("tl", "PH", "PH:tl"), # Filipino / Tagalog
157
+ ]
158
+
159
+ all_results = []
160
+ seen_titles = set()
161
+
162
+ # Search with the original query in all editions
163
+ for hl, gl, ceid in editions:
164
+ encoded = quote_plus(query)
165
+ rss_url = (
166
+ f"https://news.google.com/rss/search?"
167
+ f"q={encoded}&hl={hl}&gl={gl}&ceid={ceid}"
168
+ )
169
+ try:
170
+ feed = feedparser.parse(rss_url)
171
+ for entry in feed.entries[:max_results]:
172
+ title = entry.get("title", "")
173
+ source = ""
174
+ if " - " in title:
175
+ parts = title.rsplit(" - ", 1)
176
+ title = parts[0]
177
+ source = parts[1]
178
+
179
+ # Simple dedup by lowercase title prefix
180
+ title_key = title.lower()[:50]
181
+ if title_key not in seen_titles:
182
+ seen_titles.add(title_key)
183
+ all_results.append({
184
+ "title": title,
185
+ "source": source,
186
+ "url": entry.get("link", ""),
187
+ "published": entry.get("published", "N/A"),
188
+ })
189
+ except Exception:
190
+ continue
191
+
192
+ # If we have a translated query (e.g. English version of Filipino input),
193
+ # also search with that
194
+ if translated_query and translated_query.lower() != query.lower():
195
+ encoded = quote_plus(translated_query)
196
+ rss_url = (
197
+ f"https://news.google.com/rss/search?"
198
+ f"q={encoded}&hl=en-PH&gl=PH&ceid=PH:en"
199
+ )
200
+ try:
201
+ feed = feedparser.parse(rss_url)
202
+ for entry in feed.entries[:max_results]:
203
+ title = entry.get("title", "")
204
+ source = ""
205
+ if " - " in title:
206
+ parts = title.rsplit(" - ", 1)
207
+ title = parts[0]
208
+ source = parts[1]
209
+
210
+ title_key = title.lower()[:50]
211
+ if title_key not in seen_titles:
212
+ seen_titles.add(title_key)
213
+ all_results.append({
214
+ "title": title,
215
+ "source": source,
216
+ "url": entry.get("link", ""),
217
+ "published": entry.get("published", "N/A"),
218
+ })
219
+ except Exception:
220
+ pass
221
+
222
+ return all_results[:max_results]
db/database.py CHANGED
@@ -53,12 +53,45 @@ def init_db():
53
  CREATE TABLE IF NOT EXISTS users (
54
  id SERIAL PRIMARY KEY,
55
  email TEXT NOT NULL UNIQUE,
56
- username TEXT NOT NULL,
57
  password_hash TEXT NOT NULL,
 
 
 
58
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
59
  )
60
  """
61
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  cursor.execute(
63
  """
64
  CREATE TABLE IF NOT EXISTS check_history (
@@ -112,7 +145,7 @@ def get_user_by_id(user_id: int) -> dict | None:
112
  conn = get_connection()
113
  cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
114
  cursor.execute(
115
- "SELECT id, email, username, created_at FROM users WHERE id = %s",
116
  (user_id,),
117
  )
118
  row = cursor.fetchone()
@@ -120,6 +153,121 @@ def get_user_by_id(user_id: int) -> dict | None:
120
  return dict(row) if row else None
121
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  # ── Check-history helpers ─────────────────────────────────────────────
124
 
125
  def save_check_history(user_id: int, input_text: str, final_verdict: str, result_json: str):
 
53
  CREATE TABLE IF NOT EXISTS users (
54
  id SERIAL PRIMARY KEY,
55
  email TEXT NOT NULL UNIQUE,
56
+ username TEXT NOT NULL UNIQUE,
57
  password_hash TEXT NOT NULL,
58
+ email_verified BOOLEAN NOT NULL DEFAULT FALSE,
59
+ verification_code TEXT,
60
+ verification_code_expires TIMESTAMP,
61
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
62
  )
63
  """
64
  )
65
+ # Add UNIQUE constraint on username if it doesn't exist yet (for existing DBs)
66
+ try:
67
+ cursor.execute(
68
+ "ALTER TABLE users ADD CONSTRAINT users_username_unique UNIQUE (username)"
69
+ )
70
+ except psycopg2.errors.DuplicateTable:
71
+ conn.rollback()
72
+ cursor = conn.cursor()
73
+ # Add email verification columns for existing DBs
74
+ email_verified_added = False
75
+ for col, col_def in [
76
+ ("email_verified", "BOOLEAN NOT NULL DEFAULT FALSE"),
77
+ ("verification_code", "TEXT"),
78
+ ("verification_code_expires", "TIMESTAMP"),
79
+ ]:
80
+ try:
81
+ cursor.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}")
82
+ conn.commit()
83
+ if col == "email_verified":
84
+ email_verified_added = True
85
+ except psycopg2.errors.DuplicateColumn:
86
+ conn.rollback()
87
+ cursor = conn.cursor()
88
+ # Mark all pre-existing accounts as verified (they existed before this feature)
89
+ if email_verified_added:
90
+ cursor.execute(
91
+ "UPDATE users SET email_verified = TRUE WHERE verification_code IS NULL"
92
+ )
93
+ conn.commit()
94
+ logger.info("Marked all pre-existing accounts as email_verified = TRUE")
95
  cursor.execute(
96
  """
97
  CREATE TABLE IF NOT EXISTS check_history (
 
145
  conn = get_connection()
146
  cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
147
  cursor.execute(
148
+ "SELECT id, email, username, email_verified, created_at FROM users WHERE id = %s",
149
  (user_id,),
150
  )
151
  row = cursor.fetchone()
 
153
  return dict(row) if row else None
154
 
155
 
156
+ def get_user_by_username(username: str) -> dict | None:
157
+ """Look up a user by username. Returns row or None."""
158
+ conn = get_connection()
159
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
160
+ cursor.execute(
161
+ "SELECT id, email, username, created_at FROM users WHERE LOWER(username) = LOWER(%s)",
162
+ (username,),
163
+ )
164
+ row = cursor.fetchone()
165
+ conn.close()
166
+ return dict(row) if row else None
167
+
168
+
169
+ def update_username(user_id: int, new_username: str) -> dict | None:
170
+ """Update a user's username. Returns updated user dict or None on conflict."""
171
+ conn = get_connection()
172
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
173
+ try:
174
+ cursor.execute(
175
+ "UPDATE users SET username = %s WHERE id = %s RETURNING id, email, username, created_at",
176
+ (new_username, user_id),
177
+ )
178
+ user = cursor.fetchone()
179
+ conn.commit()
180
+ return dict(user) if user else None
181
+ except psycopg2.errors.UniqueViolation:
182
+ conn.rollback()
183
+ return None
184
+ finally:
185
+ conn.close()
186
+
187
+
188
+ def update_user_password(user_id: int, new_password_hash: str) -> bool:
189
+ """Update a user's password hash. Returns True on success."""
190
+ conn = get_connection()
191
+ cursor = conn.cursor()
192
+ cursor.execute(
193
+ "UPDATE users SET password_hash = %s WHERE id = %s",
194
+ (new_password_hash, user_id),
195
+ )
196
+ affected = cursor.rowcount
197
+ conn.commit()
198
+ conn.close()
199
+ return affected > 0
200
+
201
+
202
+ def delete_user(user_id: int) -> bool:
203
+ """Delete a user account and all related data. Returns True if deleted."""
204
+ conn = get_connection()
205
+ cursor = conn.cursor()
206
+ cursor.execute("DELETE FROM users WHERE id = %s", (user_id,))
207
+ affected = cursor.rowcount
208
+ conn.commit()
209
+ conn.close()
210
+ return affected > 0
211
+
212
+
213
+ def set_verification_code(user_id: int, code: str, expires_at) -> bool:
214
+ """Store a verification code for the user."""
215
+ conn = get_connection()
216
+ cursor = conn.cursor()
217
+ cursor.execute(
218
+ "UPDATE users SET verification_code = %s, verification_code_expires = %s WHERE id = %s",
219
+ (code, expires_at, user_id),
220
+ )
221
+ affected = cursor.rowcount
222
+ conn.commit()
223
+ conn.close()
224
+ return affected > 0
225
+
226
+
227
+ def verify_email_code(user_id: int, code: str) -> str:
228
+ """
229
+ Verify the email code for a user.
230
+ Returns 'ok', 'invalid', or 'expired'.
231
+ """
232
+ conn = get_connection()
233
+ cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
234
+ cursor.execute(
235
+ "SELECT verification_code, verification_code_expires FROM users WHERE id = %s",
236
+ (user_id,),
237
+ )
238
+ row = cursor.fetchone()
239
+ if not row or not row["verification_code"]:
240
+ conn.close()
241
+ return "invalid"
242
+
243
+ if row["verification_code"] != code:
244
+ conn.close()
245
+ return "invalid"
246
+
247
+ if row["verification_code_expires"] and row["verification_code_expires"] < datetime.now():
248
+ conn.close()
249
+ return "expired"
250
+
251
+ # Mark as verified and clear the code
252
+ cursor.execute(
253
+ "UPDATE users SET email_verified = TRUE, verification_code = NULL, verification_code_expires = NULL WHERE id = %s",
254
+ (user_id,),
255
+ )
256
+ conn.commit()
257
+ conn.close()
258
+ return "ok"
259
+
260
+
261
+ def is_email_verified(user_id: int) -> bool:
262
+ """Check if a user's email is verified."""
263
+ conn = get_connection()
264
+ cursor = conn.cursor()
265
+ cursor.execute("SELECT email_verified FROM users WHERE id = %s", (user_id,))
266
+ row = cursor.fetchone()
267
+ conn.close()
268
+ return bool(row and row[0])
269
+
270
+
271
  # ── Check-history helpers ─────────────────────────────────────────────
272
 
273
  def save_check_history(user_id: int, input_text: str, final_verdict: str, result_json: str):