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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
100
backend/app/utils.py
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
# ----------------------
|
||||
|
||||
33
backend/scripts/add_address_column.py
Normal file
33
backend/scripts/add_address_column.py
Normal 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())
|
||||
BIN
backend/static/uploads/bdb41802-b2a9-4d4b-ad60-5491be7987c8.jpg
Normal file
BIN
backend/static/uploads/bdb41802-b2a9-4d4b-ad60-5491be7987c8.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
backend/static/uploads/d35f86a2-08b5-47d6-bcfd-c59c8ccee275.jpg
Normal file
BIN
backend/static/uploads/d35f86a2-08b5-47d6-bcfd-c59c8ccee275.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
240
dashboard/app.js
240
dashboard/app.js
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
3
devtools_options.yaml
Normal 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:
|
||||
@@ -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,
|
||||
|
||||
@@ -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!))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'] ?? ''),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user