diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/config/SecurityConfig.java b/walkguide-backend/demo/src/main/java/com/walkguide/config/SecurityConfig.java index c081be3..1ece305 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/config/SecurityConfig.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/config/SecurityConfig.java @@ -9,6 +9,8 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import com.walkguide.security.JwtAuthFilter; + @Configuration @EnableWebSecurity public class SecurityConfig { @@ -20,27 +22,28 @@ public class SecurityConfig { } // Aturan jalan masuk ke API kita: + // Jangan lupa inject filter-nya di atas (tambahin parameter JwtAuthFilter jwtAuthFilter) @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { http .cors(cors -> cors.configurationSource(request -> { - var corsConfiguration = new org.springframework.web.cors.CorsConfiguration(); - // Izinkan semua origin localhost dan semua port (biar Chrome gak ngeblok) - corsConfiguration.setAllowedOriginPatterns(java.util.List.of( - "http://localhost:*", - "http://127.0.0.1:*" - )); - corsConfiguration.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - corsConfiguration.setAllowedHeaders(java.util.List.of("*")); - corsConfiguration.setAllowCredentials(true); - return corsConfiguration; + var corsConfig = new org.springframework.web.cors.CorsConfiguration(); + corsConfig.setAllowedOriginPatterns(java.util.List.of("http://localhost:*", "http://127.0.0.1:*")); + corsConfig.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + corsConfig.setAllowedHeaders(java.util.List.of("*")); + corsConfig.setAllowCredentials(true); + return corsConfig; })) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/auth/**", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // Login & Swagger bebas + .requestMatchers("/api/guardian/**").hasRole("GUARDIAN") // Khusus Guardian + .requestMatchers("/api/user/**").hasRole("USER") // Khusus Tunanetra .anyRequest().authenticated() - ); + ) + // TARUH SATPAM DI SINI + .addFilterBefore(jwtAuthFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java new file mode 100644 index 0000000..56bcbd5 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/GuardianController.java @@ -0,0 +1,43 @@ +package com.walkguide.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.walkguide.dto.ApiResponse; +import com.walkguide.service.MockDataService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/guardian") +@RequiredArgsConstructor +public class GuardianController { + + private final MockDataService mockDataService; + + // 4. Ambil Status User + @GetMapping("/user-status") + public ResponseEntity>> getUserStatus() { + return ResponseEntity.ok(new ApiResponse<>(true, mockDataService.getUserStatus(), "Data status user berhasil diambil")); + } + + // 5. Setting Hardware Shortcut + @PutMapping("/settings/shortcuts") + public ResponseEntity>> updateShortcuts(@RequestBody Map request) { + Map updated = mockDataService.updateShortcuts(request); + return ResponseEntity.ok(new ApiResponse<>(true, updated, "Shortcut berhasil diperbarui")); + } + + // 6. Setting Sensitivitas AI + @PutMapping("/settings/ai") + public ResponseEntity>> updateAiSettings(@RequestBody Map request) { + Map updated = mockDataService.updateAiSettings(request); + return ResponseEntity.ok(new ApiResponse<>(true, updated, "Setting AI berhasil diperbarui")); + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java new file mode 100644 index 0000000..13e6653 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/UserController.java @@ -0,0 +1,24 @@ +package com.walkguide.controller; + +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.walkguide.dto.ApiResponse; + +@RestController +@RequestMapping("/api/user") +public class UserController { + + // 7. Sinyal Darurat (Voice Command) + @PostMapping("/emergency") + public ResponseEntity> triggerEmergency(@RequestBody Map request) { + // Simulasi mengirim notif ke Guardian + String triggerType = (String) request.get("triggerType"); + return ResponseEntity.ok(new ApiResponse<>(true, "Darurat Terkirim", "Guardian telah diberi peringatan via: " + triggerType)); + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtAuthFilter.java b/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtAuthFilter.java new file mode 100644 index 0000000..64dd69c --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtAuthFilter.java @@ -0,0 +1,57 @@ +package com.walkguide.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + + // Kalau gak ada token, lewatin aja (biar dicegat sama SecurityConfig) + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + // Potong tulisan "Bearer " + final String jwt = authHeader.substring(7); + + try { + // Ambil email & role dari token lu + String email = jwtUtil.extractUsername(jwt); + String role = jwtUtil.extractRole(jwt); // Pastiin JwtUtil lu punya fungsi extractRole! + + // Daftarin user ini ke sistem keamanan Spring + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + email, null, Collections.singletonList(new SimpleGrantedAuthority(role)) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } catch (Exception e) { + // Token kadaluarsa / rusak + System.out.println("JWT Error: " + e.getMessage()); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtUtil.java b/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtUtil.java index 6b8b81d..f7dc924 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtUtil.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/security/JwtUtil.java @@ -2,33 +2,67 @@ package com.walkguide.security; import java.security.Key; import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; @Component public class JwtUtil { - @Value("${jwt.secret}") - private String secret; + // Kunci rahasia buat enkripsi & dekripsi token (Minimal 256-bit) + private static final String SECRET_KEY = "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"; - @Value("${jwt.expiration}") - private Long expiration; - - private Key getSigningKey() { - return Keys.hmacShaKeyFor(secret.getBytes()); + // Fungsi tambahan buat ngebongkar Email (Username) + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); } + // Fungsi tambahan buat ngebongkar Role + public String extractRole(String token) { + Claims claims = extractAllClaims(token); + return claims.get("role", String.class); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY); + return Keys.hmacShaKeyFor(keyBytes); + } + + // Fungsi lama lu buat bikin token public String generateToken(String email, String role) { + Map claims = new HashMap<>(); + claims.put("role", role); + return createToken(claims, email); + } + + private String createToken(Map claims, String subject) { return Jwts.builder() - .setSubject(email) - .claim("role", role) + .setClaims(claims) + .setSubject(subject) .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(getSigningKey()) + .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // Aktif 10 jam + .signWith(getSignInKey(), SignatureAlgorithm.HS256) .compact(); } } \ No newline at end of file diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/MockDataService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/MockDataService.java new file mode 100644 index 0000000..81c2bc5 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/MockDataService.java @@ -0,0 +1,43 @@ +package com.walkguide.service; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Service; + +@Service +public class MockDataService { + + // Tambahin 'final' di sini sesuai saran VS Code + private final Map userStatus = new HashMap<>(); + private final Map hardwareShortcuts = new HashMap<>(); + private final Map aiSettings = new HashMap<>(); + + public MockDataService() { + // Data default awal saat Spring Boot nyala + userStatus.put("status", "Sedang Berjalan"); + userStatus.put("location", "Jl. Kenangan, SBY"); + userStatus.put("batteryLevel", 85); + userStatus.put("lastSeen", "2026-04-23T08:40:00Z"); + + hardwareShortcuts.put("volumeUpAction", "accept_call"); + hardwareShortcuts.put("volumeDownAction", "emergency_ping"); + + aiSettings.put("alertDistanceMeters", 2.5); + aiSettings.put("hapticFeedback", true); + } + + public Map getUserStatus() { + return userStatus; + } + + public Map updateShortcuts(Map newShortcuts) { + hardwareShortcuts.putAll(newShortcuts); + return hardwareShortcuts; + } + + public Map updateAiSettings(Map newSettings) { + aiSettings.putAll(newSettings); + return aiSettings; + } +} \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/assets/images/hero.avif b/walkguide-mobile/walkguide_app/assets/images/hero.avif new file mode 100644 index 0000000..e03acbf Binary files /dev/null and b/walkguide-mobile/walkguide_app/assets/images/hero.avif differ diff --git a/walkguide-mobile/walkguide_app/assets/images/walk.jpg b/walkguide-mobile/walkguide_app/assets/images/walk.jpg new file mode 100644 index 0000000..ec52a2d Binary files /dev/null and b/walkguide-mobile/walkguide_app/assets/images/walk.jpg differ diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart index c622721..8dde71e 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/presentation/login_screen.dart @@ -8,222 +8,420 @@ import '../../home/presentation/user_dashboard_screen.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); - @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { - final TextEditingController _emailController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); - final ApiService _apiService = ApiService(); - final SecureStorage _secureStorage = SecureStorage(); - + final _emailCtrl = TextEditingController(); + final _passCtrl = TextEditingController(); + final _apiService = ApiService(); + final _secureStorage = SecureStorage(); bool _isLoading = false; - bool _isPasswordVisible = false; + bool _showPass = false; + int _selectedTab = 0; // 0 = Guardian, 1 = User + + final _hints = [ + ['guardian@walkguide.com', 'guardian123'], + ['user@walkguide.com', 'user123'], + ]; + + @override + void initState() { + super.initState(); + _emailCtrl.text = _hints[0][0]; + _passCtrl.text = _hints[0][1]; + } + + void _switchTab(int idx) { + setState(() { + _selectedTab = idx; + _emailCtrl.text = _hints[idx][0]; + _passCtrl.text = _hints[idx][1]; + }); + } Future _handleLogin() async { setState(() => _isLoading = true); - try { - final response = await _apiService.post('/auth/login', { - 'email': _emailController.text.trim(), - 'password': _passwordController.text.trim(), + final res = await _apiService.post('/auth/login', { + 'email': _emailCtrl.text.trim(), + 'password': _passCtrl.text.trim(), }); - - if (response.statusCode == 200) { - // Ekstraksi data dari ApiResponse wrapper (success, data, message) - final responseBody = response.data; - - if (responseBody['success'] == true) { - final userData = responseBody['data']; - final String token = userData['token']; - final String role = userData['role']; - - // Simpan token ke storage aman - await _secureStorage.saveToken(token); - - // Routing berdasarkan Role - if (mounted) { - if (role == 'ROLE_GUARDIAN') { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const GuardianDashboardScreen()), - ); - } else { - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => const UserDashboardScreen()), - ); - } - } - } else { - throw Exception(responseBody['message'] ?? 'Login gagal'); - } + if (res.statusCode == 200 && res.data['success'] == true) { + final data = res.data['data']; + await _secureStorage.saveToken(data['token']); + if (!mounted) return; + Navigator.pushReplacement(context, MaterialPageRoute( + builder: (_) => data['role'] == 'ROLE_GUARDIAN' + ? const GuardianDashboardScreen() + : const UserDashboardScreen(), + )); } } on DioException catch (e) { - if (mounted) { - // Ambil pesan error dari GlobalExceptionHandler di Spring Boot - String errorMsg = 'Gagal Terhubung ke Server'; - if (e.response?.data != null && e.response?.data is Map) { - errorMsg = e.response?.data['message'] ?? errorMsg; - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(errorMsg), - backgroundColor: Colors.redAccent, - behavior: SnackBarBehavior.floating, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); - } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(e.response?.data['message'] ?? 'Gagal terhubung'), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + )); } finally { - setState(() => _isLoading = false); + if (mounted) setState(() => _isLoading = false); } } @override Widget build(BuildContext context) { + final isWide = MediaQuery.of(context).size.width > 700; return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), + backgroundColor: const Color(0xFFF1F5F9), body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Logo/Icon Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF2563EB).withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.navigation_rounded, - size: 48, - color: Color(0xFF2563EB) - ), - ), - const SizedBox(height: 24), - Text( - 'Walk Guide', - style: GoogleFonts.outfit( - fontSize: 32, - fontWeight: FontWeight.bold, - color: const Color(0xFF0F172A) - ), - ), - const SizedBox(height: 8), - Text( - 'Masuk untuk mulai navigasi', - style: GoogleFonts.inter( - fontSize: 15, - color: const Color(0xFF64748B) - ), - ), - const SizedBox(height: 40), - - // Input Fields - _buildTextField( - _emailController, - Icons.alternate_email, - 'Email', - false - ), - const SizedBox(height: 20), - _buildTextField( - _passwordController, - Icons.lock_outline, - 'Password', - true - ), - - const SizedBox(height: 48), - - // Button Masuk - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: _isLoading ? null : _handleLogin, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF2563EB), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16) - ), - ), - child: _isLoading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2 - ), - ) - : Text( - 'Masuk', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w600 - ) - ), - ), - ), + child: Container( + constraints: const BoxConstraints(maxWidth: 900, maxHeight: 600), + margin: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 40, + offset: const Offset(0, 16), + ) ], ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Row( + children: [ + if (isWide) Expanded(child: _buildHeroPanel()), + _buildFormPanel(), + ], + ), + ), ), ), ); } - Widget _buildTextField( - TextEditingController controller, - IconData icon, - String hint, - bool isPassword - ) { + Widget _buildHeroPanel() { + return Stack( + fit: StackFit.expand, + children: [ + Image.asset( + 'assets/images/walk.jpg', + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + Container(color: const Color(0xFF0F1923)), + ), + Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Color(0xCC0A1428)], + stops: [0.4, 1.0], + ), + ), + ), + Positioned( + top: 24, + left: 24, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), // ✅ Fixed + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.15), // ✅ Fixed + ), + ), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF60EFAB), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + 'AI Navigation Active', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + Positioned( + bottom: 36, + left: 32, + right: 32, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '"WalkGuide memberi saya\nkebebasan yang luar biasa."', + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + const SizedBox(height: 14), + Text( + 'Andi Pratama', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Text( + 'Pengguna — Surabaya, Jawa Timur', + style: GoogleFonts.inter( + color: Colors.white60, + fontSize: 12, + ), + ), + const SizedBox(height: 18), + Row( + children: [ + Container( + width: 28, + height: 3, + decoration: BoxDecoration( + color: const Color(0xFF60EFAB), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Container( + width: 20, + height: 3, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Container( + width: 20, + height: 3, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildFormPanel() { return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 10, - offset: const Offset(0, 4) - ) + width: 320, + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: const Color(0xFF1A56DB), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.navigation_rounded, + color: Colors.white, size: 16), + ), + const SizedBox(width: 8), + Text( + 'WalkGuide', + style: GoogleFonts.outfit( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF0F172A), + ), + ), + ], + ), + const SizedBox(height: 32), + Text( + "Let's sign in", + style: GoogleFonts.outfit( + fontSize: 24, + fontWeight: FontWeight.w600, + color: const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 4), + Text( + 'Continue your journey with WalkGuide.', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + const SizedBox(height: 24), + // Tab switcher + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + _buildTab(0, 'Guardian'), + _buildTab(1, 'User'), + ], + ), + ), + const SizedBox(height: 22), + _buildLabel('Email address'), + const SizedBox(height: 5), + _buildInput(_emailCtrl, false), + const SizedBox(height: 14), + _buildLabel('Password'), + const SizedBox(height: 5), + _buildInput(_passCtrl, true), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Text( + 'Forgot password?', + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF1A56DB), + ), + ), + ), + const SizedBox(height: 22), + SizedBox( + width: double.infinity, + height: 42, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: _isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + 'Continue', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 20), + Center( + child: Text( + 'Need help? Contact support', + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF94A3B8), + ), + ), + ), ], ), + ); + } + + Widget _buildTab(int idx, String label) { + final active = _selectedTab == idx; + return Expanded( + child: GestureDetector( + onTap: () => _switchTab(idx), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: active ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + color: active + ? const Color(0xFF0F172A) + : const Color(0xFF64748B), + ), + ), + ), + ), + ); + } + + Widget _buildLabel(String text) => Text( + text, + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ); + + Widget _buildInput(TextEditingController ctrl, bool isPass) { + return Container( + height: 40, + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE2E8F0)), + borderRadius: BorderRadius.circular(8), + ), child: TextField( - controller: controller, - obscureText: isPassword && !_isPasswordVisible, - style: GoogleFonts.inter(fontSize: 15), + controller: ctrl, + obscureText: isPass && !_showPass, + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF0F172A), + ), decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(color: Color(0xFF94A3B8)), - prefixIcon: Icon(icon, color: const Color(0xFF94A3B8)), - suffixIcon: isPassword + border: InputBorder.none, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + suffixIcon: isPass ? IconButton( icon: Icon( - _isPasswordVisible ? Icons.visibility_off : Icons.visibility, - color: const Color(0xFF94A3B8) + _showPass ? Icons.visibility_off : Icons.visibility, + size: 16, + color: const Color(0xFF94A3B8), ), - onPressed: () => setState(() => _isPasswordVisible = !_isPasswordVisible), - ) + onPressed: () => setState(() => _showPass = !_showPass), + ) : null, - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), ), ), ); diff --git a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart index cde23f7..07d0551 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart @@ -1,19 +1,18 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:animate_do/animate_do.dart'; import '../../../core/secure_storage.dart'; import '../../auth/presentation/login_screen.dart'; class GuardianDashboardScreen extends StatelessWidget { const GuardianDashboardScreen({super.key}); - Future _handleLogout(BuildContext context) async { + Future _logout(BuildContext ctx) async { await SecureStorage().deleteToken(); - if (context.mounted) { + if (ctx.mounted) { Navigator.pushAndRemoveUntil( - context, + ctx, MaterialPageRoute(builder: (_) => const LoginScreen()), - (route) => false, // Bersihin tumpukan screen + (_) => false, ); } } @@ -22,156 +21,403 @@ class GuardianDashboardScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF8FAFC), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: FadeInDown( - child: Text('Guardian Command', style: GoogleFonts.outfit(color: const Color(0xFF0F172A), fontWeight: FontWeight.bold, fontSize: 24)), - ), - actions: [ - FadeInDown( - child: IconButton( - icon: const Icon(Icons.logout_rounded, color: Color(0xFFDC2626)), - tooltip: 'Logout', - onPressed: () => _handleLogout(context), - ), + body: Column(children: [ + _buildTopBar(context), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildPageHeader(), + const SizedBox(height: 18), + _buildKpiRow(), + const SizedBox(height: 18), + _buildMainGrid(context), + ]), ), - ], - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. KARTU STATUS USER - FadeInUp( - duration: const Duration(milliseconds: 600), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFF2563EB), Color(0xFF1D4ED8)]), - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow(color: const Color(0xFF2563EB).withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10)) - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration(color: Colors.white24, shape: BoxShape.circle), - child: const CircleAvatar( - radius: 30, - backgroundColor: Colors.white, - child: Icon(Icons.person, size: 36, color: Color(0xFF2563EB)), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('User Pantauan', style: GoogleFonts.inter(color: Colors.blue[100], fontSize: 14)), - Text('Sistem Aktif', style: GoogleFonts.outfit(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Row( - children: [ - const Icon(Icons.location_on, color: Colors.white70, size: 16), - const SizedBox(width: 4), - Text('Melacak lokasi...', style: GoogleFonts.inter(color: Colors.white70, fontSize: 12)), - ], - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 32), - - // 2. QUICK ACTIONS - FadeInUp( - delay: const Duration(milliseconds: 200), - child: Text('Aksi Cepat', style: GoogleFonts.outfit(fontSize: 18, fontWeight: FontWeight.bold, color: const Color(0xFF0F172A))), - ), - const SizedBox(height: 16), - FadeInUp( - delay: const Duration(milliseconds: 300), - child: Row( - children: [ - _buildQuickAction(Icons.videocam_outlined, 'Live View', const Color(0xFF10B981)), - const SizedBox(width: 16), - _buildQuickAction(Icons.phone_in_talk, 'Hubungi', const Color(0xFFF59E0B)), - const SizedBox(width: 16), - _buildQuickAction(Icons.settings_voice, 'Voice Conf', const Color(0xFF8B5CF6)), - ], - ), - ), - const SizedBox(height: 32), - - // 3. SETTINGAN DEVICE USER - FadeInUp( - delay: const Duration(milliseconds: 400), - child: Text('Konfigurasi Perangkat', style: GoogleFonts.outfit(fontSize: 18, fontWeight: FontWeight.bold, color: const Color(0xFF0F172A))), - ), - const SizedBox(height: 16), - FadeInUp( - delay: const Duration(milliseconds: 500), - child: _buildSettingTile(Icons.gamepad_outlined, 'Hardware Shortcuts', 'Atur fungsi tombol volume hp user'), - ), - FadeInUp( - delay: const Duration(milliseconds: 600), - child: _buildSettingTile(Icons.spatial_audio_outlined, 'Sensitivitas AI', 'Atur jarak deteksi rintangan'), - ), - ], ), - ), + ]), ); } - Widget _buildQuickAction(IconData icon, String label, Color color) { - return Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.grey.withValues(alpha: 0.1)), - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.02), blurRadius: 10, offset: const Offset(0, 4))], - ), - child: Column( - children: [ - Icon(icon, size: 32, color: color), - const SizedBox(height: 8), - Text(label, style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w600, color: const Color(0xFF475569))), - ], - ), - ), - ); - } - - Widget _buildSettingTile(IconData icon, String title, String subtitle) { + Widget _buildTopBar(BuildContext ctx) { + return Container( + height: 52, + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0), width: 0.5)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row(children: [ + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: const Color(0xFF1A56DB), + borderRadius: BorderRadius.circular(7), + ), + child: const Icon(Icons.navigation_rounded, color: Colors.white, size: 14), + ), + const SizedBox(width: 8), + Text('WalkGuide', style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(width: 20), + _navItem('Overview', true), + _navItem('Live Track', false), + _navItem('Settings', false), + _navItem('Alerts', false), + const Spacer(), + TextButton.icon( + onPressed: () => _logout(ctx), + icon: const Icon(Icons.logout, size: 14, color: Color(0xFF64748B)), + label: Text('Sign out', style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), + ), + const SizedBox(width: 10), + Row(children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Guardian', style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600)), + Text('guardian@walkguide.com', + style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF94A3B8))), + ], + ), + const SizedBox(width: 8), + CircleAvatar( + radius: 14, + backgroundColor: const Color(0xFF1A56DB), + child: Text('GD', + style: GoogleFonts.inter(fontSize: 10, color: Colors.white, fontWeight: FontWeight.w600)), + ), + ]), + ]), + ); + } + + Widget _navItem(String label, bool active) { + return Container( + margin: const EdgeInsets.only(right: 2), + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + backgroundColor: active ? const Color(0xFFF1F5F9) : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + ), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + color: active ? const Color(0xFF0F172A) : const Color(0xFF64748B), + ), + ), + ), + ); + } + + Widget _buildPageHeader() { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Guardian Command', + style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.w600, color: const Color(0xFF0F172A))), + Text('Thursday, April 23, 2026 — Real-time monitoring active', + style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), + ]); + } + + Widget _buildKpiRow() { + final kpis = [ + {'label': 'User Status', 'val': '● Active', 'sub': 'Walking — Jl. Kenangan SBY', 'isGreen': true}, + {'label': 'Battery Level', 'val': '85%', 'sub': 'Good — Est. 6h left', 'isGreen': false}, + {'label': 'AI Alerts Today', 'val': '12', 'sub': '3 obstacles detected', 'isGreen': false}, + {'label': 'Alert Distance', 'val': '2.5m', 'sub': 'Haptic feedback on', 'isGreen': false}, + ]; + return Row( + children: kpis.map((k) { + final isLast = kpis.indexOf(k) == kpis.length - 1; + return Expanded( + child: Container( + margin: EdgeInsets.only(right: isLast ? 0 : 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(k['label'] as String, + style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF94A3B8), letterSpacing: 0.04)), + const SizedBox(height: 6), + Text( + k['val'] as String, + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w600, + color: (k['isGreen'] as bool) ? const Color(0xFF16A34A) : const Color(0xFF0F172A), + ), + ), + const SizedBox(height: 3), + Text(k['sub'] as String, + style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF1A56DB))), + ]), + ), + ); + }).toList(), + ); + } + + Widget _buildMainGrid(BuildContext ctx) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column(children: [ + _buildMapCard(), + const SizedBox(height: 14), + _buildActivityCard(), + ]), + ), + const SizedBox(width: 14), + SizedBox( + width: 240, + child: Column(children: [ + _buildUserCard(), + const SizedBox(height: 12), + _buildActionsCard(), + ]), + ), + ], + ); + } + + Widget _buildMapCard() { return Container( - margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.withValues(alpha: 0.1)), - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.02), blurRadius: 8, offset: const Offset(0, 2))], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), ), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: const Color(0xFFF1F5F9), borderRadius: BorderRadius.circular(12)), - child: Icon(icon, color: const Color(0xFF0F172A)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Row(children: [ + Text('Live location', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0x1A16A34A), + borderRadius: BorderRadius.circular(20), + ), + child: Row(children: [ + Container( + width: 5, + height: 5, + decoration: const BoxDecoration(color: Color(0xFF16A34A), shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text('Live', + style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF16A34A), fontWeight: FontWeight.w600)), + ]), + ), + ]), ), - title: Text(title, style: GoogleFonts.inter(fontWeight: FontWeight.w600, color: const Color(0xFF0F172A))), - subtitle: Text(subtitle, style: GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B))), - trailing: const Icon(Icons.arrow_forward_ios, size: 16, color: Color(0xFFCBD5E1)), - onTap: () {}, + ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(12), + bottomRight: Radius.circular(12), + ), + child: Container( + height: 180, + decoration: const BoxDecoration(color: Color(0xFFF1F5F9)), + child: Stack(children: [ + Center( + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container( + width: 36, + height: 36, + decoration: const BoxDecoration(color: Color(0xFF1A56DB), shape: BoxShape.circle), + child: const Icon(Icons.location_on, color: Colors.white, size: 20), + ), + const SizedBox(height: 8), + Text('Map placeholder', + style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF94A3B8))), + ]), + ), + Positioned( + bottom: 10, + left: 10, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Jl. Kenangan No. 14', + style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600)), + Text('Surabaya, East Java', + style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF64748B))), + ]), + ), + ), + ]), + ), + ), + ]), + ); + } + + Widget _buildActivityCard() { + final items = [ + {'icon': Icons.play_circle_outline, 'title': 'Navigation started', 'time': '08:32 — User began walking route', 'color': const Color(0xFF16A34A)}, + {'icon': Icons.warning_amber_outlined, 'title': 'Obstacle detected', 'time': '08:41 — AI alert at 1.8m distance', 'color': const Color(0xFFD97706)}, + {'icon': Icons.location_on_outlined, 'title': 'Location checkpoint', 'time': '09:05 — Arrived at Jl. Kenangan', 'color': const Color(0xFF1A56DB)}, + ]; + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), ), + child: Column(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Row(children: [ + Text('Recent activity', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), + const Spacer(), + Text('Today', style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF94A3B8))), + ]), + ), + ...items.map((item) => Column(children: [ + const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE2E8F0)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row(children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: (item['color'] as Color).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(item['icon'] as IconData, size: 16, color: item['color'] as Color), + ), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(item['title'] as String, + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), + Text(item['time'] as String, + style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), + ]), + ]), + ), + ])), + ]), + ); + } + + Widget _buildUserCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: const Color(0xFF1A56DB).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(18), + ), + child: const Icon(Icons.person, color: Color(0xFF1A56DB), size: 20), + ), + const SizedBox(width: 10), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('User (Tunanetra)', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w600)), + Text('● Online now', style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF16A34A))), + ]), + ]), + const SizedBox(height: 14), + // Ganti GridView → Row+Column biasa, lebih predictable + Row(children: [ + Expanded(child: _statCell('Battery', '85%')), + const SizedBox(width: 8), + Expanded(child: _statCell('Speed', '3.2 km/h')), + ]), + const SizedBox(height: 8), + Row(children: [ + Expanded(child: _statCell('Distance', '1.4 km')), + const SizedBox(width: 8), + Expanded(child: _statCell('Alerts', '12')), + ]), + ]), + ); + } + + Widget _statCell(String label, String val) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF94A3B8))), + const SizedBox(height: 2), + Text(val, style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w600)), + ]), + ); + } + + Widget _buildActionsCard() { + final actions = [ + {'icon': Icons.videocam_outlined, 'label': 'Live view', 'sub': 'Camera feed', 'color': const Color(0xFF1A56DB)}, + {'icon': Icons.phone_outlined, 'label': 'Call user', 'sub': 'Voice call', 'color': const Color(0xFF16A34A)}, + {'icon': Icons.tune, 'label': 'AI sensitivity', 'sub': 'Obstacle distance', 'color': const Color(0xFF7C3AED)}, + {'icon': Icons.warning_rounded, 'label': 'Emergency ping', 'sub': 'Alert user now', 'color': const Color(0xFFDC2626)}, + ]; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('QUICK ACTIONS', + style: GoogleFonts.inter( + fontSize: 11, fontWeight: FontWeight.w600, color: const Color(0xFF94A3B8), letterSpacing: 0.06)), + const SizedBox(height: 8), + ...actions.map((a) => InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), + child: Row(children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: (a['color'] as Color).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(a['icon'] as IconData, size: 15, color: a['color'] as Color), + ), + const SizedBox(width: 10), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(a['label'] as String, + style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), + Text(a['sub'] as String, + style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF94A3B8))), + ]), + ]), + ), + )), + ]), ); } } \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/lib/features/home/presentation/user_dashboard_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/presentation/user_dashboard_screen.dart index f53d692..61000d0 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/presentation/user_dashboard_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/presentation/user_dashboard_screen.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:camera/camera.dart'; -import 'package:animate_do/animate_do.dart'; import '../../../core/secure_storage.dart'; import '../../auth/presentation/login_screen.dart'; -import '../../../../main.dart'; // import global cameras +import '../../../../main.dart'; class UserDashboardScreen extends StatefulWidget { const UserDashboardScreen({super.key}); @@ -13,159 +12,293 @@ class UserDashboardScreen extends StatefulWidget { State createState() => _UserDashboardScreenState(); } -class _UserDashboardScreenState extends State with SingleTickerProviderStateMixin { - CameraController? _cameraController; - late AnimationController _pulseController; +class _UserDashboardScreenState extends State with TickerProviderStateMixin { + CameraController? _camCtrl; + late AnimationController _radarCtrl; + late Animation _radarAnim; @override void initState() { super.initState(); - _initCamera(); - - // Setup animasi radar berdenyut - _pulseController = AnimationController( + _radarCtrl = AnimationController( vsync: this, duration: const Duration(seconds: 2), )..repeat(reverse: true); + _radarAnim = Tween(begin: 0.9, end: 1.08).animate( + CurvedAnimation(parent: _radarCtrl, curve: Curves.easeInOut), + ); + _initCamera(); } Future _initCamera() async { if (cameras.isEmpty) return; - // Pakai kamera belakang (index 0) - _cameraController = CameraController(cameras[0], ResolutionPreset.high, enableAudio: false); - await _cameraController!.initialize(); + _camCtrl = CameraController(cameras[0], ResolutionPreset.high, enableAudio: false); + await _camCtrl!.initialize(); if (mounted) setState(() {}); } - Future _handleLogout() async { + Future _logout() async { await SecureStorage().deleteToken(); if (mounted) { Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (_) => const LoginScreen()), - (route) => false, // Hapus history back + (_) => false, ); } } @override void dispose() { - _cameraController?.dispose(); - _pulseController.dispose(); + _camCtrl?.dispose(); + _radarCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final topPad = MediaQuery.of(context).padding.top; + final botPad = MediaQuery.of(context).padding.bottom; + return Scaffold( backgroundColor: Colors.black, - body: Stack( - children: [ - // 1. LIVE CAMERA FEED - SizedBox.expand( - child: _cameraController != null && _cameraController!.value.isInitialized - ? CameraPreview(_cameraController!) - : const Center(child: CircularProgressIndicator(color: Colors.white)), - ), + body: Stack(children: [ + // 1. Camera feed + SizedBox.expand( + child: (_camCtrl != null && _camCtrl!.value.isInitialized) + ? CameraPreview(_camCtrl!) + : Container( + decoration: const BoxDecoration(color: Color(0xFF0A1520)), + ), + ), - // 2. EFEK RADAR SCANNING (Animasi Pulse) - Positioned.fill( - child: AnimatedBuilder( - animation: _pulseController, - builder: (context, child) { - return Container( - decoration: BoxDecoration( - gradient: RadialGradient( - colors: [ - Colors.transparent, - const Color(0xFF10B981).withValues(alpha: 0.1 + (_pulseController.value * 0.15)), - ], - stops: const [0.5, 1.0], - radius: 1.5, - ), - ), - ); - }, + // 2. Grid overlay + SizedBox.expand(child: CustomPaint(painter: _GridPainter())), + + // 3. Green scan gradient + AnimatedBuilder( + animation: _radarCtrl, + builder: (_, __) => SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + colors: [ + Colors.transparent, + const Color(0xFF10B981).withValues(alpha: 0.05 + _radarCtrl.value * 0.08), + ], + stops: const [0.5, 1.0], + radius: 1.4, + ), + ), ), ), + ), - // 3. OVERLAY STATUS & LOGOUT (Atas) - Positioned( - top: 60, - left: 20, - right: 20, - child: FadeInDown( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + // 4. Scan line + AnimatedBuilder( + animation: _radarCtrl, + builder: (_, __) => Positioned( + top: size.height * (0.3 + _radarCtrl.value * 0.35), + left: 0, + right: 0, + child: Container( + height: 1.5, + decoration: BoxDecoration( + color: const Color(0xFF60EFAB).withValues(alpha: 0.25), + ), + ), + ), + ), + + // 5. Top status bar + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.fromLTRB(16, topPad + 8, 16, 12), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.54), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFF60EFAB).withValues(alpha: 0.3), + ), + ), + child: Row(children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(30), - border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.3)), - ), - child: Row( - children: [ - const Icon(Icons.spatial_audio_off, color: Color(0xFF10B981), size: 20), - const SizedBox(width: 8), - Text('Memindai Area...', style: GoogleFonts.inter(color: Colors.white, fontWeight: FontWeight.bold)), - ], + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF60EFAB), + shape: BoxShape.circle, ), ), - - // Tombol Logout - IconButton( - onPressed: _handleLogout, - icon: const Icon(Icons.power_settings_new, color: Colors.white, size: 28), - tooltip: 'Keluar', - style: IconButton.styleFrom(backgroundColor: Colors.redAccent.withValues(alpha: 0.8)), + const SizedBox(width: 7), + Text( + 'Memindai area...', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), ), - ], + ]), ), - ), + const Spacer(), + IconButton( + onPressed: _logout, + icon: const Icon(Icons.power_settings_new, color: Colors.white, size: 26), + style: IconButton.styleFrom( + backgroundColor: Colors.redAccent.withValues(alpha: 0.8), + ), + ), + ]), ), + ), - // 4. OVERLAY TOMBOL BESAR (Bawah) - Positioned( - bottom: 40, - left: 20, - right: 20, - child: FadeInUp( - delay: const Duration(milliseconds: 300), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () {}, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF2563EB).withValues(alpha: 0.9), - padding: const EdgeInsets.symmetric(vertical: 24), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - elevation: 10, - ), - child: const Icon(Icons.mic, size: 48, color: Colors.white), - ), - ), - const SizedBox(width: 16), - Expanded( - child: ElevatedButton( - onPressed: () {}, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFDC2626).withValues(alpha: 0.9), - padding: const EdgeInsets.symmetric(vertical: 24), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - elevation: 10, - ), - child: const Icon(Icons.phone_in_talk, size: 48, color: Colors.white), - ), - ), - ], + // 6. Center radar + Center( + child: AnimatedBuilder( + animation: _radarAnim, + builder: (_, __) => Transform.scale( + scale: _radarAnim.value, + child: SizedBox( + width: 100, + height: 100, + child: CustomPaint(painter: _RadarPainter()), ), ), ), - ], - ), + ), + + // 7. Obstacle alert card + Positioned( + bottom: 160, + left: 16, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFF59E0B).withValues(alpha: 0.3), + ), + ), + child: Row(children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: const Color(0x33F59E0B), + borderRadius: BorderRadius.circular(7), + ), + child: const Icon(Icons.warning_amber_rounded, color: Color(0xFFF59E0B), size: 16), + ), + const SizedBox(width: 10), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Obstacle ahead', + style: GoogleFonts.inter( + color: Colors.white, fontSize: 13, fontWeight: FontWeight.w500)), + Text('2.1m — Haptic alert sent', + style: GoogleFonts.inter(color: Colors.white60, fontSize: 11)), + ]), + ]), + ), + ), + + // 8. Bottom action buttons + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.fromLTRB(16, 16, 16, botPad + 20), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black87, Colors.transparent], + ), + ), + child: Column(children: [ + Row(children: [ + Expanded(child: _bigBtn(const Color(0xCC1A56DB), Icons.mic, () {})), + const SizedBox(width: 12), + Expanded(child: _bigBtn(const Color(0xCCDC2626), Icons.phone_in_talk, () {})), + ]), + const SizedBox(height: 8), + Text( + 'Vol+ terima panggilan • Vol- ping darurat', + style: GoogleFonts.inter(fontSize: 10, color: Colors.white30), + ), + ]), + ), + ), + ]), ); } + + Widget _bigBtn(Color color, IconData icon, VoidCallback onTap) { + return ElevatedButton( + onPressed: onTap, + style: ElevatedButton.styleFrom( + backgroundColor: color, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 22), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + child: Icon(icon, size: 40, color: Colors.white), + ); + } +} + +class _GridPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.white.withValues(alpha: 0.04) + ..strokeWidth = 0.5; + for (double x = 0; x < size.width; x += 25) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + for (double y = 0; y < size.height; y += 25) { + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _RadarPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + for (final r in [48.0, 34.0, 20.0]) { + paint.color = const Color(0xFF60EFAB).withValues(alpha: 0.15 + (50 - r) / 100); + canvas.drawCircle(center, r, paint); + } + paint + ..style = PaintingStyle.fill + ..color = const Color(0xFF60EFAB); + canvas.drawCircle(center, 5, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/pubspec.yaml b/walkguide-mobile/walkguide_app/pubspec.yaml index db834be..67bf0cc 100644 --- a/walkguide-mobile/walkguide_app/pubspec.yaml +++ b/walkguide-mobile/walkguide_app/pubspec.yaml @@ -62,6 +62,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/images/ # To add assets to your application, add an assets section, like this: # assets: