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

View File

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

View File

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

Binary file not shown.

View File

@@ -46,6 +46,7 @@ class Ticket(Base):
category = Column(String, nullable=False) category = Column(String, nullable=False)
severity = Column(Enum(SeverityLevel), nullable=False, default=SeverityLevel.NA) severity = Column(Enum(SeverityLevel), nullable=False, default=SeverityLevel.NA)
description = Column(String, default="") description = Column(String, default="")
address = Column(String, nullable=True)
status = Column(Enum(TicketStatus), nullable=False, default=TicketStatus.NEW) status = Column(Enum(TicketStatus), nullable=False, default=TicketStatus.NEW)
latitude = Column(Float, nullable=False) latitude = Column(Float, nullable=False)
longitude = 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 fastapi.responses import JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.services.ticket_service import TicketService, SeverityLevel from app.services.ticket_service import TicketService, SeverityLevel
from app.models.ticket_model import User from app.models.ticket_model import User
from app.services.global_ai import get_ai_service 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() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
UPLOAD_DIR = "static/uploads" UPLOAD_DIR = Path("static") / "uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/report") @router.post("/report")
async def report_issue( async def report_issue(
user_id: str = Form(...), user_id: Optional[str] = Form(None),
user_name: Optional[str] = Form(None),
latitude: float = Form(...), latitude: float = Form(...),
longitude: float = Form(...), longitude: float = Form(...),
address: Optional[str] = Form(None),
description: str = Form(""), description: str = Form(""),
image: UploadFile = File(...), image: UploadFile = File(...),
request: Request = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
logger.debug("Received report request") logger.debug("Received report request")
ticket_service = TicketService(db) ticket_service = TicketService(db)
# Validate user # Validate or create user
user = db.query(User).filter(User.id == user_id).first() user = None
if user_id:
user = ticket_service.get_user(user_id)
if not user: if not user:
logger.error(f"User with id {user_id} not found") # Create a guest user automatically
raise HTTPException(status_code=404, detail=f"User with id {user_id} not found") guest_email = f"guest-{uuid.uuid4()}@example.local"
logger.debug(f"User found: {user.name} ({user.email})") 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 # Validate file type
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} 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 '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: if file_ext not in allowed_extensions:
logger.error(f"Invalid file extension: {file_ext}") logger.error(f"Invalid file extension: {file_ext}")
raise HTTPException(status_code=400, detail="Only image files are allowed") raise HTTPException(status_code=400, detail="Only image files are allowed")
@@ -51,12 +67,11 @@ async def report_issue(
# Save uploaded image # Save uploaded image
filename = f"{uuid.uuid4()}{file_ext}" filename = f"{uuid.uuid4()}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, filename) file_path_obj = UPLOAD_DIR / filename
try: try:
content = await image.read() content = await image.read()
with open(file_path, "wb") as f: file_path_obj.write_bytes(content)
f.write(content) logger.debug(f"Saved image to {file_path_obj} ({len(content)} bytes)")
logger.debug(f"Saved image to {file_path} ({len(content)} bytes)")
except Exception as e: except Exception as e:
logger.exception("Failed to save uploaded image") logger.exception("Failed to save uploaded image")
raise HTTPException(status_code=500, detail="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 # Run AI predictions
try: try:
category = ai_service.classify_category(file_path) category = ai_service.classify_category(str(file_path_obj))
logger.debug(f"Classification: {category}") logger.debug(f"Classification: {category}")
if category.lower() == "pothole": 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}") logger.debug(f"Detection: severity={severity_str}, path={annotated_path}")
severity = { severity = {
"High": SeverityLevel.HIGH, "High": SeverityLevel.HIGH,
@@ -87,20 +102,27 @@ async def report_issue(
category = "Unknown" category = "Unknown"
severity = SeverityLevel.NA severity = SeverityLevel.NA
# Create ticket # Create ticket (store relative posix path)
image_path_db = file_path_obj.as_posix()
ticket = ticket_service.create_ticket( ticket = ticket_service.create_ticket(
user_id=user.id, user_id=user.id,
image_path=file_path, image_path=image_path_db,
category=category, category=category,
severity=severity, severity=severity,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
description=description description=description,
address=address
) )
logger.info(f"Ticket created: {ticket.id} for user {user.id}") 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 = { response = {
"ticket_id": ticket.id, "ticket_id": ticket.id,
"id": ticket.id,
"user_id": user.id, "user_id": user.id,
"user_name": user.name, "user_name": user.name,
"user_email": user.email, "user_email": user.email,
@@ -108,7 +130,9 @@ async def report_issue(
"severity": ticket.severity.value, "severity": ticket.severity.value,
"status": ticket.status.value, "status": ticket.status.value,
"description": ticket.description, "description": ticket.description,
"image_path": ticket.image_path "image_path": rel_path,
"image_url": image_url,
"address": ticket.address
} }
logger.debug(f"Response: {response}") logger.debug(f"Response: {response}")

View File

@@ -1,96 +1,81 @@
# app/routes/tickets.py # app/routes/tickets.py
from typing import Optional, List from typing import Optional, List
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.services.ticket_service import TicketService, TicketStatus, SeverityLevel from app.services.ticket_service import TicketService, TicketStatus, SeverityLevel
from pydantic import BaseModel from pydantic import BaseModel
from app.utils import ticket_to_dict
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
class TicketStatusUpdate(BaseModel): class TicketStatusUpdate(BaseModel):
new_status: TicketStatus status: TicketStatus
# ---------------------- # ----------------------
# GET /tickets # GET /tickets
# ---------------------- # ----------------------
@router.get("/tickets", response_model=List[dict]) @router.get("/tickets", response_model=List[dict])
def list_tickets( def list_tickets(
request: Request,
user_id: Optional[str] = Query(None, description="Filter by user ID"), user_id: Optional[str] = Query(None, description="Filter by user ID"),
category: Optional[str] = Query(None, description="Filter by category"), category: Optional[str] = Query(None, description="Filter by category"),
severity: Optional[SeverityLevel] = Query(None, description="Filter by severity"), severity: Optional[SeverityLevel] = Query(None, description="Filter by severity"),
status: Optional[TicketStatus] = Query(None, description="Filter by status"), status: Optional[TicketStatus] = Query(None, description="Filter by status"),
db: Session = Depends(get_db) 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) service = TicketService(db)
tickets = service.list_tickets(user_id=user_id, category=category, severity=severity, status=status) tickets = service.list_tickets(user_id=user_id, category=category, severity=severity, status=status)
return [ return [ticket_to_dict(t, request) for t in tickets]
{
"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
]
# ---------------------- # ----------------------
# GET /tickets/{ticket_id} # GET /tickets/{ticket_id}
# ---------------------- # ----------------------
@router.get("/tickets/{ticket_id}", response_model=dict) @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) service = TicketService(db)
ticket = service.get_ticket(ticket_id) ticket = service.get_ticket(ticket_id)
if not ticket: if not ticket:
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found") raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
return { return ticket_to_dict(ticket, request)
"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
}
# ---------------------- # ----------------------
# 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( def update_ticket_status(
ticket_id: str, 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) db: Session = Depends(get_db)
): ):
service = TicketService(db) service = TicketService(db)
try: 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: except Exception as e:
logger.error(f"Failed to update ticket status: {e}") logger.error(f"Failed to update ticket status: {e}")
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
return { return ticket_to_dict(ticket, request)
"ticket_id": ticket.id,
"user_id": ticket.user_id, # ----------------------
"category": ticket.category, # DELETE /tickets/{ticket_id} - Delete ticket + image
"severity": ticket.severity.value, # ----------------------
"status": ticket.status.value, @router.delete("/tickets/{ticket_id}", response_model=dict)
"description": ticket.description, def delete_ticket(ticket_id: str, db: Session = Depends(get_db)):
"latitude": ticket.latitude, service = TicketService(db)
"longitude": ticket.longitude, try:
"image_path": ticket.image_path, service.delete_ticket(ticket_id)
"created_at": ticket.created_at, except Exception as e:
"updated_at": ticket.updated_at 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 # app/services/ticket_service.py
import uuid import uuid
from typing import List, Optional from typing import List, Optional
from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import NoResultFound
from app.models.ticket_model import User, Ticket, TicketAudit, TicketStatus, SeverityLevel 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 import logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -26,8 +28,6 @@ class TicketService:
self.db.refresh(user) self.db.refresh(user)
logger.info(f"Created user {user}") logger.info(f"Created user {user}")
return user # <-- return User object return user # <-- return User object
def get_user(self, user_id: str) -> Optional[User]: def get_user(self, user_id: str) -> Optional[User]:
return self.db.query(User).filter(User.id == user_id).first() return self.db.query(User).filter(User.id == user_id).first()
@@ -44,16 +44,27 @@ class TicketService:
latitude: float, latitude: float,
longitude: float, longitude: float,
description: str = "", description: str = "",
address: Optional[str] = None,
) -> Ticket: ) -> 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( ticket = Ticket(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
user_id=user_id, user_id=user_id,
image_path=image_path, image_path=image_path_posix,
category=category, category=category,
severity=severity, severity=severity,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
description=description, description=description,
address=address,
) )
self.db.add(ticket) self.db.add(ticket)
self.db.commit() self.db.commit()
@@ -91,6 +102,9 @@ class TicketService:
severity: Optional[SeverityLevel] = None, severity: Optional[SeverityLevel] = None,
status: Optional[TicketStatus] = None status: Optional[TicketStatus] = None
) -> List[Ticket]: ) -> List[Ticket]:
"""
Return tickets. By default returns all tickets unless optional filters are provided.
"""
query = self.db.query(Ticket) query = self.db.query(Ticket)
if user_id: if user_id:
query = query.filter(Ticket.user_id == user_id) query = query.filter(Ticket.user_id == user_id)
@@ -101,3 +115,57 @@ class TicketService:
if status: if status:
query = query.filter(Ticket.status == status) query = query.filter(Ticket.status == status)
return query.order_by(Ticket.created_at.desc()).all() 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 contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.database import Base, engine from app.database import Base, engine
from app.routes import report, tickets, analytics, users from app.routes import report, tickets, analytics, users
from app.services.global_ai import init_ai_service from app.services.global_ai import init_ai_service
@@ -44,6 +45,25 @@ UPLOAD_DIR = "static/uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static") 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 # 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 CATEGORY_LIST = ['pothole','streetlight','signage','trash','drainage','other'];
const SEVERITIES = ['high','medium','low']; const SEVERITIES = ['high','medium','low'];
const STATUSES = ['submitted','in_progress','fixed']; 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 SEVERITY_COLOR = { high:'#D32F2F', medium:'#F57C00', low:'#388E3C' };
const STATUS_COLOR = { submitted:'#1976D2', in_progress:'#7B1FA2', fixed:'#455A64' }; const STATUS_COLOR = { submitted:'#1976D2', in_progress:'#7B1FA2', fixed:'#455A64' };
function fetchJSON(path){ return fetch(path).then(r=>r.json()); } 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 // Normalize API data to expected format
function normalizeReportData(report) { 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) { 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 { return {
id: report.ticket_id, id: report.id || report.ticket_id || report.ticketId,
category: report.category || 'other', category: report.category || 'other',
severity: report.severity || 'low', severity: report.severity || 'low',
status: report.status || 'submitted', status: report.status || 'submitted',
notes: report.description || '', notes: report.description || report.notes || '',
location: { location: {
lat: report.latitude, lat: (report.latitude !== undefined ? report.latitude : (report.lat !== undefined ? report.lat : null)),
lng: report.longitude lng: (report.longitude !== undefined ? report.longitude : (report.lng !== undefined ? report.lng : null))
}, },
createdAt: report.created_at, createdAt: report.created_at || report.createdAt,
updatedAt: report.updated_at, updatedAt: report.updated_at || report.updatedAt,
// Add missing fields with defaults userId: report.user_id || report.userId,
userId: report.user_id, userName: report.user_name || report.userName || null,
imagePath: report.image_path 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); 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(()=>{ useEffect(()=>{
// Try to fetch from backend API first, fallback to demo data setLoading(true);
fetch('http://127.0.0.1:8000/api/tickets') fetchTickets()
.then(r => r.ok ? r.json() : Promise.reject('API not available'))
.then(data => { .then(data => {
console.log('Loaded data from API:', data.length, 'reports'); console.log('Loaded data from backend:', (Array.isArray(data) ? data.length : 0), 'reports');
const normalizedData = data.map(normalizeReportData); const normalizedData = (data || []).map(normalizeReportData);
setRawData(normalizedData); setRawData(normalizedData);
setLoading(false); setLoading(false);
}) })
.catch(err => { .catch(err => {
console.log('API not available, using demo data:', err); console.warn('Failed to load tickets from backend:', err);
return fetchJSON('./data/demo-reports.json'); showToast('Failed to load tickets from backend.');
}) setRawData([]);
.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);
setLoading(false); setLoading(false);
}); });
},[]); },[]);
@@ -254,65 +308,54 @@ function App(){
}); });
},[filtered]); },[filtered]);
const cycleStatus = async (reportId)=>{ const availableStatuses = useMemo(()=>{
try { const s = new Set(STATUSES);
// Find the current report to get its status rawData.forEach(r=>{ if(r && r.status) s.add(r.status); });
const currentReport = rawData.find(r => r.id === reportId); return Array.from(s);
if (!currentReport) return; }, [rawData]);
const idx = STATUSES.indexOf(currentReport.status); const updateTicketStatus = async (reportId, newStatus) => {
const nextStatus = STATUSES[(idx + 1) % STATUSES.length]; try {
const res = await fetch(`${BACKEND_BASE}/api/tickets/${reportId}/status`, {
// Try to update via API first method: 'PATCH',
const success = await fetch(`http://127.0.0.1:8000/api/tickets/${reportId}?new_status=${encodeURIComponent(nextStatus)}`, { headers: { 'Content-Type': 'application/json' },
method: 'PATCH' body: JSON.stringify({ status: newStatus })
}).then(r => r.ok); });
if (res.ok) {
if (success) { // Prefer using returned updated ticket if provided
// If API update successful, refresh data from API let updated = null;
const response = await fetch('http://127.0.0.1:8000/api/tickets'); try { updated = await res.json(); } catch(e){ updated = null; }
if (response.ok) { if (updated) {
const data = await response.json(); const normalized = normalizeReportData(updated);
const normalizedData = data.map(normalizeReportData); setRawData(prev => prev.map(r => r.id === reportId ? normalized : r));
setRawData(normalizedData); if (selected && selected.id === reportId) setSelected(normalized);
// Update selected item
const updatedReport = normalizedData.find(r => r.id === reportId);
setSelected(updatedReport || null);
}
} else { } else {
console.error('Failed to update status via API'); // No body returned - update local state
// Fallback to local update setRawData(prev=> prev.map(r=> r.id === reportId ? {...r, status: newStatus, updatedAt: new Date().toISOString()} : r));
setRawData(prev=>{ if(selected && selected.id === reportId) setSelected(prev => ({...prev, status: newStatus, updatedAt: new Date().toISOString()}));
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;
});
} }
} catch (error) { showToast('Status updated');
console.error('Error updating status:', error); return true;
// Fallback to local update } else {
setRawData(prev=>{ const text = await res.text().catch(()=> '');
const out = prev.map(r=>{ console.warn('Status update failed', text);
if(r.id !== reportId) return r; showToast('Failed to update status', 'Retry', ()=> updateTicketStatus(reportId, newStatus));
const idx = STATUSES.indexOf(r.status); return false;
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;
});
} }
}; } 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 openInMaps = (r)=>{
const lat = r.location.lat; const lat = r.location.lat;
@@ -428,7 +471,11 @@ function App(){
<div className="queue-list" role="list"> <div className="queue-list" role="list">
{sortedQueue.map(r=>( {sortedQueue.map(r=>(
<div key={r.id} className="queue-item" role="listitem"> <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-main">
<div <div
className="item-title clickable" className="item-title clickable"
@@ -443,7 +490,10 @@ function App(){
<span className="time-ago">{dayjs(r.createdAt).fromNow()}</span> <span className="time-ago">{dayjs(r.createdAt).fromNow()}</span>
</div> </div>
</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> <button className="btn ghost" onClick={()=> { setSelected(r); }}>{t('btn.view') || 'View'}</button>
</div> </div>
</div> </div>
@@ -472,7 +522,11 @@ function App(){
<div className="drawer-content" aria-live="polite"> <div className="drawer-content" aria-live="polite">
<button className="drawer-close" onClick={()=>setSelected(null)} aria-label="Close">×</button> <button className="drawer-close" onClick={()=>setSelected(null)} aria-label="Close">×</button>
<div className="drawer-header"> <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}}> <div style={{marginLeft:12}}>
<h3 style={{margin:0}}>{t(`category.${selected.category}`) || selected.category}</h3> <h3 style={{margin:0}}>{t(`category.${selected.category}`) || selected.category}</h3>
<div style={{display:'flex',gap:8,alignItems:'center',marginTop:6}}> <div style={{display:'flex',gap:8,alignItems:'center',marginTop:6}}>
@@ -486,14 +540,16 @@ function App(){
<div className="drawer-body"> <div className="drawer-body">
<p style={{marginTop:8}}><strong>{t('drawer.details') || 'Details'}</strong></p> <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>} {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.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> <p><strong>{t('label.createdAt') || 'Created'}:</strong> {dayjs(selected.createdAt).format('YYYY-MM-DD HH:mm')}</p>
</div> </div>
<div className="drawer-actions"> <div className="drawer-actions">
<button className="btn" onClick={()=>{ cycleStatus(selected.id); }}> <select value={selected.status} onChange={(e)=> updateTicketStatus(selected.id, e.target.value)}>
{t('drawer.changeStatus') || 'Change Status'} {availableStatuses.map(s => <option key={s} value={s}>{t(`status.${s}`) || s}</option>)}
</button> </select>
<button className="btn secondary" onClick={()=>openInMaps(selected)}> <button className="btn secondary" onClick={()=>openInMaps(selected)}>
{t('drawer.openMap') || 'Open Map'} {t('drawer.openMap') || 'Open Map'}
</button> </button>

View File

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

View File

@@ -9,6 +9,7 @@
"btn.apply": "Terapkan", "btn.apply": "Terapkan",
"btn.reset": "Tetapkan Semula", "btn.reset": "Tetapkan Semula",
"btn.view": "Lihat", "btn.view": "Lihat",
"btn.viewDetails": "Lihat butiran",
"label.language": "Bahasa", "label.language": "Bahasa",
"label.location": "Lokasi", "label.location": "Lokasi",
"label.createdAt": "Dicipta Pada", "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 /// Base64 encoded photo for web platform
final String? base64Photo; final String? base64Photo;
/// Remote image URL provided by backend (absolute URL)
final String? imageUrl;
/// Geographic location where the issue was reported /// Geographic location where the issue was reported
final LocationData location; final LocationData location;
@@ -38,6 +41,8 @@ class Report {
/// Address or location description (placeholder for future use) /// Address or location description (placeholder for future use)
final String? address; final String? address;
/// Name of the user who submitted the report (API reports)
final String? submittedBy;
/// Source of the photo ("camera" or "gallery") /// Source of the photo ("camera" or "gallery")
final String source; final String source;
@@ -61,12 +66,14 @@ class Report {
required this.status, required this.status,
this.photoPath, this.photoPath,
this.base64Photo, this.base64Photo,
this.imageUrl,
required this.location, required this.location,
required this.createdAt, required this.createdAt,
required this.updatedAt, required this.updatedAt,
required this.deviceId, required this.deviceId,
this.notes, this.notes,
this.address, this.address,
this.submittedBy,
required this.source, required this.source,
this.editable = true, this.editable = true,
this.deletable = true, this.deletable = true,
@@ -89,6 +96,8 @@ class Report {
String? photoPath, String? photoPath,
String? base64Photo, String? base64Photo,
String? notes, String? notes,
String? submittedBy,
String? address,
required String source, required String source,
required String deviceId, required String deviceId,
required AISuggestion aiSuggestion, required AISuggestion aiSuggestion,
@@ -106,6 +115,8 @@ class Report {
updatedAt: now, updatedAt: now,
deviceId: deviceId, deviceId: deviceId,
notes: notes, notes: notes,
address: address,
submittedBy: submittedBy,
source: source, source: source,
aiSuggestion: aiSuggestion, aiSuggestion: aiSuggestion,
); );
@@ -118,6 +129,7 @@ class Report {
Status? status, Status? status,
String? photoPath, String? photoPath,
String? base64Photo, String? base64Photo,
String? imageUrl,
LocationData? location, LocationData? location,
String? updatedAt, String? updatedAt,
String? notes, String? notes,
@@ -133,6 +145,7 @@ class Report {
status: status ?? this.status, status: status ?? this.status,
photoPath: photoPath ?? this.photoPath, photoPath: photoPath ?? this.photoPath,
base64Photo: base64Photo ?? this.base64Photo, base64Photo: base64Photo ?? this.base64Photo,
imageUrl: imageUrl ?? this.imageUrl,
location: location ?? this.location, location: location ?? this.location,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
@@ -156,6 +169,7 @@ class Report {
'status': status.key, 'status': status.key,
'photoPath': photoPath, 'photoPath': photoPath,
'base64Photo': base64Photo, 'base64Photo': base64Photo,
'imageUrl': imageUrl,
'location': { 'location': {
'lat': location.lat, 'lat': location.lat,
'lng': location.lng, 'lng': location.lng,
@@ -166,6 +180,7 @@ class Report {
'deviceId': deviceId, 'deviceId': deviceId,
'notes': notes, 'notes': notes,
'address': address, 'address': address,
'submittedBy': submittedBy,
'source': source, 'source': source,
'editable': editable, 'editable': editable,
'deletable': deletable, 'deletable': deletable,
@@ -187,6 +202,7 @@ class Report {
status: (json['status'] as String).toStatus() ?? Status.submitted, status: (json['status'] as String).toStatus() ?? Status.submitted,
photoPath: json['photoPath'] as String?, photoPath: json['photoPath'] as String?,
base64Photo: json['base64Photo'] as String?, base64Photo: json['base64Photo'] as String?,
imageUrl: json['imageUrl'] as String?,
location: LocationData( location: LocationData(
lat: (json['location']['lat'] as num).toDouble(), lat: (json['location']['lat'] as num).toDouble(),
lng: (json['location']['lng'] as num).toDouble(), lng: (json['location']['lng'] as num).toDouble(),
@@ -199,6 +215,7 @@ class Report {
deviceId: json['deviceId'] as String, deviceId: json['deviceId'] as String,
notes: json['notes'] as String?, notes: json['notes'] as String?,
address: json['address'] as String?, address: json['address'] as String?,
submittedBy: json['submittedBy'] as String?,
source: json['source'] as String, source: json['source'] as String,
editable: json['editable'] as bool? ?? true, editable: json['editable'] as bool? ?? true,
deletable: json['deletable'] 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/enums.dart';
import '../../models/report.dart'; import '../../models/report.dart';
import '../../services/location_service.dart'; import '../../services/location_service.dart';
import '../../services/storage.dart'; import '../../services/api_service.dart';
import '../../widgets/severity_badge.dart'; import '../../widgets/severity_badge.dart';
import '../../widgets/status_badge.dart'; import '../../widgets/status_badge.dart';
import '../my_reports/my_reports_screen.dart'; import '../my_reports/my_reports_screen.dart';
@@ -59,7 +59,7 @@ class _MapScreenState extends State<MapScreen> {
Future<void> _refresh() async { Future<void> _refresh() async {
setState(() => _loading = true); setState(() => _loading = true);
final reports = await StorageService.getReports(); final reports = await ApiService.fetchTickets();
setState(() { setState(() {
_allReports = reports; _allReports = reports;
_loading = false; _loading = false;
@@ -325,6 +325,17 @@ class _MapScreenState extends State<MapScreen> {
child: Icon(Icons.photo, color: Colors.grey.shade600), 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 (kIsWeb) {
if (r.base64Photo != null && r.base64Photo!.isNotEmpty) { if (r.base64Photo != null && r.base64Photo!.isNotEmpty) {
try { try {
@@ -776,7 +787,17 @@ class MapReportDetails extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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!)) Image.memory(base64Decode(report.base64Photo!))
else if (!kIsWeb && report.photoPath != null) else if (!kIsWeb && report.photoPath != null)
Image.file(File(report.photoPath!)) Image.file(File(report.photoPath!))

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../services/storage.dart'; import '../../services/storage.dart';
import '../../services/api_service.dart';
import '../../models/report.dart'; import '../../models/report.dart';
import '../../widgets/report_card.dart'; import '../../widgets/report_card.dart';
import '../map/map_screen.dart'; import '../map/map_screen.dart';
@@ -23,14 +24,46 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
} }
Future<void> _loadReports() async { Future<void> _loadReports() async {
setState(() { setState(() => _loading = true);
_loading = true;
}); try {
final reports = await StorageService.getReports(); // Try to fetch tickets from API and filter by this device's user id
setState(() { final userId = await ApiService.getUserId();
_reports = reports.reversed.toList(); // newest first final apiReports = await ApiService.fetchTickets();
_loading = false;
}); // 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) { void _onViewReport(Report r) {

View File

@@ -54,25 +54,35 @@ class _CaptureScreenState extends State<CaptureScreen> {
Future<void> _processImage(XFile image, ImageSource source) async { Future<void> _processImage(XFile image, ImageSource source) async {
try { try {
// Get current position (Geolocator.Position) // Get current position (optional - app can work without location)
final position = await LocationService.getCurrentPosition(); 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) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: const Text( content: Text(
'Unable to get location. Please try again.', 'Location unavailable. Report will be created without GPS coordinates.',
), // TODO: Move to i18n ), // TODO: Move to i18n
duration: Duration(seconds: 3),
), ),
); );
} }
return;
} }
// Convert Position -> LocationData (app model)
final locationData = LocationService.positionToLocationData(position);
// Generate AI suggestion (seeded deterministic) // Generate AI suggestion (seeded deterministic)
final aiSuggestion = MockAIService.generateSuggestion( final aiSuggestion = MockAIService.generateSuggestion(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -113,10 +123,12 @@ class _CaptureScreenState extends State<CaptureScreen> {
); );
} }
} catch (e) { } catch (e) {
print('Critical error in image processing: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(I18n.t('error.imageProcessing', {'0': e.toString()})), content: Text(I18n.t('error.imageProcessing', {'0': e.toString()})),
backgroundColor: Colors.red,
), ),
); );
} }

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/report.dart'; import '../models/report.dart';
import '../models/enums.dart'; import '../models/enums.dart';
@@ -8,14 +9,24 @@ import '../models/enums.dart';
class ApiService { class ApiService {
// Configure this to match your backend URL // Configure this to match your backend URL
// Use localhost for web/desktop, network IP for mobile/emulator // Use localhost for web/desktop, network IP for mobile/emulator
static const String _baseUrl = 'http://192.168.100.59:8000/api'; static const String BASE_URL = 'http://192.168.100.59:8000';
static const String _uploadsUrl = 'http://192.168.100.59:8000/static/uploads'; 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 { static Future<String> _getOrCreateUserId() async {
// For now, generate a UUID for this device try {
// In a real app, this would be stored securely final prefs = await SharedPreferences.getInstance();
return const Uuid().v4(); 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 /// 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 /// Submit a report to the backend
static Future<String> submitReport({ static Future<String> submitReport({
required double latitude, required double latitude,
@@ -49,6 +63,8 @@ class ApiService {
required String description, required String description,
required List<int> imageBytes, required List<int> imageBytes,
required String imageName, required String imageName,
String? userName,
String? address,
}) async { }) async {
try { try {
final userId = await _getOrCreateUserId(); final userId = await _getOrCreateUserId();
@@ -61,6 +77,8 @@ class ApiService {
request.fields['latitude'] = latitude.toString(); request.fields['latitude'] = latitude.toString();
request.fields['longitude'] = longitude.toString(); request.fields['longitude'] = longitude.toString();
request.fields['description'] = description; 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 // Add the image file
request.files.add( 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 /// Get a single ticket by ID
static Future<Report?> getReportById(String ticketId) async { static Future<Report?> getReportById(String ticketId) async {
try { 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 /// Get analytics data
static Future<Map<String, dynamic>> getAnalytics() async { static Future<Map<String, dynamic>> getAnalytics() async {
try { try {
@@ -150,22 +189,30 @@ class ApiService {
/// Convert API ticket response to Report model /// Convert API ticket response to Report model
static Report _convertApiTicketToReport(Map<String, dynamic> data) { 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( return Report(
id: data['ticket_id'] ?? '', id: id,
category: _normalizeCategory(data['category'] ?? ''), category: _normalizeCategory(data['category'] ?? ''),
severity: _normalizeSeverity(data['severity'] ?? 'N/A'), severity: _normalizeSeverity(data['severity'] ?? 'N/A'),
status: _normalizeStatus(data['status'] ?? 'New'), status: _normalizeStatus(data['status'] ?? 'New'),
photoPath: data['image_path'] != null // For API-provided tickets prefer imageUrl; photoPath is for local files
? '$_uploadsUrl/${data['image_path'].split('/').last}' photoPath: null,
: null, imageUrl: imageUrl,
location: LocationData( location: LocationData(
lat: (data['latitude'] as num?)?.toDouble() ?? 0.0, lat: (data['latitude'] as num?)?.toDouble() ?? 0.0,
lng: (data['longitude'] as num?)?.toDouble() ?? 0.0, lng: (data['longitude'] as num?)?.toDouble() ?? 0.0,
), ),
createdAt: data['created_at'] ?? DateTime.now().toIso8601String(), createdAt: (data['created_at'] ?? data['createdAt'] ?? DateTime.now().toIso8601String()) as String,
updatedAt: data['updated_at'] ?? DateTime.now().toIso8601String(), updatedAt: (data['updated_at'] ?? data['updatedAt'] ?? DateTime.now().toIso8601String()) as String,
deviceId: 'api-${data['ticket_id'] ?? ''}', deviceId: data['user_id'] != null ? data['user_id'].toString() : 'api-$id',
notes: data['description'] as String?, notes: data['description'] as String?,
address: data['address'] as String?,
submittedBy: data['user_name'] as String?,
source: 'api', source: 'api',
aiSuggestion: AISuggestion( aiSuggestion: AISuggestion(
category: _normalizeCategory(data['category'] ?? ''), category: _normalizeCategory(data['category'] ?? ''),

View File

@@ -9,6 +9,13 @@ class LocationService {
return await Geolocator.isLocationServiceEnabled(); return await Geolocator.isLocationServiceEnabled();
} catch (e) { } catch (e) {
print('Error checking location service: $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; return false;
} }
} }
@@ -19,6 +26,13 @@ class LocationService {
return await Geolocator.checkPermission(); return await Geolocator.checkPermission();
} catch (e) { } catch (e) {
print('Error checking location permission: $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; return LocationPermission.denied;
} }
} }
@@ -29,6 +43,15 @@ class LocationService {
return await Geolocator.requestPermission(); return await Geolocator.requestPermission();
} catch (e) { } catch (e) {
print('Error requesting location permission: $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; return LocationPermission.denied;
} }
} }
@@ -58,17 +81,46 @@ class LocationService {
return null; return null;
} }
// Get current position // Get current position with multiple fallback strategies
return await Geolocator.getCurrentPosition( return await _getPositionWithFallback();
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 30),
);
} catch (e) { } catch (e) {
print('Error getting current position: $e'); print('Error getting current position: $e');
return null; 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 /// Get current position with best available accuracy
static Future<Position?> getBestAvailablePosition() async { static Future<Position?> getBestAvailablePosition() async {
try { try {
@@ -181,7 +233,10 @@ class LocationService {
} }
/// Get address from coordinates (placeholder - would need geocoding service) /// 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 // This is a placeholder implementation
// In a real app, you would use a geocoding service like Google Maps API // In a real app, you would use a geocoding service like Google Maps API
// or OpenStreetMap Nominatim API // or OpenStreetMap Nominatim API
@@ -198,4 +253,4 @@ class LocationService {
acc != null && acc != null &&
acc >= 0; acc >= 0;
} }
} }

View File

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

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../models/report.dart'; import '../models/report.dart';
import '../models/enums.dart' as enums; import '../models/enums.dart' as enums;
import '../services/storage.dart'; import '../services/storage.dart';
import '../services/api_service.dart';
import '../l10n/i18n.dart'; import '../l10n/i18n.dart';
class ReportCard extends StatelessWidget { class ReportCard extends StatelessWidget {
@@ -22,6 +23,30 @@ class ReportCard extends StatelessWidget {
}); });
Widget _buildThumbnail() { 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) { if (kIsWeb && report.base64Photo != null) {
try { try {
final bytes = base64Decode(report.base64Photo!); final bytes = base64Decode(report.base64Photo!);
@@ -32,21 +57,15 @@ class ReportCard extends StatelessWidget {
} catch (_) {} } catch (_) {}
} else if (report.photoPath != null) { } else if (report.photoPath != null) {
final file = File(report.photoPath!); final file = File(report.photoPath!);
return ClipRRect( if (file.existsSync()) {
borderRadius: BorderRadius.circular(8), return ClipRRect(
child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover), borderRadius: BorderRadius.circular(8),
); child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover),
);
}
} }
return Container( return placeholder;
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.image, color: Colors.grey.shade600),
);
} }
String _formatTime(String iso) { String _formatTime(String iso) {
@@ -78,7 +97,19 @@ class ReportCard extends StatelessWidget {
); );
if (ok == true) { 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 (success) {
if (onDeleted != null) { if (onDeleted != null) {
onDeleted!(); onDeleted!();
@@ -88,6 +119,12 @@ class ReportCard extends StatelessWidget {
SnackBar(content: Text(I18n.t('toast.reportDeleted'))), 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 // Status indicators
Row( Row(
children: [ children: [
@@ -343,7 +397,9 @@ class ReportCard extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded( Expanded(
child: Text( 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( style: TextStyle(
fontSize: 12, fontSize: 12,
color: cs.onSurface.withOpacity(0.6), color: cs.onSurface.withOpacity(0.6),
@@ -374,40 +430,36 @@ class ReportCard extends StatelessWidget {
} }
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
const PopupMenuItem( PopupMenuItem(
value: 0, value: 0,
child: Row( child: Row(
children: [ children: [
Icon(Icons.visibility), const Icon(Icons.visibility),
SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(I18n.t('btn.viewDetails')),
'View Details',
), // TODO: Move to i18n but need to handle dynamic text in popup menu
], ],
), ),
), ),
const PopupMenuItem( PopupMenuItem(
value: 1, value: 1,
child: Row( child: Row(
children: [ children: [
Icon(Icons.update), const Icon(Icons.update),
SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(I18n.t('report.updateStatus')),
'Update Status',
), // TODO: Move to i18n but need to handle dynamic text in popup menu
], ],
), ),
), ),
const PopupMenuItem( PopupMenuItem(
value: 2, value: 2,
child: Row( child: Row(
children: [ children: [
Icon(Icons.delete, color: Colors.red), const Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Delete', I18n.t('report.delete'),
style: TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), // TODO: Move to i18n but need to handle dynamic text in popup menu ),
], ],
), ),
), ),

View File

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

View File

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