Spaces:
Runtime error
Runtime error
Merge branch 'main' of https://github.com/fborj/FactCheckThesis
Browse files- Icons/648286034_929277936354011_247561233351299912_n.jpg +3 -0
- Icons/648528435_1489135242834045_123474419086154405_n.jpg +3 -0
- api/email_utils.py +139 -0
- api/main.py +227 -0
- check_app/android/app/src/main/AndroidManifest.xml +1 -1
- check_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +2 -2
- check_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +2 -2
- check_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +2 -2
- check_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +2 -2
- check_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +2 -2
- check_app/assets/images/app_logo_dark.jpg +3 -0
- check_app/assets/images/app_logo_light.jpg +3 -0
- check_app/ios/Runner/Info.plist +2 -2
- check_app/lib/main.dart +22 -3
- check_app/lib/screens/history_screen.dart +1 -1
- check_app/lib/screens/home_screen.dart +97 -30
- check_app/lib/screens/link_check_screen.dart +2 -2
- check_app/lib/screens/login_screen.dart +146 -2
- check_app/lib/screens/proofread_screen.dart +2 -2
- check_app/lib/screens/register_screen.dart +435 -11
- check_app/lib/screens/settings_screen.dart +1003 -0
- check_app/lib/screens/verify_email_screen.dart +343 -0
- check_app/lib/services/auth_service.dart +112 -3
- check_app/lib/services/localization.dart +334 -0
- check_app/lib/widgets/animated_loader.dart +9 -1
- check_app/lib/widgets/captcha_slider.dart +302 -0
- check_app/pubspec.yaml +3 -4
- check_app/web/favicon.png +2 -2
- check_app/web/icons/Icon-192.png +2 -2
- check_app/web/icons/Icon-512.png +2 -2
- check_app/web/icons/Icon-maskable-192.png +2 -2
- check_app/web/icons/Icon-maskable-512.png +2 -2
- check_app/web/index.html +3 -3
- check_app/web/manifest.json +3 -3
- check_app/windows/runner/main.cpp +1 -1
- check_app/windows/runner/resources/app_icon.ico +2 -2
- checker/external/core.py +68 -8
- checker/external/web_search.py +83 -1
- db/database.py +150 -2
Icons/648286034_929277936354011_247561233351299912_n.jpg
ADDED
|
|
Git LFS Details
|
Icons/648528435_1489135242834045_123474419086154405_n.jpg
ADDED
|
|
Git LFS Details
|
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="
|
| 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
|
|
Git LFS Details
|
check_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
check_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
check_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
check_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
check_app/assets/images/app_logo_dark.jpg
ADDED
|
Git LFS Details
|
check_app/assets/images/app_logo_light.jpg
ADDED
|
Git LFS Details
|
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>
|
| 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>
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('
|
| 156 |
Text(username, style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w700)),
|
| 157 |
]),
|
| 158 |
),
|
| 159 |
]),
|
| 160 |
const SizedBox(height: 14),
|
| 161 |
-
Text('
|
| 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('
|
| 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: '
|
| 189 |
-
subtitle: '
|
| 190 |
-
description: '
|
| 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: '
|
| 207 |
-
subtitle: '
|
| 208 |
-
description: '
|
| 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('
|
| 235 |
]),
|
| 236 |
const SizedBox(height: 16),
|
| 237 |
-
_howItWorksStep('1', '
|
| 238 |
const SizedBox(height: 12),
|
| 239 |
-
_howItWorksStep('2', '
|
| 240 |
const SizedBox(height: 12),
|
| 241 |
-
_howItWorksStep('3', '
|
| 242 |
const SizedBox(height: 12),
|
| 243 |
-
_howItWorksStep('4', '
|
| 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 |
-
|
| 301 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
const SizedBox(height: 14),
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
const SizedBox(height: 14),
|
|
|
|
|
|
|
| 161 |
TextField(
|
| 162 |
-
controller: _passwordCtrl, obscureText:
|
| 163 |
-
decoration: _inputDecoration('
|
| 164 |
suffixIcon: IconButton(
|
| 165 |
-
icon: Icon(
|
| 166 |
-
onPressed: () => setState(() =>
|
| 167 |
),
|
| 168 |
),
|
| 169 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
const SizedBox(height: 14),
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 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
|
|
|
Git LFS Details
|
check_app/web/icons/Icon-192.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
check_app/web/icons/Icon-512.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
check_app/web/icons/Icon-maskable-192.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
check_app/web/icons/Icon-maskable-512.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
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="
|
| 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="
|
| 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>
|
| 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": "
|
| 3 |
-
"short_name": "
|
| 4 |
"start_url": ".",
|
| 5 |
"display": "standalone",
|
| 6 |
"background_color": "#0175C2",
|
| 7 |
"theme_color": "#0175C2",
|
| 8 |
-
"description": "
|
| 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"
|
| 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
|
|
|
Git LFS Details
|
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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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.
|
| 48 |
query = extract_search_query(claim_text)
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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):
|