Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..
This commit is contained in:
parent
b8ad8df993
commit
a629357e8c
@ -98,6 +98,13 @@ public class GuardianController {
|
|||||||
"SOS diakui"));
|
"SOS diakui"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/sos/{id}/resolve")
|
||||||
|
public ResponseEntity<ApiResponse<SosEventResponse>> resolveSos(@PathVariable Long id) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
|
sosService.resolveSos(SecurityHelper.getCurrentUserId(), id),
|
||||||
|
"SOS ditangani"));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/ai-config")
|
@GetMapping("/ai-config")
|
||||||
public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() {
|
public ResponseEntity<ApiResponse<AiConfigResponse>> getAiConfig() {
|
||||||
// Guardian lihat config user yang dipair
|
// Guardian lihat config user yang dipair
|
||||||
@ -117,7 +124,7 @@ public class GuardianController {
|
|||||||
@GetMapping("/voice-commands")
|
@GetMapping("/voice-commands")
|
||||||
public ResponseEntity<ApiResponse<?>> getVoiceCommands() {
|
public ResponseEntity<ApiResponse<?>> getVoiceCommands() {
|
||||||
return ResponseEntity.ok(ApiResponse.ok(
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
voiceCommandService.getAll(SecurityHelper.getCurrentUserId()),
|
voiceCommandService.getAllForGuardian(SecurityHelper.getCurrentUserId()),
|
||||||
"Voice commands"));
|
"Voice commands"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,10 +139,18 @@ public class GuardianController {
|
|||||||
@GetMapping("/shortcuts")
|
@GetMapping("/shortcuts")
|
||||||
public ResponseEntity<ApiResponse<?>> getShortcuts() {
|
public ResponseEntity<ApiResponse<?>> getShortcuts() {
|
||||||
return ResponseEntity.ok(ApiResponse.ok(
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
hardwareShortcutService.getAll(SecurityHelper.getCurrentUserId()),
|
hardwareShortcutService.getAllForGuardian(SecurityHelper.getCurrentUserId()),
|
||||||
"Hardware shortcuts"));
|
"Hardware shortcuts"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/shortcuts")
|
||||||
|
public ResponseEntity<ApiResponse<HardwareShortcutResponse>> updateShortcut(
|
||||||
|
@RequestBody HardwareShortcutUpdateRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
|
hardwareShortcutService.updateByGuardian(SecurityHelper.getCurrentUserId(), req),
|
||||||
|
"Hardware shortcut diperbarui"));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/geofence")
|
@GetMapping("/geofence")
|
||||||
public ResponseEntity<ApiResponse<GeofenceResponse>> getGeofence() {
|
public ResponseEntity<ApiResponse<GeofenceResponse>> getGeofence() {
|
||||||
return ResponseEntity.ok(ApiResponse.ok(
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
|
|||||||
@ -3,8 +3,6 @@ package com.walkguide.controller;
|
|||||||
import com.walkguide.dto.ApiResponse;
|
import com.walkguide.dto.ApiResponse;
|
||||||
import com.walkguide.dto.request.*;
|
import com.walkguide.dto.request.*;
|
||||||
import com.walkguide.dto.response.*;
|
import com.walkguide.dto.response.*;
|
||||||
import com.walkguide.enums.ActivityLogType;
|
|
||||||
import com.walkguide.repository.UserRepository;
|
|
||||||
import com.walkguide.security.SecurityHelper;
|
import com.walkguide.security.SecurityHelper;
|
||||||
import com.walkguide.service.*;
|
import com.walkguide.service.*;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -29,21 +27,13 @@ public class UserController {
|
|||||||
private final AiConfigService aiConfigService;
|
private final AiConfigService aiConfigService;
|
||||||
private final VoiceCommandService voiceCommandService;
|
private final VoiceCommandService voiceCommandService;
|
||||||
private final HardwareShortcutService hardwareShortcutService;
|
private final HardwareShortcutService hardwareShortcutService;
|
||||||
private final UserRepository userRepository;
|
private final UserService userService;
|
||||||
|
|
||||||
@GetMapping("/profile")
|
@GetMapping("/profile")
|
||||||
public ResponseEntity<ApiResponse<?>> getProfile() {
|
public ResponseEntity<ApiResponse<?>> getProfile() {
|
||||||
Long userId = SecurityHelper.getCurrentUserId();
|
return ResponseEntity.ok(ApiResponse.ok(
|
||||||
var user = userRepository.findById(userId)
|
userService.getProfile(SecurityHelper.getCurrentUserId()),
|
||||||
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
|
"Profil user"));
|
||||||
var profile = java.util.Map.of(
|
|
||||||
"id", user.getId(),
|
|
||||||
"email", user.getEmail(),
|
|
||||||
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
|
|
||||||
"role", user.getRole(),
|
|
||||||
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(profile, "Profil user"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/settings")
|
@GetMapping("/settings")
|
||||||
@ -163,19 +153,13 @@ public class UserController {
|
|||||||
|
|
||||||
@PostMapping("/walkguide/start")
|
@PostMapping("/walkguide/start")
|
||||||
public ResponseEntity<ApiResponse<Void>> walkGuideStart() {
|
public ResponseEntity<ApiResponse<Void>> walkGuideStart() {
|
||||||
Long userId = SecurityHelper.getCurrentUserId();
|
userService.logWalkGuideStart(SecurityHelper.getCurrentUserId());
|
||||||
userRepository.findById(userId).ifPresent(u ->
|
|
||||||
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_START,
|
|
||||||
"WalkGuide dimulai", null));
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai"));
|
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dimulai"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/walkguide/stop")
|
@PostMapping("/walkguide/stop")
|
||||||
public ResponseEntity<ApiResponse<Void>> walkGuideStop() {
|
public ResponseEntity<ApiResponse<Void>> walkGuideStop() {
|
||||||
Long userId = SecurityHelper.getCurrentUserId();
|
userService.logWalkGuideStop(SecurityHelper.getCurrentUserId());
|
||||||
userRepository.findById(userId).ifPresent(u ->
|
|
||||||
activityLogService.createLog(u, ActivityLogType.WALKGUIDE_STOP,
|
|
||||||
"WalkGuide dihentikan", null));
|
|
||||||
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan"));
|
return ResponseEntity.ok(ApiResponse.ok(null, "WalkGuide dihentikan"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,12 @@ package com.walkguide.service;
|
|||||||
|
|
||||||
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
|
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
|
||||||
import com.walkguide.dto.response.HardwareShortcutResponse;
|
import com.walkguide.dto.response.HardwareShortcutResponse;
|
||||||
|
import com.walkguide.enums.PairingStatus;
|
||||||
import com.walkguide.enums.HardwareShortcutKey;
|
import com.walkguide.enums.HardwareShortcutKey;
|
||||||
|
import com.walkguide.exception.PairingException;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
import com.walkguide.repository.HardwareShortcutRepository;
|
import com.walkguide.repository.HardwareShortcutRepository;
|
||||||
|
import com.walkguide.repository.PairingRelationRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -15,6 +18,7 @@ import java.util.stream.Collectors;
|
|||||||
public class HardwareShortcutService {
|
public class HardwareShortcutService {
|
||||||
|
|
||||||
private final HardwareShortcutRepository hardwareShortcutRepository;
|
private final HardwareShortcutRepository hardwareShortcutRepository;
|
||||||
|
private final PairingRelationRepository pairingRelationRepository;
|
||||||
|
|
||||||
public List<HardwareShortcutResponse> getAll(Long userId) {
|
public List<HardwareShortcutResponse> getAll(Long userId) {
|
||||||
return hardwareShortcutRepository.findByUserId(userId).stream()
|
return hardwareShortcutRepository.findByUserId(userId).stream()
|
||||||
@ -25,6 +29,13 @@ public class HardwareShortcutService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<HardwareShortcutResponse> getAllForGuardian(Long guardianId) {
|
||||||
|
var pairing = pairingRelationRepository
|
||||||
|
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
||||||
|
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
|
||||||
|
return getAll(pairing.getUser().getId());
|
||||||
|
}
|
||||||
|
|
||||||
// Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian
|
// Bisa dipanggil dari HP User langsung (capture button) atau dari Guardian
|
||||||
public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) {
|
public HardwareShortcutResponse update(Long userId, HardwareShortcutUpdateRequest req) {
|
||||||
HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey());
|
HardwareShortcutKey key = HardwareShortcutKey.valueOf(req.getShortcutKey());
|
||||||
@ -43,4 +54,11 @@ public class HardwareShortcutService {
|
|||||||
.buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode())
|
.buttonName(shortcut.getButtonName()).buttonCode(shortcut.getButtonCode())
|
||||||
.enabled(shortcut.getEnabled()).build();
|
.enabled(shortcut.getEnabled()).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HardwareShortcutResponse updateByGuardian(Long guardianId, HardwareShortcutUpdateRequest req) {
|
||||||
|
var pairing = pairingRelationRepository
|
||||||
|
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
||||||
|
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
|
||||||
|
return update(pairing.getUser().getId(), req);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,7 @@ public class SosService {
|
|||||||
public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) {
|
public SosEventResponse acknowledgeSos(Long guardianId, Long sosId) {
|
||||||
SosEvent sos = sosEventRepository.findById(sosId)
|
SosEvent sos = sosEventRepository.findById(sosId)
|
||||||
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
|
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
|
||||||
|
assertGuardianOwnsSos(guardianId, sos.getUserId());
|
||||||
|
|
||||||
sos.setStatus(SosStatus.ACKNOWLEDGED);
|
sos.setStatus(SosStatus.ACKNOWLEDGED);
|
||||||
sos.setAcknowledgedAt(LocalDateTime.now());
|
sos.setAcknowledgedAt(LocalDateTime.now());
|
||||||
@ -111,6 +112,33 @@ public class SosService {
|
|||||||
return toResponse(sos);
|
return toResponse(sos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public SosEventResponse resolveSos(Long guardianId, Long sosId) {
|
||||||
|
SosEvent sos = sosEventRepository.findById(sosId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("SOS event tidak ditemukan"));
|
||||||
|
assertGuardianOwnsSos(guardianId, sos.getUserId());
|
||||||
|
|
||||||
|
if (sos.getAcknowledgedAt() == null) {
|
||||||
|
sos.setAcknowledgedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
sos.setStatus(SosStatus.RESOLVED);
|
||||||
|
sosEventRepository.save(sos);
|
||||||
|
|
||||||
|
User user = userRepository.findById(sos.getUserId())
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("User tidak ditemukan"));
|
||||||
|
activityLogService.createLog(user, ActivityLogType.SOS_ACKNOWLEDGED,
|
||||||
|
"Guardian menandai SOS sudah ditangani", null);
|
||||||
|
|
||||||
|
pairingRelationRepository.findByUser_IdAndStatus(sos.getUserId(), PairingStatus.ACTIVE)
|
||||||
|
.ifPresent(pairing -> fcmService.sendToToken(
|
||||||
|
pairing.getUser().getFcmToken(),
|
||||||
|
"SOS Sudah Ditangani",
|
||||||
|
"Guardian kamu sudah menandai SOS sebagai selesai.",
|
||||||
|
Map.of("type", "SOS_RESOLVED")));
|
||||||
|
|
||||||
|
return toResponse(sos);
|
||||||
|
}
|
||||||
|
|
||||||
public Page<SosEventResponse> getSosEvents(Long userId, Pageable pageable) {
|
public Page<SosEventResponse> getSosEvents(Long userId, Pageable pageable) {
|
||||||
return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
|
return sosEventRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
|
||||||
.map(this::toResponse);
|
.map(this::toResponse);
|
||||||
@ -125,6 +153,15 @@ public class SosService {
|
|||||||
.map(this::toResponse);
|
.map(this::toResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void assertGuardianOwnsSos(Long guardianId, Long userId) {
|
||||||
|
var pairing = pairingRelationRepository
|
||||||
|
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Tidak ada user yang dipair"));
|
||||||
|
if (!pairing.getUser().getId().equals(userId)) {
|
||||||
|
throw new ResourceNotFoundException("SOS event tidak ditemukan untuk guardian ini");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SosEventResponse toResponse(SosEvent s) {
|
private SosEventResponse toResponse(SosEvent s) {
|
||||||
return SosEventResponse.builder()
|
return SosEventResponse.builder()
|
||||||
.id(s.getId())
|
.id(s.getId())
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.walkguide.service;
|
||||||
|
|
||||||
|
import com.walkguide.enums.ActivityLogType;
|
||||||
|
import com.walkguide.repository.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ActivityLogService activityLogService;
|
||||||
|
|
||||||
|
public Map<String, Object> getProfile(Long userId) {
|
||||||
|
var user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
|
||||||
|
return Map.of(
|
||||||
|
"id", user.getId(),
|
||||||
|
"email", user.getEmail(),
|
||||||
|
"displayName", user.getDisplayName() != null ? user.getDisplayName() : "",
|
||||||
|
"role", user.getRole(),
|
||||||
|
"uniqueUserId", user.getUniqueUserId() != null ? user.getUniqueUserId() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logWalkGuideStart(Long userId) {
|
||||||
|
userRepository.findById(userId).ifPresent(user ->
|
||||||
|
activityLogService.createLog(user, ActivityLogType.WALKGUIDE_START,
|
||||||
|
"WalkGuide dimulai", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logWalkGuideStop(Long userId) {
|
||||||
|
userRepository.findById(userId).ifPresent(user ->
|
||||||
|
activityLogService.createLog(user, ActivityLogType.WALKGUIDE_STOP,
|
||||||
|
"WalkGuide dihentikan", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -52,6 +52,13 @@ public class VoiceCommandService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<VoiceCommandResponse> getAllForGuardian(Long guardianId) {
|
||||||
|
var pairing = pairingRelationRepository
|
||||||
|
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
||||||
|
.orElseThrow(() -> new PairingException("Tidak ada user yang dipair"));
|
||||||
|
return getAll(pairing.getUser().getId());
|
||||||
|
}
|
||||||
|
|
||||||
public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) {
|
public VoiceCommandResponse updateByGuardian(Long guardianId, VoiceCommandUpdateRequest req) {
|
||||||
var pairing = pairingRelationRepository
|
var pairing = pairingRelationRepository
|
||||||
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL}
|
url: ${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
username: ${DB_USERNAME}
|
username: ${DB_USERNAME:5803024001}
|
||||||
password: ${DB_PASSWORD}
|
password: ${DB_PASSWORD:pw5803024001}
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
show-sql: true
|
show-sql: true
|
||||||
@ -17,7 +17,7 @@ spring:
|
|||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET}
|
secret: ${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
|
||||||
expiration: ${JWT_EXPIRATION:86400000}
|
expiration: ${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
agora:
|
agora:
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
server.port=${SERVER_PORT:8080}
|
server.port=${SERVER_PORT:8080}
|
||||||
|
|
||||||
# ===== POSTGRESQL CONNECTION =====
|
# ===== POSTGRESQL CONNECTION =====
|
||||||
spring.datasource.url=${DB_URL}
|
spring.datasource.url=${DB_URL:jdbc:postgresql://202.46.28.160:2002/uas_5803024001}
|
||||||
spring.datasource.username=${DB_USERNAME}
|
spring.datasource.username=${DB_USERNAME:5803024001}
|
||||||
spring.datasource.password=${DB_PASSWORD}
|
spring.datasource.password=${DB_PASSWORD:pw5803024001}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
# ===== JPA / HIBERNATE =====
|
# ===== JPA / HIBERNATE =====
|
||||||
@ -19,7 +19,7 @@ spring.flyway.locations=classpath:db/migration
|
|||||||
spring.flyway.baseline-on-migrate=true
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
|
||||||
# ===== JWT =====
|
# ===== JWT =====
|
||||||
jwt.secret=${JWT_SECRET}
|
jwt.secret=${JWT_SECRET:d2Fsa2d1aWRlLWRldi1qd3Qtc2VjcmV0LWtleS0zMi1ieXRlcy1taW5pbXVt}
|
||||||
jwt.expiration=${JWT_EXPIRATION:86400000}
|
jwt.expiration=${JWT_EXPIRATION:86400000}
|
||||||
|
|
||||||
# ===== SWAGGER =====
|
# ===== SWAGGER =====
|
||||||
@ -38,4 +38,4 @@ logging.level.com.walkguide=DEBUG
|
|||||||
logging.level.org.springframework.messaging=INFO
|
logging.level.org.springframework.messaging=INFO
|
||||||
logging.level.org.springframework.web.socket=INFO
|
logging.level.org.springframework.web.socket=INFO
|
||||||
|
|
||||||
spring.profiles.active=dev
|
spring.profiles.active=dev
|
||||||
|
|||||||
@ -273,6 +273,15 @@ paths:
|
|||||||
schema: { type: integer, format: int64 }
|
schema: { type: integer, format: int64 }
|
||||||
responses:
|
responses:
|
||||||
"200": { description: SOS acknowledged }
|
"200": { description: SOS acknowledged }
|
||||||
|
/guardian/sos/{id}/resolve:
|
||||||
|
put:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: integer, format: int64 }
|
||||||
|
responses:
|
||||||
|
"200": { description: SOS handled and resolved }
|
||||||
/guardian/ai-config:
|
/guardian/ai-config:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@ -291,6 +300,9 @@ paths:
|
|||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
"200": { description: Paired user shortcuts }
|
"200": { description: Paired user shortcuts }
|
||||||
|
put:
|
||||||
|
responses:
|
||||||
|
"200": { description: Paired user shortcut updated }
|
||||||
/guardian/geofence:
|
/guardian/geofence:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@ -8,10 +8,10 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
|||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
"spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test",
|
"spring.datasource.url=jdbc:postgresql://localhost:5432/walkguide_test",
|
||||||
"spring.datasource.username=test",
|
"spring.datasource.username=test",
|
||||||
"spring.datasource.password=test",
|
"spring.datasource.password=${TEST_DB_PASSWORD}",
|
||||||
"spring.flyway.enabled=false",
|
"spring.flyway.enabled=false",
|
||||||
"spring.jpa.hibernate.ddl-auto=none",
|
"spring.jpa.hibernate.ddl-auto=none",
|
||||||
"jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"
|
"jwt.secret=${TEST_JWT_SECRET}"
|
||||||
})
|
})
|
||||||
class DemoApplicationTests {
|
class DemoApplicationTests {
|
||||||
|
|
||||||
|
|||||||
@ -211,6 +211,24 @@ class GuardianControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PUT /api/v1/guardian/sos/{id}/resolve - harus tandai SOS ditangani")
|
||||||
|
void resolveSos_shouldReturn200() throws Exception {
|
||||||
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
||||||
|
|
||||||
|
SosEventResponse resp = new SosEventResponse();
|
||||||
|
when(sosService.resolveSos(2L, 10L)).thenReturn(resp);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/v1/guardian/sos/10/resolve")
|
||||||
|
.with(csrf()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message").value("SOS ditangani"));
|
||||||
|
|
||||||
|
verify(sosService).resolveSos(2L, 10L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== AI CONFIG =====
|
// ===== AI CONFIG =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -254,7 +272,7 @@ class GuardianControllerTest {
|
|||||||
void getVoiceCommands_shouldReturn200() throws Exception {
|
void getVoiceCommands_shouldReturn200() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
||||||
when(voiceCommandService.getAll(2L)).thenReturn(List.of());
|
when(voiceCommandService.getAllForGuardian(2L)).thenReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/guardian/voice-commands"))
|
mockMvc.perform(get("/api/v1/guardian/voice-commands"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@ -288,7 +306,7 @@ class GuardianControllerTest {
|
|||||||
void getShortcuts_shouldReturn200() throws Exception {
|
void getShortcuts_shouldReturn200() throws Exception {
|
||||||
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
||||||
when(hardwareShortcutService.getAll(2L)).thenReturn(List.of());
|
when(hardwareShortcutService.getAllForGuardian(2L)).thenReturn(List.of());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v1/guardian/shortcuts"))
|
mockMvc.perform(get("/api/v1/guardian/shortcuts"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@ -296,6 +314,27 @@ class GuardianControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PUT /api/v1/guardian/shortcuts - harus update shortcut user yang dipair")
|
||||||
|
void updateShortcut_shouldReturn200() throws Exception {
|
||||||
|
try (MockedStatic<SecurityHelper> sh = mockStatic(SecurityHelper.class)) {
|
||||||
|
sh.when(SecurityHelper::getCurrentUserId).thenReturn(2L);
|
||||||
|
|
||||||
|
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
|
||||||
|
HardwareShortcutResponse resp = new HardwareShortcutResponse();
|
||||||
|
when(hardwareShortcutService.updateByGuardian(eq(2L), any())).thenReturn(resp);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/v1/guardian/shortcuts")
|
||||||
|
.with(csrf())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(req)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.message").value("Hardware shortcut diperbarui"));
|
||||||
|
|
||||||
|
verify(hardwareShortcutService).updateByGuardian(eq(2L), any(HardwareShortcutUpdateRequest.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== GEOFENCE =====
|
// ===== GEOFENCE =====
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.walkguide.integration;
|
package com.walkguide.integration;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.walkguide.dto.request.LoginRequest;
|
import com.walkguide.dto.request.LoginRequest;
|
||||||
import com.walkguide.dto.request.RefreshTokenRequest;
|
import com.walkguide.dto.request.RefreshTokenRequest;
|
||||||
@ -25,8 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
*/
|
*/
|
||||||
@DisplayName("Integration Test — Auth Flow (Testcontainers)")
|
@DisplayName("Integration Test — Auth Flow (Testcontainers)")
|
||||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
@Disabled("Requires Docker/Testcontainers")
|
class AuthIntegrationTest extends AbstractIntegrationTest {
|
||||||
class AuthIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String USER_EMAIL = "integ_user@walkguide.test";
|
private static final String USER_EMAIL = "integ_user@walkguide.test";
|
||||||
private static final String USER_PASS = "password123";
|
private static final String USER_PASS = "password123";
|
||||||
@ -294,4 +291,4 @@ class AuthIntegrationTest extends AbstractIntegrationTest {
|
|||||||
assertThat(result.getResponse().getStatus())
|
assertThat(result.getResponse().getStatus())
|
||||||
.isBetween(200, 399));
|
.isBetween(200, 399));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.walkguide.integration;
|
package com.walkguide.integration;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.walkguide.dto.request.InviteUserRequest;
|
import com.walkguide.dto.request.InviteUserRequest;
|
||||||
import com.walkguide.dto.request.PairingResponseRequest;
|
import com.walkguide.dto.request.PairingResponseRequest;
|
||||||
@ -26,8 +24,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
*/
|
*/
|
||||||
@DisplayName("Integration Test — Pairing Flow (Testcontainers)")
|
@DisplayName("Integration Test — Pairing Flow (Testcontainers)")
|
||||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
@Disabled("Requires Docker/Testcontainers")
|
class PairingIntegrationTest extends AbstractIntegrationTest {
|
||||||
class PairingIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String USER_EMAIL = "pairing_user@walkguide.test";
|
private static final String USER_EMAIL = "pairing_user@walkguide.test";
|
||||||
private static final String USER_PASS = "userpass123";
|
private static final String USER_PASS = "userpass123";
|
||||||
@ -286,4 +283,4 @@ class PairingIntegrationTest extends AbstractIntegrationTest {
|
|||||||
mockMvc.perform(get("/api/v1/shared/pairing/status"))
|
mockMvc.perform(get("/api/v1/shared/pairing/status"))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.walkguide.integration;
|
package com.walkguide.integration;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.walkguide.dto.request.*;
|
import com.walkguide.dto.request.*;
|
||||||
import org.junit.jupiter.api.*;
|
import org.junit.jupiter.api.*;
|
||||||
@ -28,8 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
*/
|
*/
|
||||||
@DisplayName("Integration Test — User Core Features (Testcontainers)")
|
@DisplayName("Integration Test — User Core Features (Testcontainers)")
|
||||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||||
@Disabled("Requires Docker/Testcontainers")
|
class UserFeatureIntegrationTest extends AbstractIntegrationTest {
|
||||||
class UserFeatureIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String USER_EMAIL = "feature_user@walkguide.test";
|
private static final String USER_EMAIL = "feature_user@walkguide.test";
|
||||||
private static final String USER_PASS = "userpass123";
|
private static final String USER_PASS = "userpass123";
|
||||||
@ -488,4 +485,4 @@ class UserFeatureIntegrationTest extends AbstractIntegrationTest {
|
|||||||
.header("Authorization", bearerToken(userToken)))
|
.header("Authorization", bearerToken(userToken)))
|
||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
package com.walkguide.service;
|
package com.walkguide.service;
|
||||||
|
|
||||||
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
|
import com.walkguide.dto.request.HardwareShortcutUpdateRequest;
|
||||||
import com.walkguide.dto.response.HardwareShortcutResponse;
|
import com.walkguide.dto.response.HardwareShortcutResponse;
|
||||||
import com.walkguide.entity.HardwareShortcut;
|
import com.walkguide.entity.PairingRelation;
|
||||||
import com.walkguide.enums.HardwareShortcutKey;
|
import com.walkguide.entity.HardwareShortcut;
|
||||||
import com.walkguide.exception.ResourceNotFoundException;
|
import com.walkguide.entity.User;
|
||||||
import com.walkguide.repository.HardwareShortcutRepository;
|
import com.walkguide.enums.HardwareShortcutKey;
|
||||||
|
import com.walkguide.enums.PairingStatus;
|
||||||
|
import com.walkguide.exception.ResourceNotFoundException;
|
||||||
|
import com.walkguide.repository.HardwareShortcutRepository;
|
||||||
|
import com.walkguide.repository.PairingRelationRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@ -15,7 +19,8 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@ -25,8 +30,10 @@ import static org.mockito.Mockito.*;
|
|||||||
@DisplayName("HardwareShortcutService Unit Tests")
|
@DisplayName("HardwareShortcutService Unit Tests")
|
||||||
class HardwareShortcutServiceTest {
|
class HardwareShortcutServiceTest {
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
HardwareShortcutRepository hardwareShortcutRepository;
|
HardwareShortcutRepository hardwareShortcutRepository;
|
||||||
|
@Mock
|
||||||
|
PairingRelationRepository pairingRelationRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
HardwareShortcutService hardwareShortcutService;
|
HardwareShortcutService hardwareShortcutService;
|
||||||
@ -173,11 +180,36 @@ class HardwareShortcutServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("update - shortcutKey enum invalid harus throw IllegalArgumentException")
|
@DisplayName("update - shortcutKey enum invalid harus throw IllegalArgumentException")
|
||||||
void update_invalidEnumKey_shouldThrow() {
|
void update_invalidEnumKey_shouldThrow() {
|
||||||
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
|
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
|
||||||
req.setShortcutKey("INVALID_KEY_XYZ");
|
req.setShortcutKey("INVALID_KEY_XYZ");
|
||||||
|
|
||||||
assertThatThrownBy(() -> hardwareShortcutService.update(10L, req))
|
assertThatThrownBy(() -> hardwareShortcutService.update(10L, req))
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateByGuardian - harus update shortcut milik user yang dipair")
|
||||||
|
void updateByGuardian_shouldUpdatePairedUserShortcut() {
|
||||||
|
HardwareShortcutUpdateRequest req = new HardwareShortcutUpdateRequest();
|
||||||
|
req.setShortcutKey("CALL_GUARDIAN");
|
||||||
|
req.setButtonName("Power Double Tap");
|
||||||
|
|
||||||
|
User guardian = User.builder().id(1L).build();
|
||||||
|
User user = User.builder().id(10L).build();
|
||||||
|
PairingRelation pairing = PairingRelation.builder()
|
||||||
|
.guardian(guardian).user(user).status(PairingStatus.ACTIVE).build();
|
||||||
|
|
||||||
|
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.of(pairing));
|
||||||
|
when(hardwareShortcutRepository.findByUserId(10L))
|
||||||
|
.thenReturn(List.of(shortcutCallGuardian));
|
||||||
|
when(hardwareShortcutRepository.save(any(HardwareShortcut.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
HardwareShortcutResponse result = hardwareShortcutService.updateByGuardian(1L, req);
|
||||||
|
|
||||||
|
assertThat(result.getButtonName()).isEqualTo("Power Double Tap");
|
||||||
|
verify(hardwareShortcutRepository).findByUserId(10L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -159,8 +159,10 @@ class SosServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("acknowledgeSos - SOS ditemukan: harus ubah status ke ACKNOWLEDGED")
|
@DisplayName("acknowledgeSos - SOS ditemukan: harus ubah status ke ACKNOWLEDGED")
|
||||||
void acknowledgeSos_sosFound_shouldChangeStatusToAcknowledged() {
|
void acknowledgeSos_sosFound_shouldChangeStatusToAcknowledged() {
|
||||||
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
|
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.of(activePairing));
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||||
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||||
.thenReturn(Optional.empty());
|
.thenReturn(Optional.empty());
|
||||||
@ -179,8 +181,10 @@ class SosServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("acknowledgeSos - ada pairing user: harus kirim FCM ke user bahwa guardian sudah respon")
|
@DisplayName("acknowledgeSos - ada pairing user: harus kirim FCM ke user bahwa guardian sudah respon")
|
||||||
void acknowledgeSos_activePairingForUser_shouldNotifyUser() {
|
void acknowledgeSos_activePairingForUser_shouldNotifyUser() {
|
||||||
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
|
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
|
||||||
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.of(activePairing));
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||||
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||||
.thenReturn(Optional.of(activePairing));
|
.thenReturn(Optional.of(activePairing));
|
||||||
@ -198,13 +202,35 @@ class SosServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("acknowledgeSos - SOS tidak ditemukan: harus throw ResourceNotFoundException")
|
@DisplayName("acknowledgeSos - SOS tidak ditemukan: harus throw ResourceNotFoundException")
|
||||||
void acknowledgeSos_sosNotFound_shouldThrowException() {
|
void acknowledgeSos_sosNotFound_shouldThrowException() {
|
||||||
when(sosEventRepository.findById(999L)).thenReturn(Optional.empty());
|
when(sosEventRepository.findById(999L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> sosService.acknowledgeSos(1L, 999L))
|
assertThatThrownBy(() -> sosService.acknowledgeSos(1L, 999L))
|
||||||
.isInstanceOf(ResourceNotFoundException.class)
|
.isInstanceOf(ResourceNotFoundException.class)
|
||||||
.hasMessageContaining("SOS event tidak ditemukan");
|
.hasMessageContaining("SOS event tidak ditemukan");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("resolveSos - SOS ditemukan: harus ubah status ke RESOLVED")
|
||||||
|
void resolveSos_sosFound_shouldChangeStatusToResolved() {
|
||||||
|
when(sosEventRepository.findById(50L)).thenReturn(Optional.of(savedSos));
|
||||||
|
when(pairingRelationRepository.findByGuardian_IdAndStatus(1L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.of(activePairing));
|
||||||
|
when(userRepository.findById(2L)).thenReturn(Optional.of(user));
|
||||||
|
when(sosEventRepository.save(any(SosEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(pairingRelationRepository.findByUser_IdAndStatus(2L, PairingStatus.ACTIVE))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
doNothing().when(activityLogService).createLog(any(), any(), any(), any());
|
||||||
|
|
||||||
|
SosEventResponse result = sosService.resolveSos(1L, 50L);
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getStatus()).isEqualTo("RESOLVED");
|
||||||
|
ArgumentCaptor<SosEvent> captor = ArgumentCaptor.forClass(SosEvent.class);
|
||||||
|
verify(sosEventRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getStatus()).isEqualTo(SosStatus.RESOLVED);
|
||||||
|
assertThat(captor.getValue().getAcknowledgedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== getSosEvents TESTS =====
|
// ===== getSosEvents TESTS =====
|
||||||
|
|
||||||
@ -250,4 +276,4 @@ class SosServiceTest {
|
|||||||
assertThatThrownBy(() -> sosService.getSosEventsForGuardian(1L, PageRequest.of(0, 10)))
|
assertThatThrownBy(() -> sosService.getSosEventsForGuardian(1L, PageRequest.of(0, 10)))
|
||||||
.isInstanceOf(ResourceNotFoundException.class);
|
.isInstanceOf(ResourceNotFoundException.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
# WalkGuide Flutter Physical Device Benchmark Checklist
|
||||||
|
|
||||||
|
Use this file as the final evidence checklist. Do not replace these rows with emulator data.
|
||||||
|
|
||||||
|
| Metric | Command / Tool | Required Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Cold start | `flutter run --profile --trace-startup` | `timeToFirstFrameMicros`, screenshot/log |
|
||||||
|
| Frame jank | DevTools Performance in profile mode | 90%+ frames under 16 ms |
|
||||||
|
| Memory baseline | DevTools Memory after app launch | Heap MB screenshot |
|
||||||
|
| Memory leak check | Navigate all major screens 10x | Heap growth before/after |
|
||||||
|
| CPU during YOLO | DevTools CPU profiler while WalkGuide active | Flame graph screenshot |
|
||||||
|
| API latency | Dio latency logs | p95 latency table |
|
||||||
|
| APK size | `flutter build apk --release --analyze-size` | Size analysis artifact |
|
||||||
|
| Delta table | Mid-sprint vs final | Week 5 vs final comparison |
|
||||||
|
|
||||||
|
Final benchmark must be captured on a physical Android phone in profile mode.
|
||||||
@ -2,56 +2,82 @@ import 'package:get_it/get_it.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import '../core/constants/app_constants.dart';
|
import '../core/constants/app_constants.dart';
|
||||||
|
import '../core/ai/obstacle_alert_strategy.dart';
|
||||||
import '../core/ai/obstacle_analyzer.dart';
|
import '../core/ai/obstacle_analyzer.dart';
|
||||||
import '../core/ai/yolo_detector.dart';
|
import '../core/ai/yolo_detector.dart';
|
||||||
import '../core/network/api_client.dart';
|
import '../core/network/api_client.dart';
|
||||||
import '../core/services/haptic_service.dart';
|
import '../core/services/haptic_service.dart';
|
||||||
import '../core/services/call_service.dart';
|
import '../core/services/call_service.dart';
|
||||||
import '../core/services/fcm_service.dart';
|
import '../core/services/fcm_service.dart';
|
||||||
|
import '../core/services/hardware_shortcut_listener.dart';
|
||||||
import '../core/services/location_reporter_service.dart';
|
import '../core/services/location_reporter_service.dart';
|
||||||
import '../core/services/offline_queue_service.dart';
|
import '../core/services/offline_queue_service.dart';
|
||||||
import '../core/services/stt_service.dart';
|
import '../core/services/stt_service.dart';
|
||||||
import '../core/services/tts_service.dart';
|
import '../core/services/tts_service.dart';
|
||||||
import '../core/services/voice_command_handler.dart';
|
import '../core/services/voice_command_handler.dart';
|
||||||
import '../core/services/websocket_service.dart';
|
import '../core/services/websocket_service.dart';
|
||||||
|
import '../core/storage/local_database.dart';
|
||||||
import '../core/storage/secure_storage.dart';
|
import '../core/storage/secure_storage.dart';
|
||||||
|
import '../core/utils/init_guard.dart';
|
||||||
|
import '../features/notifications/application/notification_cubit.dart';
|
||||||
|
import '../features/notifications/data/repositories/notification_repository_impl.dart';
|
||||||
|
import '../features/notifications/domain/repositories/notification_repository.dart';
|
||||||
|
import '../features/sos/application/sos_cubit.dart';
|
||||||
|
import '../features/sos/data/repositories/sos_repository_impl.dart';
|
||||||
|
import '../features/sos/domain/repositories/sos_repository.dart';
|
||||||
|
import '../features/walk_guide/application/walk_guide_cubit.dart';
|
||||||
|
import '../features/walk_guide/data/repositories/walk_guide_repository_impl.dart';
|
||||||
|
import '../features/walk_guide/domain/repositories/walk_guide_repository.dart';
|
||||||
|
|
||||||
final sl = GetIt.instance;
|
final sl = GetIt.instance;
|
||||||
|
|
||||||
Future<void> initDependencies() async {
|
Future<void> initDependencies() async {
|
||||||
sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
|
sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
|
||||||
|
sl.registerLazySingleton<LocalDatabase>(() => LocalDatabase());
|
||||||
sl.registerLazySingleton<ApiClient>(() => ApiClient(sl<SecureStorage>()));
|
sl.registerLazySingleton<ApiClient>(() => ApiClient(sl<SecureStorage>()));
|
||||||
sl.registerLazySingleton<TtsService>(() => TtsService());
|
sl.registerLazySingleton<TtsService>(() => TtsService());
|
||||||
sl.registerLazySingleton<SttService>(() => SttService());
|
sl.registerLazySingleton<SttService>(() => SttService());
|
||||||
sl.registerLazySingleton<HapticService>(() => HapticService());
|
sl.registerLazySingleton<HapticService>(() => HapticService());
|
||||||
|
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
||||||
|
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
||||||
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
|
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||||
sl.registerLazySingleton<OfflineQueueService>(() => OfflineQueueService());
|
sl.registerLazySingleton<OfflineQueueService>(
|
||||||
|
() => OfflineQueueService(sl<LocalDatabase>()),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
||||||
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
|
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
|
||||||
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||||
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
||||||
|
sl.registerLazySingleton<HardwareShortcutListener>(
|
||||||
|
() => HardwareShortcutListener(sl<ApiClient>()),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<VoiceCommandHandler>(
|
sl.registerLazySingleton<VoiceCommandHandler>(
|
||||||
() => VoiceCommandHandler(sl<SttService>(), sl<TtsService>()),
|
() => VoiceCommandHandler(sl<SttService>(), sl<TtsService>()),
|
||||||
);
|
);
|
||||||
|
sl.registerLazySingleton<WalkGuideRepository>(
|
||||||
|
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||||
|
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
|
||||||
|
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
||||||
|
sl.registerLazySingleton<NotificationRepository>(
|
||||||
|
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<NotificationCubit>(
|
||||||
|
() => NotificationCubit(sl<NotificationRepository>()),
|
||||||
|
);
|
||||||
|
|
||||||
final serverUrl = await AppConstants.getServerUrl();
|
final serverUrl = await AppConstants.getServerUrl();
|
||||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
if (serverUrl != null && serverUrl.isNotEmpty) {
|
||||||
await sl<ApiClient>().init(serverUrl);
|
await sl<ApiClient>().init(serverUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await ignoreInitFailure(() => sl<TtsService>().init(), label: 'TTS init');
|
||||||
await sl<TtsService>().init();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('TTS init skipped: $e');
|
|
||||||
}
|
|
||||||
await sl<YoloDetector>().init();
|
await sl<YoloDetector>().init();
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
try {
|
await ignoreInitFailure(() => sl<SttService>().init(), label: 'STT init');
|
||||||
await sl<SttService>().init();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('STT init skipped: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sl<VoiceCommandHandler>().loadDefaultCommands();
|
sl<VoiceCommandHandler>().loadDefaultCommands();
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
|
|||||||
@ -1,34 +1,42 @@
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../app/injection_container.dart';
|
||||||
import '../core/constants/app_constants.dart';
|
import '../core/constants/app_constants.dart';
|
||||||
import '../features/activity_log/activity_log_screen.dart' as activity;
|
import '../core/storage/secure_storage.dart';
|
||||||
import '../features/ai_benchmark/ai_benchmark_screen.dart' as benchmark;
|
import '../features/activity_log/presentation/screens/activity_log_screen.dart'
|
||||||
|
as activity;
|
||||||
|
import '../features/ai_benchmark/presentation/screens/ai_benchmark_screen.dart'
|
||||||
|
as benchmark;
|
||||||
import '../features/auth/login_screen.dart' as auth_login;
|
import '../features/auth/login_screen.dart' as auth_login;
|
||||||
import '../features/auth/register_screen.dart' as auth_register;
|
import '../features/auth/register_screen.dart' as auth_register;
|
||||||
import '../features/auth/splash_screen.dart' as auth_splash;
|
import '../features/auth/splash_screen.dart' as auth_splash;
|
||||||
import '../features/call/call_screen.dart' as call;
|
import '../features/call/presentation/screens/call_screen.dart' as call;
|
||||||
import '../features/guardian_dashboard/guardian_activity_log_screen.dart'
|
import '../features/guardian_dashboard/presentation/screens/guardian_activity_log_screen.dart'
|
||||||
as guardian_logs;
|
as guardian_logs;
|
||||||
import '../features/guardian_dashboard/guardian_ai_config_screen.dart'
|
import '../features/guardian_dashboard/presentation/screens/guardian_ai_config_screen.dart'
|
||||||
as guardian_ai;
|
as guardian_ai;
|
||||||
import '../features/guardian_dashboard/guardian_map_screen.dart'
|
import '../features/guardian_dashboard/presentation/screens/guardian_map_screen.dart'
|
||||||
as guardian_map;
|
as guardian_map;
|
||||||
import '../features/guardian_dashboard/guardian_send_notification_screen.dart'
|
import '../features/guardian_dashboard/presentation/screens/guardian_send_notification_screen.dart'
|
||||||
as guardian_send;
|
as guardian_send;
|
||||||
import '../features/guardian_dashboard/guardian_settings_screen.dart'
|
import '../features/guardian_dashboard/presentation/screens/guardian_settings_screen.dart'
|
||||||
as guardian_settings;
|
as guardian_settings;
|
||||||
import '../features/guardian_dashboard/guardian_tools_screen.dart'
|
import '../features/guardian_dashboard/presentation/screens/guardian_tools_screen.dart'
|
||||||
as guardian_tools;
|
as guardian_tools;
|
||||||
import '../features/home/presentation/guardian_dashboard_screen.dart'
|
import '../features/home/presentation/guardian_dashboard_screen.dart'
|
||||||
as guardian_home;
|
as guardian_home;
|
||||||
import '../features/navigation_mode/navigation_mode_screen.dart' as nav;
|
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
|
||||||
import '../features/notifications/notification_screen.dart' as notifications;
|
as nav;
|
||||||
import '../features/pairing/pairing_screens.dart' as pairing;
|
import '../features/notifications/presentation/screens/notification_screen.dart'
|
||||||
|
as notifications;
|
||||||
|
import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing;
|
||||||
import '../features/server_connect/server_connect_server.dart'
|
import '../features/server_connect/server_connect_server.dart'
|
||||||
as server_connect;
|
as server_connect;
|
||||||
import '../features/settings/user_settings_screen.dart' as user_settings;
|
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
||||||
import '../features/sos/sos_screen.dart' as sos;
|
as user_settings;
|
||||||
import '../features/walk_guide/walk_guide_screen.dart' as walk_guide;
|
import '../features/sos/presentation/screens/sos_screen.dart' as sos;
|
||||||
|
import '../features/walk_guide/presentation/screens/walk_guide_screen.dart'
|
||||||
|
as walk_guide;
|
||||||
import '../shared/widgets/app_shells.dart';
|
import '../shared/widgets/app_shells.dart';
|
||||||
|
|
||||||
final GoRouter appRouter = GoRouter(
|
final GoRouter appRouter = GoRouter(
|
||||||
@ -36,6 +44,10 @@ final GoRouter appRouter = GoRouter(
|
|||||||
redirect: (context, state) async {
|
redirect: (context, state) async {
|
||||||
final path = state.matchedLocation;
|
final path = state.matchedLocation;
|
||||||
final serverUrl = await AppConstants.getServerUrl();
|
final serverUrl = await AppConstants.getServerUrl();
|
||||||
|
final isPublicRoute = path == '/server-connect' ||
|
||||||
|
path == '/splash' ||
|
||||||
|
path == '/login' ||
|
||||||
|
path == '/register';
|
||||||
|
|
||||||
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
|
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
|
||||||
return '/server-connect';
|
return '/server-connect';
|
||||||
@ -45,6 +57,31 @@ final GoRouter appRouter = GoRouter(
|
|||||||
serverUrl.isNotEmpty) {
|
serverUrl.isNotEmpty) {
|
||||||
return '/splash';
|
return '/splash';
|
||||||
}
|
}
|
||||||
|
if (serverUrl == null || serverUrl.isEmpty) return null;
|
||||||
|
|
||||||
|
final storage = sl<SecureStorage>();
|
||||||
|
final token = await storage.getAccessToken();
|
||||||
|
final role = await storage.getUserRole();
|
||||||
|
|
||||||
|
if ((token == null || token.isEmpty) && !isPublicRoute) {
|
||||||
|
return '/login';
|
||||||
|
}
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
final home = role == 'ROLE_GUARDIAN'
|
||||||
|
? '/guardian/dashboard'
|
||||||
|
: role == 'ROLE_USER'
|
||||||
|
? '/user/walkguide'
|
||||||
|
: '/login';
|
||||||
|
if (path == '/splash' || path == '/login' || path == '/register') {
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
if (path.startsWith('/guardian') && role != 'ROLE_GUARDIAN') {
|
||||||
|
return '/user/walkguide';
|
||||||
|
}
|
||||||
|
if (path.startsWith('/user') && role != 'ROLE_USER') {
|
||||||
|
return '/guardian/dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
import '../services/haptic_service.dart';
|
||||||
|
import '../services/tts_service.dart';
|
||||||
|
import 'obstacle_analyzer.dart';
|
||||||
|
|
||||||
|
abstract class ObstacleAlertStrategy {
|
||||||
|
Future<void> alert(DetectionResult detection);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TtsOnlyObstacleAlertStrategy implements ObstacleAlertStrategy {
|
||||||
|
final TtsService _ttsService;
|
||||||
|
|
||||||
|
const TtsOnlyObstacleAlertStrategy(this._ttsService);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> alert(DetectionResult detection) {
|
||||||
|
return _ttsService.speakImmediate(detection.spokenId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HapticOnlyObstacleAlertStrategy implements ObstacleAlertStrategy {
|
||||||
|
final HapticService _hapticService;
|
||||||
|
|
||||||
|
const HapticOnlyObstacleAlertStrategy(this._hapticService);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> alert(DetectionResult detection) {
|
||||||
|
return _vibrateByDistance(detection);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _vibrateByDistance(DetectionResult detection) {
|
||||||
|
final distance = detection.estimatedDistance.toLowerCase();
|
||||||
|
if (distance.startsWith('very close')) {
|
||||||
|
return _hapticService.obstacleVeryClose();
|
||||||
|
}
|
||||||
|
if (distance.startsWith('close')) {
|
||||||
|
return _hapticService.obstacleClose();
|
||||||
|
}
|
||||||
|
return _hapticService.obstacleMedium();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TtsWithHapticObstacleAlertStrategy implements ObstacleAlertStrategy {
|
||||||
|
final TtsService _ttsService;
|
||||||
|
final HapticService _hapticService;
|
||||||
|
|
||||||
|
const TtsWithHapticObstacleAlertStrategy(
|
||||||
|
this._ttsService,
|
||||||
|
this._hapticService,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> alert(DetectionResult detection) async {
|
||||||
|
final haptic = HapticOnlyObstacleAlertStrategy(_hapticService);
|
||||||
|
await haptic.alert(detection);
|
||||||
|
await _ttsService.speakImmediate(detection.spokenId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,19 +1,28 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
class ApiService {
|
import 'constants/app_constants.dart';
|
||||||
static const baseUrl = String.fromEnvironment(
|
|
||||||
'WALKGUIDE_API_BASE_URL',
|
|
||||||
defaultValue: 'http://202.46.28.160:8080/api/v1',
|
|
||||||
);
|
|
||||||
|
|
||||||
final Dio _dio = Dio(BaseOptions(
|
@Deprecated('Use ApiClient for authenticated requests. Kept for legacy callers.')
|
||||||
baseUrl: baseUrl,
|
class ApiService {
|
||||||
connectTimeout: const Duration(seconds: 5),
|
ApiService._(String baseUrl)
|
||||||
headers: {
|
: _dio = Dio(BaseOptions(
|
||||||
'Content-Type': 'application/json',
|
baseUrl: baseUrl,
|
||||||
'Accept': 'application/json',
|
connectTimeout: const Duration(seconds: 5),
|
||||||
},
|
headers: const {
|
||||||
));
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
static Future<ApiService> create() async {
|
||||||
|
final serverUrl = await AppConstants.getServerUrl();
|
||||||
|
if (serverUrl == null || serverUrl.isEmpty) {
|
||||||
|
throw StateError('WalkGuide server URL has not been configured.');
|
||||||
|
}
|
||||||
|
return ApiService._(AppConstants.buildApiUrl(serverUrl));
|
||||||
|
}
|
||||||
|
|
||||||
Future<Response> post(String path, Map<String, dynamic> data) async {
|
Future<Response> post(String path, Map<String, dynamic> data) async {
|
||||||
return await _dio.post(path, data: data);
|
return await _dio.post(path, data: data);
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
class AppStrings {
|
||||||
|
final String localeCode;
|
||||||
|
|
||||||
|
const AppStrings(this.localeCode);
|
||||||
|
|
||||||
|
static const supportedLocales = ['id-ID', 'en-US'];
|
||||||
|
|
||||||
|
String get walkGuideStarted => _pick(
|
||||||
|
id: 'WalkGuide dimulai',
|
||||||
|
en: 'WalkGuide started',
|
||||||
|
);
|
||||||
|
|
||||||
|
String get walkGuideStopped => _pick(
|
||||||
|
id: 'WalkGuide dihentikan',
|
||||||
|
en: 'WalkGuide stopped',
|
||||||
|
);
|
||||||
|
|
||||||
|
String get sosSent => _pick(
|
||||||
|
id: 'SOS terkirim. Guardian sudah diberi tahu.',
|
||||||
|
en: 'SOS sent. Your guardian has been alerted.',
|
||||||
|
);
|
||||||
|
|
||||||
|
String get notificationsOpened => _pick(
|
||||||
|
id: 'Notifikasi dibuka',
|
||||||
|
en: 'Notifications opened',
|
||||||
|
);
|
||||||
|
|
||||||
|
String _pick({required String id, required String en}) {
|
||||||
|
return localeCode == 'en-US' ? en : id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../network/api_client.dart';
|
||||||
|
|
||||||
|
enum HardwareShortcutAction {
|
||||||
|
callGuardian,
|
||||||
|
startWalkguide,
|
||||||
|
stopWalkguide,
|
||||||
|
sendSos,
|
||||||
|
openNotification,
|
||||||
|
}
|
||||||
|
|
||||||
|
class HardwareShortcutBinding {
|
||||||
|
final HardwareShortcutAction action;
|
||||||
|
final int buttonCode;
|
||||||
|
final String buttonName;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const HardwareShortcutBinding({
|
||||||
|
required this.action,
|
||||||
|
required this.buttonCode,
|
||||||
|
required this.buttonName,
|
||||||
|
required this.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class HardwareShortcutListener {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final Map<int, HardwareShortcutBinding> _bindings = {};
|
||||||
|
|
||||||
|
bool _listening = false;
|
||||||
|
void Function(HardwareShortcutAction action)? _onAction;
|
||||||
|
void Function(int buttonCode, String buttonName)? _captureCallback;
|
||||||
|
|
||||||
|
HardwareShortcutListener(this._apiClient);
|
||||||
|
|
||||||
|
Future<void> startListening({
|
||||||
|
required void Function(HardwareShortcutAction action) onAction,
|
||||||
|
}) async {
|
||||||
|
_onAction = onAction;
|
||||||
|
if (!_listening) {
|
||||||
|
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||||
|
_listening = true;
|
||||||
|
}
|
||||||
|
await loadFromBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopListening() {
|
||||||
|
if (!_listening) return;
|
||||||
|
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||||
|
_listening = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadFromBackend() async {
|
||||||
|
final response = await _apiClient.dio.get('/user/shortcuts');
|
||||||
|
final body = response.data;
|
||||||
|
final data = body is Map ? body['data'] : body;
|
||||||
|
if (data is! List) return;
|
||||||
|
|
||||||
|
_bindings
|
||||||
|
..clear()
|
||||||
|
..addEntries(
|
||||||
|
data
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => _bindingFromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.whereType<HardwareShortcutBinding>()
|
||||||
|
.map((binding) => MapEntry(binding.buttonCode, binding)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) {
|
||||||
|
_captureCallback = onCapture;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleKeyEvent(KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent) return false;
|
||||||
|
final code = _buttonCode(event.logicalKey);
|
||||||
|
final name = event.logicalKey.keyLabel.isNotEmpty
|
||||||
|
? event.logicalKey.keyLabel
|
||||||
|
: event.logicalKey.debugName ?? 'Button $code';
|
||||||
|
|
||||||
|
final capture = _captureCallback;
|
||||||
|
if (capture != null) {
|
||||||
|
_captureCallback = null;
|
||||||
|
capture(code, name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final binding = _bindings[code];
|
||||||
|
if (binding == null || !binding.enabled) return false;
|
||||||
|
_onAction?.call(binding.action);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _buttonCode(LogicalKeyboardKey key) {
|
||||||
|
if (key == LogicalKeyboardKey.audioVolumeUp) return 24;
|
||||||
|
if (key == LogicalKeyboardKey.audioVolumeDown) return 25;
|
||||||
|
return key.keyId & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
|
||||||
|
final action = _actionFromBackend(item['shortcutKey']?.toString());
|
||||||
|
final rawCode = item['buttonCode'];
|
||||||
|
final enabled = item['enabled'] != false;
|
||||||
|
final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? '');
|
||||||
|
if (action == null || code == null || code <= 0) return null;
|
||||||
|
return HardwareShortcutBinding(
|
||||||
|
action: action,
|
||||||
|
buttonCode: code,
|
||||||
|
buttonName: item['buttonName']?.toString() ?? 'Button $code',
|
||||||
|
enabled: enabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HardwareShortcutAction? _actionFromBackend(String? key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'CALL_GUARDIAN':
|
||||||
|
return HardwareShortcutAction.callGuardian;
|
||||||
|
case 'START_WALKGUIDE':
|
||||||
|
return HardwareShortcutAction.startWalkguide;
|
||||||
|
case 'STOP_WALKGUIDE':
|
||||||
|
return HardwareShortcutAction.stopWalkguide;
|
||||||
|
case 'SEND_SOS':
|
||||||
|
return HardwareShortcutAction.sendSos;
|
||||||
|
case 'OPEN_NOTIFICATION':
|
||||||
|
return HardwareShortcutAction.openNotification;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../network/api_client.dart';
|
import '../network/api_client.dart';
|
||||||
|
import '../storage/local_database.dart';
|
||||||
|
|
||||||
class OfflineRequest {
|
class OfflineRequest {
|
||||||
final String method;
|
final String method;
|
||||||
@ -33,26 +30,35 @@ class OfflineRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class OfflineQueueService {
|
class OfflineQueueService {
|
||||||
static const _key = 'offline_request_queue';
|
final LocalDatabase _database;
|
||||||
|
|
||||||
|
OfflineQueueService(this._database);
|
||||||
|
|
||||||
Future<void> enqueue(OfflineRequest request) async {
|
Future<void> enqueue(OfflineRequest request) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _database.offlineRequests.insert(OfflineRequestRecord(
|
||||||
final queue = await readAll();
|
method: request.method,
|
||||||
queue.add(request);
|
path: request.path,
|
||||||
await prefs.setString(_key, jsonEncode(queue.map((e) => e.toJson()).toList()));
|
body: request.body,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<OfflineRequest>> readAll() async {
|
Future<List<OfflineRequest>> readAll() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final records = await _database.offlineRequests.getAll();
|
||||||
final raw = prefs.getString(_key);
|
return records
|
||||||
if (raw == null || raw.isEmpty) return [];
|
.map(
|
||||||
final decoded = jsonDecode(raw) as List<dynamic>;
|
(record) => OfflineRequest(
|
||||||
return decoded.map((e) => OfflineRequest.fromJson(Map<String, dynamic>.from(e as Map))).toList();
|
method: record.method,
|
||||||
|
path: record.path,
|
||||||
|
body: record.body,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _database.offlineRequests.clear();
|
||||||
await prefs.remove(_key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> syncPending(ApiClient apiClient) async {
|
Future<int> syncPending(ApiClient apiClient) async {
|
||||||
@ -79,11 +85,21 @@ class OfflineQueueService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (remaining.isEmpty) {
|
if (remaining.isEmpty) {
|
||||||
await prefs.remove(_key);
|
await clear();
|
||||||
} else {
|
} else {
|
||||||
await prefs.setString(_key, jsonEncode(remaining.map((e) => e.toJson()).toList()));
|
await _database.offlineRequests.replaceAll(
|
||||||
|
remaining
|
||||||
|
.map(
|
||||||
|
(request) => OfflineRequestRecord(
|
||||||
|
method: request.method,
|
||||||
|
path: request.path,
|
||||||
|
body: request.body,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return synced;
|
return synced;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
|
|
||||||
|
class LocalCacheStore {
|
||||||
|
LocalCacheStore._();
|
||||||
|
|
||||||
|
static final LocalCacheStore instance = LocalCacheStore._();
|
||||||
|
static const _table = 'walkguide_cache';
|
||||||
|
|
||||||
|
Database? _database;
|
||||||
|
|
||||||
|
Future<String?> get(String key) async {
|
||||||
|
final db = await _open();
|
||||||
|
final result = db.select(
|
||||||
|
'SELECT value FROM $_table WHERE cache_key = ? LIMIT 1',
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
if (result.isEmpty) return null;
|
||||||
|
return result.first['value'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> set(String key, String value) async {
|
||||||
|
final db = await _open();
|
||||||
|
db.execute(
|
||||||
|
'INSERT INTO $_table(cache_key, value, updated_at) VALUES (?, ?, ?) '
|
||||||
|
'ON CONFLICT(cache_key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
|
||||||
|
[key, value, DateTime.now().toIso8601String()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remove(String key) async {
|
||||||
|
final db = await _open();
|
||||||
|
db.execute('DELETE FROM $_table WHERE cache_key = ?', [key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Database> _open() async {
|
||||||
|
final existing = _database;
|
||||||
|
if (existing != null) return existing;
|
||||||
|
final directory = await getApplicationDocumentsDirectory();
|
||||||
|
final db = sqlite3.open('${directory.path}/walkguide_cache.sqlite');
|
||||||
|
db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS $_table (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
_database = db;
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class LocalCacheStore {
|
||||||
|
LocalCacheStore._();
|
||||||
|
|
||||||
|
static final LocalCacheStore instance = LocalCacheStore._();
|
||||||
|
|
||||||
|
Future<String?> get(String key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> set(String key, String value) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> remove(String key) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,239 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'local_cache_store_web.dart'
|
||||||
|
if (dart.library.io) 'local_cache_store_native.dart';
|
||||||
|
|
||||||
|
class CachedActivityLog {
|
||||||
|
final int? id;
|
||||||
|
final int? userId;
|
||||||
|
final String logType;
|
||||||
|
final String description;
|
||||||
|
final String? metadata;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final bool synced;
|
||||||
|
|
||||||
|
const CachedActivityLog({
|
||||||
|
this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.logType,
|
||||||
|
required this.description,
|
||||||
|
this.metadata,
|
||||||
|
required this.createdAt,
|
||||||
|
this.synced = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'logType': logType,
|
||||||
|
'description': description,
|
||||||
|
'metadata': metadata,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'synced': synced,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory CachedActivityLog.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedActivityLog(
|
||||||
|
id: json['id'] as int?,
|
||||||
|
userId: json['userId'] as int?,
|
||||||
|
logType: json['logType'] as String,
|
||||||
|
description: json['description'] as String,
|
||||||
|
metadata: json['metadata'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
synced: json['synced'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedObstacleLog {
|
||||||
|
final String label;
|
||||||
|
final String direction;
|
||||||
|
final String estimatedDistance;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final bool synced;
|
||||||
|
|
||||||
|
const CachedObstacleLog({
|
||||||
|
required this.label,
|
||||||
|
required this.direction,
|
||||||
|
required this.estimatedDistance,
|
||||||
|
required this.createdAt,
|
||||||
|
this.synced = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'label': label,
|
||||||
|
'direction': direction,
|
||||||
|
'estimatedDistance': estimatedDistance,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'synced': synced,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory CachedObstacleLog.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedObstacleLog(
|
||||||
|
label: json['label'] as String,
|
||||||
|
direction: json['direction'] as String,
|
||||||
|
estimatedDistance: json['estimatedDistance'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
synced: json['synced'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedNotification {
|
||||||
|
final int? id;
|
||||||
|
final String notificationType;
|
||||||
|
final String? content;
|
||||||
|
final String? voiceNoteUrl;
|
||||||
|
final bool isRead;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const CachedNotification({
|
||||||
|
this.id,
|
||||||
|
required this.notificationType,
|
||||||
|
this.content,
|
||||||
|
this.voiceNoteUrl,
|
||||||
|
required this.isRead,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'notificationType': notificationType,
|
||||||
|
'content': content,
|
||||||
|
'voiceNoteUrl': voiceNoteUrl,
|
||||||
|
'isRead': isRead,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory CachedNotification.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedNotification(
|
||||||
|
id: json['id'] as int?,
|
||||||
|
notificationType: json['notificationType'] as String,
|
||||||
|
content: json['content'] as String?,
|
||||||
|
voiceNoteUrl: json['voiceNoteUrl'] as String?,
|
||||||
|
isRead: json['isRead'] == true,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalDatabase {
|
||||||
|
final activityLogs = ActivityLogDao();
|
||||||
|
final obstacleLogs = ObstacleLogDao();
|
||||||
|
final notifications = NotificationDao();
|
||||||
|
final offlineRequests = OfflineRequestDao();
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _JsonListDao<T> {
|
||||||
|
String get storageKey;
|
||||||
|
T fromJson(Map<String, dynamic> json);
|
||||||
|
Map<String, dynamic> toJson(T item);
|
||||||
|
|
||||||
|
Future<List<T>> getAll() async {
|
||||||
|
final raw = await LocalCacheStore.instance.get(storageKey);
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
final decoded = jsonDecode(raw) as List<dynamic>;
|
||||||
|
return decoded
|
||||||
|
.map((item) => fromJson(Map<String, dynamic>.from(item as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceAll(List<T> items) async {
|
||||||
|
await LocalCacheStore.instance.set(
|
||||||
|
storageKey,
|
||||||
|
jsonEncode(items.map(toJson).toList()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> insert(T item) async {
|
||||||
|
final items = await getAll();
|
||||||
|
items.add(item);
|
||||||
|
await replaceAll(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
await LocalCacheStore.instance.remove(storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ActivityLogDao extends _JsonListDao<CachedActivityLog> {
|
||||||
|
@override
|
||||||
|
String get storageKey => 'cached_activity_logs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
CachedActivityLog fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedActivityLog.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson(CachedActivityLog item) => item.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ObstacleLogDao extends _JsonListDao<CachedObstacleLog> {
|
||||||
|
@override
|
||||||
|
String get storageKey => 'cached_obstacle_logs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
CachedObstacleLog fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedObstacleLog.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson(CachedObstacleLog item) => item.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationDao extends _JsonListDao<CachedNotification> {
|
||||||
|
@override
|
||||||
|
String get storageKey => 'cached_notifications';
|
||||||
|
|
||||||
|
@override
|
||||||
|
CachedNotification fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedNotification.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson(CachedNotification item) => item.toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineRequestRecord {
|
||||||
|
final String method;
|
||||||
|
final String path;
|
||||||
|
final Map<String, dynamic> body;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const OfflineRequestRecord({
|
||||||
|
required this.method,
|
||||||
|
required this.path,
|
||||||
|
required this.body,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'method': method,
|
||||||
|
'path': path,
|
||||||
|
'body': body,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory OfflineRequestRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OfflineRequestRecord(
|
||||||
|
method: json['method'] as String,
|
||||||
|
path: json['path'] as String,
|
||||||
|
body: Map<String, dynamic>.from(json['body'] as Map),
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineRequestDao extends _JsonListDao<OfflineRequestRecord> {
|
||||||
|
@override
|
||||||
|
String get storageKey => 'offline_request_queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
OfflineRequestRecord fromJson(Map<String, dynamic> json) {
|
||||||
|
return OfflineRequestRecord.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson(OfflineRequestRecord item) => item.toJson();
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
Future<T?> ignoreInitFailure<T>(
|
||||||
|
Future<T> Function() action, {
|
||||||
|
required String label,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('$label skipped: $error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
Future<T?> guarded<T>(
|
||||||
|
Future<T> Function() action, {
|
||||||
|
void Function(Object error)? onError,
|
||||||
|
void Function()? onTimeout,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await action();
|
||||||
|
} on TimeoutException catch (error) {
|
||||||
|
onTimeout?.call();
|
||||||
|
onError?.call(error);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
onError?.call(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../activity_log_screen.dart';
|
||||||
@ -10,6 +10,7 @@ import '../../app/injection_container.dart';
|
|||||||
import '../../core/ai/detection_export.dart';
|
import '../../core/ai/detection_export.dart';
|
||||||
import '../../core/constants/app_constants.dart';
|
import '../../core/constants/app_constants.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
|
import '../../core/utils/operation_guard.dart';
|
||||||
import '../../shared/widgets/feature_page.dart';
|
import '../../shared/widgets/feature_page.dart';
|
||||||
|
|
||||||
class AiBenchmarkScreen extends StatefulWidget {
|
class AiBenchmarkScreen extends StatefulWidget {
|
||||||
@ -79,11 +80,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
|||||||
notifWatch.stop();
|
notifWatch.stop();
|
||||||
|
|
||||||
final ttsWatch = Stopwatch()..start();
|
final ttsWatch = Stopwatch()..start();
|
||||||
try {
|
await guarded<void>(
|
||||||
await sl<TtsService>()
|
() => sl<TtsService>()
|
||||||
.speakImmediate(notificationText)
|
.speakImmediate(notificationText)
|
||||||
.timeout(const Duration(seconds: 3));
|
.timeout(const Duration(seconds: 3)),
|
||||||
} catch (_) {}
|
);
|
||||||
ttsWatch.stop();
|
ttsWatch.stop();
|
||||||
|
|
||||||
final run = {
|
final run = {
|
||||||
@ -113,23 +114,27 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
|||||||
Future<int> _measureCapture() async {
|
Future<int> _measureCapture() async {
|
||||||
final watch = Stopwatch()..start();
|
final watch = Stopwatch()..start();
|
||||||
CameraController? controller;
|
CameraController? controller;
|
||||||
try {
|
await guarded<void>(
|
||||||
|
() async {
|
||||||
final cameras =
|
final cameras =
|
||||||
await availableCameras().timeout(const Duration(seconds: 3));
|
await availableCameras().timeout(const Duration(seconds: 3));
|
||||||
if (cameras.isNotEmpty) {
|
if (cameras.isNotEmpty) {
|
||||||
controller = CameraController(
|
final activeController = CameraController(
|
||||||
cameras.first,
|
cameras.first,
|
||||||
ResolutionPreset.low,
|
ResolutionPreset.low,
|
||||||
enableAudio: false,
|
enableAudio: false,
|
||||||
);
|
);
|
||||||
await controller.initialize().timeout(const Duration(seconds: 5));
|
controller = activeController;
|
||||||
await controller.takePicture().timeout(const Duration(seconds: 5));
|
await activeController.initialize().timeout(const Duration(seconds: 5));
|
||||||
|
await activeController.takePicture().timeout(const Duration(seconds: 5));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
);
|
||||||
|
if (controller == null) {
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 16));
|
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||||
} finally {
|
|
||||||
await controller?.dispose();
|
|
||||||
}
|
}
|
||||||
|
await controller?.dispose();
|
||||||
watch.stop();
|
watch.stop();
|
||||||
return watch.elapsedMilliseconds;
|
return watch.elapsedMilliseconds;
|
||||||
}
|
}
|
||||||
@ -273,7 +278,8 @@ class _StatusBox extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<String>> _discoverTfliteModels() async {
|
Future<List<String>> _discoverTfliteModels() async {
|
||||||
try {
|
return await guarded<List<String>>(
|
||||||
|
() async {
|
||||||
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
||||||
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
||||||
final models = manifest.keys
|
final models = manifest.keys
|
||||||
@ -282,9 +288,9 @@ Future<List<String>> _discoverTfliteModels() async {
|
|||||||
.toList()
|
.toList()
|
||||||
..sort();
|
..sort();
|
||||||
return models;
|
return models;
|
||||||
} catch (_) {
|
},
|
||||||
return const [];
|
) ??
|
||||||
}
|
const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
String _two(int value) => value.toString().padLeft(2, '0');
|
String _two(int value) => value.toString().padLeft(2, '0');
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../ai_benchmark_screen.dart';
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import '../../../../core/api_service.dart';
|
import '../../../../core/network/api_client.dart';
|
||||||
import 'auth_model.dart';
|
import 'auth_model.dart';
|
||||||
|
|
||||||
abstract class AuthRemoteDataSource {
|
abstract class AuthRemoteDataSource {
|
||||||
@ -7,14 +7,14 @@ abstract class AuthRemoteDataSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||||
final ApiService apiService;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
AuthRemoteDataSourceImpl(this.apiService);
|
AuthRemoteDataSourceImpl(this.apiClient);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AuthModel> login(String email, String password) async {
|
Future<AuthModel> login(String email, String password) async {
|
||||||
try {
|
try {
|
||||||
final response = await apiService.post('/auth/login', {
|
final response = await apiClient.dio.post('/auth/login', data: {
|
||||||
'email': email,
|
'email': email,
|
||||||
'password': password,
|
'password': password,
|
||||||
});
|
});
|
||||||
@ -30,4 +30,4 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
|||||||
throw Exception(e.response?.data['message'] ?? 'Terjadi kesalahan jaringan');
|
throw Exception(e.response?.data['message'] ?? 'Terjadi kesalahan jaringan');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../call_screen.dart';
|
||||||
@ -6,9 +6,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../../../app/injection_container.dart';
|
import '../../../app/injection_container.dart';
|
||||||
import '../../../core/errors/friendly_error.dart';
|
import '../../../core/errors/friendly_error.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/utils/operation_guard.dart';
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
@ -46,9 +47,10 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_needsPairing = false;
|
_needsPairing = false;
|
||||||
});
|
});
|
||||||
try {
|
await guarded<void>(
|
||||||
final paired = await _hasActivePairing();
|
() async {
|
||||||
if (!paired) {
|
final paired = await _hasActivePairing();
|
||||||
|
if (!paired) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_needsPairing = true;
|
_needsPairing = true;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@ -69,26 +71,24 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
_alertDistanceMedium =
|
_alertDistanceMedium =
|
||||||
(data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0;
|
(data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0;
|
||||||
_maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5;
|
_maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5;
|
||||||
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
|
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
},
|
||||||
setState(() {
|
onError: (error) => setState(() {
|
||||||
_error =
|
_error = error is DioException
|
||||||
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
|
? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.')
|
||||||
});
|
: 'Gagal memuat konfigurasi AI. Coba refresh lagi.';
|
||||||
} catch (e) {
|
}),
|
||||||
setState(
|
);
|
||||||
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
|
if (mounted) setState(() => _loading = false);
|
||||||
} finally {
|
}
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
}
|
Future<void> _save() async {
|
||||||
}
|
setState(() => _saving = true);
|
||||||
|
await guarded<void>(
|
||||||
Future<void> _save() async {
|
() async {
|
||||||
setState(() => _saving = true);
|
await _api.put('/guardian/ai-config', data: {
|
||||||
try {
|
|
||||||
await _api.put('/guardian/ai-config', data: {
|
|
||||||
'confidenceThreshold': _confidenceThreshold,
|
'confidenceThreshold': _confidenceThreshold,
|
||||||
'alertDistanceClose': _alertDistanceClose,
|
'alertDistanceClose': _alertDistanceClose,
|
||||||
'alertDistanceMedium': _alertDistanceMedium,
|
'alertDistanceMedium': _alertDistanceMedium,
|
||||||
@ -100,43 +100,39 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
|||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Konfigurasi AI berhasil disimpan'),
|
content: Text('Konfigurasi AI berhasil disimpan'),
|
||||||
backgroundColor: Color(0xFF16A34A),
|
backgroundColor: Color(0xFF16A34A),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
},
|
||||||
if (mounted) {
|
onError: (error) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (!mounted) return;
|
||||||
SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
content: Text(friendlyDioMessage(e,
|
SnackBar(
|
||||||
fallback: 'Gagal menyimpan konfigurasi.')),
|
content: Text(error is DioException
|
||||||
backgroundColor: const Color(0xFFDC2626),
|
? friendlyDioMessage(error,
|
||||||
),
|
fallback: 'Gagal menyimpan konfigurasi.')
|
||||||
);
|
: 'Gagal menyimpan konfigurasi. Coba lagi.'),
|
||||||
}
|
backgroundColor: const Color(0xFFDC2626),
|
||||||
} catch (e) {
|
),
|
||||||
if (mounted) {
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
},
|
||||||
const SnackBar(
|
);
|
||||||
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
|
if (mounted) setState(() => _saving = false);
|
||||||
backgroundColor: Color(0xFFDC2626),
|
}
|
||||||
),
|
|
||||||
);
|
Future<bool> _hasActivePairing() async {
|
||||||
}
|
return await guarded<bool>(
|
||||||
} finally {
|
() async {
|
||||||
if (mounted) setState(() => _saving = false);
|
final res = await _api
|
||||||
}
|
.get('/shared/pairing/status')
|
||||||
}
|
.timeout(const Duration(seconds: 5));
|
||||||
|
final data = res.data['data'];
|
||||||
Future<bool> _hasActivePairing() async {
|
if (data is Map) return data['status'] == 'ACTIVE';
|
||||||
try {
|
return false;
|
||||||
final res = await _api
|
},
|
||||||
.get('/shared/pairing/status')
|
) ??
|
||||||
.timeout(const Duration(seconds: 5));
|
false;
|
||||||
final data = res.data['data'];
|
}
|
||||||
if (data is Map) return data['status'] == 'ACTIVE';
|
|
||||||
} catch (_) {}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
@ -15,29 +20,54 @@ class GuardianSendNotifScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||||
final _message = TextEditingController();
|
final _message = TextEditingController();
|
||||||
|
final _recorder = AudioRecorder();
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
bool _recording = false;
|
||||||
|
bool _voiceMode = false;
|
||||||
|
String? _voicePath;
|
||||||
|
DateTime? _recordStart;
|
||||||
|
int _voiceDuration = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_message.dispose();
|
_message.dispose();
|
||||||
|
_recorder.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _send() async {
|
Future<void> _send() async {
|
||||||
final message = _message.text.trim();
|
final message = _message.text.trim();
|
||||||
if (message.isEmpty) {
|
if (!_voiceMode && message.isEmpty) {
|
||||||
_snack('Tulis pesan dulu.');
|
_snack('Tulis pesan dulu.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_voiceMode && _voicePath == null) {
|
||||||
|
_snack('Rekam voice note dulu.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
|
final data = _voiceMode
|
||||||
|
? {
|
||||||
|
'notifType': 'VOICE_NOTE',
|
||||||
|
'content': await _voiceAsDataUrl(),
|
||||||
|
'voiceNoteUrl': 'inline-audio',
|
||||||
|
'voiceNoteDuration': _voiceDuration,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
'notifType': 'TEXT',
|
||||||
|
'content': message,
|
||||||
|
};
|
||||||
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
|
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
|
||||||
'notifType': 'TEXT',
|
...data,
|
||||||
'content': message,
|
}).timeout(const Duration(seconds: 12));
|
||||||
}).timeout(const Duration(seconds: 8));
|
|
||||||
_message.clear();
|
_message.clear();
|
||||||
_snack('Notifikasi terkirim ke User.');
|
_voicePath = null;
|
||||||
|
_voiceDuration = 0;
|
||||||
|
_snack(_voiceMode
|
||||||
|
? 'Voice message terkirim ke User.'
|
||||||
|
: 'Notifikasi terkirim ke User.');
|
||||||
},
|
},
|
||||||
onError: _snack,
|
onError: _snack,
|
||||||
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
|
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
|
||||||
@ -45,6 +75,50 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleRecording() async {
|
||||||
|
if (_recording) {
|
||||||
|
final path = await _recorder.stop();
|
||||||
|
final duration = _recordStart == null
|
||||||
|
? 0
|
||||||
|
: DateTime.now().difference(_recordStart!).inSeconds;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_recording = false;
|
||||||
|
_voicePath = path;
|
||||||
|
_voiceDuration = duration.clamp(1, 600).toInt();
|
||||||
|
_recordStart = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasPermission = await _recorder.hasPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
_snack('Izin mikrofon dibutuhkan untuk rekam voice message.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final path =
|
||||||
|
'${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||||
|
await _recorder.start(
|
||||||
|
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||||||
|
path: path,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_recording = true;
|
||||||
|
_recordStart = DateTime.now();
|
||||||
|
_voicePath = null;
|
||||||
|
_voiceDuration = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _voiceAsDataUrl() async {
|
||||||
|
final path = _voicePath;
|
||||||
|
if (path == null) return '';
|
||||||
|
final bytes = await File(path).readAsBytes();
|
||||||
|
return 'data:audio/mp4;base64,${base64Encode(bytes)}';
|
||||||
|
}
|
||||||
|
|
||||||
void _snack(String message) {
|
void _snack(String message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context)
|
ScaffoldMessenger.of(context)
|
||||||
@ -75,10 +149,29 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: false,
|
||||||
|
icon: Icon(Icons.message_outlined),
|
||||||
|
label: Text('Text'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: true,
|
||||||
|
icon: Icon(Icons.mic_none_outlined),
|
||||||
|
label: Text('Voice'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_voiceMode},
|
||||||
|
onSelectionChanged: _loading || _recording
|
||||||
|
? null
|
||||||
|
: (value) => setState(() => _voiceMode = value.first),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _message,
|
controller: _message,
|
||||||
minLines: 5,
|
minLines: _voiceMode ? 2 : 5,
|
||||||
maxLines: 8,
|
maxLines: _voiceMode ? 3 : 8,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Message',
|
labelText: 'Message',
|
||||||
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
||||||
@ -86,6 +179,68 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
alignLabelWithHint: true,
|
alignLabelWithHint: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_voiceMode) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF8FAFC),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: _recording
|
||||||
|
? const Color(0xFFFEE2E2)
|
||||||
|
: const Color(0xFFEFF6FF),
|
||||||
|
child: Icon(
|
||||||
|
_recording ? Icons.graphic_eq : Icons.mic,
|
||||||
|
color: _recording
|
||||||
|
? const Color(0xFFDC2626)
|
||||||
|
: const Color(0xFF2563EB),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_recording
|
||||||
|
? 'Recording... tap stop when done'
|
||||||
|
: _voicePath == null
|
||||||
|
? 'No voice note recorded'
|
||||||
|
: 'Voice note ready',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_recording
|
||||||
|
? 'Speak clearly near the microphone'
|
||||||
|
: _voicePath == null
|
||||||
|
? 'Record a short message for User'
|
||||||
|
: '${_voiceDuration}s audio attached',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF64748B), fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _loading ? null : _toggleRecording,
|
||||||
|
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
|
||||||
|
label: Text(_recording ? 'Stop' : 'Record'),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: _recording
|
||||||
|
? const Color(0xFFDC2626)
|
||||||
|
: const Color(0xFF2563EB),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _loading ? null : _send,
|
onPressed: _loading ? null : _send,
|
||||||
@ -96,7 +251,11 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
|||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.send),
|
: const Icon(Icons.send),
|
||||||
label: Text(_loading ? 'Sending...' : 'Send Message'),
|
label: Text(_loading
|
||||||
|
? 'Sending...'
|
||||||
|
: _voiceMode
|
||||||
|
? 'Send Voice Message'
|
||||||
|
: 'Send Message'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -63,6 +63,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<Map<String, dynamic>> _items = const [];
|
List<Map<String, dynamic>> _items = const [];
|
||||||
|
bool get _isVoiceCommands => widget.endpoint.contains('voice-commands');
|
||||||
|
bool get _isShortcuts => widget.endpoint.contains('shortcuts');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -131,28 +133,188 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
|||||||
itemBuilder: (_, index) => _EndpointCard(
|
itemBuilder: (_, index) => _EndpointCard(
|
||||||
icon: widget.icon,
|
icon: widget.icon,
|
||||||
item: _items[index],
|
item: _items[index],
|
||||||
|
editable: _isVoiceCommands || _isShortcuts,
|
||||||
|
onEdit: () => _editItem(_items[index]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editItem(Map<String, dynamic> item) async {
|
||||||
|
if (_isVoiceCommands) {
|
||||||
|
await _editVoiceCommand(item);
|
||||||
|
} else if (_isShortcuts) {
|
||||||
|
await _editShortcut(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editVoiceCommand(Map<String, dynamic> item) async {
|
||||||
|
final phrase = TextEditingController(
|
||||||
|
text: item['triggerPhrase']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
var enabled = item['enabled'] != false;
|
||||||
|
final saved = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
|
||||||
|
'Voice Command'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: phrase,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Trigger phrase',
|
||||||
|
prefixIcon: Icon(Icons.record_voice_over_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: enabled,
|
||||||
|
onChanged: (value) => setDialogState(() => enabled = value),
|
||||||
|
title: const Text('Enabled'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Save')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (saved != true) return;
|
||||||
|
await _submitUpdate({
|
||||||
|
'commandKey': item['commandKey'],
|
||||||
|
'triggerPhrase': phrase.text.trim(),
|
||||||
|
'enabled': enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _editShortcut(Map<String, dynamic> item) async {
|
||||||
|
final buttonName = TextEditingController(
|
||||||
|
text: item['buttonName']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
final buttonCode = TextEditingController(
|
||||||
|
text: item['buttonCode']?.toString() ?? '',
|
||||||
|
);
|
||||||
|
var enabled = item['enabled'] != false;
|
||||||
|
final saved = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
|
||||||
|
'Shortcut'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: buttonName,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Button name',
|
||||||
|
prefixIcon: Icon(Icons.touch_app_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: buttonCode,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Android key code',
|
||||||
|
prefixIcon: Icon(Icons.numbers_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: enabled,
|
||||||
|
onChanged: (value) => setDialogState(() => enabled = value),
|
||||||
|
title: const Text('Enabled'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel')),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Save')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (saved != true) return;
|
||||||
|
await _submitUpdate({
|
||||||
|
'shortcutKey': item['shortcutKey'],
|
||||||
|
'buttonName': buttonName.text.trim(),
|
||||||
|
'buttonCode': int.tryParse(buttonCode.text.trim()),
|
||||||
|
'enabled': enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitUpdate(Map<String, dynamic> payload) async {
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
await sl<ApiClient>()
|
||||||
|
.dio
|
||||||
|
.put(widget.endpoint, data: payload)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Konfigurasi berhasil disimpan.')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
},
|
||||||
|
fallback: 'Konfigurasi belum bisa disimpan.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EndpointCard extends StatelessWidget {
|
class _EndpointCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Map<String, dynamic> item;
|
final Map<String, dynamic> item;
|
||||||
|
final bool editable;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
|
||||||
const _EndpointCard({required this.icon, required this.item});
|
const _EndpointCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.item,
|
||||||
|
this.editable = false,
|
||||||
|
this.onEdit,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
|
final title = _labelFromKey(
|
||||||
|
_firstText(item, ['commandKey', 'shortcutKey', 'name', 'command']) ??
|
||||||
|
'',
|
||||||
|
) ??
|
||||||
'Item #${item['id'] ?? '-'}';
|
'Item #${item['id'] ?? '-'}';
|
||||||
final subtitle = _firstText(
|
final subtitle = _firstText(
|
||||||
item,
|
item,
|
||||||
['description', 'action', 'shortcut', 'status', 'createdAt'],
|
[
|
||||||
|
'triggerPhrase',
|
||||||
|
'buttonName',
|
||||||
|
'description',
|
||||||
|
'action',
|
||||||
|
'shortcut',
|
||||||
|
'status',
|
||||||
|
'createdAt'
|
||||||
|
],
|
||||||
) ??
|
) ??
|
||||||
'Data aktif';
|
'Data aktif';
|
||||||
|
final enabled = item['enabled'] != false;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -181,15 +343,65 @@ class _EndpointCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(subtitle,
|
Text(subtitle,
|
||||||
style: const TextStyle(color: Color(0xFF64748B))),
|
style: const TextStyle(color: Color(0xFF64748B))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
_SmallPill(
|
||||||
|
label: enabled ? 'Enabled' : 'Disabled',
|
||||||
|
color: enabled
|
||||||
|
? const Color(0xFF16A34A)
|
||||||
|
: const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
if (item['buttonCode'] != null)
|
||||||
|
_SmallPill(
|
||||||
|
label: 'Key ${item['buttonCode']}',
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (editable)
|
||||||
|
IconButton(
|
||||||
|
onPressed: onEdit,
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
tooltip: 'Edit',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SmallPill extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _SmallPill({required this.label, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
||||||
for (final key in keys) {
|
for (final key in keys) {
|
||||||
final value = item[key]?.toString().trim();
|
final value = item[key]?.toString().trim();
|
||||||
@ -197,3 +409,13 @@ String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _labelFromKey(String value) {
|
||||||
|
if (value.trim().isEmpty) return null;
|
||||||
|
return value
|
||||||
|
.split('_')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.map((part) =>
|
||||||
|
part[0].toUpperCase() + part.substring(1).toLowerCase())
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../guardian_activity_log_screen.dart';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../guardian_ai_config_screen.dart';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../guardian_map_screen.dart';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../guardian_send_notification_screen.dart';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../guardian_settings_screen.dart';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../guardian_tools_screen.dart';
|
||||||
@ -13,6 +13,7 @@ import '../../../app/injection_container.dart';
|
|||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/services/websocket_service.dart';
|
import '../../../core/services/websocket_service.dart';
|
||||||
import '../../../core/storage/secure_storage.dart';
|
import '../../../core/storage/secure_storage.dart';
|
||||||
|
import '../../../core/utils/operation_guard.dart';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// GUARDIAN DASHBOARD SCREEN
|
// GUARDIAN DASHBOARD SCREEN
|
||||||
@ -57,6 +58,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
duration: const Duration(milliseconds: 600),
|
duration: const Duration(milliseconds: 600),
|
||||||
);
|
);
|
||||||
bool _sosAlert = false;
|
bool _sosAlert = false;
|
||||||
|
List<Map<String, dynamic>> _pendingSos = const [];
|
||||||
|
|
||||||
// ── Refresh button animation ─────────────────────────────────────────────────
|
// ── Refresh button animation ─────────────────────────────────────────────────
|
||||||
late final AnimationController _refreshCtrl = AnimationController(
|
late final AnimationController _refreshCtrl = AnimationController(
|
||||||
@ -89,7 +91,8 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
try {
|
await guarded<void>(
|
||||||
|
() async {
|
||||||
_guardianName =
|
_guardianName =
|
||||||
await sl<SecureStorage>().getDisplayName() ?? 'Guardian';
|
await sl<SecureStorage>().getDisplayName() ?? 'Guardian';
|
||||||
|
|
||||||
@ -103,7 +106,8 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
final dashboard = results[0] as Map<String, dynamic>?;
|
final dashboard = results[0] as Map<String, dynamic>?;
|
||||||
final activityList =
|
final activityList =
|
||||||
results[1] as List<Map<String, dynamic>>;
|
results[1] as List<Map<String, dynamic>>;
|
||||||
final sosPending = results[2] as int;
|
final sosPendingEvents = results[2] as List<Map<String, dynamic>>;
|
||||||
|
final sosPending = sosPendingEvents.length;
|
||||||
|
|
||||||
// Extract latest GPS from dashboard
|
// Extract latest GPS from dashboard
|
||||||
final lastLoc = dashboard?['lastLocation'] as Map<String, dynamic>?;
|
final lastLoc = dashboard?['lastLocation'] as Map<String, dynamic>?;
|
||||||
@ -150,6 +154,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
recentActivity: activityList,
|
recentActivity: activityList,
|
||||||
isPaired: userStatus != null || dashboard != null,
|
isPaired: userStatus != null || dashboard != null,
|
||||||
);
|
);
|
||||||
|
_pendingSos = sosPendingEvents;
|
||||||
if (newLatLng != null) {
|
if (newLatLng != null) {
|
||||||
_liveLatLng = newLatLng;
|
_liveLatLng = newLatLng;
|
||||||
}
|
}
|
||||||
@ -163,32 +168,31 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
|
|
||||||
// Move map to latest location
|
// Move map to latest location
|
||||||
if (newLatLng != null) {
|
if (newLatLng != null) {
|
||||||
try {
|
_moveMapSafely(newLatLng);
|
||||||
_mapController.move(newLatLng, 15);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
setState(() {
|
onError: (e) => setState(() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
_error = _friendlyError(e);
|
_error = _friendlyError(e);
|
||||||
});
|
}),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> _fetchDashboard() async {
|
Future<Map<String, dynamic>?> _fetchDashboard() async {
|
||||||
try {
|
return await guarded<Map<String, dynamic>?>(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/guardian/dashboard')
|
.get('/guardian/dashboard')
|
||||||
.timeout(const Duration(seconds: 8));
|
.timeout(const Duration(seconds: 8));
|
||||||
final d = res.data['data'];
|
final d = res.data['data'];
|
||||||
return d is Map ? Map<String, dynamic>.from(d) : null;
|
return d is Map ? Map<String, dynamic>.from(d) : null;
|
||||||
} catch (_) {
|
},
|
||||||
return null;
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> _fetchActivity() async {
|
Future<List<Map<String, dynamic>>> _fetchActivity() async {
|
||||||
try {
|
return await guarded<List<Map<String, dynamic>>>(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/guardian/activity-logs',
|
.get('/guardian/activity-logs',
|
||||||
queryParameters: {'size': 5, 'page': 0})
|
queryParameters: {'size': 5, 'page': 0})
|
||||||
@ -202,12 +206,15 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
.map((e) => Map<String, dynamic>.from(e))
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
return const [];
|
||||||
return const [];
|
},
|
||||||
|
) ??
|
||||||
|
const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> _fetchSosPending() async {
|
Future<List<Map<String, dynamic>>> _fetchSosPending() async {
|
||||||
try {
|
return await guarded<List<Map<String, dynamic>>>(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/guardian/sos-events',
|
.get('/guardian/sos-events',
|
||||||
queryParameters: {'size': 10, 'page': 0})
|
queryParameters: {'size': 10, 'page': 0})
|
||||||
@ -219,17 +226,21 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
return content
|
return content
|
||||||
.whereType<Map>()
|
.whereType<Map>()
|
||||||
.where((e) => e['status'] == 'TRIGGERED')
|
.where((e) => e['status'] == 'TRIGGERED')
|
||||||
.length;
|
.map((e) => Map<String, dynamic>.from(e))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
return const [];
|
||||||
return 0;
|
},
|
||||||
|
) ??
|
||||||
|
const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket subscription ──────────────────────────────────────────────────
|
// ── WebSocket subscription ──────────────────────────────────────────────────
|
||||||
void _subscribeWebSocket() {
|
void _subscribeWebSocket() {
|
||||||
final ws = sl<WebSocketService>();
|
final ws = sl<WebSocketService>();
|
||||||
Future.microtask(() async {
|
Future.microtask(() async {
|
||||||
try {
|
await guarded<void>(
|
||||||
|
() async {
|
||||||
final userId = await _getLinkedUserId();
|
final userId = await _getLinkedUserId();
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
ws.subscribeLocation(userId, (lat, lng) {
|
ws.subscribeLocation(userId, (lat, lng) {
|
||||||
@ -239,26 +250,30 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
_liveLatLng = newPos;
|
_liveLatLng = newPos;
|
||||||
_liveConnected = true;
|
_liveConnected = true;
|
||||||
});
|
});
|
||||||
try {
|
_moveMapSafely(newPos);
|
||||||
_mapController.move(newPos, 15);
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
});
|
||||||
ws.subscribeSos((sosData) {
|
ws.subscribeSos((sosData) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_triggerSosFlash();
|
_triggerSosFlash();
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_pendingSos = [
|
||||||
|
Map<String, dynamic>.from(sosData),
|
||||||
|
..._pendingSos,
|
||||||
|
];
|
||||||
_data = _data?.copyWith(
|
_data = _data?.copyWith(
|
||||||
unreadSos: (_data?.unreadSos ?? 0) + 1);
|
unreadSos: (_data?.unreadSos ?? 0) + 1);
|
||||||
});
|
});
|
||||||
_showSosSnackbar(sosData);
|
_showSosSnackbar(sosData);
|
||||||
});
|
});
|
||||||
if (mounted) setState(() => _liveConnected = true);
|
if (mounted) setState(() => _liveConnected = true);
|
||||||
} catch (_) {}
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _getLinkedUserId() async {
|
Future<String?> _getLinkedUserId() async {
|
||||||
try {
|
return await guarded<String?>(
|
||||||
|
() async {
|
||||||
final res = await _api
|
final res = await _api
|
||||||
.get('/shared/pairing/status')
|
.get('/shared/pairing/status')
|
||||||
.timeout(const Duration(seconds: 5));
|
.timeout(const Duration(seconds: 5));
|
||||||
@ -267,8 +282,9 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
return d['pairedWithId']?.toString() ??
|
return d['pairedWithId']?.toString() ??
|
||||||
d['userId']?.toString();
|
d['userId']?.toString();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
return null;
|
||||||
return null;
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _triggerSosFlash() {
|
void _triggerSosFlash() {
|
||||||
@ -303,14 +319,56 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'Lihat',
|
label: 'Tangani',
|
||||||
textColor: Colors.white,
|
textColor: Colors.white,
|
||||||
onPressed: () => context.go('/guardian/logs'),
|
onPressed: _handleLatestSos,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleLatestSos() async {
|
||||||
|
final sosId = _pendingSos
|
||||||
|
.map((e) => e['id'])
|
||||||
|
.where((id) => id != null)
|
||||||
|
.map((id) => int.tryParse(id.toString()))
|
||||||
|
.firstWhere((id) => id != null, orElse: () => null);
|
||||||
|
if (sosId == null) {
|
||||||
|
await _loadAll(silent: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await guarded<void>(
|
||||||
|
() async {
|
||||||
|
await _api
|
||||||
|
.put('/guardian/sos/$sosId/resolve')
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingSos =
|
||||||
|
_pendingSos.where((e) => e['id']?.toString() != '$sosId').toList();
|
||||||
|
_data = _data?.copyWith(unreadSos: _pendingSos.length);
|
||||||
|
if (_pendingSos.isEmpty) {
|
||||||
|
_sosAlert = false;
|
||||||
|
_sosCtrl.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('SOS ditandai sudah ditangani.')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Gagal menandai SOS. Coba refresh.')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _moveMapSafely(LatLng position) {
|
||||||
|
guarded<void>(() async => _mapController.move(position, 15));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
_refreshCtrl.forward(from: 0);
|
_refreshCtrl.forward(from: 0);
|
||||||
@ -517,10 +575,10 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/guardian/logs'),
|
onPressed: _handleLatestSos,
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: Colors.white),
|
foregroundColor: Colors.white),
|
||||||
child: const Text('Tangani'),
|
child: const Text('Handle'),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
|
import '../../core/utils/operation_guard.dart';
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -70,7 +71,8 @@ class _NavState extends Cubit<int> {
|
|||||||
// ── locate ──────────────────────────────────────────────────────────────
|
// ── locate ──────────────────────────────────────────────────────────────
|
||||||
Future<bool> locate() async {
|
Future<bool> locate() async {
|
||||||
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
|
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
|
||||||
try {
|
final located = await guarded<bool>(
|
||||||
|
() async {
|
||||||
LocationPermission perm = await Geolocator.checkPermission();
|
LocationPermission perm = await Geolocator.checkPermission();
|
||||||
if (perm == LocationPermission.denied) {
|
if (perm == LocationPermission.denied) {
|
||||||
perm = await Geolocator.requestPermission();
|
perm = await Geolocator.requestPermission();
|
||||||
@ -86,14 +88,12 @@ class _NavState extends Cubit<int> {
|
|||||||
_set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.');
|
_set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.');
|
||||||
_reportToBackend(pos);
|
_reportToBackend(pos);
|
||||||
return true;
|
return true;
|
||||||
} on TimeoutException {
|
},
|
||||||
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
|
onTimeout: () => _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'),
|
||||||
return false;
|
onError: (_) => _set(_NavPhase.error,
|
||||||
} catch (e) {
|
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'),
|
||||||
_set(_NavPhase.error,
|
);
|
||||||
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
|
return located ?? false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _reportToBackend(Position pos) {
|
void _reportToBackend(Position pos) {
|
||||||
@ -112,7 +112,8 @@ class _NavState extends Cubit<int> {
|
|||||||
// ── search Nominatim ─────────────────────────────────────────────────────
|
// ── search Nominatim ─────────────────────────────────────────────────────
|
||||||
Future<List<_Place>> searchPlaces(String query) async {
|
Future<List<_Place>> searchPlaces(String query) async {
|
||||||
if (query.trim().isEmpty) return const [];
|
if (query.trim().isEmpty) return const [];
|
||||||
try {
|
return await guarded<List<_Place>>(
|
||||||
|
() async {
|
||||||
final res = await Dio().get(
|
final res = await Dio().get(
|
||||||
'https://nominatim.openstreetmap.org/search',
|
'https://nominatim.openstreetmap.org/search',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
@ -137,9 +138,8 @@ class _NavState extends Cubit<int> {
|
|||||||
position: LatLng(lat, lng),
|
position: LatLng(lat, lng),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (_) {
|
},
|
||||||
return const [];
|
) ?? const [];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _viewbox(LatLng c) =>
|
String _viewbox(LatLng c) =>
|
||||||
@ -147,7 +147,8 @@ class _NavState extends Cubit<int> {
|
|||||||
|
|
||||||
// ── reverse geocode ──────────────────────────────────────────────────────
|
// ── reverse geocode ──────────────────────────────────────────────────────
|
||||||
Future<String> reverseGeocode(LatLng pos) async {
|
Future<String> reverseGeocode(LatLng pos) async {
|
||||||
try {
|
return await guarded<String>(
|
||||||
|
() async {
|
||||||
final res = await Dio().get(
|
final res = await Dio().get(
|
||||||
'https://nominatim.openstreetmap.org/reverse',
|
'https://nominatim.openstreetmap.org/reverse',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
@ -162,9 +163,9 @@ class _NavState extends Cubit<int> {
|
|||||||
);
|
);
|
||||||
return res.data['display_name']?.toString() ??
|
return res.data['display_name']?.toString() ??
|
||||||
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
||||||
} catch (_) {
|
},
|
||||||
return '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
) ??
|
||||||
}
|
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OSRM routing ─────────────────────────────────────────────────────────
|
// ── OSRM routing ─────────────────────────────────────────────────────────
|
||||||
@ -177,7 +178,8 @@ class _NavState extends Cubit<int> {
|
|||||||
_set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}…');
|
_set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}…');
|
||||||
|
|
||||||
final origin = currentPosition!;
|
final origin = currentPosition!;
|
||||||
try {
|
await guarded<void>(
|
||||||
|
() async {
|
||||||
final url = 'http://router.project-osrm.org/route/v1/foot/'
|
final url = 'http://router.project-osrm.org/route/v1/foot/'
|
||||||
'${origin.longitude},${origin.latitude};'
|
'${origin.longitude},${origin.latitude};'
|
||||||
'${dest.position.longitude},${dest.position.latitude}'
|
'${dest.position.longitude},${dest.position.latitude}'
|
||||||
@ -219,10 +221,10 @@ class _NavState extends Cubit<int> {
|
|||||||
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
||||||
_notify();
|
_notify();
|
||||||
_startTracking();
|
_startTracking();
|
||||||
} catch (e) {
|
},
|
||||||
_set(_NavPhase.error,
|
onError: (_) => _set(_NavPhase.error,
|
||||||
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
|
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.'),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _shortName(_Place p) {
|
String _shortName(_Place p) {
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../navigation_mode_screen.dart';
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../domain/entities/guardian_notification.dart';
|
||||||
|
import '../domain/repositories/notification_repository.dart';
|
||||||
|
|
||||||
|
class NotificationState {
|
||||||
|
final bool loading;
|
||||||
|
final List<GuardianNotificationEntity> items;
|
||||||
|
final String? error;
|
||||||
|
final bool markingAll;
|
||||||
|
|
||||||
|
const NotificationState({
|
||||||
|
this.loading = false,
|
||||||
|
this.items = const [],
|
||||||
|
this.error,
|
||||||
|
this.markingAll = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
NotificationState copyWith({
|
||||||
|
bool? loading,
|
||||||
|
List<GuardianNotificationEntity>? items,
|
||||||
|
String? error,
|
||||||
|
bool? markingAll,
|
||||||
|
}) {
|
||||||
|
return NotificationState(
|
||||||
|
loading: loading ?? this.loading,
|
||||||
|
items: items ?? this.items,
|
||||||
|
error: error,
|
||||||
|
markingAll: markingAll ?? this.markingAll,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationCubit extends Cubit<NotificationState> {
|
||||||
|
final NotificationRepository _repository;
|
||||||
|
|
||||||
|
NotificationCubit(this._repository) : super(const NotificationState());
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
emit(const NotificationState(loading: true));
|
||||||
|
final result = await _repository.getNotifications();
|
||||||
|
result.fold(
|
||||||
|
(failure) => emit(NotificationState(error: failure.message)),
|
||||||
|
(items) => emit(NotificationState(items: items)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markOneRead(int id) async {
|
||||||
|
final result = await _repository.markOneRead(id);
|
||||||
|
result.fold(
|
||||||
|
(failure) => emit(state.copyWith(error: failure.message)),
|
||||||
|
(_) => emit(state.copyWith(
|
||||||
|
items: state.items
|
||||||
|
.map((item) => item.id == id
|
||||||
|
? GuardianNotificationEntity(
|
||||||
|
id: item.id,
|
||||||
|
notificationType: item.notificationType,
|
||||||
|
content: item.content,
|
||||||
|
voiceNoteUrl: item.voiceNoteUrl,
|
||||||
|
voiceNoteDuration: item.voiceNoteDuration,
|
||||||
|
isRead: true,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
)
|
||||||
|
: item)
|
||||||
|
.toList(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAllRead() async {
|
||||||
|
emit(state.copyWith(markingAll: true));
|
||||||
|
final result = await _repository.markAllRead();
|
||||||
|
result.fold(
|
||||||
|
(failure) => emit(state.copyWith(error: failure.message, markingAll: false)),
|
||||||
|
(_) => emit(state.copyWith(
|
||||||
|
markingAll: false,
|
||||||
|
items: state.items
|
||||||
|
.map((item) => GuardianNotificationEntity(
|
||||||
|
id: item.id,
|
||||||
|
notificationType: item.notificationType,
|
||||||
|
content: item.content,
|
||||||
|
voiceNoteUrl: item.voiceNoteUrl,
|
||||||
|
voiceNoteDuration: item.voiceNoteDuration,
|
||||||
|
isRead: true,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../../../core/storage/local_database.dart';
|
||||||
|
import '../../domain/entities/guardian_notification.dart';
|
||||||
|
import '../../domain/repositories/notification_repository.dart';
|
||||||
|
|
||||||
|
class NotificationRepositoryImpl implements NotificationRepository {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final LocalDatabase _database;
|
||||||
|
|
||||||
|
const NotificationRepositoryImpl(this._apiClient, this._database);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications() async {
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.dio.get('/user/notifications');
|
||||||
|
final body = response.data;
|
||||||
|
final data = body is Map ? body['data'] : body;
|
||||||
|
final rawList = data is Map ? data['content'] : data;
|
||||||
|
final list = rawList is List
|
||||||
|
? rawList
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => _fromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.toList()
|
||||||
|
: <GuardianNotificationEntity>[];
|
||||||
|
await _database.notifications.replaceAll(
|
||||||
|
list
|
||||||
|
.map((item) => CachedNotification(
|
||||||
|
id: item.id,
|
||||||
|
notificationType: item.notificationType,
|
||||||
|
content: item.content,
|
||||||
|
voiceNoteUrl: item.voiceNoteUrl,
|
||||||
|
isRead: item.isRead,
|
||||||
|
createdAt: item.createdAt ?? DateTime.now(),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
return Right(list);
|
||||||
|
} catch (_) {
|
||||||
|
final cached = await _database.notifications.getAll();
|
||||||
|
return Right(
|
||||||
|
cached
|
||||||
|
.map((item) => GuardianNotificationEntity(
|
||||||
|
id: item.id,
|
||||||
|
notificationType: item.notificationType,
|
||||||
|
content: item.content,
|
||||||
|
voiceNoteUrl: item.voiceNoteUrl,
|
||||||
|
isRead: item.isRead,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> markAllRead() async {
|
||||||
|
try {
|
||||||
|
await _apiClient.dio.put('/user/notifications/mark-all-read');
|
||||||
|
return const Right(null);
|
||||||
|
} catch (_) {
|
||||||
|
return const Left(NetworkFailure('Gagal menandai semua notifikasi.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> markOneRead(int id) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.dio.put('/user/notifications/$id/read');
|
||||||
|
return const Right(null);
|
||||||
|
} catch (_) {
|
||||||
|
return const Left(NetworkFailure('Gagal menandai notifikasi.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GuardianNotificationEntity _fromJson(Map<String, dynamic> json) {
|
||||||
|
return GuardianNotificationEntity(
|
||||||
|
id: (json['id'] as num?)?.toInt(),
|
||||||
|
notificationType:
|
||||||
|
json['notifType']?.toString() ?? json['notificationType']?.toString() ?? 'TEXT',
|
||||||
|
content: json['content']?.toString(),
|
||||||
|
voiceNoteUrl: json['voiceNoteUrl']?.toString(),
|
||||||
|
voiceNoteDuration: (json['voiceNoteDuration'] as num?)?.toInt(),
|
||||||
|
isRead: json['isRead'] == true,
|
||||||
|
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
class GuardianNotificationEntity {
|
||||||
|
final int? id;
|
||||||
|
final String notificationType;
|
||||||
|
final String? content;
|
||||||
|
final String? voiceNoteUrl;
|
||||||
|
final int? voiceNoteDuration;
|
||||||
|
final bool isRead;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
const GuardianNotificationEntity({
|
||||||
|
this.id,
|
||||||
|
required this.notificationType,
|
||||||
|
this.content,
|
||||||
|
this.voiceNoteUrl,
|
||||||
|
this.voiceNoteDuration,
|
||||||
|
this.isRead = false,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../entities/guardian_notification.dart';
|
||||||
|
|
||||||
|
abstract class NotificationRepository {
|
||||||
|
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications();
|
||||||
|
Future<Either<Failure, void>> markAllRead();
|
||||||
|
Future<Either<Failure, void>> markOneRead(int id);
|
||||||
|
}
|
||||||
@ -2,16 +2,19 @@
|
|||||||
// ignore_for_file: use_build_context_synchronously
|
// ignore_for_file: use_build_context_synchronously
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:dio/dio.dart';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/errors/friendly_error.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/network/api_client.dart';
|
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import 'application/notification_cubit.dart';
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
import 'domain/entities/guardian_notification.dart';
|
||||||
|
|
||||||
class NotificationScreen extends StatefulWidget {
|
class NotificationScreen extends StatefulWidget {
|
||||||
const NotificationScreen({super.key});
|
const NotificationScreen({super.key});
|
||||||
@ -21,58 +24,31 @@ class NotificationScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NotificationScreenState extends State<NotificationScreen> {
|
class _NotificationScreenState extends State<NotificationScreen> {
|
||||||
List<_NotifItem> _items = [];
|
late final NotificationCubit _notificationCubit;
|
||||||
bool _loading = true;
|
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||||
String? _error;
|
|
||||||
bool _markingAll = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_notificationCubit = sl<NotificationCubit>();
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
@override
|
||||||
setState(() {
|
void dispose() {
|
||||||
_loading = true;
|
_audioPlayer.dispose();
|
||||||
_error = null;
|
_notificationCubit.close();
|
||||||
});
|
super.dispose();
|
||||||
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) {
|
Future<void> _load() async {
|
||||||
final data = responseBody is Map ? responseBody['data'] : null;
|
await _notificationCubit.load();
|
||||||
final rawList = data is Map ? data['content'] : data;
|
|
||||||
if (rawList is! List) return const [];
|
|
||||||
return rawList
|
|
||||||
.whereType<Map>()
|
|
||||||
.map((item) => Map<String, dynamic>.from(item))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markRead(int id) async {
|
Future<void> _markRead(int id) async {
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
await _api
|
await _notificationCubit.markOneRead(id);
|
||||||
.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: (_) {},
|
onError: (_) {},
|
||||||
fallback: 'Gagal menandai notifikasi.',
|
fallback: 'Gagal menandai notifikasi.',
|
||||||
@ -80,45 +56,80 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _markAllRead() async {
|
Future<void> _markAllRead() async {
|
||||||
setState(() => _markingAll = true);
|
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
await _api
|
await _notificationCubit.markAllRead();
|
||||||
.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.');
|
_snack('Semua notifikasi ditandai sudah dibaca.');
|
||||||
},
|
},
|
||||||
onError: _snack,
|
onError: _snack,
|
||||||
fallback: 'Gagal menandai semua dibaca.',
|
fallback: 'Gagal menandai semua dibaca.',
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _markingAll = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _readAloud(_NotifItem notif) async {
|
Future<void> _readAloud(_NotifItem notif) async {
|
||||||
final tts = sl<TtsService>();
|
if (notif.type == 'VOICE_NOTE') {
|
||||||
tts.speak(notif.content ?? 'Voice note dari Guardian.');
|
final source = notif.voiceNoteUrl == 'inline-audio'
|
||||||
|
? notif.content
|
||||||
|
: notif.voiceNoteUrl;
|
||||||
|
if (source == null || source.isEmpty) {
|
||||||
|
_snack('Voice note kosong atau belum tersedia.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _playVoiceNote(source);
|
||||||
|
} else {
|
||||||
|
final tts = sl<TtsService>();
|
||||||
|
tts.speak(notif.content ?? 'Pesan dari Guardian.');
|
||||||
|
}
|
||||||
await _markRead(notif.id);
|
await _markRead(notif.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _playVoiceNote(String source) async {
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
String? localPath;
|
||||||
|
if (source.startsWith('data:audio')) {
|
||||||
|
final comma = source.indexOf(',');
|
||||||
|
if (comma == -1) {
|
||||||
|
throw const FormatException('Invalid inline audio payload');
|
||||||
|
}
|
||||||
|
final bytes = base64Decode(source.substring(comma + 1));
|
||||||
|
final dir = await getTemporaryDirectory();
|
||||||
|
final file = File(
|
||||||
|
'${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a');
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
localPath = file.path;
|
||||||
|
}
|
||||||
|
if (localPath != null) {
|
||||||
|
await _audioPlayer.setFilePath(localPath);
|
||||||
|
} else {
|
||||||
|
await _audioPlayer.setUrl(source);
|
||||||
|
}
|
||||||
|
await _audioPlayer.play();
|
||||||
|
},
|
||||||
|
onError: _snack,
|
||||||
|
fallback: 'Voice note belum bisa diputar.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _snack(String msg) {
|
void _snack(String msg) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int get _unreadCount => _items.where((n) => !n.isRead).length;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return BlocBuilder<NotificationCubit, NotificationState>(
|
||||||
child: Padding(
|
bloc: _notificationCubit,
|
||||||
padding: const EdgeInsets.all(16),
|
builder: (context, state) {
|
||||||
child: Column(
|
final items = state.items.map(_NotifItem.fromEntity).toList();
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final unreadCount = items.where((n) => !n.isRead).length;
|
||||||
children: [
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -135,9 +146,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
.headlineSmall
|
.headlineSmall
|
||||||
?.copyWith(fontWeight: FontWeight.w800),
|
?.copyWith(fontWeight: FontWeight.w800),
|
||||||
),
|
),
|
||||||
if (_unreadCount > 0) ...[
|
if (unreadCount > 0) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_UnreadBadge(count: _unreadCount),
|
_UnreadBadge(count: unreadCount),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -148,10 +159,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_unreadCount > 0)
|
if (unreadCount > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _markingAll ? null : _markAllRead,
|
onPressed: state.markingAll ? null : _markAllRead,
|
||||||
icon: _markingAll
|
icon: state.markingAll
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 14,
|
height: 14,
|
||||||
@ -170,29 +181,31 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
|
|
||||||
// Body
|
// Body
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _loading
|
child: state.loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _error != null
|
: state.error != null
|
||||||
? _ErrorPanel(message: _error!, onRetry: _load)
|
? _ErrorPanel(message: state.error!, onRetry: _load)
|
||||||
: _items.isEmpty
|
: items.isEmpty
|
||||||
? const _EmptyPanel()
|
? const _EmptyPanel()
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
itemCount: _items.length,
|
itemCount: items.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
itemBuilder: (ctx, i) => _NotifCard(
|
itemBuilder: (ctx, i) => _NotifCard(
|
||||||
notif: _items[i],
|
notif: items[i],
|
||||||
onMarkRead: () => _markRead(_items[i].id),
|
onMarkRead: () => _markRead(items[i].id),
|
||||||
onReadAloud: () => _readAloud(_items[i]),
|
onReadAloud: () => _readAloud(items[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -216,14 +229,14 @@ class _NotifItem {
|
|||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _NotifItem.fromJson(Map<String, dynamic> j) => _NotifItem(
|
factory _NotifItem.fromEntity(GuardianNotificationEntity entity) =>
|
||||||
id: j['id'] as int,
|
_NotifItem(
|
||||||
type: j['notifType']?.toString() ?? 'TEXT',
|
id: entity.id ?? 0,
|
||||||
content: j['content']?.toString(),
|
type: entity.notificationType,
|
||||||
voiceNoteUrl: j['voiceNoteUrl']?.toString(),
|
content: entity.content,
|
||||||
isRead: j['isRead'] == true,
|
voiceNoteUrl: entity.voiceNoteUrl,
|
||||||
createdAt: DateTime.tryParse(j['createdAt']?.toString() ?? '') ??
|
isRead: entity.isRead,
|
||||||
DateTime.now(),
|
createdAt: entity.createdAt ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
_NotifItem copyWith({bool? isRead}) => _NotifItem(
|
_NotifItem copyWith({bool? isRead}) => _NotifItem(
|
||||||
@ -329,7 +342,9 @@ class _NotifCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (notif.content != null && notif.content!.isNotEmpty) ...[
|
if (!isVoice &&
|
||||||
|
notif.content != null &&
|
||||||
|
notif.content!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
notif.content!,
|
notif.content!,
|
||||||
@ -342,8 +357,10 @@ class _NotifCard extends StatelessWidget {
|
|||||||
// Read aloud button
|
// Read aloud button
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: onReadAloud,
|
onPressed: onReadAloud,
|
||||||
icon: const Icon(Icons.volume_up, size: 16),
|
icon: Icon(isVoice ? Icons.play_arrow : Icons.volume_up,
|
||||||
label: const Text('Bacakan', style: TextStyle(fontSize: 13)),
|
size: 16),
|
||||||
|
label: Text(isVoice ? 'Putar' : 'Bacakan',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../notification_screen.dart';
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../pairing_screens.dart';
|
||||||
@ -27,7 +27,7 @@ class ServerConnectScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||||
final _url = TextEditingController(text: 'http://202.46.28.160:8080');
|
final _url = TextEditingController();
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
bool _ok = false;
|
bool _ok = false;
|
||||||
String? _message;
|
String? _message;
|
||||||
@ -76,7 +76,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
|||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Server URL',
|
labelText: 'Server URL',
|
||||||
hintText: 'http://202.46.28.160:8080',
|
hintText: 'http://server-ip:8080',
|
||||||
prefixIcon: Icon(Icons.dns_outlined),
|
prefixIcon: Icon(Icons.dns_outlined),
|
||||||
)),
|
)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../user_settings_screen.dart';
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../domain/repositories/sos_repository.dart';
|
||||||
|
|
||||||
|
enum SosPhase { idle, sending, sent, error }
|
||||||
|
|
||||||
|
class SosState {
|
||||||
|
final SosPhase phase;
|
||||||
|
final String? message;
|
||||||
|
|
||||||
|
const SosState({this.phase = SosPhase.idle, this.message});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SosCubit extends Cubit<SosState> {
|
||||||
|
final SosRepository _repository;
|
||||||
|
|
||||||
|
SosCubit(this._repository) : super(const SosState());
|
||||||
|
|
||||||
|
Future<void> trigger({
|
||||||
|
String triggerType = 'MANUAL',
|
||||||
|
double? lat,
|
||||||
|
double? lng,
|
||||||
|
}) async {
|
||||||
|
emit(const SosState(phase: SosPhase.sending));
|
||||||
|
final result = await _repository.triggerSos(
|
||||||
|
triggerType: triggerType,
|
||||||
|
lat: lat,
|
||||||
|
lng: lng,
|
||||||
|
);
|
||||||
|
result.fold(
|
||||||
|
(failure) => emit(SosState(phase: SosPhase.error, message: failure.message)),
|
||||||
|
(_) => emit(const SosState(
|
||||||
|
phase: SosPhase.sent,
|
||||||
|
message: 'SOS terkirim. Guardian sudah diberi tahu.',
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../domain/repositories/sos_repository.dart';
|
||||||
|
|
||||||
|
class SosRepositoryImpl implements SosRepository {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
const SosRepositoryImpl(this._apiClient);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> triggerSos({
|
||||||
|
required String triggerType,
|
||||||
|
double? lat,
|
||||||
|
double? lng,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.dio.post('/user/sos', data: {
|
||||||
|
'triggerType': triggerType,
|
||||||
|
'lat': lat,
|
||||||
|
'lng': lng,
|
||||||
|
});
|
||||||
|
return const Right(null);
|
||||||
|
} catch (_) {
|
||||||
|
return const Left(NetworkFailure('SOS belum terkirim. Coba lagi.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
class SosEvent {
|
||||||
|
final int? id;
|
||||||
|
final String triggerType;
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
final String status;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
const SosEvent({
|
||||||
|
this.id,
|
||||||
|
required this.triggerType,
|
||||||
|
this.lat,
|
||||||
|
this.lng,
|
||||||
|
this.status = 'TRIGGERED',
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
|
||||||
|
abstract class SosRepository {
|
||||||
|
Future<Either<Failure, void>> triggerSos({
|
||||||
|
required String triggerType,
|
||||||
|
double? lat,
|
||||||
|
double? lng,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../sos_screen.dart';
|
||||||
@ -5,6 +5,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
@ -12,6 +13,7 @@ import '../../core/errors/friendly_error.dart';
|
|||||||
import '../../core/network/api_client.dart';
|
import '../../core/network/api_client.dart';
|
||||||
import '../../core/services/haptic_service.dart';
|
import '../../core/services/haptic_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
|
import 'application/sos_cubit.dart';
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
@ -61,7 +63,7 @@ class SosScreen extends StatefulWidget {
|
|||||||
class _SosScreenState extends State<SosScreen>
|
class _SosScreenState extends State<SosScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
// State
|
// State
|
||||||
bool _sending = false;
|
late final SosCubit _sosCubit;
|
||||||
bool _historyLoading = true;
|
bool _historyLoading = true;
|
||||||
List<_SosEvent> _events = const [];
|
List<_SosEvent> _events = const [];
|
||||||
String? _historyError;
|
String? _historyError;
|
||||||
@ -76,6 +78,7 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_sosCubit = sl<SosCubit>();
|
||||||
_pulseCtrl = AnimationController(
|
_pulseCtrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 900),
|
duration: const Duration(milliseconds: 900),
|
||||||
@ -89,6 +92,7 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pulseCtrl.dispose();
|
_pulseCtrl.dispose();
|
||||||
|
_sosCubit.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,7 +138,7 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmAndSend() async {
|
Future<void> _confirmAndSend() async {
|
||||||
if (_sending) return;
|
if (_sosCubit.state.phase == SosPhase.sending) return;
|
||||||
|
|
||||||
// Confirmation dialog — prevents accidental tap
|
// Confirmation dialog — prevents accidental tap
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
@ -178,15 +182,17 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendSos() async {
|
Future<void> _sendSos() async {
|
||||||
setState(() => _sending = true);
|
|
||||||
await runFriendlyAction(
|
await runFriendlyAction(
|
||||||
() async {
|
() async {
|
||||||
final pos = await _getPosition();
|
final pos = await _getPosition();
|
||||||
await _api.post('/user/sos', data: {
|
await _sosCubit.trigger(
|
||||||
'triggerType': 'BUTTON',
|
triggerType: 'BUTTON',
|
||||||
'lat': pos?.latitude,
|
lat: pos?.latitude,
|
||||||
'lng': pos?.longitude,
|
lng: pos?.longitude,
|
||||||
});
|
);
|
||||||
|
if (_sosCubit.state.phase == SosPhase.error) {
|
||||||
|
throw StateError(_sosCubit.state.message ?? 'Gagal kirim SOS.');
|
||||||
|
}
|
||||||
await sl<HapticService>().sosTriggered();
|
await sl<HapticService>().sosTriggered();
|
||||||
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
||||||
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
||||||
@ -195,19 +201,22 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
onError: _snack,
|
onError: _snack,
|
||||||
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
|
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
|
||||||
);
|
);
|
||||||
if (mounted) setState(() => _sending = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Build ──────────────────────────────────────────────────────────────────
|
// ── Build ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return BlocBuilder<SosCubit, SosState>(
|
||||||
child: Padding(
|
bloc: _sosCubit,
|
||||||
padding: const EdgeInsets.all(16),
|
builder: (context, sosState) {
|
||||||
child: Column(
|
final sending = sosState.phase == SosPhase.sending;
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
return SafeArea(
|
||||||
children: [
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -247,7 +256,7 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
|
|
||||||
// SOS Button
|
// SOS Button
|
||||||
Center(
|
Center(
|
||||||
child: _sending
|
child: sending
|
||||||
? const _SendingIndicator()
|
? const _SendingIndicator()
|
||||||
: AnimatedBuilder(
|
: AnimatedBuilder(
|
||||||
animation: _pulseAnim,
|
animation: _pulseAnim,
|
||||||
@ -288,15 +297,17 @@ class _SosScreenState extends State<SosScreen>
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _SosHistory(
|
child: _SosHistory(
|
||||||
loading: _historyLoading,
|
loading: _historyLoading,
|
||||||
error: _historyError,
|
error: _historyError,
|
||||||
events: _events,
|
events: _events,
|
||||||
onRefresh: _loadHistory,
|
onRefresh: _loadHistory,
|
||||||
)),
|
)),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../core/ai/obstacle_analyzer.dart';
|
||||||
|
import '../domain/repositories/walk_guide_repository.dart';
|
||||||
|
|
||||||
|
class WalkGuideState {
|
||||||
|
final bool active;
|
||||||
|
final DetectionResult? latestDetection;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
const WalkGuideState({
|
||||||
|
this.active = false,
|
||||||
|
this.latestDetection,
|
||||||
|
this.status = 'Ready',
|
||||||
|
});
|
||||||
|
|
||||||
|
WalkGuideState copyWith({
|
||||||
|
bool? active,
|
||||||
|
DetectionResult? latestDetection,
|
||||||
|
String? status,
|
||||||
|
}) {
|
||||||
|
return WalkGuideState(
|
||||||
|
active: active ?? this.active,
|
||||||
|
latestDetection: latestDetection ?? this.latestDetection,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalkGuideCubit extends Cubit<WalkGuideState> {
|
||||||
|
final WalkGuideRepository _repository;
|
||||||
|
|
||||||
|
WalkGuideCubit(this._repository) : super(const WalkGuideState());
|
||||||
|
|
||||||
|
Future<void> start() async {
|
||||||
|
emit(state.copyWith(active: true, status: 'WalkGuide active'));
|
||||||
|
await _repository.startSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
emit(state.copyWith(active: false, status: 'WalkGuide stopped'));
|
||||||
|
await _repository.stopSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateStatus(String status) {
|
||||||
|
emit(state.copyWith(status: status));
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearDetection({String status = 'Ready'}) {
|
||||||
|
emit(WalkGuideState(active: state.active, status: status));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordObstacle(DetectionResult detection) async {
|
||||||
|
emit(state.copyWith(latestDetection: detection, status: detection.spokenId));
|
||||||
|
await _repository.logObstacle({
|
||||||
|
'label': detection.label,
|
||||||
|
'confidence': detection.confidence,
|
||||||
|
'direction': detection.directionName,
|
||||||
|
'estimatedDist': detection.estimatedDistance,
|
||||||
|
'lat': null,
|
||||||
|
'lng': null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
import '../../../../core/network/api_client.dart';
|
||||||
|
import '../../../../core/services/offline_queue_service.dart';
|
||||||
|
import '../../domain/repositories/walk_guide_repository.dart';
|
||||||
|
|
||||||
|
class WalkGuideRepositoryImpl implements WalkGuideRepository {
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
final OfflineQueueService _offlineQueue;
|
||||||
|
|
||||||
|
const WalkGuideRepositoryImpl(this._apiClient, this._offlineQueue);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> startSession() {
|
||||||
|
return _post('/user/walkguide/start', const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> stopSession() {
|
||||||
|
return _post('/user/walkguide/stop', const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload) {
|
||||||
|
return _post('/user/obstacle', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Either<Failure, void>> _post(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await _apiClient.dio.post(path, data: payload);
|
||||||
|
return const Right(null);
|
||||||
|
} catch (_) {
|
||||||
|
await _offlineQueue.enqueue(OfflineRequest(
|
||||||
|
method: 'POST',
|
||||||
|
path: path,
|
||||||
|
body: payload,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
));
|
||||||
|
return const Right(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
class WalkSession {
|
||||||
|
final String sessionId;
|
||||||
|
final DateTime startedAt;
|
||||||
|
final DateTime? stoppedAt;
|
||||||
|
final int obstacleCount;
|
||||||
|
|
||||||
|
const WalkSession({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.startedAt,
|
||||||
|
this.stoppedAt,
|
||||||
|
this.obstacleCount = 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import 'package:dartz/dartz.dart';
|
||||||
|
|
||||||
|
import '../../../../core/errors/failures.dart';
|
||||||
|
|
||||||
|
abstract class WalkGuideRepository {
|
||||||
|
Future<Either<Failure, void>> startSession();
|
||||||
|
Future<Either<Failure, void>> stopSession();
|
||||||
|
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload);
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export '../../walk_guide_screen.dart';
|
||||||
@ -5,14 +5,16 @@ import 'dart:async';
|
|||||||
import 'package:camera/camera.dart';
|
import 'package:camera/camera.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
import '../../core/ai/detection_export.dart';
|
import '../../core/ai/detection_export.dart';
|
||||||
import '../../core/network/api_client.dart';
|
import '../../core/ai/obstacle_alert_strategy.dart';
|
||||||
import '../../core/services/haptic_service.dart';
|
import '../../core/errors/friendly_error.dart';
|
||||||
import '../../core/services/location_reporter_service.dart';
|
import '../../core/services/location_reporter_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
|
import 'application/walk_guide_cubit.dart';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WalkGuideScreen
|
// WalkGuideScreen
|
||||||
@ -26,15 +28,19 @@ class WalkGuideScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||||
bool _active = false;
|
late final WalkGuideCubit _cubit;
|
||||||
String _status = 'Ready';
|
|
||||||
CameraController? _camera;
|
CameraController? _camera;
|
||||||
DetectionResult? _lastDetection;
|
|
||||||
bool _processingFrame = false;
|
bool _processingFrame = false;
|
||||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cubit = sl<WalkGuideCubit>();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
final camera = _camera;
|
final camera = _camera;
|
||||||
@ -43,25 +49,23 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
_camera?.dispose();
|
_camera?.dispose();
|
||||||
sl<LocationReporterService>().stop();
|
sl<LocationReporterService>().stop();
|
||||||
|
_cubit.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggle() async {
|
Future<void> _toggle() async {
|
||||||
final next = !_active;
|
final next = !_cubit.state.active;
|
||||||
if (next) {
|
if (next) {
|
||||||
await _startCamera();
|
await _startCamera();
|
||||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||||
|
await _cubit.start();
|
||||||
|
_cubit.updateStatus(_activeStatusText());
|
||||||
} else {
|
} else {
|
||||||
await _stopCamera();
|
await _stopCamera();
|
||||||
await sl<LocationReporterService>().stop();
|
await sl<LocationReporterService>().stop();
|
||||||
|
await _cubit.stop();
|
||||||
|
_cubit.clearDetection(status: 'Stopped');
|
||||||
}
|
}
|
||||||
setState(() {
|
|
||||||
_active = next;
|
|
||||||
_status = next ? _activeStatusText() : 'Stopped';
|
|
||||||
});
|
|
||||||
await sl<ApiClient>()
|
|
||||||
.dio
|
|
||||||
.post(next ? '/user/walkguide/start' : '/user/walkguide/stop');
|
|
||||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +84,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
|
|
||||||
Future<void> _startCamera() async {
|
Future<void> _startCamera() async {
|
||||||
if (_camera != null) return;
|
if (_camera != null) return;
|
||||||
try {
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
final cameras = await availableCameras();
|
final cameras = await availableCameras();
|
||||||
if (cameras.isEmpty) return;
|
if (cameras.isEmpty) return;
|
||||||
final backCamera = cameras.firstWhere(
|
final backCamera = cameras.firstWhere(
|
||||||
@ -98,33 +103,40 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
await controller.dispose();
|
await controller.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
await runFriendlyAction(
|
||||||
await controller.startImageStream(_onCameraImage);
|
() => controller.startImageStream(_onCameraImage),
|
||||||
} catch (_) {
|
onError: (_) {
|
||||||
setState(() => _status = kIsWeb
|
_cubit.updateStatus(kIsWeb
|
||||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||||
}
|
},
|
||||||
|
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||||
|
);
|
||||||
setState(() => _camera = controller);
|
setState(() => _camera = controller);
|
||||||
} catch (_) {
|
},
|
||||||
setState(() => _status = 'Camera unavailable.');
|
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
||||||
}
|
fallback: 'Camera unavailable.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _stopCamera() async {
|
Future<void> _stopCamera() async {
|
||||||
final camera = _camera;
|
final camera = _camera;
|
||||||
_camera = null;
|
_camera = null;
|
||||||
if (camera == null) return;
|
if (camera == null) return;
|
||||||
try {
|
await runFriendlyAction(
|
||||||
if (camera.value.isStreamingImages) {
|
() async {
|
||||||
await camera.stopImageStream();
|
if (camera.value.isStreamingImages) {
|
||||||
}
|
await camera.stopImageStream();
|
||||||
} catch (_) {}
|
}
|
||||||
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Camera stream already stopped.',
|
||||||
|
);
|
||||||
await camera.dispose();
|
await camera.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCameraImage(CameraImage image) {
|
void _onCameraImage(CameraImage image) {
|
||||||
if (!_active || _processingFrame) return;
|
if (!_cubit.state.active || _processingFrame) return;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
|
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
|
||||||
return;
|
return;
|
||||||
@ -141,7 +153,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
|
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
|
||||||
_lastModelWarningAt = now;
|
_lastModelWarningAt = now;
|
||||||
setState(() => _status = detector.isReady
|
_cubit.updateStatus(detector.isReady
|
||||||
? 'Scanning... ${detector.diagnosticsSummary}'
|
? 'Scanning... ${detector.diagnosticsSummary}'
|
||||||
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
|
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
|
||||||
}
|
}
|
||||||
@ -154,8 +166,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
DetectionResult detection, {
|
DetectionResult detection, {
|
||||||
bool forceAlert = false,
|
bool forceAlert = false,
|
||||||
}) async {
|
}) async {
|
||||||
_lastDetection = detection;
|
_cubit.updateStatus(
|
||||||
setState(() => _status =
|
|
||||||
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
|
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@ -165,94 +176,94 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
|||||||
}
|
}
|
||||||
_lastAlertAt = now;
|
_lastAlertAt = now;
|
||||||
|
|
||||||
try {
|
await runFriendlyAction(
|
||||||
await sl<ApiClient>().dio.post('/user/obstacle', data: {
|
() => _cubit.recordObstacle(detection),
|
||||||
'label': detection.label,
|
onError: (_) {},
|
||||||
'confidence': detection.confidence,
|
fallback: 'Obstacle tersimpan offline.',
|
||||||
'direction': detection.directionName,
|
);
|
||||||
'estimatedDist': detection.estimatedDistance,
|
await sl<ObstacleAlertStrategy>().alert(detection);
|
||||||
'lat': null,
|
|
||||||
'lng': null,
|
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
await sl<HapticService>().obstacleClose();
|
|
||||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _Page(
|
return BlocBuilder<WalkGuideCubit, WalkGuideState>(
|
||||||
title: 'WalkGuide',
|
bloc: _cubit,
|
||||||
subtitle: 'On-device AI detection surface',
|
builder: (context, state) => _Page(
|
||||||
actions: [
|
title: 'WalkGuide',
|
||||||
IconButton(
|
subtitle: 'On-device AI detection surface',
|
||||||
onPressed: () => context.go('/user/benchmark'),
|
actions: [
|
||||||
icon: const Icon(Icons.speed)),
|
IconButton(
|
||||||
IconButton(
|
onPressed: () => context.go('/user/benchmark'),
|
||||||
onPressed: () => context.go('/user/pairing'),
|
icon: const Icon(Icons.speed)),
|
||||||
icon: const Icon(Icons.link)),
|
IconButton(
|
||||||
],
|
onPressed: () => context.go('/user/pairing'),
|
||||||
child: Column(
|
icon: const Icon(Icons.link)),
|
||||||
children: [
|
],
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Container(
|
children: [
|
||||||
width: double.infinity,
|
Expanded(
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: const Color(0xFF0F172A),
|
width: double.infinity,
|
||||||
borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
child: Stack(
|
color: const Color(0xFF0F172A),
|
||||||
children: [
|
borderRadius: BorderRadius.circular(16)),
|
||||||
if (_camera != null && _camera!.value.isInitialized)
|
child: Stack(
|
||||||
Positioned.fill(child: CameraPreview(_camera!))
|
children: [
|
||||||
else
|
if (_camera != null && _camera!.value.isInitialized)
|
||||||
const Center(
|
Positioned.fill(child: CameraPreview(_camera!))
|
||||||
child: Icon(Icons.videocam_outlined,
|
else
|
||||||
color: Colors.white30, size: 96)),
|
const Center(
|
||||||
if (_lastDetection?.box != null)
|
child: Icon(Icons.videocam_outlined,
|
||||||
Positioned.fill(
|
color: Colors.white30, size: 96)),
|
||||||
child: CustomPaint(
|
if (state.latestDetection?.box != null)
|
||||||
painter: _DetectionOverlayPainter(_lastDetection!),
|
Positioned.fill(
|
||||||
|
child: CustomPaint(
|
||||||
|
painter:
|
||||||
|
_DetectionOverlayPainter(state.latestDetection!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 16,
|
|
||||||
left: 16,
|
|
||||||
child: _Pill(
|
|
||||||
text: _active ? 'AI ACTIVE' : 'STANDBY',
|
|
||||||
color: _active ? Colors.green : Colors.orange)),
|
|
||||||
if (_lastDetection != null)
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 64,
|
top: 16,
|
||||||
left: 16,
|
left: 16,
|
||||||
child: _Pill(
|
child: _Pill(
|
||||||
text:
|
text: state.active ? 'AI ACTIVE' : 'STANDBY',
|
||||||
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
|
color:
|
||||||
color: Colors.redAccent),
|
state.active ? Colors.green : Colors.orange)),
|
||||||
),
|
if (state.latestDetection != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 16,
|
top: 64,
|
||||||
right: 16,
|
left: 16,
|
||||||
bottom: 16,
|
child: _Pill(
|
||||||
child: Text(_status,
|
text:
|
||||||
style: const TextStyle(
|
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
|
||||||
color: Colors.white,
|
color: Colors.redAccent),
|
||||||
fontSize: 18,
|
),
|
||||||
fontWeight: FontWeight.w700))),
|
Positioned(
|
||||||
],
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: Text(state.status,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700))),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 14),
|
||||||
const SizedBox(height: 14),
|
Row(
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
Expanded(
|
child: FilledButton.icon(
|
||||||
child: FilledButton.icon(
|
onPressed: _toggle,
|
||||||
onPressed: _toggle,
|
icon:
|
||||||
icon: Icon(_active ? Icons.stop : Icons.play_arrow),
|
Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||||
label: Text(_active ? 'Stop' : 'Start'))),
|
label: Text(state.active ? 'Stop' : 'Start'))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,25 +4,21 @@ import 'package:firebase_core/firebase_core.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'app/injection_container.dart';
|
import 'app/injection_container.dart';
|
||||||
import 'app/app.dart';
|
import 'app/app.dart';
|
||||||
|
import 'core/utils/init_guard.dart';
|
||||||
|
|
||||||
List<CameraDescription> cameras = [];
|
List<CameraDescription> cameras = [];
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Init cameras
|
cameras = await ignoreInitFailure(
|
||||||
try {
|
availableCameras,
|
||||||
cameras = await availableCameras();
|
label: 'Camera init',
|
||||||
} catch (e) {
|
) ??
|
||||||
debugPrint('Camera init error: $e');
|
[];
|
||||||
}
|
|
||||||
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
try {
|
await ignoreInitFailure(() => Firebase.initializeApp(), label: 'Firebase init');
|
||||||
await Firebase.initializeApp();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Firebase init skipped: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init GetIt dependencies
|
// Init GetIt dependencies
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../app/injection_container.dart';
|
import '../../app/injection_container.dart';
|
||||||
|
import '../../core/errors/friendly_error.dart';
|
||||||
|
import '../../core/network/api_client.dart';
|
||||||
|
import '../../core/services/hardware_shortcut_listener.dart';
|
||||||
import '../../core/services/stt_service.dart';
|
import '../../core/services/stt_service.dart';
|
||||||
import '../../core/services/tts_service.dart';
|
import '../../core/services/tts_service.dart';
|
||||||
import '../../core/services/voice_command_handler.dart';
|
import '../../core/services/voice_command_handler.dart';
|
||||||
@ -20,6 +23,8 @@ class _UserShellState extends State<UserShell> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadVoiceCommands();
|
||||||
|
_startHardwareShortcuts();
|
||||||
sl<SttService>().startListening();
|
sl<SttService>().startListening();
|
||||||
sl<VoiceCommandHandler>().onCommand = (key) {
|
sl<VoiceCommandHandler>().onCommand = (key) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@ -58,6 +63,70 @@ class _UserShellState extends State<UserShell> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVoiceCommands() async {
|
||||||
|
await runFriendlyAction(
|
||||||
|
() async {
|
||||||
|
final res = await sl<ApiClient>()
|
||||||
|
.dio
|
||||||
|
.get('/user/voice-commands')
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
final body = res.data;
|
||||||
|
final data = body is Map ? body['data'] : body;
|
||||||
|
if (data is! List) return;
|
||||||
|
final commands = data
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => _voiceCommandFromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.whereType<VoiceCommand>()
|
||||||
|
.toList();
|
||||||
|
if (commands.isNotEmpty) {
|
||||||
|
sl<VoiceCommandHandler>().loadCommands(commands);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Voice command belum bisa dimuat.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startHardwareShortcuts() async {
|
||||||
|
await runFriendlyAction(
|
||||||
|
() => sl<HardwareShortcutListener>().startListening(
|
||||||
|
onAction: (action) {
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (action) {
|
||||||
|
case HardwareShortcutAction.callGuardian:
|
||||||
|
context.go('/user/call');
|
||||||
|
sl<TtsService>().speak('Memanggil guardian');
|
||||||
|
break;
|
||||||
|
case HardwareShortcutAction.startWalkguide:
|
||||||
|
context.go('/user/walkguide');
|
||||||
|
sl<TtsService>().speak('WalkGuide dibuka');
|
||||||
|
break;
|
||||||
|
case HardwareShortcutAction.stopWalkguide:
|
||||||
|
context.go('/user/walkguide');
|
||||||
|
sl<TtsService>().speak('WalkGuide dibuka untuk dihentikan');
|
||||||
|
break;
|
||||||
|
case HardwareShortcutAction.sendSos:
|
||||||
|
context.go('/user/sos');
|
||||||
|
sl<TtsService>().speak('SOS dibuka');
|
||||||
|
break;
|
||||||
|
case HardwareShortcutAction.openNotification:
|
||||||
|
context.go('/user/notifications');
|
||||||
|
sl<TtsService>().speak('Notifikasi dibuka');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onError: (_) {},
|
||||||
|
fallback: 'Hardware shortcut belum bisa dimuat.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
sl<HardwareShortcutListener>().stopListening();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final location = GoRouterState.of(context).matchedLocation;
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
@ -169,3 +238,49 @@ String _spokenRouteName(VoiceCommandKey key) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VoiceCommand? _voiceCommandFromJson(Map<String, dynamic> item) {
|
||||||
|
final key = _commandKeyFromBackend(item['commandKey']?.toString());
|
||||||
|
final phrase = item['triggerPhrase']?.toString().trim();
|
||||||
|
if (key == null || phrase == null || phrase.isEmpty) return null;
|
||||||
|
return VoiceCommand(
|
||||||
|
key: key,
|
||||||
|
phrase: phrase,
|
||||||
|
enabled: item['enabled'] != false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VoiceCommandKey? _commandKeyFromBackend(String? key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'OPEN_WALKGUIDE':
|
||||||
|
return VoiceCommandKey.openWalkguide;
|
||||||
|
case 'START_WALKGUIDE':
|
||||||
|
return VoiceCommandKey.startWalkguide;
|
||||||
|
case 'STOP_WALKGUIDE':
|
||||||
|
return VoiceCommandKey.stopWalkguide;
|
||||||
|
case 'CALL_GUARDIAN':
|
||||||
|
return VoiceCommandKey.callGuardian;
|
||||||
|
case 'OPEN_NOTIFICATION':
|
||||||
|
return VoiceCommandKey.openNotification;
|
||||||
|
case 'READ_ALL_NOTIF':
|
||||||
|
return VoiceCommandKey.readAllNotif;
|
||||||
|
case 'OPEN_SOS':
|
||||||
|
return VoiceCommandKey.openSos;
|
||||||
|
case 'SEND_SOS':
|
||||||
|
return VoiceCommandKey.sendSos;
|
||||||
|
case 'WHERE_AM_I':
|
||||||
|
return VoiceCommandKey.whereAmI;
|
||||||
|
case 'OPEN_ACTIVITY':
|
||||||
|
return VoiceCommandKey.openActivity;
|
||||||
|
case 'OPEN_NAVIGATION':
|
||||||
|
return VoiceCommandKey.openNavigation;
|
||||||
|
case 'OPEN_SETTINGS':
|
||||||
|
return VoiceCommandKey.openSettings;
|
||||||
|
case 'REPEAT_LAST':
|
||||||
|
return VoiceCommandKey.repeatLast;
|
||||||
|
case 'STOP_TTS':
|
||||||
|
return VoiceCommandKey.stopTts;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -947,10 +947,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.16.0"
|
||||||
mgrs_dart:
|
mgrs_dart:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1501,7 +1501,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
sqlite3:
|
sqlite3:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||||
@ -1592,26 +1592,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test
|
name: test
|
||||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.26.3"
|
version: "1.26.2"
|
||||||
test_api:
|
test_api:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.7"
|
version: "0.7.6"
|
||||||
test_core:
|
test_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_core
|
name: test_core
|
||||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.12"
|
version: "0.6.11"
|
||||||
tflite_flutter:
|
tflite_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -23,6 +23,7 @@ dependencies:
|
|||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
shared_preferences: ^2.3.2
|
shared_preferences: ^2.3.2
|
||||||
drift: ^2.18.0
|
drift: ^2.18.0
|
||||||
|
sqlite3: ^2.4.7
|
||||||
sqlite3_flutter_libs: ^0.5.24
|
sqlite3_flutter_libs: ^0.5.24
|
||||||
path_provider: ^2.1.3
|
path_provider: ^2.1.3
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
// Live Spring Boot E2E smoke tests.
|
||||||
|
//
|
||||||
|
// Run on a physical Android device/profile build when the backend is online:
|
||||||
|
// flutter test test/integration_test/live_api_e2e_test.dart \
|
||||||
|
// --dart-define=LIVE_API_BASE_URL=http://202.46.28.160:8080/api/v1 \
|
||||||
|
// --dart-define=LIVE_USER_EMAIL=user@example.com \
|
||||||
|
// --dart-define=LIVE_USER_PASSWORD=password \
|
||||||
|
// --dart-define=LIVE_GUARDIAN_EMAIL=guardian@example.com \
|
||||||
|
// --dart-define=LIVE_GUARDIAN_PASSWORD=password
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
|
||||||
|
const _baseUrl = String.fromEnvironment('LIVE_API_BASE_URL');
|
||||||
|
const _userEmail = String.fromEnvironment('LIVE_USER_EMAIL');
|
||||||
|
const _userPassword = String.fromEnvironment('LIVE_USER_PASSWORD');
|
||||||
|
const _guardianEmail = String.fromEnvironment('LIVE_GUARDIAN_EMAIL');
|
||||||
|
const _guardianPassword = String.fromEnvironment('LIVE_GUARDIAN_PASSWORD');
|
||||||
|
|
||||||
|
bool get _liveApiConfigured =>
|
||||||
|
_baseUrl.isNotEmpty &&
|
||||||
|
_userEmail.isNotEmpty &&
|
||||||
|
_userPassword.isNotEmpty &&
|
||||||
|
_guardianEmail.isNotEmpty &&
|
||||||
|
_guardianPassword.isNotEmpty;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('WalkGuide live Spring Boot API E2E', () {
|
||||||
|
late Dio dio;
|
||||||
|
String? userToken;
|
||||||
|
String? guardianToken;
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
dio = Dio(BaseOptions(
|
||||||
|
baseUrl: _baseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 15),
|
||||||
|
validateStatus: (status) => status != null && status < 500,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('flow 1: ping, user login, profile', (tester) async {
|
||||||
|
if (!_liveApiConfigured) {
|
||||||
|
// Keep CI green when live credentials are not injected.
|
||||||
|
// The final benchmark run should pass LIVE_* dart-defines.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ping = await dio.get('/auth/ping');
|
||||||
|
expect(ping.statusCode, 200);
|
||||||
|
|
||||||
|
final login = await dio.post('/auth/login', data: {
|
||||||
|
'email': _userEmail,
|
||||||
|
'password': _userPassword,
|
||||||
|
});
|
||||||
|
expect(login.statusCode, 200);
|
||||||
|
userToken = login.data['data']['accessToken'] as String?;
|
||||||
|
expect(userToken, isNotNull);
|
||||||
|
|
||||||
|
final profile = await dio.get(
|
||||||
|
'/user/profile',
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $userToken'}),
|
||||||
|
);
|
||||||
|
expect(profile.statusCode, 200);
|
||||||
|
expect(profile.data['data']['email'], isNotEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('flow 2: WalkGuide start, SOS, stop', (tester) async {
|
||||||
|
if (!_liveApiConfigured) {
|
||||||
|
// Keep CI green when live credentials are not injected.
|
||||||
|
// The final benchmark run should pass LIVE_* dart-defines.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(userToken, isNotNull);
|
||||||
|
final auth = Options(headers: {'Authorization': 'Bearer $userToken'});
|
||||||
|
|
||||||
|
final start = await dio.post('/user/walkguide/start', options: auth);
|
||||||
|
expect(start.statusCode, 200);
|
||||||
|
|
||||||
|
final sos = await dio.post('/user/sos', data: {
|
||||||
|
'triggerType': 'MANUAL',
|
||||||
|
'lat': null,
|
||||||
|
'lng': null,
|
||||||
|
}, options: auth);
|
||||||
|
expect(sos.statusCode, 200);
|
||||||
|
|
||||||
|
final stop = await dio.post('/user/walkguide/stop', options: auth);
|
||||||
|
expect(stop.statusCode, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('flow 3: guardian dashboard and user notifications', (tester) async {
|
||||||
|
if (!_liveApiConfigured) {
|
||||||
|
// Keep CI green when live credentials are not injected.
|
||||||
|
// The final benchmark run should pass LIVE_* dart-defines.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final guardianLogin = await dio.post('/auth/login', data: {
|
||||||
|
'email': _guardianEmail,
|
||||||
|
'password': _guardianPassword,
|
||||||
|
});
|
||||||
|
expect(guardianLogin.statusCode, 200);
|
||||||
|
guardianToken = guardianLogin.data['data']['accessToken'] as String?;
|
||||||
|
expect(guardianToken, isNotNull);
|
||||||
|
|
||||||
|
final guardianAuth =
|
||||||
|
Options(headers: {'Authorization': 'Bearer $guardianToken'});
|
||||||
|
final dashboard = await dio.get('/guardian/dashboard', options: guardianAuth);
|
||||||
|
expect(dashboard.statusCode, 200);
|
||||||
|
|
||||||
|
final userAuth = Options(headers: {'Authorization': 'Bearer $userToken'});
|
||||||
|
final notifications = await dio.get('/user/notifications', options: userAuth);
|
||||||
|
expect(notifications.statusCode, 200);
|
||||||
|
|
||||||
|
final markAll =
|
||||||
|
await dio.put('/user/notifications/mark-all-read', options: userAuth);
|
||||||
|
expect(markAll.statusCode, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user