Compare commits

...

5 Commits

53 changed files with 3194 additions and 3860 deletions

11
.gitignore vendored
View File

@ -46,3 +46,14 @@ walkguide-backend/demo/hs_err_pid*.log
# Android SDK path (generated by Android Studio)
walkguide-mobile/walkguide_app/android/local.properties
# Local Python/YOLO export artifacts - do not commit
walkguide-mobile/walkguide_app/.venv*/
walkguide-mobile/walkguide_app/yolov8n.onnx
walkguide-mobile/walkguide_app/yolov8n.pt
walkguide-mobile/walkguide_app/yolov8n_saved_model/
walkguide-mobile/walkguide_app/calibration_image_sample_data_*.npy
walkguide-mobile/walkguide_app/devtools_options.yaml
walkguide-mobile/walkguide_app/android/app/src/main/java/dev/flutter/plugins/
walkguide-mobile/walkguide_app/android/app/src/main/java/io/flutter/plugins/
new_file

View File

@ -4,12 +4,9 @@ import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import com.walkguide.service.CallNotificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -17,23 +14,13 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Controller untuk VoIP call via Agora RTC.
*
* FLOW:
* 1. Caller POST /shared/call/token dapat Agora token + channelName
* 2. Caller join Agora channel (di Flutter)
* 3. Caller POST /shared/call/notify FCM "Incoming Call" dikirim ke receiver
* 4. Receiver terima FCM join channel yang sama
* 5. Audio call tersambung via Agora
*
* Route: /api/v1/shared/call
* Auth: Authenticated (Guardian ATAU User)
*/
@RestController
@RequestMapping("/api/v1/shared/call")
@RequiredArgsConstructor
@ -43,17 +30,10 @@ import java.util.Map;
public class CallController {
private final AgoraTokenService agoraTokenService;
private final FcmService fcmService;
private final UserRepository userRepository;
private final CallNotificationService callNotificationService;
/**
* Generate Agora RTC token untuk call session.
* Dipanggil oleh CALLER sebelum mulai call.
*
* POST /api/v1/shared/call/token
*/
@PostMapping("/token")
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel")
@Operation(summary = "Generate Agora token", description = "Caller requests a token before joining Agora")
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
@Valid @RequestBody CallTokenRequest req) {
@ -66,84 +46,24 @@ public class CallController {
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
}
/**
* Kirim FCM "Incoming Call" ke receiver.
* Dipanggil oleh CALLER setelah berhasil join Agora channel.
*
* POST /api/v1/shared/call/notify
*/
@PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call",
description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama")
@Operation(summary = "Notify receiver of incoming call")
public ResponseEntity<ApiResponse<Void>> notifyCall(
@Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
// Ambil info caller untuk notifikasi
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
// Ambil FCM token receiver
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} tidak punya FCM token — call notify gagal", req.getReceiverId());
return ResponseEntity.ok(ApiResponse.ok(null,
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
}
// Payload FCM untuk incoming call
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(),
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
// High priority karena incoming call harus segera muncul
fcmService.sendHighPriority(
receiver.getFcmToken(),
"📞 Panggilan Masuk",
"Panggilan dari " + (caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail()),
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi panggilan berhasil dikirim"));
String message = callNotificationService.notifyIncomingCall(callerId, req);
return ResponseEntity.ok(ApiResponse.ok(null, message));
}
/**
* End call notifikasi ke pihak lain bahwa call sudah berakhir.
* Opsional: Flutter bisa handle end call secara lokal juga.
*
* POST /api/v1/shared/call/end
*/
@PostMapping("/end")
@Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall(
@RequestBody Map<String, Long> body) {
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
if (otherId != null) {
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() != null && !other.getFcmToken().isBlank()) {
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
}
});
}
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
callNotificationService.notifyCallEnded(callerId, otherId);
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
}

View File

@ -51,7 +51,7 @@ public class GuardianController {
Long guardianId = SecurityHelper.getCurrentUserId();
// Perlu ambil userId dulu delegasikan ke service
return ResponseEntity.ok(ApiResponse.ok(
locationService.getLocationHistory(guardianId,
locationService.getLocationHistoryForGuardian(guardianId,
PageRequest.of(page, size, Sort.by("createdAt").descending())),
"Riwayat lokasi"));
}

View File

@ -2,9 +2,11 @@ package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.*;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.PairingService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -16,12 +18,26 @@ public class PairingController {
private final PairingService pairingService;
@GetMapping("/code")
public ResponseEntity<ApiResponse<PairingCodeResponse>> code() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.getOrCreatePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code aktif"));
}
@PostMapping("/code/regenerate")
public ResponseEntity<ApiResponse<PairingCodeResponse>> regenerateCode() {
return ResponseEntity.ok(ApiResponse.ok(
pairingService.regeneratePairingCode(SecurityHelper.getCurrentUserId()),
"Pairing code baru dibuat"));
}
@PostMapping("/invite")
public ResponseEntity<ApiResponse<PairingStatusResponse>> invite(
@RequestBody InviteUserRequest req) {
@Valid @RequestBody InviteUserRequest req) {
Long guardianId = SecurityHelper.getCurrentUserId();
return ResponseEntity.ok(ApiResponse.ok(
pairingService.inviteUser(guardianId, req.getUniqueUserId()),
pairingService.inviteUser(guardianId, req.resolveSubmittedCode()),
"Undangan dikirim ke user"));
}

View File

@ -5,7 +5,16 @@ import lombok.Data;
@Data
public class InviteUserRequest {
@NotBlank(message = "User ID tidak boleh kosong")
@Size(min = 12, max = 12, message = "User ID harus tepat 12 karakter")
private String uniqueUserId;
@Size(min = 8, max = 8, message = "Pairing code harus tepat 8 karakter")
private String pairingCode;
public String resolveSubmittedCode() {
if (pairingCode != null && !pairingCode.isBlank()) {
return pairingCode.trim().toUpperCase();
}
return uniqueUserId == null ? null : uniqueUserId.trim().toUpperCase();
}
}

View File

@ -0,0 +1,14 @@
package com.walkguide.dto.response;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class PairingCodeResponse {
private String pairingCode;
private LocalDateTime expiresAt;
private long expiresInSeconds;
}

View File

@ -12,6 +12,8 @@ public class PairingStatusResponse {
private String pairedWithName; // nama Guardian (untuk User) atau nama User (untuk Guardian)
private String pairedWithEmail;
private String uniqueUserId; // ID user yang di-pair
private String pairingCode; // temporary code for new pairing flow
private LocalDateTime pairingCodeExpiresAt;
private LocalDateTime invitedAt;
private LocalDateTime respondedAt;
}

View File

@ -29,6 +29,12 @@ public class User {
@Column(name = "unique_user_id", unique = true, length = 12)
private String uniqueUserId;
@Column(name = "pairing_code", unique = true, length = 8)
private String pairingCode;
@Column(name = "pairing_code_expires_at")
private LocalDateTime pairingCodeExpiresAt;
@Column(name = "display_name", length = 100)
private String displayName;

View File

@ -9,5 +9,6 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUniqueUserId(String uniqueUserId);
Optional<User> findByPairingCode(String pairingCode);
boolean existsByEmail(String email);
}

View File

@ -0,0 +1,73 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class CallNotificationService {
private final FcmService fcmService;
private final UserRepository userRepository;
public String notifyIncomingCall(Long callerId, CallNotifyRequest req) {
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} has no FCM token; push notification skipped", req.getReceiverId());
return "Panggilan dikirim (receiver mungkin tidak menerima push notification)";
}
String callerName = caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail();
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", callerName,
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
fcmService.sendHighPriority(
receiver.getFcmToken(),
"Panggilan Masuk",
"Panggilan dari " + callerName,
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return "Notifikasi panggilan berhasil dikirim";
}
public void notifyCallEnded(Long callerId, Long otherId) {
if (otherId == null) {
return;
}
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() == null || other.getFcmToken().isBlank()) {
return;
}
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
});
}
}

View File

@ -6,6 +6,7 @@ import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
@ -79,6 +80,15 @@ public class LocationService {
.map(this::toResponse);
}
public Page<LocationResponse> getLocationHistoryForGuardian(Long guardianId, Pageable pageable) {
Long pairedUserId = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
.map(pairing -> pairing.getUser().getId())
.orElseThrow(() -> new PairingException("Guardian belum terhubung dengan User aktif"));
return getLocationHistory(pairedUserId, pageable);
}
// ========== GEOFENCE ==========
private void checkGeofence(Long userId, double lat, double lng) {

View File

@ -1,6 +1,7 @@
package com.walkguide.service;
import com.walkguide.dto.response.PairingStatusResponse;
import com.walkguide.dto.response.PairingCodeResponse;
import com.walkguide.entity.*;
import com.walkguide.enums.*;
import com.walkguide.exception.PairingException;
@ -10,7 +11,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
@ -26,8 +29,46 @@ public class PairingService {
private final ActivityLogService activityLogService;
private final FcmService fcmService;
private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private static final int PAIRING_CODE_LENGTH = 8;
private static final int PAIRING_CODE_TTL_MINUTES = 15;
private static final SecureRandom RANDOM = new SecureRandom();
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String uniqueUserId) {
public PairingCodeResponse getOrCreatePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
if (user.getPairingCode() == null
|| user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(now)) {
assignNewPairingCode(user, now);
userRepository.save(user);
}
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingCodeResponse regeneratePairingCode(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("Hanya akun User yang bisa membuat pairing code.");
}
LocalDateTime now = LocalDateTime.now();
assignNewPairingCode(user, now);
userRepository.save(user);
activityLogService.createLog(user, ActivityLogType.PAIRING_INVITE_SENT,
"User membuat pairing code baru", null);
return buildPairingCodeResponse(user, now);
}
@Transactional
public PairingStatusResponse inviteUser(Long guardianId, String submittedCode) {
// Guardian tidak boleh punya pairing ACTIVE atau PENDING
if (pairingRelationRepository.existsByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)) {
throw new PairingException("Kamu sudah memiliki user yang dipair. Unpair dulu sebelum invite user baru.");
@ -38,8 +79,7 @@ public class PairingService {
User guardian = userRepository.findById(guardianId)
.orElseThrow(() -> new ResourceNotFoundException("Guardian tidak ditemukan"));
User user = userRepository.findByUniqueUserId(uniqueUserId)
.orElseThrow(() -> new ResourceNotFoundException("User dengan ID '" + uniqueUserId + "' tidak ditemukan"));
User user = resolveUserByPairingCode(submittedCode);
if (!"ROLE_USER".equals(user.getRole())) {
throw new PairingException("ID tersebut bukan milik User. Pastikan kamu memasukkan ID yang benar.");
@ -55,6 +95,10 @@ public class PairingService {
.build();
pairing = pairingRelationRepository.save(pairing);
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
// Kirim FCM ke user
fcmService.sendToToken(user.getFcmToken(),
"Pairing Request",
@ -199,6 +243,51 @@ public class PairingService {
.enabled(name != null).build();
}
private User resolveUserByPairingCode(String submittedCode) {
if (submittedCode == null || submittedCode.isBlank()) {
throw new PairingException("Pairing code tidak boleh kosong.");
}
String code = submittedCode.trim().toUpperCase();
User user = userRepository.findByPairingCode(code)
.orElseThrow(() -> new ResourceNotFoundException(
"Pairing code '" + code + "' tidak ditemukan. Minta User generate kode baru."));
if (user.getPairingCodeExpiresAt() == null
|| !user.getPairingCodeExpiresAt().isAfter(LocalDateTime.now())) {
user.setPairingCode(null);
user.setPairingCodeExpiresAt(null);
userRepository.save(user);
throw new PairingException("Pairing code sudah kadaluarsa. Minta User generate kode baru.");
}
return user;
}
private void assignNewPairingCode(User user, LocalDateTime now) {
String candidate;
do {
candidate = randomCode();
} while (userRepository.findByPairingCode(candidate).isPresent());
user.setPairingCode(candidate);
user.setPairingCodeExpiresAt(now.plusMinutes(PAIRING_CODE_TTL_MINUTES));
}
private String randomCode() {
StringBuilder sb = new StringBuilder(PAIRING_CODE_LENGTH);
for (int i = 0; i < PAIRING_CODE_LENGTH; i++) {
sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length())));
}
return sb.toString();
}
private PairingCodeResponse buildPairingCodeResponse(User user, LocalDateTime now) {
long seconds = Math.max(0,
ChronoUnit.SECONDS.between(now, user.getPairingCodeExpiresAt()));
return PairingCodeResponse.builder()
.pairingCode(user.getPairingCode())
.expiresAt(user.getPairingCodeExpiresAt())
.expiresInSeconds(seconds)
.build();
}
private PairingStatusResponse buildStatus(PairingRelation p, User guardian, User user, String viewerRole) {
String pairedWithName = "GUARDIAN".equals(viewerRole)
? user.getDisplayName() : guardian.getDisplayName();
@ -211,6 +300,8 @@ public class PairingService {
.pairedWithName(pairedWithName)
.pairedWithEmail(pairedWithEmail)
.uniqueUserId(user.getUniqueUserId())
.pairingCode(user.getPairingCode())
.pairingCodeExpiresAt(user.getPairingCodeExpiresAt())
.invitedAt(p.getInvitedAt())
.respondedAt(p.getRespondedAt())
.build();

View File

@ -4,11 +4,11 @@
# atau set env: SPRING_PROFILES_ACTIVE=dev
# ===================================================
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
username: ${DB_USERNAME:5803024001}
password: ${DB_PASSWORD:pw5803024001}
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
show-sql: true
@ -16,9 +16,9 @@ spring:
hibernate:
format_sql: true
jwt:
secret: ${JWT_SECRET:404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970}
expiration: 86400000
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
agora:
app-id: ${AGORA_APP_ID:}
@ -28,4 +28,4 @@ logging:
level:
com.walkguide: DEBUG
org.springframework.messaging: DEBUG
org.springframework.web.socket: DEBUG
org.springframework.web.socket: DEBUG

View File

@ -0,0 +1,9 @@
-- V17: Expiring pairing code for lecturer revision.
-- unique_user_id remains a stable account identifier; pairing_code is temporary.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS pairing_code VARCHAR(8) UNIQUE,
ADD COLUMN IF NOT EXISTS pairing_code_expires_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_pairing_code ON users(pairing_code);
CREATE INDEX IF NOT EXISTS idx_users_pairing_code_expires_at ON users(pairing_code_expires_at);

View File

@ -4,7 +4,8 @@ info:
version: 1.0.0
description: Design contract for WalkGuide Flutter and Spring Boot integration.
servers:
- url: http://localhost:8080/api/v1
- url: https://api.walkguide.example/api/v1
description: Production deployment URL placeholder
security:
- bearerAuth: []
components:
@ -37,9 +38,16 @@ components:
role: { type: string, enum: [USER, GUARDIAN] }
PairingInviteRequest:
type: object
required: [uniqueUserId]
required: [pairingCode]
properties:
uniqueUserId: { type: string, minLength: 12, maxLength: 12 }
pairingCode: { type: string, minLength: 8, maxLength: 8, description: "Temporary code generated by the User app; expires automatically." }
uniqueUserId: { type: string, minLength: 12, maxLength: 12, deprecated: true }
PairingCodeResponse:
type: object
properties:
pairingCode: { type: string }
expiresAt: { type: string, format: date-time }
expiresInSeconds: { type: integer, format: int64 }
PairingRespondRequest:
type: object
required: [pairingId, accept]
@ -107,6 +115,14 @@ paths:
schema: { $ref: "#/components/schemas/PairingInviteRequest" }
responses:
"200": { description: Invite sent }
/shared/pairing/code:
get:
responses:
"200": { description: Active expiring pairing code }
/shared/pairing/code/regenerate:
post:
responses:
"200": { description: New expiring pairing code generated }
/shared/pairing/respond:
post:
requestBody:

View File

@ -4,13 +4,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.JwtAuthFilter;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import org.junit.jupiter.api.BeforeEach;
import com.walkguide.service.CallNotificationService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
@ -18,19 +15,22 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import com.walkguide.security.JwtAuthFilter;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc(addFilters = false)
@WebMvcTest(CallController.class)
@ -38,7 +38,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@DisplayName("CallController Unit Tests")
class CallControllerTest {
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean
private JwtAuthFilter jwtAuthFilter;
@Autowired
private MockMvc mockMvc;
@ -50,35 +51,10 @@ class CallControllerTest {
private AgoraTokenService agoraTokenService;
@MockBean
private FcmService fcmService;
@MockBean
private UserRepository userRepository;
private User sampleCaller;
private User sampleReceiver;
@BeforeEach
void setUp() {
sampleCaller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.fcmToken("fcm-caller-token")
.build();
sampleReceiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver Guardian")
.fcmToken("fcm-receiver-token")
.build();
}
// ===== GENERATE TOKEN =====
private CallNotificationService callNotificationService;
@Test
@DisplayName("POST /api/v1/shared/call/token - valid request harus return Agora token")
@DisplayName("POST /api/v1/shared/call/token - valid request returns Agora token")
void generateToken_validRequest_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@ -88,7 +64,7 @@ class CallControllerTest {
AgoraTokenResponse tokenResp = AgoraTokenResponse.builder()
.token("agora-rtc-token-xyz")
.channelName("call_1_2_1234567890")
.channelName("call_1_2")
.uid(1001)
.build();
@ -102,34 +78,24 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Token Agora berhasil digenerate"))
.andExpect(jsonPath("$.data.token").value("agora-rtc-token-xyz"))
.andExpect(jsonPath("$.data.channelName").value("call_1_2_1234567890"));
verify(agoraTokenService).generateToken(1L, 2L);
.andExpect(jsonPath("$.data.channelName").value("call_1_2"));
}
}
@Test
@DisplayName("POST /api/v1/shared/call/token - receiverId null harus return 400")
@DisplayName("POST /api/v1/shared/call/token - null receiverId returns 400")
void generateToken_nullReceiverId_shouldReturn400() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
CallTokenRequest req = new CallTokenRequest();
// CallTokenRequest dengan receiverId null @Valid harus menolak
CallTokenRequest req = new CallTokenRequest();
// receiverId tidak di-set (null)
mockMvc.perform(post("/api/v1/shared/call/token")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
verify(agoraTokenService, never()).generateToken(anyLong(), anyLong());
}
mockMvc.perform(post("/api/v1/shared/call/token")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("POST /api/v1/shared/call/token - service throw harus return 500")
@DisplayName("POST /api/v1/shared/call/token - service error returns 500")
void generateToken_serviceThrows_shouldReturn500() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@ -148,21 +114,17 @@ class CallControllerTest {
}
}
// ===== NOTIFY CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver punya FCM token harus kirim notifikasi")
void notifyCall_receiverHasFcmToken_shouldReturn200AndSendFcm() throws Exception {
@DisplayName("POST /api/v1/shared/call/notify - delegates to call notification service")
void notifyCall_validRequest_shouldReturnServiceMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
when(callNotificationService.notifyIncomingCall(eq(1L), any(CallNotifyRequest.class)))
.thenReturn("Notifikasi panggilan berhasil dikirim");
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_1234567890");
req.setChannelName("call_1_2");
req.setAgoraToken("agora-token-xyz");
req.setReceiverUid(1002);
@ -174,260 +136,40 @@ class CallControllerTest {
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Notifikasi panggilan berhasil dikirim"));
verify(fcmService).sendHighPriority(
eq("fcm-receiver-token"),
eq("📞 Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
verify(callNotificationService).notifyIncomingCall(eq(1L), any(CallNotifyRequest.class));
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak punya FCM token harus return 200 tanpa FCM")
void notifyCall_receiverNoFcmToken_shouldReturn200WithWarningMessage() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
@DisplayName("POST /api/v1/shared/call/notify - validation failure does not call service")
void notifyCall_invalidRequest_shouldReturn400() throws Exception {
CallNotifyRequest req = new CallNotifyRequest();
req.setChannelName("call_1_2");
User receiverNoFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(null) // tidak punya FCM token
.build();
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverNoFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("call_1_2_abc");
req.setAgoraToken("token-xyz");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
// FCM tidak boleh dipanggil karena tidak ada token
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
verify(callNotificationService, never()).notifyIncomingCall(any(), any());
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver FCM token blank harus return 200 tanpa FCM")
void notifyCall_receiverBlankFcmToken_shouldReturn200WithoutFcm() throws Exception {
@DisplayName("POST /api/v1/shared/call/end - delegates to call notification service")
void endCall_validOtherId_shouldDelegateToService() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User receiverBlankFcm = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken(" ") // blank
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiverBlankFcm));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value(
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller tidak ditemukan harus return 404")
void notifyCall_callerNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L))
.thenThrow(new ResourceNotFoundException("Caller not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - receiver tidak ditemukan harus return 404")
void notifyCall_receiverNotFound_shouldReturn404() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
when(userRepository.findById(2L))
.thenThrow(new ResourceNotFoundException("Receiver not found"));
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isNotFound());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/notify - caller displayName null harus pakai email sebagai pengganti")
void notifyCall_callerNoDisplayName_shouldUseEmailAsFallback() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User callerNoName = User.builder()
.id(1L)
.email("noreply@test.com")
.displayName(null) // tidak ada displayName
.build();
when(userRepository.findById(1L)).thenReturn(Optional.of(callerNoName));
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
CallNotifyRequest req = new CallNotifyRequest();
req.setReceiverId(2L);
req.setChannelName("channel-abc");
req.setAgoraToken("token");
req.setReceiverUid(1002);
mockMvc.perform(post("/api/v1/shared/call/notify")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isOk());
// Pastikan body notifikasi menggunakan email sebagai fallback
verify(fcmService).sendHighPriority(
anyString(),
anyString(),
contains("noreply@test.com"),
anyMap()
);
}
}
// ===== END CALL =====
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId valid dan punya FCM token harus kirim notifikasi berakhir")
void endCall_otherHasFcmToken_shouldSendEndNotification() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
when(userRepository.findById(2L)).thenReturn(Optional.of(sampleReceiver));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.content(objectMapper.writeValueAsString(Map.of("otherId", 2L))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService).sendToToken(
eq("fcm-receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - otherId null harus return 200 tanpa kirim FCM")
void endCall_nullOtherId_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
Map<String, Long> body = Map.of(); // tidak ada otherId
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@DisplayName("POST /api/v1/shared/call/end - other tidak punya FCM token harus return 200 tanpa FCM")
void endCall_otherNoFcmToken_shouldReturn200WithoutFcm() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(1L);
User otherNoFcm = User.builder()
.id(2L)
.email("other@test.com")
.fcmToken(null)
.build();
when(userRepository.findById(2L)).thenReturn(Optional.of(otherNoFcm));
Map<String, Long> body = Map.of("otherId", 2L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}
@Test
@WithMockUser(username = "2", roles = "GUARDIAN")
@DisplayName("POST /api/v1/shared/call/end - Guardian juga bisa end call")
void endCall_asGuardian_shouldReturn200() throws Exception {
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
when(userRepository.findById(1L)).thenReturn(Optional.of(sampleCaller));
doNothing().when(fcmService).sendToToken(anyString(), anyString(), anyString(), anyMap());
Map<String, Long> body = Map.of("otherId", 1L);
mockMvc.perform(post("/api/v1/shared/call/end")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(body)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Call ended"));
verify(callNotificationService).notifyCallEnded(1L, 2L);
}
}
}

View File

@ -92,9 +92,9 @@ class PairingControllerTest {
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
InviteUserRequest req = new InviteUserRequest();
req.setUniqueUserId("INVALID999");
req.setPairingCode("BAD99999");
when(pairingService.inviteUser(2L, "INVALID999"))
when(pairingService.inviteUser(2L, "BAD99999"))
.thenThrow(new RuntimeException("User dengan ID tersebut tidak ditemukan"));
mockMvc.perform(post("/api/v1/shared/pairing/invite")

View File

@ -0,0 +1,128 @@
package com.walkguide.service;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("CallNotificationService Unit Tests")
class CallNotificationServiceTest {
@Mock
private FcmService fcmService;
@Mock
private UserRepository userRepository;
@InjectMocks
private CallNotificationService service;
private User caller;
private User receiver;
private CallNotifyRequest request;
@BeforeEach
void setUp() {
caller = User.builder()
.id(1L)
.email("caller@test.com")
.displayName("Caller User")
.build();
receiver = User.builder()
.id(2L)
.email("receiver@test.com")
.displayName("Receiver")
.fcmToken("receiver-token")
.build();
request = new CallNotifyRequest();
request.setReceiverId(2L);
request.setChannelName("call_1_2");
request.setAgoraToken("agora-token");
request.setReceiverUid(1002);
}
@Test
@DisplayName("notifyIncomingCall sends high priority FCM when receiver has token")
void notifyIncomingCall_receiverHasToken_shouldSendFcm() {
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Notifikasi panggilan berhasil dikirim", message);
verify(fcmService).sendHighPriority(
eq("receiver-token"),
eq("Panggilan Masuk"),
contains("Caller User"),
anyMap()
);
}
@Test
@DisplayName("notifyIncomingCall skips FCM when receiver token is missing")
void notifyIncomingCall_receiverHasNoToken_shouldSkipFcm() {
receiver.setFcmToken(null);
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
String message = service.notifyIncomingCall(1L, request);
assertEquals("Panggilan dikirim (receiver mungkin tidak menerima push notification)", message);
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyIncomingCall throws 404 when caller is missing")
void notifyIncomingCall_missingCaller_shouldThrow() {
when(userRepository.findById(1L)).thenReturn(Optional.empty());
assertThrows(ResourceNotFoundException.class, () -> service.notifyIncomingCall(1L, request));
verify(fcmService, never()).sendHighPriority(anyString(), anyString(), anyString(), anyMap());
}
@Test
@DisplayName("notifyCallEnded sends FCM when other user has token")
void notifyCallEnded_otherHasToken_shouldSendFcm() {
when(userRepository.findById(2L)).thenReturn(Optional.of(receiver));
service.notifyCallEnded(1L, 2L);
verify(fcmService).sendToToken(
eq("receiver-token"),
eq("Panggilan Berakhir"),
eq("Panggilan telah berakhir"),
anyMap()
);
}
@Test
@DisplayName("notifyCallEnded skips repository lookup when otherId is null")
void notifyCallEnded_nullOtherId_shouldReturn() {
service.notifyCallEnded(1L, null);
verify(userRepository, never()).findById(2L);
verify(fcmService, never()).sendToToken(anyString(), anyString(), anyString(), anyMap());
}
}

View File

@ -5,8 +5,9 @@ import com.walkguide.dto.response.LocationResponse;
import com.walkguide.entity.GeofenceConfig;
import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.PairingRelation;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.entity.User;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.PairingException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
import org.junit.jupiter.api.BeforeEach;
@ -217,9 +218,9 @@ class LocationServiceTest {
// ===== getLocationHistory TESTS =====
@Test
@DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable")
void getLocationHistory_shouldReturnPagedLocations() {
@Test
@DisplayName("getLocationHistory - harus return halaman lokasi sesuai pageable")
void getLocationHistory_shouldReturnPagedLocations() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
@ -229,10 +230,39 @@ class LocationServiceTest {
assertThat(result).isNotNull();
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
}
// ===== haversineMeters TESTS =====
assertThat(result.getContent().get(0).getLat()).isEqualTo(-7.257);
}
@Test
@DisplayName("getLocationHistoryForGuardian - harus ambil histori milik User yang dipair, bukan guardian")
void getLocationHistoryForGuardian_activePairing_shouldUsePairedUserId() {
Pageable pageable = PageRequest.of(0, 10);
Page<LocationHistory> page = new PageImpl<>(List.of(sampleLocation), pageable, 1);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.of(activePairing));
when(locationHistoryRepository.findByUserIdOrderByCreatedAtDesc(2L, pageable)).thenReturn(page);
Page<LocationResponse> result = locationService.getLocationHistoryForGuardian(1L, pageable);
assertThat(result.getContent()).hasSize(1);
verify(locationHistoryRepository).findByUserIdOrderByCreatedAtDesc(2L, pageable);
verify(locationHistoryRepository, never()).findByUserIdOrderByCreatedAtDesc(1L, pageable);
}
@Test
@DisplayName("getLocationHistoryForGuardian - tanpa pairing aktif harus error pairing")
void getLocationHistoryForGuardian_noPairing_shouldThrow() {
Pageable pageable = PageRequest.of(0, 10);
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> locationService.getLocationHistoryForGuardian(1L, pageable))
.isInstanceOf(PairingException.class)
.hasMessageContaining("Guardian belum terhubung");
}
// ===== haversineMeters TESTS =====
@Test
@DisplayName("haversineMeters - titik sama: jarak harus 0")
@ -256,4 +286,4 @@ class LocationServiceTest {
double d2 = LocationService.haversineMeters(-7.300, 112.800, -7.257, 112.752);
assertThat(d1).isCloseTo(d2, within(0.001));
}
}
}

View File

@ -15,7 +15,8 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import java.util.Optional;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@ -52,10 +53,12 @@ class PairingServiceTest {
.id(2L)
.email("user@test.com")
.role("ROLE_USER")
.displayName("User Test")
.uniqueUserId("ABC123DEF456")
.fcmToken("user-fcm-token")
.build();
.displayName("User Test")
.uniqueUserId("ABC123DEF456")
.pairingCode("AB12CD34")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.fcmToken("user-fcm-token")
.build();
}
// ===== INVITE USER TESTS =====
@ -66,7 +69,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.save(any(PairingRelation.class))).thenAnswer(inv -> {
PairingRelation p = inv.getArgument(0);
@ -74,7 +77,7 @@ class PairingServiceTest {
return p;
});
PairingStatusResponse result = pairingService.inviteUser(1L, "ABC123DEF456");
PairingStatusResponse result = pairingService.inviteUser(1L, "AB12CD34");
assertThat(result).isNotNull();
verify(pairingRelationRepository).save(any(PairingRelation.class));
@ -113,7 +116,7 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("INVALID")).thenReturn(Optional.empty());
when(userRepository.findByPairingCode("INVALID")).thenReturn(Optional.empty());
assertThatThrownBy(() -> pairingService.inviteUser(1L, "INVALID"))
.isInstanceOf(ResourceNotFoundException.class)
@ -123,17 +126,20 @@ class PairingServiceTest {
@Test
@DisplayName("inviteUser - target bukan ROLE_USER harus throw PairingException")
void inviteUser_targetNotUser_shouldThrow() {
User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN").uniqueUserId("GRD000000001").build();
User anotherGuardian = User.builder()
.id(3L).role("ROLE_GUARDIAN")
.pairingCode("GRD00001")
.pairingCodeExpiresAt(LocalDateTime.now().plusMinutes(10))
.build();
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("GRD000000001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD000000001"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User");
when(userRepository.findByPairingCode("GRD00001")).thenReturn(Optional.of(anotherGuardian));
assertThatThrownBy(() -> pairingService.inviteUser(1L, "GRD00001"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("bukan milik User");
}
@Test
@ -142,12 +148,12 @@ class PairingServiceTest {
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE)).thenReturn(false);
when(pairingRelationRepository.existsByGuardian_IdAndStatus(1L, PairingStatus.PENDING)).thenReturn(false);
when(userRepository.findById(1L)).thenReturn(Optional.of(guardian));
when(userRepository.findByUniqueUserId("ABC123DEF456")).thenReturn(Optional.of(user));
when(userRepository.findByPairingCode("AB12CD34")).thenReturn(Optional.of(user));
when(pairingRelationRepository.existsByUser_IdAndStatus(2L, PairingStatus.ACTIVE)).thenReturn(true);
assertThatThrownBy(() -> pairingService.inviteUser(1L, "ABC123DEF456"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain");
assertThatThrownBy(() -> pairingService.inviteUser(1L, "AB12CD34"))
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah dipair dengan Guardian lain");
}
// ===== RESPOND TO PAIRING TESTS =====
@ -211,4 +217,4 @@ class PairingServiceTest {
.isInstanceOf(PairingException.class)
.hasMessageContaining("sudah direspons");
}
}
}

View File

@ -49,3 +49,13 @@ hs_err_pid*.log
# Android SDK path (generated by Android Studio)
android/local.properties
# Local Python/YOLO export artifacts - do not commit
.venv*/
yolov8n.onnx
yolov8n.pt
yolov8n_saved_model/
calibration_image_sample_data_*.npy
devtools_options.yaml
android/app/src/main/java/dev/flutter/plugins/
android/app/src/main/java/io/flutter/plugins/

View File

@ -31,6 +31,10 @@ android {
versionName = flutter.versionName
}
androidResources {
noCompress += listOf("tflite", "lite")
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.

View File

@ -1,10 +1,80 @@
person
bicycle
car
motorcycle
bicycle
airplane
bus
train
truck
chair
boat
traffic light
fire hydrant
stop sign
parking meter
bench
door
stairs
bird
cat
dog
horse
sheep
cow
elephant
bear
zebra
giraffe
backpack
umbrella
handbag
tie
suitcase
frisbee
skis
snowboard
sports ball
kite
baseball bat
baseball glove
skateboard
surfboard
tennis racket
bottle
wine glass
cup
fork
knife
spoon
bowl
banana
apple
sandwich
orange
broccoli
carrot
hot dog
pizza
donut
cake
chair
couch
potted plant
bed
dining table
toilet
tv
laptop
mouse
remote
keyboard
cell phone
microwave
oven
toaster
sink
refrigerator
book
clock
vase
scissors
teddy bear
hair drier
toothbrush

View File

@ -19,37 +19,81 @@ class WalkGuideApp extends StatelessWidget {
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: seed),
scaffoldBackgroundColor: const Color(0xFFF8FAFC),
textTheme: GoogleFonts.interTextTheme(),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Colors.white,
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.white,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 46),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
textTheme: GoogleFonts.interTextTheme(),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Color(0xFFF4F7FB),
foregroundColor: Color(0xFF0F172A),
elevation: 0,
surfaceTintColor: Colors.transparent,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 76,
backgroundColor: Colors.white.withValues(alpha: 0.96),
indicatorColor: const Color(0xFFE0E7FF),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontSize: 12,
fontWeight: states.contains(WidgetState.selected)
? FontWeight.w800
: FontWeight.w500,
),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: seed,
foregroundColor: Colors.white,
minimumSize: const Size(0, 50),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 50),
foregroundColor: seed,
textStyle: const TextStyle(fontWeight: FontWeight.w800),
side: const BorderSide(color: Color(0xFFCBD5E1)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: seed, width: 1.5),
),
),
),
),
),
);

View File

@ -2,8 +2,33 @@ import 'package:go_router/go_router.dart';
import '../core/constants/app_constants.dart';
import '../features/activity_log/activity_log_screen.dart' as activity;
import '../features/ai_benchmark/ai_benchmark_screen.dart' as benchmark;
import '../features/auth/login_screen.dart' as auth_login;
import '../features/auth/register_screen.dart' as auth_register;
import '../features/auth/splash_screen.dart' as auth_splash;
import '../features/call/call_screen.dart' as call;
import '../features/guardian_dashboard/guardian_activity_log_screen.dart'
as guardian_logs;
import '../features/guardian_dashboard/guardian_ai_config_screen.dart'
as guardian_ai;
import '../features/guardian_dashboard/guardian_map_screen.dart'
as guardian_map;
import '../features/guardian_dashboard/guardian_send_notification_screen.dart'
as guardian_send;
import '../features/guardian_dashboard/guardian_settings_screen.dart'
as guardian_settings;
import '../features/guardian_dashboard/guardian_tools_screen.dart'
as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home;
import '../features/navigation_mode/navigation_mode_screen.dart' as nav;
import '../features/notifications/notification_screen.dart' as notifications;
import '../features/screens.dart';
import '../features/pairing/pairing_screens.dart' as pairing;
import '../features/server_connect/server_connect_server.dart'
as server_connect;
import '../features/settings/user_settings_screen.dart' as user_settings;
import '../features/sos/sos_screen.dart' as sos;
import '../features/walk_guide/walk_guide_screen.dart' as walk_guide;
import '../shared/widgets/app_shells.dart';
final GoRouter appRouter = GoRouter(
@ -25,19 +50,23 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/server-connect',
builder: (_, __) => const ServerConnectScreen()),
GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
builder: (_, __) => const server_connect.ServerConnectScreen()),
GoRoute(
path: '/incoming-call', builder: (_, __) => const IncomingCallScreen()),
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
GoRoute(
path: '/register',
builder: (_, __) => const auth_register.RegisterScreen()),
GoRoute(
path: '/incoming-call',
builder: (_, __) => const call.IncomingCallScreen()),
ShellRoute(
builder: (_, __, child) => UserShell(child: child),
routes: [
GoRoute(
path: '/user/walkguide',
builder: (_, __) => const WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const SosScreen()),
builder: (_, __) => const walk_guide.WalkGuideScreen()),
GoRoute(path: '/user/sos', builder: (_, __) => const sos.SosScreen()),
GoRoute(
path: '/user/activity',
builder: (_, __) => const activity.ActivityLogScreen()),
@ -46,17 +75,18 @@ final GoRouter appRouter = GoRouter(
builder: (_, __) => const notifications.NotificationScreen()),
GoRoute(
path: '/user/navigation',
builder: (_, __) => const NavigationModeScreen()),
builder: (_, __) => const nav.NavigationModeScreen()),
GoRoute(
path: '/user/settings',
builder: (_, __) => const UserSettingsScreen()),
builder: (_, __) => const user_settings.UserSettingsScreen()),
GoRoute(
path: '/user/pairing',
builder: (_, __) => const UserPairingScreen()),
GoRoute(path: '/user/call', builder: (_, __) => const CallScreen()),
builder: (_, __) => const pairing.UserPairingScreen()),
GoRoute(
path: '/user/call', builder: (_, __) => const call.CallScreen()),
GoRoute(
path: '/user/benchmark',
builder: (_, __) => const AiBenchmarkScreen()),
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
],
),
ShellRoute(
@ -64,37 +94,39 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/guardian/dashboard',
builder: (_, __) => const GuardianDashboardScreen()),
builder: (_, __) => const guardian_home.GuardianDashboardScreen()),
GoRoute(
path: '/guardian/map',
builder: (_, __) => const GuardianMapScreen()),
builder: (_, __) => const guardian_map.GuardianMapScreen()),
GoRoute(
path: '/guardian/logs',
builder: (_, __) => const GuardianActivityLogScreen()),
builder: (_, __) =>
const guardian_logs.GuardianActivityLogScreen()),
GoRoute(
path: '/guardian/send-notif',
builder: (_, __) => const GuardianSendNotifScreen()),
builder: (_, __) => const guardian_send.GuardianSendNotifScreen()),
GoRoute(
path: '/guardian/ai-config',
builder: (_, __) => const GuardianAiConfigScreen()),
builder: (_, __) => const guardian_ai.GuardianAiConfigScreen()),
GoRoute(
path: '/guardian/voice-cmd',
builder: (_, __) => const GuardianVoiceCmdScreen()),
builder: (_, __) => const guardian_tools.GuardianVoiceCmdScreen()),
GoRoute(
path: '/guardian/shortcuts',
builder: (_, __) => const GuardianShortcutScreen()),
builder: (_, __) => const guardian_tools.GuardianShortcutScreen()),
GoRoute(
path: '/guardian/geofence',
builder: (_, __) => const GuardianGeofenceScreen()),
builder: (_, __) => const guardian_tools.GuardianGeofenceScreen()),
GoRoute(
path: '/guardian/pairing',
builder: (_, __) => const GuardianPairingScreen()),
builder: (_, __) => const pairing.GuardianPairingScreen()),
GoRoute(
path: '/guardian/settings',
builder: (_, __) => const GuardianSettingsScreen()),
builder: (_, __) =>
const guardian_settings.GuardianSettingsScreen()),
GoRoute(
path: '/guardian/benchmark',
builder: (_, __) => const AiBenchmarkScreen()),
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
],
),
],

View File

@ -23,12 +23,14 @@ class DetectionResult {
final double confidence;
final ObstacleDirection direction;
final String estimatedDistance;
final BoundingBox? box;
const DetectionResult({
required this.label,
required this.confidence,
required this.direction,
required this.estimatedDistance,
this.box,
});
String get directionName => direction.name.toUpperCase();
@ -39,7 +41,7 @@ class DetectionResult {
ObstacleDirection.center => 'tengah',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
return 'Hati-hati, ${ObstacleAnalyzer.spokenLabel(label)} di $area. Jarak $estimatedDistance.';
}
}
@ -68,7 +70,7 @@ class ObstacleAnalyzer {
ObstacleDirection.center => 'depan',
ObstacleDirection.right => 'kanan',
};
return 'Hati-hati, ${result.label} di $directionLabel. '
return 'Hati-hati, ${spokenLabel(result.label)} di $directionLabel. '
'Jarak ${result.estimatedDistance}.';
}
@ -90,7 +92,12 @@ class ObstacleAnalyzer {
final bi = order.indexOf(b.estimatedDistance);
final aRank = ai == -1 ? order.length : ai;
final bRank = bi == -1 ? order.length : bi;
return aRank.compareTo(bRank);
final distanceCompare = aRank.compareTo(bRank);
if (distanceCompare != 0) return distanceCompare;
final directionCompare =
_directionRisk(a.direction).compareTo(_directionRisk(b.direction));
if (directionCompare != 0) return directionCompare;
return b.confidence.compareTo(a.confidence);
});
return sorted.first;
}
@ -102,15 +109,43 @@ class ObstacleAnalyzer {
return detections.where((d) => d.confidence >= threshold).toList();
}
DetectionResult analyzeFallback({
String label = 'person',
double confidence = 0.86,
}) {
return DetectionResult(
label: label,
confidence: confidence,
direction: ObstacleDirection.center,
estimatedDistance: 'Close (1-2m)',
);
int _directionRisk(ObstacleDirection direction) {
return switch (direction) {
ObstacleDirection.center => 0,
ObstacleDirection.left => 1,
ObstacleDirection.right => 1,
};
}
static String spokenLabel(String label) {
return switch (label.toLowerCase()) {
'person' => 'orang',
'bicycle' => 'sepeda',
'car' => 'mobil',
'motorcycle' => 'motor',
'airplane' => 'pesawat',
'bus' => 'bus',
'train' => 'kereta',
'truck' => 'truk',
'boat' => 'perahu',
'traffic light' => 'lampu lalu lintas',
'fire hydrant' => 'hidran',
'stop sign' => 'rambu berhenti',
'parking meter' => 'meter parkir',
'bench' => 'bangku',
'chair' => 'kursi',
'couch' => 'sofa',
'potted plant' => 'pot tanaman',
'dining table' => 'meja',
'tv' => 'televisi',
'backpack' => 'tas',
'umbrella' => 'payung',
'handbag' => 'tas tangan',
'suitcase' => 'koper',
'skateboard' => 'papan skate',
'surfboard' => 'papan selancar',
'sports ball' => 'bola',
_ => label,
};
}
}

View File

@ -18,11 +18,31 @@ class YoloDetector {
YoloTensorType? _outputType;
bool _ready = false;
String? _lastError;
int _lastDecodedCount = 0;
int _lastConfidenceCount = 0;
int _lastObstacleCount = 0;
int _lastKeptCount = 0;
int _lastInferenceMs = 0;
String? _lastBestLabel;
double? _lastBestConfidence;
YoloDetector(this._analyzer);
bool get isReady => _ready && _runtime != null;
String? get lastError => _lastError;
String get diagnosticsSummary {
if (!isReady) {
return _lastError == null
? 'YOLO runtime belum aktif'
: 'YOLO runtime error: $_lastError';
}
final bestLabel = _lastBestLabel;
final bestConfidence = _lastBestConfidence;
final bestText = bestLabel == null || bestConfidence == null
? 'best: none'
: 'best: $bestLabel ${(bestConfidence * 100).toStringAsFixed(0)}%';
return 'frames ok, decoded $_lastDecodedCount, >=threshold $_lastConfidenceCount, obstacle $_lastObstacleCount, kept $_lastKeptCount, $bestText, ${_lastInferenceMs}ms';
}
Future<void> init() async {
dispose();
@ -47,55 +67,64 @@ class YoloDetector {
_ready = true;
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO fallback mode: $e');
debugPrint('YOLO runtime initialization skipped: $e');
_ready = false;
}
}
Future<DetectionResult?> detect(
CameraImage image, {
double confidenceThreshold = 0.45,
double confidenceThreshold = 0.25,
}) async {
if (!isReady) return detectFallback();
if (!isReady) return null;
try {
final stopwatch = Stopwatch()..start();
final input = _buildCameraInput(image);
final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output);
final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold);
return _analyzer.prioritize(filtered);
final kept = _nonMaxSuppression(filtered);
_lastInferenceMs = stopwatch.elapsedMilliseconds;
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO inference fallback: $e');
return detectFallback();
debugPrint('YOLO inference skipped: $e');
return null;
}
}
Future<DetectionResult?> detectSynthetic({
double confidenceThreshold = 0.25,
}) async {
if (!isReady) return detectFallback();
if (!isReady) return null;
try {
final input = _buildSyntheticInput();
final output = Uint8List(_runtime!.outputByteLength);
_runtime!.run(input, output);
final detections = _decodeDetections(output);
_recordDecoded(detections);
final filtered =
_analyzer.filterByConfidence(detections, confidenceThreshold);
return _analyzer.prioritize(filtered) ?? detectFallback();
final kept = _nonMaxSuppression(filtered);
_lastConfidenceCount = filtered.length;
_lastObstacleCount =
filtered.where((d) => _isObstacleLabel(d.label)).length;
_lastKeptCount = kept.length;
return _analyzer.prioritize(kept);
} catch (e) {
_lastError = e.toString();
debugPrint('YOLO synthetic fallback: $e');
return detectFallback();
debugPrint('YOLO synthetic skipped: $e');
return null;
}
}
Future<DetectionResult?> detectFallback() async {
final label = _labels.isNotEmpty ? _labels.first : 'person';
return _analyzer.analyzeFallback(label: label);
}
void dispose() {
_runtime?.close();
_runtime = null;
@ -104,6 +133,26 @@ class YoloDetector {
_inputType = null;
_outputType = null;
_ready = false;
_lastDecodedCount = 0;
_lastConfidenceCount = 0;
_lastObstacleCount = 0;
_lastKeptCount = 0;
_lastInferenceMs = 0;
_lastBestLabel = null;
_lastBestConfidence = null;
}
void _recordDecoded(List<DetectionResult> detections) {
_lastDecodedCount = detections.length;
_lastBestLabel = null;
_lastBestConfidence = null;
for (final detection in detections) {
if (_lastBestConfidence == null ||
detection.confidence > _lastBestConfidence!) {
_lastBestLabel = detection.label;
_lastBestConfidence = detection.confidence;
}
}
}
Uint8List _buildCameraInput(CameraImage image) {
@ -292,7 +341,7 @@ class YoloDetector {
}
if (type == YoloTensorType.float16) {
// Most exported YOLOv8 TFLite files use float32 output. Float16 is rare;
// fallback keeps the app usable if an unsupported variant is selected.
// Keep preprocessing explicit if an unsupported variant is selected.
throw StateError('Float16 YOLO output is not supported yet');
}
throw StateError('Unsupported YOLO output type: $type');
@ -484,19 +533,164 @@ class YoloDetector {
confidence: confidence,
direction: _analyzer.analyzeDirection(box),
estimatedDistance: _analyzer.estimateDistance(box),
box: box,
);
}
List<DetectionResult> _nonMaxSuppression(
List<DetectionResult> detections, {
double iouThreshold = 0.45,
int maxDetections = 8,
}) {
final candidates = detections
.where((d) => d.box != null && _isObstacleLabel(d.label))
.toList()
..sort((a, b) => b.confidence.compareTo(a.confidence));
final selected = <DetectionResult>[];
for (final detection in candidates) {
final box = detection.box;
if (box == null) continue;
final overlaps = selected.any((kept) =>
kept.label == detection.label &&
kept.box != null &&
_iou(box, kept.box!) > iouThreshold);
if (overlaps) continue;
selected.add(detection);
if (selected.length >= maxDetections) break;
}
return selected;
}
bool _isObstacleLabel(String label) {
return _walkGuideObstacleLabels.contains(label.toLowerCase());
}
double _iou(BoundingBox a, BoundingBox b) {
final left = math.max(a.left, b.left);
final top = math.max(a.top, b.top);
final right = math.min(a.right, b.right);
final bottom = math.min(a.bottom, b.bottom);
final width = math.max(0.0, right - left);
final height = math.max(0.0, bottom - top);
final intersection = width * height;
final union = a.width * a.height + b.width * b.height - intersection;
if (union <= 0) return 0;
return intersection / union;
}
}
const Set<String> _walkGuideObstacleLabels = {
'person',
'bicycle',
'car',
'motorcycle',
'bus',
'train',
'truck',
'traffic light',
'fire hydrant',
'stop sign',
'parking meter',
'bench',
'backpack',
'umbrella',
'handbag',
'suitcase',
'chair',
'couch',
'potted plant',
'dining table',
'tv',
'laptop',
'cell phone',
'bottle',
'cup',
'book',
};
const Map<int, String> _cocoObstacleLabels = {
0: 'person',
1: 'bicycle',
2: 'car',
3: 'motorcycle',
4: 'airplane',
5: 'bus',
6: 'train',
7: 'truck',
8: 'boat',
9: 'traffic light',
10: 'fire hydrant',
11: 'stop sign',
12: 'parking meter',
13: 'bench',
14: 'bird',
15: 'cat',
16: 'dog',
17: 'horse',
18: 'sheep',
19: 'cow',
20: 'elephant',
21: 'bear',
22: 'zebra',
23: 'giraffe',
24: 'backpack',
25: 'umbrella',
26: 'handbag',
27: 'tie',
28: 'suitcase',
29: 'frisbee',
30: 'skis',
31: 'snowboard',
32: 'sports ball',
33: 'kite',
34: 'baseball bat',
35: 'baseball glove',
36: 'skateboard',
37: 'surfboard',
38: 'tennis racket',
39: 'bottle',
40: 'wine glass',
41: 'cup',
42: 'fork',
43: 'knife',
44: 'spoon',
45: 'bowl',
46: 'banana',
47: 'apple',
48: 'sandwich',
49: 'orange',
50: 'broccoli',
51: 'carrot',
52: 'hot dog',
53: 'pizza',
54: 'donut',
55: 'cake',
56: 'chair',
57: 'couch',
58: 'potted plant',
59: 'bed',
60: 'dining table',
61: 'toilet',
62: 'tv',
63: 'laptop',
64: 'mouse',
65: 'remote',
66: 'keyboard',
67: 'cell phone',
68: 'microwave',
69: 'oven',
70: 'toaster',
71: 'sink',
72: 'refrigerator',
73: 'book',
74: 'clock',
75: 'vase',
76: 'scissors',
77: 'teddy bear',
78: 'hair drier',
79: 'toothbrush',
};
class _InputInfo {

View File

@ -1,21 +1,14 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; // Wajib import ini
class ApiService {
static String get baseUrl {
if (kIsWeb) {
// Jika di Chrome/Web, tembak localhost langsung
return 'http://localhost:8080/api';
} else {
// Jika di Emulator Android, tembak IP khusus 10.0.2.2
return 'http://10.0.2.2:8080/api';
}
}
static const baseUrl = String.fromEnvironment(
'WALKGUIDE_API_BASE_URL',
defaultValue: 'http://202.46.28.160:8080/api/v1',
);
final Dio _dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 5),
// Penting buat Web agar tidak kena error CORS di sisi Client
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
@ -25,4 +18,4 @@ class ApiService {
Future<Response> post(String path, Map<String, dynamic> data) async {
return await _dio.post(path, data: data);
}
}
}

View File

@ -0,0 +1,113 @@
import 'package:dio/dio.dart';
String friendlyErrorMessage(
Object error, {
required String fallback,
String? connectionHint,
}) {
if (error is DioException) {
return friendlyDioMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
);
}
return fallback;
}
String friendlyDioMessage(
DioException error, {
required String fallback,
String? connectionHint,
}) {
final backendMessage = _cleanBackendMessage(error.response?.data);
if (backendMessage != null) return backendMessage;
final status = error.response?.statusCode;
if (status == 401) return 'Sesi sudah habis atau data login salah.';
if (status == 403) return 'Akun kamu belum punya akses ke fitur ini.';
if (status == 404) return 'Data belum ditemukan.';
if (status == 409) return 'Data sudah dipakai. Coba gunakan data lain.';
if (status != null && status >= 500) {
return 'Server sedang bermasalah. Coba lagi sebentar.';
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Coba lagi.';
}
if (error.type == DioExceptionType.connectionError) {
return connectionHint ?? 'Tidak bisa terhubung ke server.';
}
return fallback;
}
String? _cleanBackendMessage(Object? data) {
Object? raw;
if (data is Map) {
raw = data['message'] ?? data['error'] ?? data['errorCode'];
} else if (data is String) {
raw = data;
}
final message = raw?.toString().trim();
if (message == null || message.isEmpty || _looksTechnical(message)) {
return null;
}
return message;
}
bool _looksTechnical(String message) {
final lower = message.toLowerCase();
const blocked = [
'exception',
'dioexception',
'typeerror',
'stacktrace',
'instance of',
'_jsonmap',
'socketexception',
'package:',
'null check operator',
'nosuchmethod',
'formatexception',
];
return blocked.any(lower.contains);
}
Future<T?> runFriendly<T>(
Future<T?> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
return await action();
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return null;
}
}
Future<bool> runFriendlyAction(
Future<void> Function() action, {
required void Function(String message) onError,
required String fallback,
String? connectionHint,
}) async {
try {
await action();
return true;
} catch (error) {
onError(friendlyErrorMessage(
error,
fallback: fallback,
connectionHint: connectionHint,
));
return false;
}
}

View File

@ -37,7 +37,7 @@ class WebSocketService {
bool get isConnected => _connected;
/// Connect ke WebSocket server.
/// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices).
/// Dipanggil setelah login berhasil untuk membuka channel realtime.
Future<void> connect(String serverUrl) async {
await disconnect();

View File

@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
@ -44,26 +45,22 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
_loading = true;
_error = null;
});
try {
final res = await _api
.get('/user/activity-logs')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
final items = list.map(_LogItem.fromJson).toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat activity log.';
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await _api
.get('/user/activity-logs')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
final items = list.map(_LogItem.fromJson).toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
});
},
onError: (message) => setState(() => _error = message),
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic responseBody) {

View File

@ -0,0 +1,290 @@
import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/services/tts_service.dart';
import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget {
const AiBenchmarkScreen({super.key});
@override
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
}
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
static const _runsKey = 'ai_benchmark_runs';
List<String> _models = const [];
String _selectedModel = AppConstants.yoloModelPath;
List<Map<String, dynamic>> _runs = const [];
bool _running = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final models = await _discoverTfliteModels();
final selected = await AppConstants.getSelectedYoloModelPath();
final prefs = await SharedPreferences.getInstance();
final rawRuns = prefs.getStringList(_runsKey) ?? const [];
setState(() {
_models = models.isEmpty ? [selected] : models;
_selectedModel = _models.contains(selected) ? selected : _models.first;
_runs = rawRuns
.map((e) => Map<String, dynamic>.from(jsonDecode(e) as Map))
.toList()
.reversed
.toList();
});
}
Future<void> _setModel(String? value) async {
if (value == null) return;
await AppConstants.setSelectedYoloModelPath(value);
sl<YoloDetector>().dispose();
await sl<YoloDetector>().init();
setState(() => _selectedModel = value);
_snack('Model aktif: ${value.split('/').last}');
}
Future<void> _runBenchmark() async {
setState(() => _running = true);
final started = DateTime.now();
final captureMs = await _measureCapture();
final inferenceWatch = Stopwatch()..start();
final modelLoaded = sl<YoloDetector>().isReady;
final detection = await sl<YoloDetector>().detectSynthetic();
inferenceWatch.stop();
final label = detection?.label ?? 'no detection';
final direction = detection?.directionName ?? '-';
final distance = detection?.estimatedDistance ?? '-';
final text = detection == null
? 'Tidak ada obstacle di atas threshold.'
: 'Obstacle $label di $direction, jarak $distance';
final notifWatch = Stopwatch()..start();
final notificationText = text;
notifWatch.stop();
final ttsWatch = Stopwatch()..start();
try {
await sl<TtsService>()
.speakImmediate(notificationText)
.timeout(const Duration(seconds: 3));
} catch (_) {}
ttsWatch.stop();
final run = {
'time': started.toIso8601String(),
'model': _selectedModel,
'modelLoaded': modelLoaded,
'captureMs': captureMs,
'inferenceMs': inferenceWatch.elapsedMilliseconds,
'notificationMs': notifWatch.elapsedMicroseconds / 1000,
'ttsMs': ttsWatch.elapsedMilliseconds,
'label': label,
'direction': direction,
};
final prefs = await SharedPreferences.getInstance();
final next = [
jsonEncode(run),
...((prefs.getStringList(_runsKey) ?? const []).take(24)),
];
await prefs.setStringList(_runsKey, next);
if (!mounted) return;
setState(() {
_runs = [run, ..._runs].take(25).toList();
_running = false;
});
}
Future<int> _measureCapture() async {
final watch = Stopwatch()..start();
CameraController? controller;
try {
final cameras =
await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) {
controller = CameraController(
cameras.first,
ResolutionPreset.low,
enableAudio: false,
);
await controller.initialize().timeout(const Duration(seconds: 5));
await controller.takePicture().timeout(const Duration(seconds: 5));
}
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
}
watch.stop();
return watch.elapsedMilliseconds;
}
Future<void> _clearRuns() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_runsKey);
setState(() => _runs = const []);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
return FeaturePage(
title: 'AI Benchmark',
subtitle: 'Capture, inference, notification text, and TTS timing',
child: ListView(
children: [
DropdownButtonFormField<String>(
initialValue: _selectedModel,
decoration: const InputDecoration(labelText: 'Model file'),
items: [
for (final model in _models)
DropdownMenuItem(
value: model,
child: Text(model.split('/').last),
),
],
onChanged: _setModel,
),
if (!hasRealModel) ...[
const SizedBox(height: 10),
const _StatusBox(
success: false,
message:
'Belum ada file .tflite di assets/models. Tambahkan model lalu restart app.',
),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _running ? null : _runBenchmark,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow),
label: Text(_running ? 'Running benchmark...' : 'Run benchmark'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _clearRuns,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear log'),
),
const SizedBox(height: 16),
for (final run in _runs) _BenchmarkCard(run: run),
if (_runs.isEmpty)
const FeatureEmptyPanel(
icon: Icons.speed,
title: 'Belum ada log',
message:
'Klik Run benchmark untuk mencatat capture, inference, notification, dan TTS.',
),
],
),
);
}
}
class _BenchmarkCard extends StatelessWidget {
final Map<String, dynamic> run;
const _BenchmarkCard({required this.run});
@override
Widget build(BuildContext context) {
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
time == null
? 'Benchmark run'
: '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}',
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Text(
'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'not ready'})'),
Text('Capture: ${run['captureMs']} ms'),
Text('Model/inference: ${run['inferenceMs']} ms'),
Text('Notification text: ${run['notificationMs']} ms'),
Text('TTS start: ${run['ttsMs']} ms'),
Text('Result: ${run['label']} ${run['direction']}'),
],
),
);
}
}
class _StatusBox extends StatelessWidget {
final bool success;
final String message;
const _StatusBox({required this.success, required this.message});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
message,
style: TextStyle(
color: success ? const Color(0xFF166534) : const Color(0xFF991B1B),
),
),
),
);
}
}
Future<List<String>> _discoverTfliteModels() async {
try {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys
.where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList()
..sort();
return models;
} catch (_) {
return const [];
}
}
String _two(int value) => value.toString().padLeft(2, '0');

View File

@ -12,19 +12,34 @@ class AuthRepositoryImpl implements AuthRepository {
AuthRepositoryImpl(this.remoteDataSource, this.secureStorage);
@override
Future<Either<Failure, UserEntity>> login(String email, String password) async {
Future<Either<Failure, UserEntity>> login(
String email, String password) async {
try {
// 1. Suruh data source nembak API
final userModel = await remoteDataSource.login(email, password);
// 2. Simpan token langsung ke HP biar UI gausah ribet
await secureStorage.saveToken(userModel.token);
// 3. Balikin data sukses (Right)
return Right(userModel);
} catch (e) {
// 4. Balikin pesan error (Left)
return Left(ServerFailure(e.toString().replaceAll('Exception: ', '')));
return Left(ServerFailure(_safeAuthFailure(e)));
}
}
}
}
String _safeAuthFailure(Object error) {
final message = error.toString().replaceFirst(RegExp(r'^\w+:\s*'), '').trim();
final lower = message.toLowerCase();
final technical = lower.contains('exception') ||
lower.contains('typeerror') ||
lower.contains('stacktrace') ||
lower.contains('instance of') ||
lower.contains('package:');
if (message.isEmpty || technical) {
return 'Login gagal. Periksa email dan password kamu.';
}
return message;
}

View File

@ -2,7 +2,6 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@ -11,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/tts_service.dart';
@ -65,27 +65,27 @@ class _LoginScreenState extends State<LoginScreen> {
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
'email': _email.text.trim(),
'password': _password.text,
});
await _saveAuthAndRoute(
context, Map<String, dynamic>.from(res.data['data'] as Map));
} on DioException catch (e) {
_snack(context, _friendlyDioMessage(e, fallback: 'Login gagal'));
} catch (e) {
_snack(context, 'Login gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/auth/login', data: {
'email': _email.text.trim(),
'password': _password.text,
});
await _saveAuthAndRoute(
context, Map<String, dynamic>.from(res.data['data'] as Map));
},
onError: (message) => _snack(context, message),
fallback: 'Login gagal. Periksa email dan password kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return _AuthFrame(
title: 'Sign in',
subtitle: 'Masuk sebagai Guardian atau User.',
subtitle: 'Masuk ke navigasi asistif realtime WalkGuide.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -147,42 +147,134 @@ class _AuthFrame extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
),
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
),
);
}
@ -219,16 +311,14 @@ Future<void> _saveAuthAndRoute(
void _startPostLoginServices(String serverUrl) {
Future.microtask(() async {
try {
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
} catch (e) {
debugPrint('Post-login services skipped: $e');
}
await sl<WebSocketService>()
.connect(serverUrl)
.timeout(const Duration(seconds: 2));
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
}).catchError((Object e) {
debugPrint('Post-login services skipped: $e');
});
}
@ -238,19 +328,3 @@ void _snack(BuildContext context, String message) {
.showSnackBar(SnackBar(content: Text(message)));
}
}
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 401) {
return 'Email atau password salah.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
}
return fallback;
}

View File

@ -1 +1 @@
export '../../screens.dart';
export '../login_screen.dart';

View File

@ -1,11 +1,11 @@
// ignore_for_file: use_build_context_synchronously
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
// ---------------------------------------------------------------------------
@ -52,26 +52,26 @@ class _RegisterScreenState extends State<RegisterScreen> {
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'displayName': _name.text.trim(),
'email': _email.text.trim(),
'password': _password.text,
'role': _role,
});
final data = Map<String, dynamic>.from(res.data['data'] as Map);
if (!mounted) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pending_login_email', _email.text.trim());
await _showRegisterSuccess(context, data);
if (mounted) context.go('/login');
} on DioException catch (e) {
_snack(context, _friendlyDioMessage(e, fallback: 'Registrasi gagal'));
} catch (e) {
_snack(context, 'Registrasi gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/auth/register', data: {
'displayName': _name.text.trim(),
'email': _email.text.trim(),
'password': _password.text,
'role': _role,
});
final data = Map<String, dynamic>.from(res.data['data'] as Map);
if (!mounted) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('pending_login_email', _email.text.trim());
await _showRegisterSuccess(context, data);
if (mounted) context.go('/login');
},
onError: (message) => _snack(context, message),
fallback: 'Registrasi gagal. Periksa data akun kamu.',
connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.',
);
if (mounted) setState(() => _loading = false);
}
@override
@ -81,7 +81,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
subtitle: _step == 0
? 'Who are you in the WalkGuide system?'
: _role == 'USER'
? 'User akan mendapat Unique ID untuk pairing.'
? 'User bisa membuat Pairing Code sementara setelah login.'
: 'Guardian dapat monitor dan konfigurasi User.',
child: _step == 0 ? _buildRoleStep(context) : _buildFormStep(context),
);
@ -298,42 +298,134 @@ class _AuthFrame extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.navigation_rounded,
color: Color(0xFF1A56DB), size: 42),
const SizedBox(height: 14),
Text(title,
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B))),
const SizedBox(height: 22),
child,
],
backgroundColor: const Color(0xFFEAF4FF),
body: Stack(
children: [
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)],
),
),
),
),
),
Positioned(
top: -90,
right: -60,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.85, end: 1),
duration: const Duration(milliseconds: 900),
curve: Curves.easeOutCubic,
builder: (_, value, child) => Transform.scale(
scale: value,
child: child,
),
child: Container(
width: 260,
height: 260,
decoration: BoxDecoration(
color: const Color(0xFF2563EB).withValues(alpha: 0.14),
shape: BoxShape.circle,
),
),
),
),
Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, offset, child) => Opacity(
opacity: (1 - offset / 18).clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, offset),
child: child,
),
),
child: RepaintBoundary(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: Colors.white.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color:
const Color(0xFF1E3A8A).withValues(alpha: 0.14),
blurRadius: 40,
offset: const Offset(0, 20),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFF1D4ED8),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.navigation_rounded,
color: Colors.white, size: 30),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFF0F172A),
),
),
),
],
),
const SizedBox(height: 22),
Text(
title,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.35,
),
),
const SizedBox(height: 26),
child,
],
),
),
),
),
),
),
),
),
],
),
);
}
@ -348,12 +440,12 @@ Future<void> _showRegisterSuccess(
final uniqueId = data['uniqueUserId']?.toString();
final message = uniqueId == null || uniqueId.isEmpty
? 'Registrasi berhasil. Silakan login.'
: 'Registrasi berhasil!\n\nUnique User ID kamu:\n$uniqueId\n\nBagikan ID ini ke Guardian untuk pairing. Silakan login.';
: 'Registrasi berhasil.\n\nSilakan login, lalu buka menu Pairing Code untuk membuat kode sementara yang bisa dibagikan ke Guardian.';
_snack(
context,
uniqueId == null
? 'Registrasi berhasil.'
: 'Registrasi berhasil. ID: $uniqueId');
: 'Registrasi berhasil. Buat Pairing Code setelah login.');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
@ -375,19 +467,3 @@ void _snack(BuildContext context, String message) {
.showSnackBar(SnackBar(content: Text(message)));
}
}
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 409) {
return 'Email sudah terdaftar. Gunakan email lain atau login.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di HP pakai IP server, bukan localhost.';
}
return fallback;
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
@ -51,29 +52,33 @@ class _SplashScreenState extends State<SplashScreen>
}
Future<void> _route() async {
try {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final routed = await runFriendlyAction(
() async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
if (!mounted) return;
if (!mounted) return;
if (token == null || role == null) {
context.go('/login');
return;
}
if (token == null || role == null) {
context.go('/login');
return;
}
// Auto-login: arahkan ke home sesuai role.
context.go(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide');
} catch (_) {
if (mounted) context.go('/login');
}
// Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: '/user/walkguide');
},
onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
);
if (!routed && mounted) context.go('/login');
}
@override

View File

@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio;
@ -48,66 +49,67 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
_error = null;
_needsPairing = false;
});
try {
// Cek pairing dulu
final paired = await _hasActivePairing();
if (!paired) {
await runFriendlyAction(
() async {
// Cek pairing dulu
final paired = await _hasActivePairing();
if (!paired) {
setState(() {
_needsPairing = true;
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_needsPairing = true;
_items = items;
_applyFilter(_selectedFilter);
_loading = false;
});
return;
}
final res = await _api.get('/guardian/activity-logs', queryParameters: {
'size': 50,
'page': 0
}).timeout(const Duration(seconds: 10));
// Response bisa berupa list langsung atau paged {content: [...]}
final data = res.data['data'];
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map && data['content'] is List) {
list = data['content'] as List;
} else {
list = [];
}
final items = list
.whereType<Map>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.from(e)))
.toList();
setState(() {
_items = items;
_applyFilter(_selectedFilter);
},
onError: (message) => setState(() {
_error = message;
_loading = false;
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat activity log.';
_loading = false;
});
} catch (e) {
setState(() {
_error = 'Timeout / error: $e';
_loading = false;
});
}
}),
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
);
}
Future<bool> _hasActivePairing() async {
try {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) return data['status'] == 'ACTIVE';
} catch (_) {}
return false;
final active = await runFriendly<bool>(
() async {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) return data['status'] == 'ACTIVE';
return false;
},
onError: (_) {},
fallback: 'Status pairing belum bisa dicek.',
);
return active ?? false;
}
void _applyFilter(String filter) {

View File

@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
Dio get _api => sl<ApiClient>().dio;
@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
}
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat konfigurasi AI.';
_error =
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
setState(
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
} finally {
if (mounted) setState(() => _loading = false);
}
@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.response?.data?['message']?.toString() ??
'Gagal menyimpan konfigurasi.'),
content: Text(friendlyDioMessage(e,
fallback: 'Gagal menyimpan konfigurasi.')),
backgroundColor: const Color(0xFFDC2626),
),
);
@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: const Color(0xFFDC2626),
const SnackBar(
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
backgroundColor: Color(0xFFDC2626),
),
);
}

View File

@ -0,0 +1,301 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget {
const GuardianMapScreen({super.key});
@override
State<GuardianMapScreen> createState() => _GuardianMapScreenState();
}
class _GuardianMapScreenState extends State<GuardianMapScreen> {
bool _loading = true;
String? _error;
Map<String, dynamic>? _lastLocation;
List<Map<String, dynamic>> _history = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final dio = sl<ApiClient>().dio;
final current = await dio
.get('/guardian/user-location')
.timeout(const Duration(seconds: 8));
final history = await dio.get('/guardian/location-history',
queryParameters: {'size': 80}).timeout(const Duration(seconds: 8));
final currentData = current.data is Map ? current.data['data'] : null;
final historyData = history.data is Map ? history.data['data'] : null;
_lastLocation =
currentData is Map ? Map<String, dynamic>.from(currentData) : null;
final content = historyData is Map ? historyData['content'] : null;
_history = content is List
? content
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList()
: const [];
},
onError: (message) => _error = message,
fallback: 'Lokasi User belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Live Map',
subtitle: 'Lokasi terakhir User dan timeline perjalanan',
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: Column(
children: [
Expanded(
flex: 3,
child: _GuardianMapCard(
location: _lastLocation,
history: _history,
),
),
const SizedBox(height: 12),
Expanded(
flex: 2,
child: _TimelineList(history: _history, onRetry: _load),
),
],
),
);
}
}
class _GuardianMapCard extends StatelessWidget {
final Map<String, dynamic>? location;
final List<Map<String, dynamic>> history;
const _GuardianMapCard({required this.location, required this.history});
@override
Widget build(BuildContext context) {
final points = _pointsFrom(history);
final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521);
return ClipRRect(
borderRadius: BorderRadius.circular(20),
child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.walkguide.app',
),
if (points.length > 1)
PolylineLayer(
polylines: [
Polyline(
points: points,
strokeWidth: 4,
color: const Color(0xFF2563EB),
),
],
),
MarkerLayer(
markers: [
Marker(
point: center,
width: 54,
height: 54,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
),
child:
const Icon(Icons.person_pin_circle, color: Colors.white),
),
),
],
),
],
),
);
}
}
class _TimelineList extends StatelessWidget {
final List<Map<String, dynamic>> history;
final VoidCallback onRetry;
const _TimelineList({required this.history, required this.onRetry});
@override
Widget build(BuildContext context) {
final segments = _segments(history);
if (segments.isEmpty) {
return FeatureEmptyPanel(
icon: Icons.timeline,
title: 'Belum ada timeline',
message:
'Timeline akan muncul setelah User mengirim beberapa titik lokasi.',
action: OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
);
}
return ListView.separated(
itemCount: segments.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
);
}
}
class _TimelineCard extends StatelessWidget {
final _TimelineSegment segment;
const _TimelineCard({required this.segment});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(segment.title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(segment.subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
class _TimelineSegment {
final String title;
final String subtitle;
final IconData icon;
const _TimelineSegment({
required this.title,
required this.subtitle,
required this.icon,
});
}
List<_TimelineSegment> _segments(List<Map<String, dynamic>> history) {
final items = [...history]..sort((a, b) => _time(a).compareTo(_time(b)));
if (items.length < 2) return const [];
final chunks = <_TimelineSegment>[];
for (var i = 0; i < items.length - 1; i += 6) {
final start = items[i];
final end = items[math.min(i + 5, items.length - 1)];
final distance = _distanceBetween(start, end);
final speed = _avgSpeed(items.sublist(i, math.min(i + 6, items.length)));
final mode = speed > 2.8
? 'Naik kendaraan'
: speed > 1.1
? 'Berjalan cepat'
: 'Berjalan';
chunks.add(
_TimelineSegment(
title: '${_clock(start)} - ${_clock(end)} $mode',
subtitle:
'${distance.toStringAsFixed(0)} m, avg ${speed.toStringAsFixed(1)} m/s',
icon: speed > 2.8 ? Icons.directions_bike : Icons.directions_walk,
),
);
}
return chunks.reversed.toList();
}
List<LatLng> _pointsFrom(List<Map<String, dynamic>> history) {
return history.map(_pointFrom).whereType<LatLng>().toList();
}
LatLng? _pointFrom(Map<String, dynamic>? item) {
if (item == null) return null;
final lat = (item['lat'] as num?)?.toDouble();
final lng = (item['lng'] as num?)?.toDouble();
if (lat == null || lng == null) return null;
return LatLng(lat, lng);
}
DateTime _time(Map<String, dynamic> item) {
return DateTime.tryParse(item['createdAt']?.toString() ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
}
String _clock(Map<String, dynamic> item) {
final time = _time(item).toLocal();
return DateFormat('HH:mm').format(time);
}
double _distanceBetween(Map<String, dynamic> a, Map<String, dynamic> b) {
final start = _pointFrom(a);
final end = _pointFrom(b);
if (start == null || end == null) return 0;
return const Distance().as(LengthUnit.Meter, start, end);
}
double _avgSpeed(List<Map<String, dynamic>> items) {
final speeds = items
.map((e) => (e['speed'] as num?)?.toDouble())
.whereType<double>()
.where((speed) => speed >= 0)
.toList();
if (speeds.isEmpty) return 0;
return speeds.reduce((a, b) => a + b) / speeds.length;
}

View File

@ -1,18 +1,15 @@
export '../home/presentation/guardian_dashboard_screen.dart'
show GuardianDashboardScreen;
export 'guardian_activity_log_screen.dart'
show
GuardianActivityLogScreen;
export 'guardian_ai_config_screen.dart'
show
GuardianAiConfigScreen;
export 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
export '../screens.dart'
show
GuardianMapScreen,
GuardianSendNotifScreen,
GuardianVoiceCmdScreen,
GuardianShortcutScreen,
GuardianGeofenceScreen;
export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
export 'guardian_map_screen.dart' show GuardianMapScreen;
export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
export 'guardian_tools_screen.dart'
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;

View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget {
const GuardianSendNotifScreen({super.key});
@override
State<GuardianSendNotifScreen> createState() =>
_GuardianSendNotifScreenState();
}
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
final _message = TextEditingController();
bool _loading = false;
@override
void dispose() {
_message.dispose();
super.dispose();
}
Future<void> _send() async {
final message = _message.text.trim();
if (message.isEmpty) {
_snack('Tulis pesan dulu.');
return;
}
setState(() => _loading = true);
await runFriendlyAction(
() async {
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
'notifType': 'TEXT',
'content': message,
}).timeout(const Duration(seconds: 8));
_message.clear();
_snack('Notifikasi terkirim ke User.');
},
onError: _snack,
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
);
if (mounted) setState(() => _loading = false);
}
void _snack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Send Notification',
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _message,
minLines: 5,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading ? 'Sending...' : 'Send Message'),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,122 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSettingsScreen extends StatelessWidget {
const GuardianSettingsScreen({super.key});
Future<void> _logout(BuildContext context) async {
final appCubit = context.read<AppCubit>();
await sl<SecureStorage>().clearAll();
appCubit.clearSession();
unawaited(_notifyBackendLogout());
if (context.mounted) context.go('/login');
}
Future<void> _notifyBackendLogout() async {
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.post('/auth/logout')
.timeout(const Duration(seconds: 3));
},
onError: (_) {},
fallback: 'Logout server belum bisa dikonfirmasi.',
);
}
Future<void> _changeServer(BuildContext context) async {
await AppConstants.clearServerUrl();
await sl<SecureStorage>().clearAll();
if (context.mounted) context.go('/server-connect');
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: 'Guardian Settings',
subtitle: 'Account, pairing, AI tools, and server',
child: ListView(
children: [
_SettingsTile(
icon: Icons.link,
title: 'Pair User',
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
onTap: () => context.go('/guardian/pairing'),
),
_SettingsTile(
icon: Icons.speed,
title: 'AI Benchmark',
subtitle: 'Catat capture, inference, notification, dan TTS.',
onTap: () => context.go('/guardian/benchmark'),
),
_SettingsTile(
icon: Icons.tune,
title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
const SizedBox(height: 18),
OutlinedButton.icon(
onPressed: () => _changeServer(context),
icon: const Icon(Icons.dns_outlined),
label: const Text('Change server'),
),
const SizedBox(height: 8),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFDC2626),
),
onPressed: () => _logout(context),
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
],
),
);
}
}
class _SettingsTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: ListTile(
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}

View File

@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianVoiceCmdScreen extends StatelessWidget {
const GuardianVoiceCmdScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Voice Commands',
subtitle: 'Daftar voice command yang aktif untuk User',
endpoint: '/guardian/voice-commands',
icon: Icons.record_voice_over_outlined,
);
}
class GuardianShortcutScreen extends StatelessWidget {
const GuardianShortcutScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Hardware Shortcuts',
subtitle: 'Shortcut tombol untuk aksi cepat WalkGuide',
endpoint: '/guardian/shortcuts',
icon: Icons.keyboard_command_key_outlined,
);
}
class GuardianGeofenceScreen extends StatelessWidget {
const GuardianGeofenceScreen({super.key});
@override
Widget build(BuildContext context) => const _GuardianEndpointScreen(
title: 'Geofence',
subtitle: 'Area aman dan peringatan lokasi User',
endpoint: '/guardian/geofence',
icon: Icons.fence_outlined,
);
}
class _GuardianEndpointScreen extends StatefulWidget {
final String title;
final String subtitle;
final String endpoint;
final IconData icon;
const _GuardianEndpointScreen({
required this.title,
required this.subtitle,
required this.endpoint,
required this.icon,
});
@override
State<_GuardianEndpointScreen> createState() =>
_GuardianEndpointScreenState();
}
class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
bool _loading = true;
String? _error;
List<Map<String, dynamic>> _items = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.get(widget.endpoint,
queryParameters: {'size': 50}).timeout(const Duration(seconds: 8));
_items = _extractList(res.data);
},
onError: (message) => _error = message,
fallback: 'Data belum bisa dimuat. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic body) {
final data = body is Map ? body['data'] : body;
final raw = data is Map ? data['content'] : data;
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
@override
Widget build(BuildContext context) {
return FeaturePage(
title: widget.title,
subtitle: widget.subtitle,
trailing: IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? FeatureErrorPanel(message: _error!, onRetry: _load)
: _items.isEmpty
? FeatureEmptyPanel(
icon: widget.icon,
title: 'Belum ada data',
message:
'Data akan muncul setelah Guardian membuat konfigurasi atau User mulai memakai fitur ini.',
action: OutlinedButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => _EndpointCard(
icon: widget.icon,
item: _items[index],
),
),
),
);
}
}
class _EndpointCard extends StatelessWidget {
final IconData icon;
final Map<String, dynamic> item;
const _EndpointCard({required this.icon, required this.item});
@override
Widget build(BuildContext context) {
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
'Item #${item['id'] ?? '-'}';
final subtitle = _firstText(
item,
['description', 'action', 'shortcut', 'status', 'createdAt'],
) ??
'Data aktif';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: const Color(0xFF1D4ED8)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 3),
Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
],
),
),
],
),
);
}
}
String? _firstText(Map<String, dynamic> item, List<String> keys) {
for (final key in keys) {
final value = item[key]?.toString().trim();
if (value != null && value.isNotEmpty && value != 'null') return value;
}
return null;
}

View File

@ -9,6 +9,7 @@ import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
@ -38,12 +39,14 @@ class _Step {
required this.point});
}
// BLoC-lite state (plain ChangeNotifier to avoid heavy BLoC boilerplate
// while staying consistent with the rest of screens.dart approach)
// Navigation state is kept in a lightweight Cubit so the app uses one
// state-management family consistently.
enum _NavPhase { idle, locating, routing, navigating, error }
class _NavState extends ChangeNotifier {
class _NavState extends Cubit<int> {
_NavState() : super(0);
_NavPhase phase = _NavPhase.idle;
String statusText = 'Ketuk tombol lokasi atau cari tujuan.';
LatLng? currentPosition;
@ -59,9 +62,11 @@ class _NavState extends ChangeNotifier {
void _set(_NavPhase p, String status) {
phase = p;
statusText = status;
notifyListeners();
_notify();
}
void _notify() => emit(state + 1);
// locate
Future<bool> locate() async {
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
@ -85,7 +90,8 @@ class _NavState extends ChangeNotifier {
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
return false;
} catch (e) {
_set(_NavPhase.error, 'GPS error: $e');
_set(_NavPhase.error,
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
return false;
}
}
@ -211,10 +217,11 @@ class _NavState extends ChangeNotifier {
_set(_NavPhase.navigating,
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
notifyListeners();
_notify();
_startTracking();
} catch (e) {
_set(_NavPhase.error, 'Gagal mendapat rute: $e');
_set(_NavPhase.error,
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
}
}
@ -283,7 +290,7 @@ class _NavState extends ChangeNotifier {
currentPosition = LatLng(pos.latitude, pos.longitude);
_reportToBackend(pos);
_updateStep();
notifyListeners();
_notify();
});
}
@ -302,7 +309,7 @@ class _NavState extends ChangeNotifier {
final next = steps[currentStepIndex];
statusText = next.instruction;
sl<TtsService>().speak(next.instruction);
notifyListeners();
_notify();
}
}
}
@ -318,9 +325,9 @@ class _NavState extends ChangeNotifier {
}
@override
void dispose() {
Future<void> close() {
_posStream?.cancel();
super.dispose();
return super.close();
}
}
@ -339,6 +346,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
final _searchCtrl = TextEditingController();
final _searchFocus = FocusNode();
StreamSubscription<int>? _navSubscription;
List<_Place> _suggestions = const [];
bool _searchLoading = false;
bool _showSuggestions = false;
@ -348,7 +356,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
@override
void initState() {
super.initState();
_navState.addListener(_onStateChange);
_navSubscription = _navState.stream.listen((_) => _onStateChange());
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
}
@ -433,8 +441,8 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
@override
void dispose() {
_debounce?.cancel();
_navState.removeListener(_onStateChange);
_navState.dispose();
_navSubscription?.cancel();
_navState.close();
_searchCtrl.dispose();
_searchFocus.dispose();
super.dispose();

View File

@ -6,6 +6,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/tts_service.dart';
import '../../core/theme/app_colors.dart';
@ -36,24 +37,20 @@ class _NotificationScreenState extends State<NotificationScreen> {
_loading = true;
_error = null;
});
try {
final res = await _api
.get('/user/notifications')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
setState(() {
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
});
} on DioException catch (e) {
setState(() {
_error = e.response?.data?['message']?.toString() ??
'Gagal memuat notifikasi.';
});
} catch (e) {
setState(() => _error = 'Timeout / error: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await _api
.get('/user/notifications')
.timeout(const Duration(seconds: 10));
final list = _extractList(res.data);
setState(() {
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
});
},
onError: (message) => setState(() => _error = message),
fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
@ -67,35 +64,37 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
Future<void> _markRead(int id) async {
try {
await _api
.put('/user/notifications/$id/read')
.timeout(const Duration(seconds: 6));
setState(() {
final idx = _items.indexWhere((n) => n.id == id);
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
});
} catch (_) {}
await runFriendlyAction(
() async {
await _api
.put('/user/notifications/$id/read')
.timeout(const Duration(seconds: 6));
setState(() {
final idx = _items.indexWhere((n) => n.id == id);
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
});
},
onError: (_) {},
fallback: 'Gagal menandai notifikasi.',
);
}
Future<void> _markAllRead() async {
setState(() => _markingAll = true);
try {
await _api
.put('/user/notifications/mark-all-read')
.timeout(const Duration(seconds: 8));
setState(() {
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
});
_snack('Semua notifikasi ditandai sudah dibaca.');
} on DioException catch (e) {
_snack(e.response?.data?['message']?.toString() ??
'Gagal menandai semua dibaca.');
} catch (_) {
_snack('Timeout. Coba lagi.');
} finally {
if (mounted) setState(() => _markingAll = false);
}
await runFriendlyAction(
() async {
await _api
.put('/user/notifications/mark-all-read')
.timeout(const Duration(seconds: 8));
setState(() {
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
});
_snack('Semua notifikasi ditandai sudah dibaca.');
},
onError: _snack,
fallback: 'Gagal menandai semua dibaca.',
);
if (mounted) setState(() => _markingAll = false);
}
Future<void> _readAloud(_NotifItem notif) async {

View File

@ -2,10 +2,10 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.dart';
@ -14,7 +14,7 @@ import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_USER.
// - Tampilkan uniqueUserId mereka (besar, bisa di-copy/share).
// - Tampilkan pairing code sementara yang bisa di-copy/share.
// - Jika ada pending invite tampilkan nama Guardian + tombol Accept / Reject.
// - Jika sudah paired tampilkan info Guardian + tombol Unpair.
// ---------------------------------------------------------------------------
@ -28,6 +28,10 @@ class UserPairingScreen extends StatefulWidget {
class _UserPairingScreenState extends State<UserPairingScreen> {
String? _uniqueId;
String? _pairingCode;
DateTime? _pairingCodeExpiresAt;
int? _pairingCodeSeconds;
bool _regenerating = false;
@override
void initState() {
@ -38,36 +42,87 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
Future<void> _loadUniqueId() async {
var value = await sl<SecureStorage>().getUniqueUserId();
if (value == null || value.isEmpty) {
try {
final res = await sl<ApiClient>()
.dio
.get('/user/profile')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) value = data['uniqueUserId']?.toString();
} catch (_) {}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.get('/user/profile')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) value = data['uniqueUserId']?.toString();
},
onError: (_) {},
fallback: 'Profil belum bisa dimuat.',
);
}
if (mounted) setState(() => _uniqueId = value);
}
Future<void> _regeneratePairingCode() async {
setState(() => _regenerating = true);
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.dio
.post('/shared/pairing/code/regenerate')
.timeout(const Duration(seconds: 8));
_applyPairingCode(res.data['data']);
_snack(context, 'Pairing code baru sudah dibuat.');
},
onError: (message) => _snack(context, message),
fallback: 'Gagal membuat pairing code baru.',
);
if (mounted) setState(() => _regenerating = false);
}
void _applyPairingCode(dynamic raw) {
if (raw is! Map) return;
final expires = DateTime.tryParse(raw['expiresAt']?.toString() ?? '');
setState(() {
_pairingCode = raw['pairingCode']?.toString();
_pairingCodeExpiresAt = expires;
_pairingCodeSeconds = int.tryParse(raw['expiresInSeconds'].toString());
});
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pairing',
subtitle: 'Bagikan Unique ID ini ke Guardian untuk terhubung.',
subtitle: 'Bagikan pairing code sementara ini ke Guardian.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_uniqueId == null || _uniqueId!.isEmpty)
if (_pairingCode == null || _pairingCode!.isEmpty)
_InfoCard(
title: 'Your Unique ID',
value: 'Login sebagai User untuk melihat ID',
icon: Icons.qr_code_2)
title: 'Pairing Code',
value: 'Tap Generate',
icon: Icons.qr_code_2,
helper:
'Kode dibuat saat dibutuhkan, berlaku sementara, dan bisa dibuat ulang kapan saja.')
else
_InfoCard(
title: 'Your Unique ID',
value: _uniqueId!,
icon: Icons.qr_code_2),
title: 'Pairing Code',
value: _pairingCode!,
icon: Icons.qr_code_2,
helper:
'Valid ${_formatRemaining(_pairingCodeSeconds, _pairingCodeExpiresAt)}. Kode ini akan berubah dan kadaluarsa otomatis.'),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _regenerating ? null : _regeneratePairingCode,
icon: _regenerating
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.autorenew),
label: Text(_regenerating ? 'Generating...' : 'Generate New Code'),
),
if (_uniqueId != null && _uniqueId!.isNotEmpty) ...[
const SizedBox(height: 8),
Text('Account ID: $_uniqueId',
style: const TextStyle(color: Color(0xFF64748B), fontSize: 12)),
],
const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true),
],
@ -81,7 +136,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
// ---------------------------------------------------------------------------
//
// Ditampilkan ke akun ROLE_GUARDIAN.
// - Input field 12-char User ID.
// - Input field 8-char temporary pairing code.
// - Tombol "Send Invite".
// - Status card: jika sudah paired info User + tombol Unpair.
// Jika pending waiting state.
@ -100,51 +155,46 @@ class _GuardianPairingScreenState extends State<GuardianPairingScreen> {
int _statusReload = 0;
Future<void> _invite() async {
final uniqueId = _id.text.trim().toUpperCase();
if (uniqueId.isEmpty || uniqueId.length != 12) {
_snack(context, 'Unique ID harus 12 karakter dari akun User.');
final pairingCode = _id.text.trim().toUpperCase();
if (pairingCode.isEmpty || pairingCode.length != 8) {
_snack(context, 'Pairing code harus 8 karakter dari akun User.');
return;
}
setState(() => _loading = true);
try {
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
data: {'uniqueUserId': uniqueId}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
setState(() => _statusReload++);
} on DioException catch (e) {
_snack(
context,
_friendlyDioMessage(e,
fallback:
'Invite gagal. Pastikan kamu login sebagai Guardian dan ID User benar.'));
} on TimeoutException {
_snack(context,
'Server terlalu lama merespons invite. Coba Refresh status, jangan klik berkali-kali.');
} catch (e) {
_snack(context, 'Invite gagal: $e');
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final res = await sl<ApiClient>().dio.post('/shared/pairing/invite',
data: {
'pairingCode': pairingCode
}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
'Invite terkirim. Minta User buka menu Pairing lalu Accept.');
setState(() => _statusReload++);
},
onError: (message) => _snack(context, message),
fallback:
'Invite gagal. Pastikan kamu login sebagai Guardian dan pairing code benar.',
);
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return _Page(
title: 'Pair User',
subtitle: 'Masukkan 12 karakter Unique ID milik User.',
subtitle: 'Masukkan 8 karakter pairing code aktif dari User.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _id,
textCapitalization: TextCapitalization.characters,
maxLength: 12,
maxLength: 8,
decoration: const InputDecoration(
labelText: 'Unique User ID',
hintText: 'Contoh: AB1C2D3E4F5G',
labelText: 'Pairing Code',
hintText: 'Contoh: A7K9Q2M4',
prefixIcon: Icon(Icons.link),
)),
FilledButton.icon(
@ -192,50 +242,42 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
Future<void> _load() async {
setState(() => _loading = true);
try {
final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
await runFriendlyAction(
() async {
final token = await sl<SecureStorage>().getAccessToken();
if (token == null || token.isEmpty) {
_active = false;
_data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.';
return;
}
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.from(data) : null;
_active = data is Map && data['status'] == 'ACTIVE';
if (data is Map && data['status'] == 'ACTIVE') {
_active = true;
_status =
'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.';
} else if (data is Map && data['status'] == 'PENDING') {
_status = widget.allowUserResponse
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
} else {
_status = 'Belum pairing. Bagikan pairing code aktif ke Guardian.';
}
},
onError: (message) {
_active = false;
_data = null;
_status = 'Belum login. Login dulu supaya status pairing bisa dicek.';
return;
}
final res = await sl<ApiClient>()
.dio
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
_data = data is Map ? Map<String, dynamic>.from(data) : null;
_active = data is Map && data['status'] == 'ACTIVE';
if (data is Map && data['status'] == 'ACTIVE') {
_active = true;
_status =
'Sudah pairing dengan ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'akun lain'}.';
} else if (data is Map && data['status'] == 'PENDING') {
_status = widget.allowUserResponse
? 'Ada undangan pairing dari ${data['pairedWithName'] ?? data['pairedWithEmail'] ?? 'Guardian'}.'
: 'Invite sudah terkirim. Tunggu User membuka menu Pairing lalu Accept.';
} else {
_status = 'Belum pairing. Bagikan Unique ID kamu ke Guardian.';
}
} on DioException catch (e) {
_active = false;
_data = null;
_status = _friendlyDioMessage(e,
fallback:
'Belum bisa mengecek server, tapi Unique ID tetap bisa dibagikan.');
} on TimeoutException {
_active = false;
_data = null;
_status =
'Server terlalu lama merespons status pairing. Cek backend masih running dan URL server benar.';
} catch (e) {
_active = false;
_data = null;
_status = 'Status pairing belum bisa dicek: $e';
} finally {
if (mounted) setState(() => _loading = false);
}
_status = message;
},
fallback: 'Status pairing belum bisa dicek. Coba refresh lagi.',
);
if (mounted) setState(() => _loading = false);
}
Future<void> _respond(bool accept) async {
@ -245,25 +287,23 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
return;
}
setState(() => _responding = true);
try {
final res =
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
'pairingId': pairingId,
'accept': accept,
}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
await _load();
} on DioException catch (e) {
_snack(context,
_friendlyDioMessage(e, fallback: 'Gagal merespons pairing.'));
} on TimeoutException {
_snack(context, 'Server terlalu lama merespons pairing.');
} finally {
if (mounted) setState(() => _responding = false);
}
await runFriendlyAction(
() async {
final res =
await sl<ApiClient>().dio.post('/shared/pairing/respond', data: {
'pairingId': pairingId,
'accept': accept,
}).timeout(const Duration(seconds: 8));
_snack(
context,
res.data['message']?.toString() ??
(accept ? 'Pairing diterima.' : 'Pairing ditolak.'));
await _load();
},
onError: (message) => _snack(context, message),
fallback: 'Gagal merespons pairing.',
);
if (mounted) setState(() => _responding = false);
}
Future<void> _unpair() async {
@ -287,19 +327,19 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
);
if (confirmed != true) return;
setState(() => _responding = true);
try {
await sl<ApiClient>()
.dio
.delete('/shared/pairing/unpair')
.timeout(const Duration(seconds: 8));
_snack(context, 'Pairing telah diputus.');
await _load();
} on DioException catch (e) {
_snack(
context, _friendlyDioMessage(e, fallback: 'Gagal memutus pairing.'));
} finally {
if (mounted) setState(() => _responding = false);
}
await runFriendlyAction(
() async {
await sl<ApiClient>()
.dio
.delete('/shared/pairing/unpair')
.timeout(const Duration(seconds: 8));
_snack(context, 'Pairing telah diputus.');
await _load();
},
onError: (message) => _snack(context, message),
fallback: 'Gagal memutus pairing.',
);
if (mounted) setState(() => _responding = false);
}
@override
@ -424,8 +464,12 @@ class _InfoCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final String? helper;
const _InfoCard(
{required this.title, required this.value, required this.icon});
{required this.title,
required this.value,
required this.icon,
this.helper});
@override
Widget build(BuildContext context) {
@ -445,7 +489,13 @@ class _InfoCard extends StatelessWidget {
Text(title),
SelectableText(value,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800))
fontSize: 22, fontWeight: FontWeight.w800)),
if (helper != null) ...[
const SizedBox(height: 4),
Text(helper!,
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12)),
],
])),
],
),
@ -464,21 +514,11 @@ void _snack(BuildContext context, String message) {
}
}
String _friendlyDioMessage(DioException e, {required String fallback}) {
final data = e.response?.data;
if (data is Map && data['message'] != null) return data['message'].toString();
if (e.response?.statusCode == 401) {
return 'Sesi login habis. Logout lalu login ulang.';
}
if (e.response?.statusCode == 403) {
return 'Role akun tidak cocok untuk fitur ini. Pastikan User/Guardian benar.';
}
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout) {
return 'Server terlalu lama merespons. Pastikan backend masih running dan URL server benar.';
}
if (e.type == DioExceptionType.connectionError) {
return 'Tidak bisa ke server. Di Chrome pakai http://localhost:8080. Di HP pakai IP laptop/server, bukan localhost.';
}
return fallback;
String _formatRemaining(int? seconds, DateTime? expiresAt) {
final value = seconds ?? expiresAt?.difference(DateTime.now()).inSeconds;
if (value == null || value <= 0) return 'sudah kadaluarsa';
final minutes = value ~/ 60;
final secs = value % 60;
if (minutes <= 0) return '$secs detik';
return '$minutes menit ${secs.toString().padLeft(2, '0')} detik';
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
// ---------------------------------------------------------------------------
@ -37,21 +38,22 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
_ok = false;
_message = null;
});
try {
final clean = AppConstants.normalizeServerUrl(_url.text);
final res = await Dio(BaseOptions(
connectTimeout: AppConstants.pingTimeout,
receiveTimeout: AppConstants.pingTimeout,
)).get('$clean/api/v1/auth/ping');
_ok = res.statusCode == 200 && res.data['success'] == true;
_message = _ok
? 'Server aktif dan siap dipakai.'
: 'Server merespons dengan format tidak valid.';
} catch (e) {
_message = 'Tidak bisa terhubung. Periksa URL dan jaringan.';
} finally {
if (mounted) setState(() => _loading = false);
}
await runFriendlyAction(
() async {
final clean = AppConstants.normalizeServerUrl(_url.text);
final res = await Dio(BaseOptions(
connectTimeout: AppConstants.pingTimeout,
receiveTimeout: AppConstants.pingTimeout,
)).get('$clean/api/v1/auth/ping');
_ok = res.statusCode == 200 && res.data['success'] == true;
_message = _ok
? 'Server aktif dan siap dipakai.'
: 'Server merespons dengan format tidak valid.';
},
onError: (message) => _message = message,
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
);
if (mounted) setState(() => _loading = false);
}
Future<void> _continue() async {

View File

@ -12,6 +12,7 @@ import 'package:go_router/go_router.dart';
import '../../app/app_cubit.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart';
@ -78,40 +79,46 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
}
Future<void> _loadSettings() async {
try {
final res =
await _api.get('/user/settings').timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (data is Map) {
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
_ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0;
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
}
} catch (_) {
// offline: tetap pakai default / nilai lokal
}
await runFriendlyAction(
() async {
final res = await _api
.get('/user/settings')
.timeout(const Duration(seconds: 6));
final data = res.data['data'];
if (data is Map) {
_ttsLanguage = data['ttsLanguage']?.toString() ?? 'id-ID';
_ttsPitch = (data['ttsPitch'] as num?)?.toDouble() ?? 1.0;
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true;
}
},
onError: (_) {},
fallback: 'Settings belum bisa dimuat.',
);
}
Future<void> _loadPairing() async {
try {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) {
_paired = data['status'] == 'ACTIVE';
final partner = data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
_pairingStatus = _paired
? 'Terhubung dengan $partner'
: data['status'] == 'PENDING'
? 'Menunggu konfirmasi Guardian'
: 'Belum paired';
}
} catch (_) {
_pairingStatus = 'Tidak bisa cek status';
}
await runFriendlyAction(
() async {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) {
_paired = data['status'] == 'ACTIVE';
final partner =
data['pairedWithName'] ?? data['pairedWithEmail'] ?? '';
_pairingStatus = _paired
? 'Terhubung dengan $partner'
: data['status'] == 'PENDING'
? 'Menunggu konfirmasi Guardian'
: 'Belum paired';
}
},
onError: (_) => _pairingStatus = 'Tidak bisa cek status',
fallback: 'Tidak bisa cek status',
);
}
Future<void> _save() async {
@ -122,34 +129,28 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
await sl<HapticService>().success();
}
try {
await _api.put('/user/settings', data: {
'ttsLanguage': _ttsLanguage,
'ttsPitch': _ttsPitch,
'ttsSpeed': _ttsSpeed,
'warnNoGuardian': _warnNoGuardian,
'hapticEnabled': _hapticEnabled,
}).timeout(const Duration(seconds: 8));
_snack('Settings tersimpan.');
sl<TtsService>().speak('Settings disimpan.');
} on DioException catch (e) {
final msg = e.response?.data['message']?.toString() ??
'Server tidak merespons, settings lokal sudah diterapkan.';
_snack(msg);
} catch (_) {
_snack('Settings lokal sudah diterapkan, gagal sync ke server.');
} finally {
if (mounted) setState(() => _saving = false);
}
await runFriendlyAction(
() async {
await _api.put('/user/settings', data: {
'ttsLanguage': _ttsLanguage,
'ttsPitch': _ttsPitch,
'ttsSpeed': _ttsSpeed,
'warnNoGuardian': _warnNoGuardian,
'hapticEnabled': _hapticEnabled,
}).timeout(const Duration(seconds: 8));
_snack('Settings tersimpan.');
sl<TtsService>().speak('Settings disimpan.');
},
onError: _snack,
fallback: 'Settings lokal sudah diterapkan, gagal sync ke server.',
);
if (mounted) setState(() => _saving = false);
}
Future<void> _logout() async {
await sl<SecureStorage>().clearAll();
context.read<AppCubit>().clearSession();
_api
.post('/auth/logout')
.timeout(const Duration(seconds: 3))
.ignore();
_api.post('/auth/logout').timeout(const Duration(seconds: 3)).ignore();
if (mounted) context.go('/login');
}

View File

@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart';
@ -94,15 +95,17 @@ class _SosScreenState extends State<SosScreen>
// API Calls
Future<Position?> _getPosition() async {
try {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) return null;
return await Geolocator.getCurrentPosition()
.timeout(const Duration(seconds: 6));
} catch (_) {
return null;
}
return runFriendly<Position>(
() async {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) return null;
return await Geolocator.getCurrentPosition()
.timeout(const Duration(seconds: 6));
},
onError: (_) {},
fallback: 'Lokasi belum bisa dibaca.',
);
}
Future<void> _loadHistory() async {
@ -110,26 +113,24 @@ class _SosScreenState extends State<SosScreen>
_historyLoading = true;
_historyError = null;
});
try {
final res = await _api.get('/user/sos-events',
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
final data = res.data['data'];
final content = data is Map ? data['content'] : null;
final items = content is List
? content
.whereType<Map>()
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e)))
.toList()
: <_SosEvent>[];
setState(() => _events = items);
} on DioException catch (e) {
final msg = e.response?.data?['message']?.toString();
setState(() => _historyError = msg ?? 'Tidak bisa memuat riwayat SOS.');
} catch (_) {
setState(() => _historyError = 'Tidak bisa memuat riwayat SOS.');
} finally {
if (mounted) setState(() => _historyLoading = false);
}
await runFriendlyAction(
() async {
final res = await _api.get('/user/sos-events',
queryParameters: {'size': 10}).timeout(const Duration(seconds: 8));
final data = res.data['data'];
final content = data is Map ? data['content'] : null;
final items = content is List
? content
.whereType<Map>()
.map((e) => _SosEvent.fromMap(Map<String, dynamic>.from(e)))
.toList()
: <_SosEvent>[];
setState(() => _events = items);
},
onError: (message) => setState(() => _historyError = message),
fallback: 'Tidak bisa memuat riwayat SOS.',
);
if (mounted) setState(() => _historyLoading = false);
}
Future<void> _confirmAndSend() async {
@ -178,25 +179,23 @@ class _SosScreenState extends State<SosScreen>
Future<void> _sendSos() async {
setState(() => _sending = true);
try {
final pos = await _getPosition();
await _api.post('/user/sos', data: {
'triggerType': 'BUTTON',
'lat': pos?.latitude,
'lng': pos?.longitude,
});
await sl<HapticService>().sosTriggered();
sl<TtsService>().speak('SOS terkirim ke Guardian.');
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
await _loadHistory();
} on DioException catch (e) {
final msg = e.response?.data?['message']?.toString() ?? 'Gagal kirim SOS';
_snack(msg);
} catch (e) {
_snack('Gagal kirim SOS: $e');
} finally {
if (mounted) setState(() => _sending = false);
}
await runFriendlyAction(
() async {
final pos = await _getPosition();
await _api.post('/user/sos', data: {
'triggerType': 'BUTTON',
'lat': pos?.latitude,
'lng': pos?.longitude,
});
await sl<HapticService>().sosTriggered();
sl<TtsService>().speak('SOS terkirim ke Guardian.');
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
await _loadHistory();
},
onError: _snack,
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
);
if (mounted) setState(() => _sending = false);
}
// Build

View File

@ -1,17 +1,14 @@
// ignore_for_file: use_build_context_synchronously, deprecated_member_use
import 'dart:async';
import 'dart:convert';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/location_reporter_service.dart';
@ -36,6 +33,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
bool _processingFrame = false;
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
@override
void dispose() {
@ -59,7 +57,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
setState(() {
_active = next;
_status = next ? 'Camera stream active. YOLO ready.' : 'Stopped';
_status = next ? _activeStatusText() : 'Stopped';
});
await sl<ApiClient>()
.dio
@ -67,13 +65,30 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
}
String _activeStatusText() {
final detector = sl<YoloDetector>();
if (kIsWeb) {
return 'Camera preview aktif, tapi YOLO TFLite tidak jalan di Chrome/web. Test AI harus lewat APK Android.';
}
if (!detector.isReady) {
return detector.lastError == null
? 'YOLO model belum siap. Pastikan assets/models/yolov8n.tflite ada.'
: 'YOLO model error: ${detector.lastError}';
}
return 'Camera stream active. YOLO ready. Waiting for camera frames...';
}
Future<void> _startCamera() async {
if (_camera != null) return;
try {
final cameras = await availableCameras();
if (cameras.isEmpty) return;
final backCamera = cameras.firstWhere(
(camera) => camera.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(
cameras.first,
backCamera,
ResolutionPreset.medium,
enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420,
@ -86,11 +101,13 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
try {
await controller.startImageStream(_onCameraImage);
} catch (_) {
// Preview still works; manual Demo Detect remains available.
setState(() => _status = kIsWeb
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
: 'Camera preview aktif, tapi image stream belum tersedia.');
}
setState(() => _camera = controller);
} catch (_) {
setState(() => _status = 'Camera unavailable. Demo mode active.');
setState(() => _status = 'Camera unavailable.');
}
}
@ -118,24 +135,28 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
Future<void> _runYolo(CameraImage image) async {
final detection = await sl<YoloDetector>().detect(image);
if (detection == null || !mounted) return;
final detector = sl<YoloDetector>();
final detection = await detector.detect(image, confidenceThreshold: 0.25);
if (detection == null || !mounted) {
final now = DateTime.now();
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
_lastModelWarningAt = now;
setState(() => _status = detector.isReady
? 'Scanning... ${detector.diagnosticsSummary}'
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
}
return;
}
await _handleDetection(detection);
}
Future<void> _simulateObstacle() async {
final detection = await sl<YoloDetector>().detectFallback();
if (detection == null) return;
await _handleDetection(detection, forceAlert: true);
}
Future<void> _handleDetection(
DetectionResult detection, {
bool forceAlert = false,
}) async {
_lastDetection = detection;
setState(() => _status =
'Obstacle: ${detection.label} ${detection.directionName} ${detection.estimatedDistance}');
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
final now = DateTime.now();
if (!forceAlert &&
@ -187,6 +208,12 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
const Center(
child: Icon(Icons.videocam_outlined,
color: Colors.white30, size: 96)),
if (_lastDetection?.box != null)
Positioned.fill(
child: CustomPaint(
painter: _DetectionOverlayPainter(_lastDetection!),
),
),
Positioned(
top: 16,
left: 16,
@ -199,7 +226,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
left: 16,
child: _Pill(
text:
'${_lastDetection!.label} ${_lastDetection!.directionName}',
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
color: Colors.redAccent),
),
Positioned(
@ -223,12 +250,6 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
onPressed: _toggle,
icon: Icon(_active ? Icons.stop : Icons.play_arrow),
label: Text(_active ? 'Stop' : 'Start'))),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: _simulateObstacle,
icon: const Icon(Icons.radar),
label: const Text('Demo Detect'))),
],
),
],
@ -237,229 +258,74 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
}
}
// ---------------------------------------------------------------------------
// AiBenchmarkScreen
// ---------------------------------------------------------------------------
class AiBenchmarkScreen extends StatefulWidget {
const AiBenchmarkScreen({super.key});
class _DetectionOverlayPainter extends CustomPainter {
final DetectionResult detection;
const _DetectionOverlayPainter(this.detection);
@override
State<AiBenchmarkScreen> createState() => _AiBenchmarkScreenState();
}
void paint(Canvas canvas, Size size) {
final box = detection.box;
if (box == null) return;
class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
static const _runsKey = 'ai_benchmark_runs';
List<String> _models = const [];
String _selectedModel = AppConstants.yoloModelPath;
List<Map<String, dynamic>> _runs = const [];
bool _running = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final models = await _discoverTfliteModels();
final selected = await AppConstants.getSelectedYoloModelPath();
final prefs = await SharedPreferences.getInstance();
final rawRuns = prefs.getStringList(_runsKey) ?? const [];
setState(() {
_models = models.isEmpty ? [selected] : models;
_selectedModel = models.contains(selected) ? selected : _models.first;
_runs = rawRuns
.map((e) => Map<String, dynamic>.from(jsonDecode(e) as Map))
.toList()
.reversed
.toList();
});
}
Future<void> _setModel(String? value) async {
if (value == null) return;
await AppConstants.setSelectedYoloModelPath(value);
sl<YoloDetector>().dispose();
await sl<YoloDetector>().init();
setState(() => _selectedModel = value);
_snack(context, 'Model aktif: ${value.split('/').last}');
}
Future<void> _runBenchmark() async {
setState(() => _running = true);
final started = DateTime.now();
final captureMs = await _measureCapture();
final inferenceWatch = Stopwatch()..start();
String label = 'person';
String direction = 'CENTER';
String distance = 'Demo';
final modelLoaded = sl<YoloDetector>().isReady;
final detection = await sl<YoloDetector>().detectSynthetic();
if (detection != null) {
label = detection.label;
direction = detection.directionName;
distance = detection.estimatedDistance;
}
inferenceWatch.stop();
final notifWatch = Stopwatch()..start();
final text = 'Obstacle $label di $direction, jarak $distance';
notifWatch.stop();
final ttsWatch = Stopwatch()..start();
try {
await sl<TtsService>()
.speakImmediate(text)
.timeout(const Duration(seconds: 3));
} catch (_) {}
ttsWatch.stop();
final run = {
'time': started.toIso8601String(),
'model': _selectedModel,
'modelLoaded': modelLoaded,
'captureMs': captureMs,
'inferenceMs': inferenceWatch.elapsedMilliseconds,
'notificationMs': notifWatch.elapsedMicroseconds / 1000,
'ttsMs': ttsWatch.elapsedMilliseconds,
'label': label,
'direction': direction,
final sx = size.width / ObstacleAnalyzer.frameWidth;
final sy = size.height / ObstacleAnalyzer.frameHeight;
final rect = Rect.fromLTRB(
box.left * sx,
box.top * sy,
box.right * sx,
box.bottom * sy,
);
final color = switch (detection.direction) {
ObstacleDirection.center => const Color(0xFFEF4444),
ObstacleDirection.left => const Color(0xFFF59E0B),
ObstacleDirection.right => const Color(0xFFF59E0B),
};
final prefs = await SharedPreferences.getInstance();
final next = [
jsonEncode(run),
...((prefs.getStringList(_runsKey) ?? const []).take(24))
];
await prefs.setStringList(_runsKey, next);
if (mounted) {
setState(() {
_runs = [run, ..._runs].take(25).toList();
_running = false;
});
}
}
Future<int> _measureCapture() async {
final watch = Stopwatch()..start();
CameraController? controller;
try {
final cameras =
await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) {
controller = CameraController(cameras.first, ResolutionPreset.low,
enableAudio: false);
await controller.initialize().timeout(const Duration(seconds: 5));
await controller.takePicture().timeout(const Duration(seconds: 5));
}
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 16));
} finally {
await controller?.dispose();
}
watch.stop();
return watch.elapsedMilliseconds;
}
Future<void> _clearRuns() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_runsKey);
setState(() => _runs = const []);
}
@override
Widget build(BuildContext context) {
final hasRealModel = _models.any((model) => model.endsWith('.tflite'));
return _Page(
title: 'AI Benchmark',
subtitle: 'Capture, model, notification text, and TTS timing',
child: ListView(
children: [
DropdownButtonFormField<String>(
value: _selectedModel,
decoration: const InputDecoration(labelText: 'Model file'),
items: [
for (final model in _models)
DropdownMenuItem(
value: model, child: Text(model.split('/').last))
],
onChanged: _setModel,
),
if (!hasRealModel) ...[
const SizedBox(height: 10),
const _StatusBox(
success: false,
message:
'Belum ada file .tflite di assets/models. Taruh 3-5 model di folder itu, lalu restart app untuk muncul di dropdown.',
),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _running ? null : _runBenchmark,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.play_arrow),
label: Text(_running ? 'Running benchmark...' : 'Run benchmark'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _clearRuns,
icon: const Icon(Icons.delete_outline),
label: const Text('Clear log')),
const SizedBox(height: 16),
for (final run in _runs) _BenchmarkCard(run: run),
if (_runs.isEmpty)
const _EmptyPanel(
icon: Icons.speed,
title: 'Belum Ada Log',
message:
'Klik Run benchmark untuk mencatat waktu capture, model/inference, text notification, dan TTS.',
),
],
),
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
Paint()
..color = color.withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
Paint()
..color = color
..strokeWidth = 3
..style = PaintingStyle.stroke,
);
}
}
class _BenchmarkCard extends StatelessWidget {
final Map<String, dynamic> run;
const _BenchmarkCard({required this.run});
@override
Widget build(BuildContext context) {
final time = DateTime.tryParse(run['time']?.toString() ?? '')?.toLocal();
return Card(
elevation: 0,
margin: const EdgeInsets.only(bottom: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE2E8F0))),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
time == null
? 'Benchmark run'
: '${_two(time.hour)}:${_two(time.minute)}:${_two(time.second)}',
style: const TextStyle(fontWeight: FontWeight.w800)),
const SizedBox(height: 8),
Text(
'Model: ${run['model'].toString().split('/').last} (${run['modelLoaded'] == true ? 'loaded' : 'fallback'})'),
Text('Capture: ${run['captureMs']} ms'),
Text('Model/inference: ${run['inferenceMs']} ms'),
Text('Notification text: ${run['notificationMs']} ms'),
Text('TTS start: ${run['ttsMs']} ms'),
Text('Result: ${run['label']} ${run['direction']}'),
],
final label =
'${ObstacleAnalyzer.spokenLabel(detection.label)} ${(detection.confidence * 100).round()}% ${detection.directionName}';
final textPainter = TextPainter(
text: TextSpan(
text: label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
)..layout(maxWidth: size.width - 24);
final labelRect = Rect.fromLTWH(
rect.left.clamp(8.0, size.width - textPainter.width - 16),
(rect.top - textPainter.height - 10).clamp(8.0, size.height - 32),
textPainter.width + 14,
textPainter.height + 8,
);
canvas.drawRRect(
RRect.fromRectAndRadius(labelRect, const Radius.circular(8)),
Paint()..color = color.withValues(alpha: 0.92),
);
textPainter.paint(canvas, labelRect.topLeft + const Offset(7, 4));
}
@override
bool shouldRepaint(covariant _DetectionOverlayPainter oldDelegate) {
return oldDelegate.detection != detection;
}
}
@ -533,88 +399,3 @@ class _Pill extends StatelessWidget {
);
}
}
class _StatusBox extends StatelessWidget {
final bool success;
final String message;
const _StatusBox({required this.success, required this.message});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(message,
style: TextStyle(
color: success
? const Color(0xFF166534)
: const Color(0xFF991B1B))),
),
);
}
}
class _EmptyPanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _EmptyPanel(
{required this.icon, required this.title, required this.message});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 180),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0))),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: const Color(0xFF64748B), size: 48),
const SizedBox(height: 12),
Text(title,
style:
const TextStyle(fontWeight: FontWeight.w800, fontSize: 18)),
const SizedBox(height: 6),
Text(message, textAlign: TextAlign.center),
],
),
);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Future<List<String>> _discoverTfliteModels() async {
try {
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys
.where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList()
..sort();
return models;
} catch (_) {
return const [];
}
}
String _two(int value) => value.toString().padLeft(2, '0');
void _snack(BuildContext context, String message) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}

View File

@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
class FeaturePage extends StatelessWidget {
final String title;
final String subtitle;
final Widget child;
final Widget? trailing;
const FeaturePage({
super.key,
required this.title,
required this.subtitle,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 16),
Expanded(child: child),
],
),
),
);
}
}
class FeatureEmptyPanel extends StatelessWidget {
final IconData icon;
final String title;
final String message;
final Widget? action;
const FeatureEmptyPanel({
super.key,
required this.icon,
required this.title,
required this.message,
this.action,
});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: const Color(0xFF64748B)),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const SizedBox(height: 6),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF64748B), height: 1.35),
),
if (action != null) ...[
const SizedBox(height: 16),
action!,
],
],
),
),
);
}
}
class FeatureErrorPanel extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const FeatureErrorPanel({
super.key,
required this.message,
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 420),
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFFECACA)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Color(0xFFDC2626), size: 34),
const SizedBox(height: 10),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF991B1B), height: 1.35),
),
if (onRetry != null) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
],
),
),
);
}
}