feat(api,ui,db): add address, guest users, image URLs; update API

- Backend:
  - Add address column to tickets and migration script
  - Create guest users when user_id is missing; accept user_name and address
  - Normalize stored image paths and expose absolute image_url
  - Introduce utils for path normalization and ticket serialization
  - Add CORS configuration for dashboard/emulator origins
  - Tickets API:
    - Serialize via ticket_to_dict with consistent schema
    - Change status update to PATCH /api/tickets/{id}/status with JSON body
    - Add DELETE /api/tickets/{id} with safe file removal
- Dashboard:
  - Fetch tickets from backend, show thumbnails, absolute image URLs
  - Status select + PATCH updates, toasts for feedback
  - Add i18n key btn.viewDetails
- Mobile app:
  - Persist device user_id via SharedPreferences
  - Fetch and merge API tickets; prefer network imageUrl
  - Submit user_name and address; delete via API when available
  - Make location acquisition robust with fallbacks and non-blocking UX
- Android/deps:
  - Disable Geolocator NMEA listener to prevent crashes
  - Downgrade geolocator to ^11.0.0 for stability

BREAKING CHANGE:
- Status endpoint changed from PATCH /api/tickets/{id}?new_status=... to
  PATCH /api/tickets/{id}/status with JSON body: {"status":"in_progress"}.
- /api/tickets and /api/tickets/{id} responses now use "id" (replacing
  "ticket_id"), include "image_url", and normalize fields for clients. Update
  consumers to use the new schema.
This commit is contained in:
2025-09-27 09:31:40 +08:00
parent 0e3eea7de9
commit 77d5be8fd1
27 changed files with 800 additions and 256 deletions

View File

@@ -35,6 +35,11 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Disable NMEA listener to prevent crashes on Android -->
<meta-data
android:name="com.baseflow.geolocator.nmea"
android:value="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -18,6 +18,7 @@
"btn.changeStatus": "Change Status",
"btn.view": "View",
"btn.details": "Details",
"btn.viewDetails": "View details",
"btn.retry": "Retry",
"btn.allow": "Allow",
"btn.deny": "Deny",

View File

@@ -18,6 +18,7 @@
"btn.changeStatus": "Tukar Status",
"btn.view": "Lihat",
"btn.details": "Butiran",
"btn.viewDetails": "Lihat butiran",
"btn.retry": "Cuba Semula",
"btn.allow": "Benarkan",
"btn.deny": "Tolak",

Binary file not shown.

View File

@@ -46,6 +46,7 @@ class Ticket(Base):
category = Column(String, nullable=False)
severity = Column(Enum(SeverityLevel), nullable=False, default=SeverityLevel.NA)
description = Column(String, default="")
address = Column(String, nullable=True)
status = Column(Enum(TicketStatus), nullable=False, default=TicketStatus.NEW)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)

View File

@@ -1,37 +1,53 @@
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.ticket_service import TicketService, SeverityLevel
from app.models.ticket_model import User
from app.services.global_ai import get_ai_service
import os, uuid, logging
from app.utils import make_image_url, normalize_image_path_for_url
from pathlib import Path
import logging, uuid
router = APIRouter()
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
UPLOAD_DIR = "static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
UPLOAD_DIR = Path("static") / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/report")
async def report_issue(
user_id: str = Form(...),
user_id: Optional[str] = Form(None),
user_name: Optional[str] = Form(None),
latitude: float = Form(...),
longitude: float = Form(...),
address: Optional[str] = Form(None),
description: str = Form(""),
image: UploadFile = File(...),
request: Request = None,
db: Session = Depends(get_db)
):
logger.debug("Received report request")
ticket_service = TicketService(db)
# Validate user
user = db.query(User).filter(User.id == user_id).first()
# Validate or create user
user = None
if user_id:
user = ticket_service.get_user(user_id)
if not user:
logger.error(f"User with id {user_id} not found")
raise HTTPException(status_code=404, detail=f"User with id {user_id} not found")
logger.debug(f"User found: {user.name} ({user.email})")
# Create a guest user automatically
guest_email = f"guest-{uuid.uuid4()}@example.local"
guest_name = user_name or f"Guest-{str(uuid.uuid4())[:8]}"
try:
user = ticket_service.create_user(name=guest_name, email=guest_email)
logger.info(f"Created guest user: {user}")
except Exception as e:
logger.exception("Failed to create guest user")
raise HTTPException(status_code=500, detail="Failed to ensure user")
logger.debug(f"Using user: {user.name} ({user.email})")
# Validate file type
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
@@ -40,7 +56,7 @@ async def report_issue(
'application/octet-stream' # Some cameras/mobile devices use this
}
file_ext = os.path.splitext(image.filename.lower())[1]
file_ext = Path(image.filename).suffix.lower()
if file_ext not in allowed_extensions:
logger.error(f"Invalid file extension: {file_ext}")
raise HTTPException(status_code=400, detail="Only image files are allowed")
@@ -51,12 +67,11 @@ async def report_issue(
# Save uploaded image
filename = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, filename)
file_path_obj = UPLOAD_DIR / filename
try:
content = await image.read()
with open(file_path, "wb") as f:
f.write(content)
logger.debug(f"Saved image to {file_path} ({len(content)} bytes)")
file_path_obj.write_bytes(content)
logger.debug(f"Saved image to {file_path_obj} ({len(content)} bytes)")
except Exception as e:
logger.exception("Failed to save uploaded image")
raise HTTPException(status_code=500, detail="Failed to save uploaded image")
@@ -67,11 +82,11 @@ async def report_issue(
# Run AI predictions
try:
category = ai_service.classify_category(file_path)
category = ai_service.classify_category(str(file_path_obj))
logger.debug(f"Classification: {category}")
if category.lower() == "pothole":
severity_str, annotated_path = ai_service.detect_pothole_severity(file_path)
severity_str, annotated_path = ai_service.detect_pothole_severity(str(file_path_obj))
logger.debug(f"Detection: severity={severity_str}, path={annotated_path}")
severity = {
"High": SeverityLevel.HIGH,
@@ -87,20 +102,27 @@ async def report_issue(
category = "Unknown"
severity = SeverityLevel.NA
# Create ticket
# Create ticket (store relative posix path)
image_path_db = file_path_obj.as_posix()
ticket = ticket_service.create_ticket(
user_id=user.id,
image_path=file_path,
image_path=image_path_db,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description
description=description,
address=address
)
logger.info(f"Ticket created: {ticket.id} for user {user.id}")
# Normalize stored path and build absolute URL
rel_path = normalize_image_path_for_url(ticket.image_path)
image_url = make_image_url(rel_path, request)
response = {
"ticket_id": ticket.id,
"id": ticket.id,
"user_id": user.id,
"user_name": user.name,
"user_email": user.email,
@@ -108,7 +130,9 @@ async def report_issue(
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"image_path": ticket.image_path
"image_path": rel_path,
"image_url": image_url,
"address": ticket.address
}
logger.debug(f"Response: {response}")

View File

@@ -1,96 +1,81 @@
# app/routes/tickets.py
from typing import Optional, List
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.services.ticket_service import TicketService, TicketStatus, SeverityLevel
from pydantic import BaseModel
from app.utils import ticket_to_dict
router = APIRouter()
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
class TicketStatusUpdate(BaseModel):
new_status: TicketStatus
status: TicketStatus
# ----------------------
# GET /tickets
# ----------------------
@router.get("/tickets", response_model=List[dict])
def list_tickets(
request: Request,
user_id: Optional[str] = Query(None, description="Filter by user ID"),
category: Optional[str] = Query(None, description="Filter by category"),
severity: Optional[SeverityLevel] = Query(None, description="Filter by severity"),
status: Optional[TicketStatus] = Query(None, description="Filter by status"),
db: Session = Depends(get_db)
):
"""
Return all tickets by default. Optional query params may filter results.
Each item is serialized using ticket_to_dict(...) which guarantees:
- image_url is an absolute forward-slash URL
- created_at is ISO-8601 string
- consistent schema for dashboard & mobile clients
"""
service = TicketService(db)
tickets = service.list_tickets(user_id=user_id, category=category, severity=severity, status=status)
return [
{
"ticket_id": t.id,
"user_id": t.user_id,
"category": t.category,
"severity": t.severity.value,
"status": t.status.value,
"description": t.description,
"latitude": t.latitude,
"longitude": t.longitude,
"image_path": t.image_path,
"created_at": t.created_at,
"updated_at": t.updated_at
} for t in tickets
]
return [ticket_to_dict(t, request) for t in tickets]
# ----------------------
# GET /tickets/{ticket_id}
# ----------------------
@router.get("/tickets/{ticket_id}", response_model=dict)
def get_ticket(ticket_id: str, db: Session = Depends(get_db)):
def get_ticket(ticket_id: str, request: Request, db: Session = Depends(get_db)):
service = TicketService(db)
ticket = service.get_ticket(ticket_id)
if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
return {
"ticket_id": ticket.id,
"user_id": ticket.user_id,
"category": ticket.category,
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"image_path": ticket.image_path,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(ticket, request)
# ----------------------
# PATCH /tickets/{ticket_id} - Update status
# PATCH /tickets/{ticket_id}/status - Update status
# ----------------------
@router.patch("/tickets/{ticket_id}", response_model=dict)
@router.patch("/tickets/{ticket_id}/status", response_model=dict)
def update_ticket_status(
ticket_id: str,
status_update: TicketStatusUpdate, # JSON body with new_status
status_update: TicketStatusUpdate, # JSON body with status
request: Request,
db: Session = Depends(get_db)
):
service = TicketService(db)
try:
ticket = service.update_ticket_status(ticket_id, status_update.new_status)
ticket = service.update_ticket_status(ticket_id, status_update.status)
except Exception as e:
logger.error(f"Failed to update ticket status: {e}")
raise HTTPException(status_code=400, detail=str(e))
return {
"ticket_id": ticket.id,
"user_id": ticket.user_id,
"category": ticket.category,
"severity": ticket.severity.value,
"status": ticket.status.value,
"description": ticket.description,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"image_path": ticket.image_path,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
return ticket_to_dict(ticket, request)
# ----------------------
# DELETE /tickets/{ticket_id} - Delete ticket + image
# ----------------------
@router.delete("/tickets/{ticket_id}", response_model=dict)
def delete_ticket(ticket_id: str, db: Session = Depends(get_db)):
service = TicketService(db)
try:
service.delete_ticket(ticket_id)
except Exception as e:
logger.error(f"Failed to delete ticket {ticket_id}: {e}")
raise HTTPException(status_code=400, detail=str(e))
return {"deleted": True, "id": ticket_id}

View File

@@ -1,9 +1,11 @@
# app/services/ticket_service.py
import uuid
from typing import List, Optional
from pathlib import Path
from sqlalchemy.orm import Session
from sqlalchemy.exc import NoResultFound
from app.models.ticket_model import User, Ticket, TicketAudit, TicketStatus, SeverityLevel
from app.utils import normalize_image_path_for_url, UPLOADS_DIR_RESOLVED
import logging
logging.basicConfig(level=logging.INFO)
@@ -26,8 +28,6 @@ class TicketService:
self.db.refresh(user)
logger.info(f"Created user {user}")
return user # <-- return User object
def get_user(self, user_id: str) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first()
@@ -44,16 +44,27 @@ class TicketService:
latitude: float,
longitude: float,
description: str = "",
address: Optional[str] = None,
) -> Ticket:
"""
Create a Ticket record.
image_path should be a relative POSIX path (e.g. 'static/uploads/uuid.jpg').
report.route uses Path.as_posix() to ensure forward slashes on save.
"""
# Normalize stored path to POSIX
image_path_posix = Path(str(image_path)).as_posix() if image_path else None
ticket = Ticket(
id=str(uuid.uuid4()),
user_id=user_id,
image_path=image_path,
image_path=image_path_posix,
category=category,
severity=severity,
latitude=latitude,
longitude=longitude,
description=description,
address=address,
)
self.db.add(ticket)
self.db.commit()
@@ -91,6 +102,9 @@ class TicketService:
severity: Optional[SeverityLevel] = None,
status: Optional[TicketStatus] = None
) -> List[Ticket]:
"""
Return tickets. By default returns all tickets unless optional filters are provided.
"""
query = self.db.query(Ticket)
if user_id:
query = query.filter(Ticket.user_id == user_id)
@@ -101,3 +115,57 @@ class TicketService:
if status:
query = query.filter(Ticket.status == status)
return query.order_by(Ticket.created_at.desc()).all()
def delete_ticket(self, ticket_id: str) -> bool:
"""
Delete a ticket and its associated image file if it exists.
Safety:
- Normalize stored image_path to a relative POSIX path under the static/ mount using
normalize_image_path_for_url().
- Resolve the resulting path and only delete if the resolved path is under the configured
uploads directory (UPLOADS_DIR_RESOLVED) to prevent path traversal.
"""
ticket = self.db.query(Ticket).filter(Ticket.id == ticket_id).first()
if not ticket:
raise NoResultFound(f"Ticket with id {ticket_id} not found")
# Attempt to delete the image file if present
try:
rel = normalize_image_path_for_url(ticket.image_path)
if rel:
file_path = Path(rel)
# Resolve to absolute path safely (works if file missing too)
try:
absolute = file_path.resolve()
except Exception:
absolute = (Path.cwd() / file_path).resolve()
# Ensure the file is inside the uploads directory
try:
absolute.relative_to(UPLOADS_DIR_RESOLVED)
inside_uploads = True
except Exception:
inside_uploads = False
if inside_uploads and absolute.exists():
try:
absolute.unlink()
logger.info(f"Deleted image file: {absolute}")
except Exception as e:
logger.warning(f"Failed to delete image file {absolute}: {e}")
else:
logger.debug(f"Image file not deleted (missing or outside uploads): {absolute}")
except Exception as e:
logger.exception(f"Error while attempting to remove image for ticket {ticket_id}: {e}")
# Delete ticket record
try:
self.db.delete(ticket)
self.db.commit()
logger.info(f"Deleted ticket {ticket_id}")
return True
except Exception as e:
logger.exception(f"Failed to delete ticket {ticket_id} from DB: {e}")
self.db.rollback()
raise

100
backend/app/utils.py Normal file
View File

@@ -0,0 +1,100 @@
from typing import Optional
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
# Path where uploaded images are stored (relative to project root)
UPLOADS_DIR = Path("static") / "uploads"
# Resolved absolute path for safety checks
try:
UPLOADS_DIR_RESOLVED = UPLOADS_DIR.resolve()
except Exception:
UPLOADS_DIR_RESOLVED = UPLOADS_DIR
def normalize_image_path_for_url(image_path: Optional[str]) -> Optional[str]:
"""
Normalize stored image_path to a web-accessible relative path under the /static/ mount.
Examples:
r"static\\uploads\\uuid.jpg" -> "static/uploads/uuid.jpg"
"C:\\project\\static\\uploads\\uuid.jpg" -> "static/uploads/uuid.jpg"
"uploads/uuid.jpg" -> "static/uploads/uuid.jpg"
"""
if not image_path:
return None
p = str(image_path).replace("\\", "/")
# strip leading './' and leading slashes
while p.startswith("./"):
p = p[2:]
p = p.lstrip("/")
# prefer existing 'static/' segment if present
if "static/" in p:
p = p[p.find("static/"):]
elif p.startswith("uploads/"):
p = f"static/{p}"
else:
# fallback to treating as filename only
p = f"static/uploads/{Path(p).name}"
# collapse accidental duplicates
p = p.replace("static/static", "static").replace("uploads/uploads", "uploads")
return p
def make_image_url(image_path: Optional[str], request) -> Optional[str]:
"""
Build an absolute URL for the given stored image_path using the FastAPI request.
Returns None if image_path is falsy.
"""
rel = normalize_image_path_for_url(image_path)
if not rel:
return None
base = str(request.base_url).rstrip("/")
return f"{base}/{rel.lstrip('/')}"
def ticket_to_dict(ticket, request=None) -> dict:
"""
Serialize a Ticket ORM object to the normalized schema expected by clients.
Schema:
id, category, severity, status, description,
user_id, user_name, user_email,
created_at (ISO8601), latitude, longitude, address,
image_url (absolute), image_path (relative POSIX under static/)
"""
created = None
try:
if getattr(ticket, "created_at", None):
created = ticket.created_at.isoformat()
except Exception:
created = None
# Normalize stored image path to a safe relative POSIX path under 'static/'
normalized_path = normalize_image_path_for_url(getattr(ticket, "image_path", None))
image_url = None
if request is not None:
try:
image_url = make_image_url(normalized_path, request)
except Exception:
logger.exception("Failed to build image_url")
image_url = None
return {
"id": ticket.id,
"category": ticket.category,
"severity": ticket.severity.value if getattr(ticket, "severity", None) else None,
"status": ticket.status.value if getattr(ticket, "status", None) else None,
"description": ticket.description,
"user_id": ticket.user_id,
"user_name": ticket.user.name if getattr(ticket, "user", None) else None,
"user_email": ticket.user.email if getattr(ticket, "user", None) else None,
"created_at": created,
"latitude": ticket.latitude,
"longitude": ticket.longitude,
"address": ticket.address,
"image_url": image_url,
"image_path": normalized_path
}

View File

@@ -3,6 +3,7 @@ import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.database import Base, engine
from app.routes import report, tickets, analytics, users
from app.services.global_ai import init_ai_service
@@ -44,6 +45,25 @@ UPLOAD_DIR = "static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
# ----------------------
# CORS - allow dashboard & emulator origins
# ----------------------
DEFAULT_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000,http://10.0.2.2:3000,http://192.168.100.59:3000"
origins_env = os.environ.get("FIXMATE_CORS_ORIGINS", DEFAULT_ORIGINS)
allowed_origins = [o.strip() for o in origins_env.split(",") if o.strip()]
# Ensure common development origins are always allowed (localhost, emulator, LAN)
for origin in ("http://localhost:3000", "http://127.0.0.1:3000", "http://10.0.2.2:3000", "http://192.168.100.59:3000"):
if origin not in allowed_origins:
allowed_origins.append(origin)
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ----------------------
# Include routers
# ----------------------

View File

@@ -0,0 +1,33 @@
import sqlite3
import os
import sys
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
db_path = os.path.normpath(os.path.join(script_dir, '..', 'app', 'db', 'fixmate.db'))
print(f"Using database: {db_path}")
if not os.path.exists(db_path):
print(f"DB not found: {db_path}")
return 2
conn = sqlite3.connect(db_path)
try:
cur = conn.cursor()
cur.execute("PRAGMA table_info(tickets);")
cols = [row[1] for row in cur.fetchall()]
if 'address' in cols:
print("Column 'address' already exists")
return 0
cur.execute("ALTER TABLE tickets ADD COLUMN address TEXT;")
conn.commit()
print("Added 'address' column to 'tickets' table")
return 0
except Exception as e:
print("Failed to add 'address' column:", e)
return 1
finally:
conn.close()
if __name__ == '__main__':
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -5,35 +5,58 @@ dayjs.extend(window.dayjs_plugin_relativeTime);
const CATEGORY_LIST = ['pothole','streetlight','signage','trash','drainage','other'];
const SEVERITIES = ['high','medium','low'];
const STATUSES = ['submitted','in_progress','fixed'];
const BACKEND_BASE = "http://192.168.100.59:8000";
const SEVERITY_COLOR = { high:'#D32F2F', medium:'#F57C00', low:'#388E3C' };
const STATUS_COLOR = { submitted:'#1976D2', in_progress:'#7B1FA2', fixed:'#455A64' };
function fetchJSON(path){ return fetch(path).then(r=>r.json()); }
// Fetch tickets from backend
async function fetchTickets(){
const res = await fetch(`${BACKEND_BASE}/api/tickets`);
if(!res.ok) throw new Error('Failed to fetch tickets');
const data = await res.json();
return data;
}
// Normalize API data to expected format
function normalizeReportData(report) {
// If it's already in the expected format (from demo data), return as is
// Already normalized demo format (has location.lat)
if (report.location && report.location.lat !== undefined) {
return report;
return {
id: report.id || report.ticket_id || report.ticketId,
category: report.category || 'other',
severity: report.severity || 'low',
status: report.status || 'submitted',
notes: report.notes || report.description || '',
location: report.location,
createdAt: report.createdAt || report.created_at,
updatedAt: report.updatedAt || report.updated_at,
userId: report.userId || report.user_id,
userName: report.userName || report.user_name || null,
address: report.address || null,
image_url: report.image_url || report.imagePath || report.image_path || null
};
}
// Convert API format to expected format
// Convert backend API format to the app format
return {
id: report.ticket_id,
id: report.id || report.ticket_id || report.ticketId,
category: report.category || 'other',
severity: report.severity || 'low',
status: report.status || 'submitted',
notes: report.description || '',
notes: report.description || report.notes || '',
location: {
lat: report.latitude,
lng: report.longitude
lat: (report.latitude !== undefined ? report.latitude : (report.lat !== undefined ? report.lat : null)),
lng: (report.longitude !== undefined ? report.longitude : (report.lng !== undefined ? report.lng : null))
},
createdAt: report.created_at,
updatedAt: report.updated_at,
// Add missing fields with defaults
userId: report.user_id,
imagePath: report.image_path
createdAt: report.created_at || report.createdAt,
updatedAt: report.updated_at || report.updatedAt,
userId: report.user_id || report.userId,
userName: report.user_name || report.userName || null,
address: report.address || null,
image_url: report.image_url || report.image_path || report.imagePath || null
};
}
@@ -86,31 +109,62 @@ function App(){
const [heatEnabled,setHeatEnabled] = useState(false);
// simple toast container for non-blocking errors / retry actions
const toastContainerRef = useRef(null);
useEffect(()=> {
const c = document.createElement('div');
c.style.position = 'fixed';
c.style.right = '12px';
c.style.bottom = '12px';
c.style.zIndex = 9999;
toastContainerRef.current = c;
document.body.appendChild(c);
return ()=> { if(c.parentNode) c.parentNode.removeChild(c); };
}, []);
const showToast = (msg, actionLabel, action) => {
const c = toastContainerRef.current;
if(!c) { console.warn(msg); return; }
const el = document.createElement('div');
el.style.background = '#111';
el.style.color = '#fff';
el.style.padding = '8px 12px';
el.style.marginTop = '8px';
el.style.borderRadius = '6px';
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
el.style.display = 'flex';
el.style.alignItems = 'center';
el.textContent = msg;
if(actionLabel && action){
const btn = document.createElement('button');
btn.textContent = actionLabel;
btn.style.marginLeft = '12px';
btn.style.background = 'transparent';
btn.style.color = '#4FC3F7';
btn.style.border = 'none';
btn.style.cursor = 'pointer';
btn.onclick = ()=> { action(); if(el.parentNode) el.parentNode.removeChild(el); };
el.appendChild(btn);
}
c.appendChild(el);
setTimeout(()=> { if(el.parentNode) el.parentNode.removeChild(el); }, 8000);
};
const PLACEHOLDER_SRC = 'data:image/svg+xml;utf8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="90"><rect width="100%" height="100%" fill="#e5e7eb"/><text x="50%" y="50%" dy=".3em" font-size="12" text-anchor="middle" fill="#6b7280">No image</text></svg>');
useEffect(()=>{
// Try to fetch from backend API first, fallback to demo data
fetch('http://127.0.0.1:8000/api/tickets')
.then(r => r.ok ? r.json() : Promise.reject('API not available'))
setLoading(true);
fetchTickets()
.then(data => {
console.log('Loaded data from API:', data.length, 'reports');
const normalizedData = data.map(normalizeReportData);
console.log('Loaded data from backend:', (Array.isArray(data) ? data.length : 0), 'reports');
const normalizedData = (data || []).map(normalizeReportData);
setRawData(normalizedData);
setLoading(false);
})
.catch(err => {
console.log('API not available, using demo data:', err);
return fetchJSON('./data/demo-reports.json');
})
.then(data => {
if (data) {
console.log('Loaded demo data:', data.length, 'reports');
// Demo data is already in the correct format, but normalize just in case
const normalizedData = data.map(normalizeReportData);
setRawData(normalizedData);
}
setLoading(false);
})
.catch(err => {
console.error('Error loading data:', err);
console.warn('Failed to load tickets from backend:', err);
showToast('Failed to load tickets from backend.');
setRawData([]);
setLoading(false);
});
},[]);
@@ -254,65 +308,54 @@ function App(){
});
},[filtered]);
const cycleStatus = async (reportId)=>{
try {
// Find the current report to get its status
const currentReport = rawData.find(r => r.id === reportId);
if (!currentReport) return;
const availableStatuses = useMemo(()=>{
const s = new Set(STATUSES);
rawData.forEach(r=>{ if(r && r.status) s.add(r.status); });
return Array.from(s);
}, [rawData]);
const idx = STATUSES.indexOf(currentReport.status);
const nextStatus = STATUSES[(idx + 1) % STATUSES.length];
// Try to update via API first
const success = await fetch(`http://127.0.0.1:8000/api/tickets/${reportId}?new_status=${encodeURIComponent(nextStatus)}`, {
method: 'PATCH'
}).then(r => r.ok);
if (success) {
// If API update successful, refresh data from API
const response = await fetch('http://127.0.0.1:8000/api/tickets');
if (response.ok) {
const data = await response.json();
const normalizedData = data.map(normalizeReportData);
setRawData(normalizedData);
// Update selected item
const updatedReport = normalizedData.find(r => r.id === reportId);
setSelected(updatedReport || null);
}
const updateTicketStatus = async (reportId, newStatus) => {
try {
const res = await fetch(`${BACKEND_BASE}/api/tickets/${reportId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
// Prefer using returned updated ticket if provided
let updated = null;
try { updated = await res.json(); } catch(e){ updated = null; }
if (updated) {
const normalized = normalizeReportData(updated);
setRawData(prev => prev.map(r => r.id === reportId ? normalized : r));
if (selected && selected.id === reportId) setSelected(normalized);
} else {
console.error('Failed to update status via API');
// Fallback to local update
setRawData(prev=>{
const out = prev.map(r=>{
if(r.id !== reportId) return r;
return {...r, status: nextStatus, updatedAt: new Date().toISOString() };
});
if(selected && selected.id === reportId){
const newSel = out.find(r=>r.id === reportId);
setSelected(newSel || null);
}
return out;
});
// No body returned - update local state
setRawData(prev=> prev.map(r=> r.id === reportId ? {...r, status: newStatus, updatedAt: new Date().toISOString()} : r));
if(selected && selected.id === reportId) setSelected(prev => ({...prev, status: newStatus, updatedAt: new Date().toISOString()}));
}
} catch (error) {
console.error('Error updating status:', error);
// Fallback to local update
setRawData(prev=>{
const out = prev.map(r=>{
if(r.id !== reportId) return r;
const idx = STATUSES.indexOf(r.status);
const ni = (idx + 1) % STATUSES.length;
return {...r, status: STATUSES[ni], updatedAt: new Date().toISOString() };
});
if(selected && selected.id === reportId){
const newSel = out.find(r=>r.id === reportId);
setSelected(newSel || null);
}
return out;
});
showToast('Status updated');
return true;
} else {
const text = await res.text().catch(()=> '');
console.warn('Status update failed', text);
showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus));
return false;
}
};
} catch (err) {
console.error('Error updating status:', err);
showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus));
return false;
}
};
const cycleStatus = async (reportId) => {
const currentReport = rawData.find(r => r.id === reportId);
if (!currentReport) return;
const idx = availableStatuses.indexOf(currentReport.status);
const nextStatus = availableStatuses[(idx + 1) % availableStatuses.length] || STATUSES[(STATUSES.indexOf(currentReport.status) + 1) % STATUSES.length];
await updateTicketStatus(reportId, nextStatus);
};
const openInMaps = (r)=>{
const lat = r.location.lat;
@@ -428,7 +471,11 @@ function App(){
<div className="queue-list" role="list">
{sortedQueue.map(r=>(
<div key={r.id} className="queue-item" role="listitem">
<div className="thumb">{t(`category.${r.category}`) || r.category}</div>
<div className="thumb">
{(r.image_url || r.imagePath) ? (
<img src={r.image_url || r.imagePath} alt={r.category} style={{width:64,height:48,objectFit:'cover',borderRadius:6}} onError={(e)=>{ e.currentTarget.style.display='none'; }} />
) : (t(`category.${r.category}`) || r.category)}
</div>
<div className="item-main">
<div
className="item-title clickable"
@@ -443,7 +490,10 @@ function App(){
<span className="time-ago">{dayjs(r.createdAt).fromNow()}</span>
</div>
</div>
<div className="item-actions">
<div className="item-actions" style={{display:'flex',flexDirection:'column',gap:8,alignItems:'flex-end'}}>
<select value={r.status} onChange={(e)=> updateTicketStatus(r.id, e.target.value)}>
{availableStatuses.map(s => <option key={s} value={s}>{t(`status.${s}`) || s}</option>)}
</select>
<button className="btn ghost" onClick={()=> { setSelected(r); }}>{t('btn.view') || 'View'}</button>
</div>
</div>
@@ -472,7 +522,11 @@ function App(){
<div className="drawer-content" aria-live="polite">
<button className="drawer-close" onClick={()=>setSelected(null)} aria-label="Close">×</button>
<div className="drawer-header">
<div className="drawer-thumb large">{/* placeholder */}{t(`category.${selected.category}`) || selected.category}</div>
<div className="drawer-thumb large">
{(selected.image_url || selected.imagePath) ? (
<img src={selected.image_url || selected.imagePath} alt={selected.category} style={{width:88,height:64,objectFit:'cover',borderRadius:6}} onError={(e)=>{ e.currentTarget.style.display='none'; }} />
) : (t(`category.${selected.category}`) || selected.category)}
</div>
<div style={{marginLeft:12}}>
<h3 style={{margin:0}}>{t(`category.${selected.category}`) || selected.category}</h3>
<div style={{display:'flex',gap:8,alignItems:'center',marginTop:6}}>
@@ -486,14 +540,16 @@ function App(){
<div className="drawer-body">
<p style={{marginTop:8}}><strong>{t('drawer.details') || 'Details'}</strong></p>
{selected.notes ? <p>{selected.notes}</p> : <p style={{opacity:0.7}}>{t('drawer.noNotes') || 'No additional notes'}</p>}
<p><strong>{t('label.submittedBy') || 'Submitted by'}:</strong> {selected.userName || (t('label.guest') || 'Guest')}</p>
<p><strong>{t('label.place') || 'Place'}:</strong> {selected.address ? selected.address : `${selected.location.lat.toFixed(5)}, ${selected.location.lng.toFixed(5)}`}</p>
<p><strong>{t('label.location') || 'Location'}:</strong> {selected.location.lat.toFixed(5)}, {selected.location.lng.toFixed(5)}</p>
<p><strong>{t('label.createdAt') || 'Created'}:</strong> {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}</p>
</div>
<div className="drawer-actions">
<button className="btn" onClick={()=>{ cycleStatus(selected.id); }}>
{t('drawer.changeStatus') || 'Change Status'}
</button>
<select value={selected.status} onChange={(e)=> updateTicketStatus(selected.id, e.target.value)}>
{availableStatuses.map(s => <option key={s} value={s}>{t(`status.${s}`) || s}</option>)}
</select>
<button className="btn secondary" onClick={()=>openInMaps(selected)}>
{t('drawer.openMap') || 'Open Map'}
</button>

View File

@@ -9,6 +9,7 @@
"btn.apply": "Apply",
"btn.reset": "Reset",
"btn.view": "View",
"btn.viewDetails": "View details",
"label.language": "Language",
"label.location": "Location",
"label.createdAt": "Created At",

View File

@@ -9,6 +9,7 @@
"btn.apply": "Terapkan",
"btn.reset": "Tetapkan Semula",
"btn.view": "Lihat",
"btn.viewDetails": "Lihat butiran",
"label.language": "Bahasa",
"label.location": "Lokasi",
"label.createdAt": "Dicipta Pada",

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -21,6 +21,9 @@ class Report {
/// Base64 encoded photo for web platform
final String? base64Photo;
/// Remote image URL provided by backend (absolute URL)
final String? imageUrl;
/// Geographic location where the issue was reported
final LocationData location;
@@ -38,6 +41,8 @@ class Report {
/// Address or location description (placeholder for future use)
final String? address;
/// Name of the user who submitted the report (API reports)
final String? submittedBy;
/// Source of the photo ("camera" or "gallery")
final String source;
@@ -61,12 +66,14 @@ class Report {
required this.status,
this.photoPath,
this.base64Photo,
this.imageUrl,
required this.location,
required this.createdAt,
required this.updatedAt,
required this.deviceId,
this.notes,
this.address,
this.submittedBy,
required this.source,
this.editable = true,
this.deletable = true,
@@ -89,6 +96,8 @@ class Report {
String? photoPath,
String? base64Photo,
String? notes,
String? submittedBy,
String? address,
required String source,
required String deviceId,
required AISuggestion aiSuggestion,
@@ -106,6 +115,8 @@ class Report {
updatedAt: now,
deviceId: deviceId,
notes: notes,
address: address,
submittedBy: submittedBy,
source: source,
aiSuggestion: aiSuggestion,
);
@@ -118,6 +129,7 @@ class Report {
Status? status,
String? photoPath,
String? base64Photo,
String? imageUrl,
LocationData? location,
String? updatedAt,
String? notes,
@@ -133,6 +145,7 @@ class Report {
status: status ?? this.status,
photoPath: photoPath ?? this.photoPath,
base64Photo: base64Photo ?? this.base64Photo,
imageUrl: imageUrl ?? this.imageUrl,
location: location ?? this.location,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
@@ -156,6 +169,7 @@ class Report {
'status': status.key,
'photoPath': photoPath,
'base64Photo': base64Photo,
'imageUrl': imageUrl,
'location': {
'lat': location.lat,
'lng': location.lng,
@@ -166,6 +180,7 @@ class Report {
'deviceId': deviceId,
'notes': notes,
'address': address,
'submittedBy': submittedBy,
'source': source,
'editable': editable,
'deletable': deletable,
@@ -187,6 +202,7 @@ class Report {
status: (json['status'] as String).toStatus() ?? Status.submitted,
photoPath: json['photoPath'] as String?,
base64Photo: json['base64Photo'] as String?,
imageUrl: json['imageUrl'] as String?,
location: LocationData(
lat: (json['location']['lat'] as num).toDouble(),
lng: (json['location']['lng'] as num).toDouble(),
@@ -199,6 +215,7 @@ class Report {
deviceId: json['deviceId'] as String,
notes: json['notes'] as String?,
address: json['address'] as String?,
submittedBy: json['submittedBy'] as String?,
source: json['source'] as String,
editable: json['editable'] as bool? ?? true,
deletable: json['deletable'] as bool? ?? true,

View File

@@ -12,7 +12,7 @@ import '../../l10n/i18n.dart';
import '../../models/enums.dart';
import '../../models/report.dart';
import '../../services/location_service.dart';
import '../../services/storage.dart';
import '../../services/api_service.dart';
import '../../widgets/severity_badge.dart';
import '../../widgets/status_badge.dart';
import '../my_reports/my_reports_screen.dart';
@@ -59,7 +59,7 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _refresh() async {
setState(() => _loading = true);
final reports = await StorageService.getReports();
final reports = await ApiService.fetchTickets();
setState(() {
_allReports = reports;
_loading = false;
@@ -325,6 +325,17 @@ class _MapScreenState extends State<MapScreen> {
child: Icon(Icons.photo, color: Colors.grey.shade600),
);
// Prefer backend-provided image URL when available
if (r.imageUrl != null && r.imageUrl!.isNotEmpty) {
return Image.network(
r.imageUrl!,
width: 120,
height: 90,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => placeholder,
);
}
if (kIsWeb) {
if (r.base64Photo != null && r.base64Photo!.isNotEmpty) {
try {
@@ -776,7 +787,17 @@ class MapReportDetails extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (kIsWeb && report.base64Photo != null)
if (report.imageUrl != null)
Image.network(
report.imageUrl!,
errorBuilder: (_, __, ___) => Container(
height: 180,
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Icon(Icons.photo, size: 64),
),
)
else if (kIsWeb && report.base64Photo != null)
Image.memory(base64Decode(report.base64Photo!))
else if (!kIsWeb && report.photoPath != null)
Image.file(File(report.photoPath!))

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../services/storage.dart';
import '../../services/api_service.dart';
import '../../models/report.dart';
import '../../widgets/report_card.dart';
import '../map/map_screen.dart';
@@ -23,14 +24,46 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
}
Future<void> _loadReports() async {
setState(() {
_loading = true;
});
final reports = await StorageService.getReports();
setState(() {
_reports = reports.reversed.toList(); // newest first
_loading = false;
});
setState(() => _loading = true);
try {
// Try to fetch tickets from API and filter by this device's user id
final userId = await ApiService.getUserId();
final apiReports = await ApiService.fetchTickets();
// Keep only reports that belong to this device/user
final myApiReports = apiReports.where((r) => r.deviceId == userId).toList();
// Also include any local reports stored that belong to this device
final localReports = await StorageService.getReports();
final myLocalReports = localReports.where((r) => r.deviceId == userId).toList();
// Merge by id, prefer API version when available
final Map<String, Report> merged = {};
for (final r in myApiReports) merged[r.id] = r;
for (final r in myLocalReports) {
if (!merged.containsKey(r.id)) merged[r.id] = r;
}
final combined = merged.values.toList();
setState(() {
if (combined.isNotEmpty) {
_reports = combined.reversed.toList(); // newest first
} else {
// Fallback: show local reports if no API-backed reports found for this user
_reports = localReports.reversed.toList();
}
_loading = false;
});
} catch (e) {
// Conservative fallback to local storage
final reports = await StorageService.getReports();
setState(() {
_reports = reports.reversed.toList();
_loading = false;
});
}
}
void _onViewReport(Report r) {

View File

@@ -54,25 +54,35 @@ class _CaptureScreenState extends State<CaptureScreen> {
Future<void> _processImage(XFile image, ImageSource source) async {
try {
// Get current position (Geolocator.Position)
// Get current position (optional - app can work without location)
final position = await LocationService.getCurrentPosition();
if (position == null) {
// Create location data even if position is null (with default values)
LocationData? locationData;
if (position != null) {
locationData = LocationService.positionToLocationData(position);
print('Location acquired: ${locationData.lat}, ${locationData.lng}');
} else {
// Create a fallback location with zero coordinates
// This allows the app to continue working without location
locationData = LocationData(lat: 0.0, lng: 0.0, accuracy: null);
print(
'Using fallback location (0.0, 0.0) - location services unavailable',
);
// Show a non-blocking warning to the user
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Unable to get location. Please try again.',
const SnackBar(
content: Text(
'Location unavailable. Report will be created without GPS coordinates.',
), // TODO: Move to i18n
duration: Duration(seconds: 3),
),
);
}
return;
}
// Convert Position -> LocationData (app model)
final locationData = LocationService.positionToLocationData(position);
// Generate AI suggestion (seeded deterministic)
final aiSuggestion = MockAIService.generateSuggestion(
id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -113,10 +123,12 @@ class _CaptureScreenState extends State<CaptureScreen> {
);
}
} catch (e) {
print('Critical error in image processing: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(I18n.t('error.imageProcessing', {'0': e.toString()})),
backgroundColor: Colors.red,
),
);
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../models/report.dart';
import '../models/enums.dart';
@@ -8,14 +9,24 @@ import '../models/enums.dart';
class ApiService {
// Configure this to match your backend URL
// Use localhost for web/desktop, network IP for mobile/emulator
static const String _baseUrl = 'http://192.168.100.59:8000/api';
static const String _uploadsUrl = 'http://192.168.100.59:8000/static/uploads';
static const String BASE_URL = 'http://192.168.100.59:8000';
static const String _baseUrl = '$BASE_URL/api';
static const String _uploadsUrl = '$BASE_URL/static/uploads';
// Create a user ID for this device if not exists
// Create a user ID for this device if not exists (persisted)
static Future<String> _getOrCreateUserId() async {
// For now, generate a UUID for this device
// In a real app, this would be stored securely
return const Uuid().v4();
try {
final prefs = await SharedPreferences.getInstance();
const key = 'fixmate_user_id';
final existing = prefs.getString(key);
if (existing != null && existing.isNotEmpty) return existing;
final newId = Uuid().v4();
await prefs.setString(key, newId);
return newId;
} catch (e) {
// If SharedPreferences fails for any reason, fallback to an in-memory UUID
return Uuid().v4();
}
}
/// Create a new user
@@ -42,6 +53,9 @@ class ApiService {
}
}
/// Get or create the current device / user id used when submitting reports
static Future<String> getUserId() => _getOrCreateUserId();
/// Submit a report to the backend
static Future<String> submitReport({
required double latitude,
@@ -49,6 +63,8 @@ class ApiService {
required String description,
required List<int> imageBytes,
required String imageName,
String? userName,
String? address,
}) async {
try {
final userId = await _getOrCreateUserId();
@@ -61,6 +77,8 @@ class ApiService {
request.fields['latitude'] = latitude.toString();
request.fields['longitude'] = longitude.toString();
request.fields['description'] = description;
if (userName != null && userName.isNotEmpty) request.fields['user_name'] = userName;
if (address != null && address.isNotEmpty) request.fields['address'] = address;
// Add the image file
request.files.add(
@@ -101,6 +119,9 @@ class ApiService {
}
}
/// Preferred API name for fetching tickets (alias for getReports)
static Future<List<Report>> fetchTickets() => getReports();
/// Get a single ticket by ID
static Future<Report?> getReportById(String ticketId) async {
try {
@@ -132,6 +153,24 @@ class ApiService {
}
}
/// Delete a ticket by ID
static Future<bool> deleteTicket(String ticketId) async {
try {
final response = await http.delete(
Uri.parse('$_baseUrl/tickets/$ticketId'),
);
if (response.statusCode == 200 || response.statusCode == 204) {
return true;
} else {
print('Failed to delete ticket: ${response.statusCode} ${response.body}');
return false;
}
} catch (e) {
print('Error deleting ticket: $e');
return false;
}
}
/// Get analytics data
static Future<Map<String, dynamic>> getAnalytics() async {
try {
@@ -150,22 +189,30 @@ class ApiService {
/// Convert API ticket response to Report model
static Report _convertApiTicketToReport(Map<String, dynamic> data) {
final id = (data['id'] ?? data['ticket_id'] ?? '').toString();
final imageUrl = (data['image_url'] as String?) ??
(data['image_path'] != null
? '$_uploadsUrl/${(data['image_path'] as String).split('/').last}'
: null);
return Report(
id: data['ticket_id'] ?? '',
id: id,
category: _normalizeCategory(data['category'] ?? ''),
severity: _normalizeSeverity(data['severity'] ?? 'N/A'),
status: _normalizeStatus(data['status'] ?? 'New'),
photoPath: data['image_path'] != null
? '$_uploadsUrl/${data['image_path'].split('/').last}'
: null,
// For API-provided tickets prefer imageUrl; photoPath is for local files
photoPath: null,
imageUrl: imageUrl,
location: LocationData(
lat: (data['latitude'] as num?)?.toDouble() ?? 0.0,
lng: (data['longitude'] as num?)?.toDouble() ?? 0.0,
),
createdAt: data['created_at'] ?? DateTime.now().toIso8601String(),
updatedAt: data['updated_at'] ?? DateTime.now().toIso8601String(),
deviceId: 'api-${data['ticket_id'] ?? ''}',
createdAt: (data['created_at'] ?? data['createdAt'] ?? DateTime.now().toIso8601String()) as String,
updatedAt: (data['updated_at'] ?? data['updatedAt'] ?? DateTime.now().toIso8601String()) as String,
deviceId: data['user_id'] != null ? data['user_id'].toString() : 'api-$id',
notes: data['description'] as String?,
address: data['address'] as String?,
submittedBy: data['user_name'] as String?,
source: 'api',
aiSuggestion: AISuggestion(
category: _normalizeCategory(data['category'] ?? ''),

View File

@@ -9,6 +9,13 @@ class LocationService {
return await Geolocator.isLocationServiceEnabled();
} catch (e) {
print('Error checking location service: $e');
// Handle specific Android exceptions that can cause crashes
if (e.toString().contains('DeadSystemException') ||
e.toString().contains('DeadSystemRuntimeException') ||
e.toString().contains('SecurityException')) {
print('System-level location service error detected, returning false');
return false;
}
return false;
}
}
@@ -19,6 +26,13 @@ class LocationService {
return await Geolocator.checkPermission();
} catch (e) {
print('Error checking location permission: $e');
// Handle specific Android exceptions that can cause crashes
if (e.toString().contains('DeadSystemException') ||
e.toString().contains('DeadSystemRuntimeException') ||
e.toString().contains('SecurityException')) {
print('System-level permission error detected, returning denied');
return LocationPermission.denied;
}
return LocationPermission.denied;
}
}
@@ -29,6 +43,15 @@ class LocationService {
return await Geolocator.requestPermission();
} catch (e) {
print('Error requesting location permission: $e');
// Handle specific Android exceptions that can cause crashes
if (e.toString().contains('DeadSystemException') ||
e.toString().contains('DeadSystemRuntimeException') ||
e.toString().contains('SecurityException')) {
print(
'System-level permission request error detected, returning denied',
);
return LocationPermission.denied;
}
return LocationPermission.denied;
}
}
@@ -58,17 +81,46 @@ class LocationService {
return null;
}
// Get current position
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 30),
);
// Get current position with multiple fallback strategies
return await _getPositionWithFallback();
} catch (e) {
print('Error getting current position: $e');
return null;
}
}
/// Get position with fallback strategies to avoid crashes
static Future<Position?> _getPositionWithFallback() async {
try {
// Try high accuracy first with a reasonable timeout
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 15),
);
} catch (e) {
print('High accuracy failed, trying medium accuracy: $e');
try {
// Fallback to medium accuracy
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.medium,
timeLimit: const Duration(seconds: 10),
);
} catch (e2) {
print('Medium accuracy failed, trying low accuracy: $e2');
try {
// Final fallback to low accuracy
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.low,
timeLimit: const Duration(seconds: 5),
);
} catch (e3) {
print('All accuracy levels failed: $e3');
return null;
}
}
}
}
/// Get current position with best available accuracy
static Future<Position?> getBestAvailablePosition() async {
try {
@@ -181,7 +233,10 @@ class LocationService {
}
/// Get address from coordinates (placeholder - would need geocoding service)
static Future<String?> getAddressFromCoordinates(double lat, double lng) async {
static Future<String?> getAddressFromCoordinates(
double lat,
double lng,
) async {
// This is a placeholder implementation
// In a real app, you would use a geocoding service like Google Maps API
// or OpenStreetMap Nominatim API
@@ -198,4 +253,4 @@ class LocationService {
acc != null &&
acc >= 0;
}
}
}

View File

@@ -57,6 +57,8 @@ class StorageService {
description: report.notes ?? '',
imageBytes: imageBytes,
imageName: '${report.id}.jpg',
userName: report.submittedBy,
address: report.address,
);
return true;
}
@@ -85,32 +87,38 @@ class StorageService {
/// Delete a report from storage (API first, fallback to local)
static Future<bool> deleteReport(String reportId) async {
try {
// Try API first (note: API doesn't have delete endpoint, so this will always fallback)
final apiReport = await ApiService.getReportById(reportId);
if (apiReport != null) {
// For now, the API doesn't have a delete endpoint, so we can't delete from API
// This would need to be added to the backend
print('API delete not available, keeping local copy');
// Try API delete first
final apiDeleted = await ApiService.deleteTicket(reportId);
if (apiDeleted) {
// Clean up local copies if any
try {
final reports = await getReports();
final updatedReports = reports.where((r) => r.id != reportId).toList();
if (!kIsWeb) {
await _deletePhotoFile(reportId);
}
await _saveReportsList(updatedReports);
} catch (e) {
print('Error cleaning local copies after API delete: $e');
}
return true;
}
} catch (e) {
print('API not available: $e');
print('API delete failed or API not available: $e');
}
// Fallback to local storage
// Fallback to local storage deletion
try {
final reports = await getReports();
final updatedReports = reports.where((r) => r.id != reportId).toList();
// Delete photo file if it exists
if (kIsWeb) {
// On web, base64 is stored in memory, no file to delete
} else {
if (!kIsWeb) {
await _deletePhotoFile(reportId);
}
return await _saveReportsList(updatedReports);
} catch (e) {
print('Error deleting report: $e');
print('Error deleting report locally: $e');
return false;
}
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../models/report.dart';
import '../models/enums.dart' as enums;
import '../services/storage.dart';
import '../services/api_service.dart';
import '../l10n/i18n.dart';
class ReportCard extends StatelessWidget {
@@ -22,6 +23,30 @@ class ReportCard extends StatelessWidget {
});
Widget _buildThumbnail() {
final placeholder = Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.image, color: Colors.grey.shade600),
);
// Prefer backend-provided image URL when available
if (report.imageUrl != null && report.imageUrl!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
report.imageUrl!,
width: 72,
height: 72,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => placeholder,
),
);
}
if (kIsWeb && report.base64Photo != null) {
try {
final bytes = base64Decode(report.base64Photo!);
@@ -32,21 +57,15 @@ class ReportCard extends StatelessWidget {
} catch (_) {}
} else if (report.photoPath != null) {
final file = File(report.photoPath!);
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover),
);
if (file.existsSync()) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover),
);
}
}
return Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.image, color: Colors.grey.shade600),
);
return placeholder;
}
String _formatTime(String iso) {
@@ -78,7 +97,19 @@ class ReportCard extends StatelessWidget {
);
if (ok == true) {
final success = await StorageService.deleteReport(report.id);
bool success = false;
try {
success = await ApiService.deleteTicket(report.id);
} catch (e) {
print('Error deleting via API: $e');
success = false;
}
// Fallback to local delete if API delete fails
if (!success) {
success = await StorageService.deleteReport(report.id);
}
if (success) {
if (onDeleted != null) {
onDeleted!();
@@ -88,6 +119,12 @@ class ReportCard extends StatelessWidget {
SnackBar(content: Text(I18n.t('toast.reportDeleted'))),
);
}
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(I18n.t('error.saving', {'0': 'Failed to delete report'}))),
);
}
}
}
}
@@ -236,7 +273,24 @@ class ReportCard extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// Submitted by (if available)
if (report.submittedBy != null) ...[
Row(
children: [
Icon(Icons.person, size: 14, color: cs.onSurface.withOpacity(0.6)),
const SizedBox(width: 6),
Expanded(
child: Text(
'Submitted by ${report.submittedBy}',
style: TextStyle(fontSize: 12, color: cs.onSurface.withOpacity(0.7)),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
],
// Status indicators
Row(
children: [
@@ -343,7 +397,9 @@ class ReportCard extends StatelessWidget {
const SizedBox(width: 4),
Expanded(
child: Text(
'${report.location.lat.toStringAsFixed(4)}, ${report.location.lng.toStringAsFixed(4)}',
report.address != null && report.address!.isNotEmpty
? report.address!
: '${report.location.lat.toStringAsFixed(4)}, ${report.location.lng.toStringAsFixed(4)}',
style: TextStyle(
fontSize: 12,
color: cs.onSurface.withOpacity(0.6),
@@ -374,40 +430,36 @@ class ReportCard extends StatelessWidget {
}
},
itemBuilder: (_) => [
const PopupMenuItem(
PopupMenuItem(
value: 0,
child: Row(
children: [
Icon(Icons.visibility),
SizedBox(width: 8),
Text(
'View Details',
), // TODO: Move to i18n but need to handle dynamic text in popup menu
const Icon(Icons.visibility),
const SizedBox(width: 8),
Text(I18n.t('btn.viewDetails')),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 1,
child: Row(
children: [
Icon(Icons.update),
SizedBox(width: 8),
Text(
'Update Status',
), // TODO: Move to i18n but need to handle dynamic text in popup menu
const Icon(Icons.update),
const SizedBox(width: 8),
Text(I18n.t('report.updateStatus')),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 2,
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(
'Delete',
style: TextStyle(color: Colors.red),
), // TODO: Move to i18n but need to handle dynamic text in popup menu
I18n.t('report.delete'),
style: const TextStyle(color: Colors.red),
),
],
),
),

View File

@@ -217,10 +217,10 @@ packages:
dependency: "direct main"
description:
name: geolocator
sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b"
sha256: "6cb9fb6e5928b58b9a84bdf85012d757fd07aab8215c5205337021c4999bad27"
url: "https://pub.dev"
source: hosted
version: "12.0.0"
version: "11.1.0"
geolocator_android:
dependency: transitive
description:
@@ -249,10 +249,10 @@ packages:
dependency: transitive
description:
name: geolocator_web
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
sha256: "49d8f846ebeb5e2b6641fe477a7e97e5dd73f03cbfef3fd5c42177b7300fb0ed"
url: "https://pub.dev"
source: hosted
version: "4.1.3"
version: "3.0.0"
geolocator_windows:
dependency: transitive
description:

View File

@@ -36,7 +36,7 @@ dependencies:
flutter_map: ^8.2.2
flutter_map_marker_cluster: ^8.2.2
latlong2: ^0.9.1
geolocator: ^12.0.0
geolocator: ^11.0.0
image_picker: ^1.1.2
path_provider: ^2.1.4
shared_preferences: ^2.3.2