ADD ALOT OF POLISHING AND UPDATES...
This commit is contained in:
parent
2e3ecdf1d1
commit
b8ad8df993
11
.gitignore
vendored
11
.gitignore
vendored
@ -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
|
||||
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
walkguide-mobile/walkguide_app/.gitignore
vendored
10
walkguide-mobile/walkguide_app/.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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()),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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');
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
export '../../screens.dart';
|
||||
export '../login_screen.dart';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
@ -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 {
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user