diff --git a/lib/core/api/api_services.dart b/lib/core/api/api_services.dart index 9e1db00..60debe1 100644 --- a/lib/core/api/api_services.dart +++ b/lib/core/api/api_services.dart @@ -1,11 +1,13 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:http_parser/http_parser.dart'; import 'package:http/http.dart' as http; import 'package:rijig_mobile/core/api/api_exception.dart'; +import 'package:rijig_mobile/core/network/network_service.dart'; import 'package:rijig_mobile/core/storage/expired_token.dart'; import 'package:rijig_mobile/core/storage/secure_storage.dart'; @@ -17,6 +19,11 @@ class Https { final String? _baseUrl = dotenv.env["BASE_URL"]; final String? _apiKey = dotenv.env["API_KEY"]; final SecureStorage _secureStorage = SecureStorage(); + final NetworkService _networkService = NetworkService(); + + final int _maxRetries = 3; + final List _retryStatusCodes = [408, 429, 500, 502, 503, 504]; + final Duration _baseRetryDelay = const Duration(seconds: 1); Future> _getHeaders() async { String? token = await _secureStorage.readSecureData('token'); @@ -51,52 +58,78 @@ class Https { Encoding? encoding, String? baseUrl, http.MultipartRequest? multipartRequest, + bool checkNetwork = true, + int retryCount = 0, }) async { + if (checkNetwork) { + final bool isConnected = await _networkService.checkConnection(); + if (!isConnected) { + throw NetworkException( + 'No internet connection available', + NetworkErrorType.noConnection, + ); + } + } + final requestHeaders = await _getHeaders(); String url = "${baseUrl ?? _baseUrl}$desturl"; - debugPrint("url $url"); + debugPrint("Request URL: $url"); + debugPrint("Network Quality: ${_networkService.currentQuality}"); + + final timeout = _networkService.getTimeoutDuration(); http.Response response; try { if (multipartRequest != null) { - response = await multipartRequest.send().then( - (response) => http.Response.fromStream(response), - ); + response = await multipartRequest + .send() + .timeout(timeout) + .then((response) => http.Response.fromStream(response)); } else { switch (method.toLowerCase()) { case 'get': - response = await http.get(Uri.parse(url), headers: requestHeaders); + response = await http + .get(Uri.parse(url), headers: requestHeaders) + .timeout(timeout); break; case 'post': - response = await http.post( - Uri.parse(url), - body: jsonEncode(body), - headers: requestHeaders, - encoding: encoding, - ); + response = await http + .post( + Uri.parse(url), + body: jsonEncode(body), + headers: requestHeaders, + encoding: encoding, + ) + .timeout(timeout); break; case 'put': - response = await http.put( - Uri.parse(url), - body: jsonEncode(body), - headers: requestHeaders, - encoding: encoding, - ); + response = await http + .put( + Uri.parse(url), + body: jsonEncode(body), + headers: requestHeaders, + encoding: encoding, + ) + .timeout(timeout); break; case 'delete': - response = await http.delete( - Uri.parse(url), - body: jsonEncode(body), - headers: requestHeaders, - ); + response = await http + .delete( + Uri.parse(url), + body: jsonEncode(body), + headers: requestHeaders, + ) + .timeout(timeout); break; case 'patch': - response = await http.patch( - Uri.parse(url), - body: jsonEncode(body), - headers: requestHeaders, - encoding: encoding, - ); + response = await http + .patch( + Uri.parse(url), + body: jsonEncode(body), + headers: requestHeaders, + encoding: encoding, + ) + .timeout(timeout); break; default: throw ApiException('Unsupported HTTP method: $method', 405); @@ -104,15 +137,75 @@ class Https { } final int statusCode = response.statusCode; + + if (_shouldRetry(statusCode, retryCount)) { + return await _retryRequest( + method, + desturl: desturl, + headers: headers, + body: body, + encoding: encoding, + baseUrl: baseUrl, + multipartRequest: multipartRequest, + retryCount: retryCount + 1, + ); + } + if (statusCode < 200 || statusCode >= 400) { throw ApiException( 'Error during HTTP $method request: ${response.body}', statusCode, ); } + return jsonDecode(response.body); + } on TimeoutException catch (e) { + // Pindahkan TimeoutException ke atas sebelum SocketException + debugPrint('Timeout Exception: $e'); + + if (_shouldRetryOnNetworkError(retryCount)) { + return await _retryRequest( + method, + desturl: desturl, + headers: headers, + body: body, + encoding: encoding, + baseUrl: baseUrl, + multipartRequest: multipartRequest, + retryCount: retryCount + 1, + isNetworkError: true, + ); + } + + throw NetworkException( + 'Request timeout: ${e.toString()}', + NetworkErrorType.timeout, + ); + } on SocketException catch (e) { + debugPrint('Socket Exception: $e'); + + if (_shouldRetryOnNetworkError(retryCount)) { + return await _retryRequest( + method, + desturl: desturl, + headers: headers, + body: body, + encoding: encoding, + baseUrl: baseUrl, + multipartRequest: multipartRequest, + retryCount: retryCount + 1, + isNetworkError: true, + ); + } + + throw NetworkException( + 'Connection failed: ${e.message}', + NetworkErrorType.connectionFailed, + ); } catch (error) { - if (error is ApiException) { + debugPrint('Request error: $error'); + + if (error is ApiException || error is NetworkException) { rethrow; } else { throw ApiException('Network error: $error', 500); @@ -120,6 +213,58 @@ class Https { } } + bool _shouldRetry(int statusCode, int retryCount) { + return retryCount < _maxRetries && _retryStatusCodes.contains(statusCode); + } + + bool _shouldRetryOnNetworkError(int retryCount) { + return retryCount < _maxRetries; + } + + Future _retryRequest( + String method, { + required String desturl, + Map headers = const {}, + dynamic body, + Encoding? encoding, + String? baseUrl, + http.MultipartRequest? multipartRequest, + required int retryCount, + bool isNetworkError = false, + }) async { + final delay = Duration( + milliseconds: _baseRetryDelay.inMilliseconds * (retryCount * retryCount), + ); + + debugPrint( + 'Retrying request (attempt ${retryCount + 1}/$_maxRetries) after ${delay.inMilliseconds}ms', + ); + + await Future.delayed(delay); + + if (isNetworkError) { + final bool isConnected = await _networkService.checkConnection(); + if (!isConnected) { + throw NetworkException( + 'No internet connection available after retry', + NetworkErrorType.noConnection, + ); + } + } + + return await _request( + method, + desturl: desturl, + headers: headers, + body: body, + encoding: encoding, + baseUrl: baseUrl, + multipartRequest: multipartRequest, + checkNetwork: false, + retryCount: retryCount, + ); + } + Future get( String desturl, { Map headers = const {}, @@ -243,3 +388,15 @@ class Https { ); } } + +enum NetworkErrorType { noConnection, connectionFailed, timeout, poor } + +class NetworkException implements Exception { + final String message; + final NetworkErrorType type; + + NetworkException(this.message, this.type); + + @override + String toString() => 'NetworkException: $message'; +} \ No newline at end of file diff --git a/lib/core/network/network_aware_widgets.dart b/lib/core/network/network_aware_widgets.dart new file mode 100644 index 0000000..6e63e73 --- /dev/null +++ b/lib/core/network/network_aware_widgets.dart @@ -0,0 +1,545 @@ +// ignore_for_file: avoid_shadowing_type_parameters +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:rijig_mobile/core/api/api_services.dart'; +import 'package:rijig_mobile/core/router.dart'; +import 'package:rijig_mobile/core/utils/guide.dart'; +import 'package:rijig_mobile/widget/showmodal.dart'; + +import 'network_service.dart'; + +abstract class NetworkAwareWidget extends StatefulWidget { + const NetworkAwareWidget({super.key}); +} + +abstract class NetworkAwareState + extends State { + final NetworkService _networkService = NetworkService(); + StreamSubscription? _networkSubscription; + bool _hasShownNetworkDialog = false; + + @override + void initState() { + super.initState(); + _initNetworkListener(); + } + + void _initNetworkListener() { + _networkSubscription = _networkService.networkStatusStream.listen((status) { + if (mounted) { + _handleNetworkStatusChange(status); + } + }); + } + + void _handleNetworkStatusChange(NetworkStatus status) { + switch (status) { + case NetworkStatus.disconnected: + if (!_hasShownNetworkDialog) { + _showNetworkErrorDialog(); + } + onNetworkDisconnected(); + break; + case NetworkStatus.connected: + if (_hasShownNetworkDialog) { + _dismissNetworkDialog(); + } + onNetworkConnected(); + break; + case NetworkStatus.checking: + onNetworkChecking(); + break; + case NetworkStatus.poor: + onNetworkPoor(); + break; + } + } + + void onNetworkConnected() {} + void onNetworkDisconnected() {} + void onNetworkChecking() {} + void onNetworkPoor() {} + + Future performNetworkOperation( + Future Function() operation, { + bool showLoadingDialog = true, + String? loadingMessage, + }) async { + if (showLoadingDialog) { + _showLoadingDialog(loadingMessage ?? 'Loading...'); + } + + try { + final result = await operation(); + if (showLoadingDialog && mounted) { + router.pop(context); + } + return result; + } catch (e) { + if (showLoadingDialog && mounted) { + router.pop(context); + } + _handleNetworkException(e); + rethrow; + } finally { + if (showLoadingDialog && mounted) { + router.pop(context); + } + } + } + + void _handleNetworkException(e) { + switch (e.type) { + case NetworkErrorType.noConnection: + _showNetworkErrorDialog(); + break; + case NetworkErrorType.timeout: + _showTimeoutDialog(); + break; + case NetworkErrorType.connectionFailed: + _showConnectionFailedDialog(); + break; + case NetworkErrorType.poor: + _showPoorConnectionDialog(); + break; + } + } + + void _showLoadingDialog(String message) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => LoadingDialog(message: message), + ); + } + + void _showNetworkErrorDialog() { + _hasShownNetworkDialog = true; + NetworkDialogManager.showNoInternetDialog( + context: context, + onRetry: () async { + router.pop(context); + _hasShownNetworkDialog = false; + await _networkService.checkConnection(); + }, + onExit: () { + SystemNavigator.pop(); + }, + ); + } + + void _showTimeoutDialog() { + NetworkDialogManager.showTimeoutDialog( + context: context, + onRetry: () { + router.pop(context); + }, + ); + } + + void _showConnectionFailedDialog() { + NetworkDialogManager.showConnectionFailedDialog( + context: context, + onRetry: () { + router.pop(context); + }, + ); + } + + void _showPoorConnectionDialog() { + NetworkDialogManager.showPoorConnectionDialog( + context: context, + onContinue: () { + router.pop(context); + }, + ); + } + + void _dismissNetworkDialog() { + if (_hasShownNetworkDialog && mounted) { + router.pop(context); + _hasShownNetworkDialog = false; + } + } + + @override + void dispose() { + _networkSubscription?.cancel(); + super.dispose(); + } +} + +class NetworkDialogManager { + static void showNoInternetDialog({ + required BuildContext context, + required VoidCallback onRetry, + required VoidCallback onExit, + }) { + CustomModalDialog.show( + context: context, + showCloseIcon: false, + variant: ModalVariant.imageVersion, + title: 'Tidak Ada Koneksi Internet', + content: + 'Sepertinya koneksi internet Anda bermasalah. Periksa koneksi WiFi atau data seluler Anda, lalu coba lagi.', + imageAsset: 'assets/image/bad_connection.png', + buttonCount: 2, + button1: ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Coba Lagi'), + ), + button2: OutlinedButton( + onPressed: onExit, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Keluar'), + ), + ); + } + + static void showTimeoutDialog({ + required BuildContext context, + required VoidCallback onRetry, + }) { + CustomModalDialog.show( + context: context, + showCloseIcon: false, + variant: ModalVariant.imageVersion, + title: 'Koneksi Timeout', + content: + 'Permintaan memakan waktu terlalu lama. Periksa koneksi internet Anda dan coba lagi.', + imageAsset: 'assets/images/timeout.png', // Ganti dengan path gambar Anda + buttonCount: 2, + button1: ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Coba Lagi'), + ), + button2: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Tutup'), + ), + ); + } + + static void showConnectionFailedDialog({ + required BuildContext context, + required VoidCallback onRetry, + }) { + CustomModalDialog.show( + context: context, + showCloseIcon: false, + variant: ModalVariant.imageVersion, + title: 'Koneksi Gagal', + content: + 'Tidak dapat terhubung ke server. Pastikan koneksi internet Anda stabil.', + imageAsset: + 'assets/images/connection_failed.png', // Ganti dengan path gambar Anda + buttonCount: 2, + button1: ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Coba Lagi'), + ), + button2: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Tutup'), + ), + ); + } + + static void showPoorConnectionDialog({ + required BuildContext context, + required VoidCallback onContinue, + }) { + CustomModalDialog.show( + context: context, + showCloseIcon: false, + variant: ModalVariant.imageVersion, + title: 'Koneksi Lambat', + content: + 'Koneksi internet Anda lambat. Beberapa fitur mungkin tidak berfungsi optimal.', + imageAsset: + 'assets/images/poor_connection.png', + buttonCount: 2, + button1: ElevatedButton( + onPressed: onContinue, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Lanjutkan'), + ), + button2: OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Tutup'), + ), + ); + } +} + +// void _showLoadingDialog(String message) { +// CustomModalDialog.show( +// context: context, +// variant: ModalVariant.imageVersion, +// title: 'Memuat', +// content: message, +// imageAsset: 'assets/images/loading.png', // Ganti dengan path gambar loading Anda +// buttonCount: 0, // Tidak ada button untuk loading dialog +// ); +// } + +class NetworkErrorDialog extends StatelessWidget { + final String title; + final String message; + final IconData iconData; + final String primaryButtonText; + final VoidCallback onPrimaryPressed; + final String secondaryButtonText; + final VoidCallback onSecondaryPressed; + + const NetworkErrorDialog({ + super.key, + required this.title, + required this.message, + required this.iconData, + required this.primaryButtonText, + required this.onPrimaryPressed, + required this.secondaryButtonText, + required this.onSecondaryPressed, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade50, + shape: BoxShape.circle, + ), + child: Icon(iconData, size: 48, color: Colors.red.shade400), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + message, + style: const TextStyle(fontSize: 14, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onSecondaryPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text(secondaryButtonText), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: onPrimaryPressed, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text(primaryButtonText), + ), + ), + ], + ), + ], + ), + ); + } +} + +class LoadingDialog extends StatelessWidget { + final String message; + + const LoadingDialog({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: whiteColor, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle(fontSize: 14), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} + +class NetworkStatusIndicator extends StatefulWidget { + final Widget child; + final bool showIndicator; + + const NetworkStatusIndicator({ + super.key, + required this.child, + this.showIndicator = true, + }); + + @override + State createState() => _NetworkStatusIndicatorState(); +} + +class _NetworkStatusIndicatorState extends State { + final NetworkService _networkService = NetworkService(); + StreamSubscription? _subscription; + NetworkStatus _currentStatus = NetworkStatus.connected; + + @override + void initState() { + super.initState(); + _currentStatus = _networkService.currentStatus; + _subscription = _networkService.networkStatusStream.listen((status) { + if (mounted) { + setState(() { + _currentStatus = status; + }); + } + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (widget.showIndicator && _currentStatus != NetworkStatus.connected) + Positioned( + top: MediaQuery.of(context).padding.top, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: _getStatusColor(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getStatusIcon(), size: 16, color: whiteColor), + const SizedBox(width: 8), + Text( + _getStatusText(), + style: TextStyle( + color: whiteColor, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ); + } + + Color _getStatusColor() { + switch (_currentStatus) { + case NetworkStatus.disconnected: + return Colors.red; + case NetworkStatus.checking: + return Colors.orange; + case NetworkStatus.poor: + return Colors.amber; + case NetworkStatus.connected: + return Colors.green; + } + } + + IconData _getStatusIcon() { + switch (_currentStatus) { + case NetworkStatus.disconnected: + return Icons.wifi_off; + case NetworkStatus.checking: + return Icons.wifi_find; + case NetworkStatus.poor: + return Icons.signal_wifi_bad; + case NetworkStatus.connected: + return Icons.wifi; + } + } + + String _getStatusText() { + switch (_currentStatus) { + case NetworkStatus.disconnected: + return 'Tidak ada koneksi internet'; + case NetworkStatus.checking: + return 'Memeriksa koneksi...'; + case NetworkStatus.poor: + return 'Koneksi lambat'; + case NetworkStatus.connected: + return 'Terhubung'; + } + } +} diff --git a/lib/core/network/network_info.dart b/lib/core/network/network_info.dart index 673459d..f7b35c1 100644 --- a/lib/core/network/network_info.dart +++ b/lib/core/network/network_info.dart @@ -1,23 +1,58 @@ // ignore_for_file: unrelated_type_equality_checks +import 'dart:async'; import 'dart:io'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:rijig_mobile/core/network/network_service.dart'; +import 'package:rijig_mobile/core/utils/exportimportview.dart'; class NetworkInfo { - final Connectivity _connectivity = Connectivity(); - Future checkConnection() async { - var connectivityResult = await _connectivity.checkConnectivity(); - - if (connectivityResult == ConnectivityResult.none) { - return false; - } - try { - final lookupResult = await InternetAddress.lookup('google.com'); - return lookupResult.isNotEmpty && lookupResult[0].rawAddress.isNotEmpty; - } catch (_) { + var connectivityResult = await Connectivity().checkConnectivity(); + if (connectivityResult == ConnectivityResult.none) { + return false; + } + + final result = await InternetAddress.lookup( + 'google.com', + ).timeout(const Duration(seconds: 5)); + + return result.isNotEmpty && result[0].rawAddress.isNotEmpty; + } on SocketException catch (e) { + debugPrint('Network check - Socket exception: $e'); + return false; + } on TimeoutException catch (e) { + debugPrint('Network check - Timeout: $e'); + return false; + } catch (e) { + debugPrint('Network check - General error: $e'); return false; } } + + Future checkNetworkQuality() async { + final stopwatch = Stopwatch()..start(); + + try { + await InternetAddress.lookup( + 'google.com', + ).timeout(const Duration(seconds: 3)); + stopwatch.stop(); + + final responseTime = stopwatch.elapsedMilliseconds; + + if (responseTime < 1000) { + return NetworkQuality.excellent; + } else if (responseTime < 2000) { + return NetworkQuality.good; + } else if (responseTime < 3000) { + return NetworkQuality.fair; + } else { + return NetworkQuality.poor; + } + } catch (e) { + return NetworkQuality.none; + } + } } diff --git a/lib/core/network/network_service.dart b/lib/core/network/network_service.dart new file mode 100644 index 0000000..42653eb --- /dev/null +++ b/lib/core/network/network_service.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +enum NetworkStatus { connected, disconnected, checking, poor } + +enum NetworkQuality { excellent, good, fair, poor, none } + +class NetworkService { + static final NetworkService _instance = NetworkService._internal(); + NetworkService._internal(); + factory NetworkService() => _instance; + + final Connectivity _connectivity = Connectivity(); + final StreamController _networkStatusController = + StreamController.broadcast(); + + NetworkStatus _currentStatus = NetworkStatus.checking; + NetworkQuality _currentQuality = NetworkQuality.none; + Timer? _connectionTimer; + Timer? _qualityTimer; + bool _isInitialized = false; + + Stream get networkStatusStream => + _networkStatusController.stream; + NetworkStatus get currentStatus => _currentStatus; + NetworkQuality get currentQuality => _currentQuality; + bool get isConnected => _currentStatus == NetworkStatus.connected; + + Future initialize() async { + if (_isInitialized) return; + _isInitialized = true; + await _checkConnection(); + _connectivity.onConnectivityChanged.listen(_handleConnectivityChange); + _startQualityMonitoring(); + } + + Future checkConnection({int timeoutSeconds = 10}) async { + _updateStatus(NetworkStatus.checking); + + try { + final connectivityResult = await _connectivity.checkConnectivity(); + + if (connectivityResult.contains(ConnectivityResult.none) || + connectivityResult.isEmpty) { + _updateStatus(NetworkStatus.disconnected); + return false; + } + + final bool canReachInternet = await _canReachInternet(timeoutSeconds); + + if (canReachInternet) { + _updateStatus(NetworkStatus.connected); + await _checkNetworkQuality(); + return true; + } else { + _updateStatus(NetworkStatus.disconnected); + return false; + } + } catch (e) { + debugPrint('Network check error: $e'); + _updateStatus(NetworkStatus.disconnected); + return false; + } + } + + Future _checkConnection() async { + await checkConnection(); + } + + void _handleConnectivityChange(List results) { + if (results.contains(ConnectivityResult.none) || results.isEmpty) { + _updateStatus(NetworkStatus.disconnected); + _currentQuality = NetworkQuality.none; + } else { + Timer(const Duration(seconds: 1), () async { + await _checkConnection(); + }); + } + } + + Future _canReachInternet(int timeoutSeconds) async { + final List testHosts = ['google.com', 'cloudflare.com', '8.8.8.8']; + + for (String host in testHosts) { + try { + final result = await InternetAddress.lookup( + host, + ).timeout(Duration(seconds: timeoutSeconds)); + if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + return true; + } + } catch (e) { + debugPrint('Failed to reach $host: $e'); + continue; + } + } + return false; + } + + Future _checkNetworkQuality() async { + try { + final stopwatch = Stopwatch()..start(); + + await InternetAddress.lookup( + 'google.com', + ).timeout(const Duration(seconds: 5)); + + stopwatch.stop(); + final responseTime = stopwatch.elapsedMilliseconds; + + if (responseTime < 200) { + _currentQuality = NetworkQuality.excellent; + } else if (responseTime < 500) { + _currentQuality = NetworkQuality.good; + } else if (responseTime < 1000) { + _currentQuality = NetworkQuality.fair; + } else { + _currentQuality = NetworkQuality.poor; + } + } catch (e) { + _currentQuality = NetworkQuality.none; + } + } + + void _startQualityMonitoring() { + _qualityTimer = Timer.periodic(const Duration(minutes: 2), (_) async { + if (_currentStatus == NetworkStatus.connected) { + await _checkNetworkQuality(); + } + }); + } + + void _updateStatus(NetworkStatus status) { + if (_currentStatus != status) { + _currentStatus = status; + _networkStatusController.add(status); + debugPrint('Network status changed to: $status'); + } + } + + Duration getTimeoutDuration() { + switch (_currentQuality) { + case NetworkQuality.excellent: + return const Duration(seconds: 10); + case NetworkQuality.good: + return const Duration(seconds: 15); + case NetworkQuality.fair: + return const Duration(seconds: 20); + case NetworkQuality.poor: + return const Duration(seconds: 30); + case NetworkQuality.none: + return const Duration(seconds: 10); + } + } + + void dispose() { + _connectionTimer?.cancel(); + _qualityTimer?.cancel(); + _networkStatusController.close(); + } +} diff --git a/lib/core/router.dart b/lib/core/router.dart index 8627f57..ecff919 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -1,10 +1,12 @@ import 'package:rijig_mobile/core/utils/exportimportview.dart'; +import 'package:rijig_mobile/features/profil/components/secure_pin_input.dart'; final router = GoRouter( routes: [ GoRoute(path: '/', builder: (context, state) => SplashScreen()), GoRoute(path: '/trashview', builder: (context, state) => TestRequestPickScreen()), GoRoute(path: '/ordersumary', builder: (context, state) => OrderSummaryScreen()), + GoRoute(path: '/pinsecureinput', builder: (context, state) => SecurityCodeScreen()), GoRoute( path: '/cmapview', builder: (context, state) => CollectorRouteMapScreen(), diff --git a/lib/features/home/presentation/components/cart_test_screen.dart b/lib/features/home/presentation/components/cart_test_screen.dart index 20790ab..d0fb7c5 100644 --- a/lib/features/home/presentation/components/cart_test_screen.dart +++ b/lib/features/home/presentation/components/cart_test_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:gap/gap.dart'; +import 'package:rijig_mobile/core/router.dart'; +import 'package:rijig_mobile/core/utils/guide.dart'; class OrderSummaryScreen extends StatefulWidget { const OrderSummaryScreen({super.key}); @@ -9,7 +12,6 @@ class OrderSummaryScreen extends StatefulWidget { } class _OrderSummaryScreenState extends State { - // List untuk menyimpan item yang dipilih List> selectedItems = [ { 'name': 'Plastik', @@ -30,37 +32,81 @@ class _OrderSummaryScreenState extends State { ]; void _removeItem(int index) { + if (index < 0 || index >= selectedItems.length) return; + + final removedItem = selectedItems[index]; setState(() { selectedItems.removeAt(index); }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Item berhasil dihapus'), - duration: Duration(seconds: 2), - ), + + _showSnackbar('${removedItem['name']} berhasil dihapus'); + } + + void _clearAllItems() { + if (selectedItems.isEmpty) return; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Hapus Semua Item'), + content: Text( + 'Apakah Anda yakin ingin menghapus semua item dari daftar?', + ), + actions: [ + TextButton( + onPressed: () => router.pop(context), + child: Text('Batal'), + ), + ElevatedButton( + onPressed: () { + setState(() { + selectedItems.clear(); + }); + router.pop(context); + _showSnackbar('Semua item berhasil dihapus'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: redColor, + foregroundColor: whiteColor, + ), + child: Text('Hapus Semua'), + ), + ], + ); + }, ); } void _incrementQuantity(int index) { + if (index < 0 || index >= selectedItems.length) return; + setState(() { - selectedItems[index]['quantity'] += 2.5; + selectedItems[index]['quantity'] += 0.5; }); } void _decrementQuantity(int index) { + if (index < 0 || index >= selectedItems.length) return; + setState(() { - if (selectedItems[index]['quantity'] > 0) { - selectedItems[index]['quantity'] = - (selectedItems[index]['quantity'] - 2.5).clamp(0.0, double.infinity); + final currentQuantity = selectedItems[index]['quantity'] as double; + if (currentQuantity > 0.5) { + selectedItems[index]['quantity'] = (currentQuantity - 0.5).clamp( + 0.0, + double.infinity, + ); } }); } void _showQuantityDialog(int index) { - TextEditingController controller = TextEditingController( - text: selectedItems[index]['quantity'].toString() + if (index < 0 || index >= selectedItems.length) return; + + final TextEditingController controller = TextEditingController( + text: selectedItems[index]['quantity'].toString(), ); - + showDialog( context: context, builder: (BuildContext context) { @@ -77,21 +123,19 @@ class _OrderSummaryScreenState extends State { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => router.pop(context), child: Text('Batal'), ), ElevatedButton( onPressed: () { - double? newQuantity = double.tryParse(controller.text); - if (newQuantity != null && newQuantity >= 0) { + final newQuantity = double.tryParse(controller.text); + if (newQuantity != null && newQuantity > 0) { setState(() { selectedItems[index]['quantity'] = newQuantity; }); - Navigator.pop(context); + router.pop(context); } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Masukkan angka yang valid')), - ); + _showSnackbar('Masukkan angka yang valid (lebih dari 0)'); } }, child: Text('Simpan'), @@ -102,33 +146,43 @@ class _OrderSummaryScreenState extends State { ); } + void _showSnackbar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: Duration(seconds: 2)), + ); + } + double get totalWeight { - return selectedItems.fold(0.0, (sum, item) => sum + item['quantity']); + return selectedItems.fold( + 0.0, + (sum, item) => sum + (item['quantity'] as double), + ); } int get estimatedEarnings { - return selectedItems.fold(0, (sum, item) => - sum + ((item['price'] as int) * (item['quantity'] as double)).round().toInt()); + return selectedItems.fold( + 0, + (sum, item) => + sum + ((item['price'] as int) * (item['quantity'] as double)).round(), + ); } - int get applicationFee { - return 550; - } + int get applicationFee => 550; - int get estimatedIncome { - return estimatedEarnings - applicationFee; - } + int get estimatedIncome => estimatedEarnings - applicationFee; + + bool get hasItems => selectedItems.isNotEmpty; @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey.shade50, appBar: AppBar( - backgroundColor: Colors.white, + backgroundColor: whiteColor, elevation: 0, leading: IconButton( icon: Icon(Icons.arrow_back, color: Colors.black), - onPressed: () => Navigator.pop(context), + onPressed: () => router.pop(context), ), title: Text( 'Detail Pesanan', @@ -139,417 +193,255 @@ class _OrderSummaryScreenState extends State { ), ), centerTitle: true, + actions: [ + if (hasItems) + PopupMenuButton( + icon: Icon(Icons.more_vert, color: Colors.black), + onSelected: (value) { + if (value == 'clear_all') { + _clearAllItems(); + } + }, + itemBuilder: + (BuildContext context) => [ + PopupMenuItem( + value: 'clear_all', + child: Row( + children: [ + Icon(Icons.clear_all, color: redColor, size: 20), + Gap(8), + Text( + 'Hapus Semua', + style: TextStyle(color: redColor), + ), + ], + ), + ), + ], + ), + ], ), body: Column( children: [ Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header Jenis Sampah - Container( - width: double.infinity, - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha:0.1), - spreadRadius: 1, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Row( - children: [ - Container( - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.orange.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.delete_outline, - color: Colors.orange, - size: 20, - ), - ), - SizedBox(width: 12), - Text( - 'Jenis Sampah', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - Spacer(), - TextButton( - onPressed: () { - // Navigate back to selection screen - Navigator.pop(context); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, color: Colors.blue, size: 16), - SizedBox(width: 4), - Text( - 'Tambah', - style: TextStyle( - color: Colors.blue, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ], - ), - SizedBox(height: 16), - - // List Items dengan Slidable - ...selectedItems.asMap().entries.map((entry) { - int index = entry.key; - Map item = entry.value; - - return Padding( - padding: EdgeInsets.only(bottom: 12), - child: Slidable( - key: ValueKey(item['name']), - endActionPane: ActionPane( - motion: ScrollMotion(), - children: [ - SlidableAction( - onPressed: (context) => _removeItem(index), - backgroundColor: Colors.red, - foregroundColor: Colors.white, - icon: Icons.delete, - label: 'Hapus', - borderRadius: BorderRadius.circular(12), - ), - ], - ), - child: Container( - padding: EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade200), - ), - child: Row( - children: [ - // Icon - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: item['backgroundColor'], - borderRadius: BorderRadius.circular(20), - ), - child: Icon( - item['icon'], - color: item['iconColor'], - size: 20, - ), - ), - SizedBox(width: 12), - - // Item info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item['name'], - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - SizedBox(height: 4), - Container( - padding: EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.orange, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'Rp ${item['price']}/kg', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), - ), - SizedBox(height: 4), - // Delete icon placeholder (always there for consistent height) - SizedBox( - height: 16, - child: Icon( - Icons.delete_outline, - color: Colors.red, - size: 12, - ), - ), - ], - ), - ), - - // Quantity controls - Row( - children: [ - // Decrease button - GestureDetector( - onTap: () => _decrementQuantity(index), - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), - ), - child: Icon( - Icons.remove, - color: Colors.grey.shade600, - size: 16, - ), - ), - ), - SizedBox(width: 8), - - // Quantity display (clickable) - GestureDetector( - onTap: () => _showQuantityDialog(index), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), - ), - child: Text( - '${item['quantity'].toString().replaceAll('.0', '')} kg', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - SizedBox(width: 8), - - // Increase button - GestureDetector( - onTap: () => _incrementQuantity(index), - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.add, - color: Colors.white, - size: 16, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - }), - - SizedBox(height: 16), - - // Total Weight - Container( - padding: EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Container( - padding: EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.scale, - color: Colors.grey.shade700, - size: 16, - ), - ), - SizedBox(width: 12), - Text( - 'Berat total', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, - ), - ), - Spacer(), - Text( - '${totalWeight.toString().replaceAll('.0', '')} kg', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - ], - ), - ), - ], - ), - ), - - SizedBox(height: 20), - - // Perkiraan Pendapatan - Container( - width: double.infinity, - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha:0.1), - spreadRadius: 1, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.orange.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.folder, - color: Colors.orange, - size: 20, - ), - ), - SizedBox(width: 12), - Text( - 'Perkiraan Pendapatan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black, - ), - ), - ], - ), - SizedBox(height: 16), - - // Estimasi pembayaran - _buildIncomeRow( - 'Estimasi pembayaran', - estimatedEarnings, - Colors.orange, - ), - SizedBox(height: 8), - - // Biaya jasa aplikasi - _buildIncomeRow( - 'Biaya jasa aplikasi', - applicationFee, - Colors.orange, - showInfo: true, - ), - SizedBox(height: 8), - - // Estimasi pendapatan - _buildIncomeRow( - 'Estimasi pendapatan', - estimatedIncome, - Colors.orange, - isBold: true, - ), - ], - ), - ), - ], - ), + child: + hasItems + ? SingleChildScrollView( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildItemsSection(), + Gap(20), + _buildEarningsSection(), + ], + ), + ) + : _buildEmptyState(), + ), + _buildBottomButton(), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.delete_outline, + size: 48, + color: Colors.grey.shade400, ), ), - - // Bottom Continue Button - Container( - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha:0.2), - spreadRadius: 1, - blurRadius: 10, - offset: Offset(0, -2), - ), - ], + Gap(16), + Text( + 'Belum ada item', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, ), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: selectedItems.isNotEmpty ? () { - // Handle continue action - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Lanjut ke proses selanjutnya'), - ), - ); - } : null, - style: ElevatedButton.styleFrom( - backgroundColor: selectedItems.isNotEmpty ? Colors.blue : Colors.grey, - padding: EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Lanjut', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + ), + Gap(8), + Text( + 'Tambahkan item sampah untuk melanjutkan', + style: TextStyle(fontSize: 14, color: Colors.grey.shade500), + ), + Gap(24), + ElevatedButton.icon( + onPressed: () => router.pop(context), + icon: Icon(Icons.add), + label: Text('Tambah Item'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: whiteColor, + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + ), + ], + ), + ); + } + + Widget _buildItemsSection() { + return Container( + width: double.infinity, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: whiteColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.1), + spreadRadius: 1, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + _buildSectionHeader(), + Gap(16), + ...selectedItems.asMap().entries.map( + (entry) => _buildItemCard(entry.key, entry.value), + ), + Gap(16), + _buildTotalWeight(), + ], + ), + ); + } + + Widget _buildSectionHeader() { + return Row( + children: [ + Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.delete_outline, color: Colors.orange, size: 20), + ), + Gap(12), + Text( + 'Jenis Sampah', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + Spacer(), + TextButton( + onPressed: () => router.pop(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, color: Colors.blue, size: 16), + Gap(4), + Text( + 'Tambah', + style: TextStyle( + color: Colors.blue, + fontSize: 14, + fontWeight: FontWeight.w500, ), ), + ], + ), + ), + ], + ); + } + + Widget _buildItemCard(int index, Map item) { + return Padding( + padding: EdgeInsets.only(bottom: 12), + child: Slidable( + key: ValueKey('${item['name']}_$index'), + endActionPane: ActionPane( + motion: ScrollMotion(), + children: [ + SlidableAction( + onPressed: (context) => _removeItem(index), + backgroundColor: redColor, + foregroundColor: whiteColor, + icon: Icons.delete, + label: 'Hapus', + borderRadius: BorderRadius.circular(12), + ), + ], + ), + child: Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + _buildItemIcon(item), + Gap(12), + _buildItemInfo(item), + _buildQuantityControls(index, item), + ], + ), + ), + ), + ); + } + + Widget _buildItemIcon(Map item) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: item['backgroundColor'], + borderRadius: BorderRadius.circular(20), + ), + child: Icon(item['icon'], color: item['iconColor'], size: 20), + ); + } + + Widget _buildItemInfo(Map item) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item['name'], + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + Gap(4), + Container( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Rp ${item['price']}/kg', + style: TextStyle( + color: whiteColor, + fontSize: 10, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -557,7 +449,178 @@ class _OrderSummaryScreenState extends State { ); } - Widget _buildIncomeRow(String title, int amount, Color color, {bool showInfo = false, bool isBold = false}) { + Widget _buildQuantityControls(int index, Map item) { + return Row( + children: [ + _buildQuantityButton( + icon: Icons.remove, + onTap: () => _decrementQuantity(index), + backgroundColor: whiteColor, + iconColor: Colors.grey.shade600, + ), + Gap(8), + GestureDetector( + onTap: () => _showQuantityDialog(index), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: whiteColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey.shade300), + ), + child: Text( + '${_formatQuantity(item['quantity'])} kg', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + ), + Gap(8), + _buildQuantityButton( + icon: Icons.add, + onTap: () => _incrementQuantity(index), + backgroundColor: Colors.blue, + iconColor: whiteColor, + ), + ], + ); + } + + Widget _buildQuantityButton({ + required IconData icon, + required VoidCallback onTap, + required Color backgroundColor, + required Color iconColor, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(6), + border: + backgroundColor == whiteColor + ? Border.all(color: Colors.grey.shade300) + : null, + ), + child: Icon(icon, color: iconColor, size: 16), + ), + ); + } + + Widget _buildTotalWeight() { + return Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + child: Icon(Icons.scale, color: Colors.grey.shade700, size: 16), + ), + Gap(12), + Text( + 'Berat total', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + Spacer(), + Text( + '${_formatQuantity(totalWeight)} kg', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + ); + } + + Widget _buildEarningsSection() { + return Container( + width: double.infinity, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: whiteColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.1), + spreadRadius: 1, + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.folder, color: Colors.orange, size: 20), + ), + Gap(12), + Text( + 'Perkiraan Pendapatan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + ], + ), + Gap(16), + _buildIncomeRow( + 'Estimasi pembayaran', + estimatedEarnings, + Colors.orange, + ), + Gap(8), + _buildIncomeRow( + 'Biaya jasa aplikasi', + applicationFee, + Colors.orange, + showInfo: true, + ), + Gap(8), + _buildIncomeRow( + 'Estimasi pendapatan', + estimatedIncome, + Colors.orange, + isBold: true, + ), + ], + ), + ); + } + + Widget _buildIncomeRow( + String title, + int amount, + Color color, { + bool showInfo = false, + bool isBold = false, + }) { return Row( children: [ Container( @@ -566,13 +629,9 @@ class _OrderSummaryScreenState extends State { color: color, borderRadius: BorderRadius.circular(10), ), - child: Icon( - Icons.currency_exchange, - color: Colors.white, - size: 12, - ), + child: Icon(Icons.currency_exchange, color: whiteColor, size: 12), ), - SizedBox(width: 8), + Gap(8), Expanded( child: Row( children: [ @@ -585,18 +644,14 @@ class _OrderSummaryScreenState extends State { ), ), if (showInfo) ...[ - SizedBox(width: 4), - Icon( - Icons.info_outline, - color: Colors.blue, - size: 16, - ), + Gap(4), + Icon(Icons.info_outline, color: Colors.blue, size: 16), ], ], ), ), Text( - amount.toString(), + 'Rp ${_formatCurrency(amount)}', style: TextStyle( fontSize: 14, fontWeight: isBold ? FontWeight.w600 : FontWeight.w500, @@ -606,4 +661,61 @@ class _OrderSummaryScreenState extends State { ], ); } -} \ No newline at end of file + + Widget _buildBottomButton() { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: whiteColor, + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.2), + spreadRadius: 1, + blurRadius: 10, + offset: Offset(0, -2), + ), + ], + ), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + hasItems + ? () { + _showSnackbar('Lanjut ke proses selanjutnya'); + // Handle continue action + } + : () => router.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: hasItems ? Colors.blue : Colors.grey.shade400, + padding: EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + hasItems ? 'Lanjut' : 'Tambah Item', + style: TextStyle( + color: whiteColor, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + String _formatQuantity(double quantity) { + return quantity % 1 == 0 + ? quantity.toInt().toString() + : quantity.toString(); + } + + String _formatCurrency(int amount) { + return amount.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } +} diff --git a/lib/features/home/presentation/components/trash_testview.dart b/lib/features/home/presentation/components/trash_testview.dart index eac97f9..9fb7d8b 100644 --- a/lib/features/home/presentation/components/trash_testview.dart +++ b/lib/features/home/presentation/components/trash_testview.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/utils/guide.dart'; +import 'package:rijig_mobile/widget/appbar.dart'; class TestRequestPickScreen extends StatefulWidget { const TestRequestPickScreen({super.key}); @@ -143,40 +145,41 @@ class _TestRequestPickScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Colors.grey.shade50, - appBar: AppBar( - backgroundColor: whiteColor, - elevation: 0, - leading: IconButton( - icon: Icon(Icons.arrow_back, color: Colors.black), - onPressed: () => Navigator.pop(context), - ), - title: Row( - children: [ - Icon(Icons.location_on, color: Colors.grey), - SizedBox(width: 8), - Text( - 'Purbalingga', - style: TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - Spacer(), - TextButton( - onPressed: () {}, - child: Text('Ganti', style: TextStyle(color: Colors.blue)), - ), - ], - ), - ), + backgroundColor: whiteColor, + appBar: CustomAppBar(judul: "Pilih Sampah"), + // appBar: AppBar( + // backgroundColor: whiteColor, + // elevation: 0, + // leading: IconButton( + // icon: Icon(Icons.arrow_back, color: Colors.black), + // onPressed: () => Navigator.pop(context), + // ), + // title: Row( + // children: [ + // Icon(Icons.location_on, color: Colors.grey), + // Gap(8), + // Text( + // 'Purbalingga', + // style: TextStyle( + // color: Colors.black, + // fontSize: 16, + // fontWeight: FontWeight.w500, + // ), + // ), + // Spacer(), + // TextButton( + // onPressed: () {}, + // child: Text('Ganti', style: TextStyle(color: Colors.blue)), + // ), + // ], + // ), + // ), body: Column( children: [ // Header Container( width: double.infinity, - padding: EdgeInsets.all(16), + padding: PaddingCustom().paddingAll(16), color: whiteColor, child: Text( 'Pilih Sampah', @@ -191,7 +194,7 @@ class _TestRequestPickScreenState extends State { // List Items Expanded( child: ListView.builder( - padding: EdgeInsets.all(16), + padding: PaddingCustom().paddingAll(16), itemCount: quantities.keys.length, itemBuilder: (context, index) { String itemName = quantities.keys.elementAt(index); @@ -200,7 +203,7 @@ class _TestRequestPickScreenState extends State { return Container( margin: EdgeInsets.only(bottom: 12), - padding: EdgeInsets.all(16), + padding: PaddingCustom().paddingAll(16), decoration: BoxDecoration( color: whiteColor, borderRadius: BorderRadius.circular(12), @@ -229,7 +232,7 @@ class _TestRequestPickScreenState extends State { size: 24, ), ), - SizedBox(width: 16), + Gap(16), // Item info Expanded( @@ -245,7 +248,7 @@ class _TestRequestPickScreenState extends State { color: Colors.black, ), ), - SizedBox(height: 4), + Gap(4), Container( padding: EdgeInsets.symmetric( horizontal: 8, @@ -265,7 +268,7 @@ class _TestRequestPickScreenState extends State { ), ), // Show delete icon when quantity > 0 (below price) - SizedBox(height: 4), + Gap(4), SizedBox( height: 24, // Fixed height untuk consistency child: @@ -273,7 +276,9 @@ class _TestRequestPickScreenState extends State { ? GestureDetector( onTap: () => _resetQuantity(itemName), child: Container( - padding: EdgeInsets.all(4), + padding: PaddingCustom().paddingAll( + 4, + ), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular( @@ -314,7 +319,7 @@ class _TestRequestPickScreenState extends State { ), ), ), - SizedBox(width: 12), + Gap(12), // Quantity display (clickable) GestureDetector( @@ -339,8 +344,7 @@ class _TestRequestPickScreenState extends State { ), ), ), - SizedBox(width: 12), - + Gap(12), // Increase button GestureDetector( onTap: () => _incrementQuantity(itemName), @@ -395,7 +399,8 @@ class _TestRequestPickScreenState extends State { // Bottom summary Container( - padding: EdgeInsets.all(16), + // padding: PaddingCustom().paddingAll(16), + padding: PaddingCustom().paddingAll(16), decoration: BoxDecoration( color: whiteColor, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), @@ -422,7 +427,7 @@ class _TestRequestPickScreenState extends State { color: Colors.grey.shade700, ), ), - SizedBox(height: 4), + Gap(4), Row( children: [ Text( @@ -452,7 +457,7 @@ class _TestRequestPickScreenState extends State { ), ], ), - SizedBox(height: 4), + Gap(4), Text( 'Minimum total berat 3kg', style: TextStyle(fontSize: 12, color: Colors.red), @@ -460,18 +465,12 @@ class _TestRequestPickScreenState extends State { ], ), ), - SizedBox(width: 16), + Gap(16), ElevatedButton( onPressed: totalWeight >= 3 ? () { - // Handle continue action router.push('/ordersumary'); - // ScaffoldMessenger.of(context).showSnackBar( - // SnackBar( - // content: Text('Lanjut ke proses selanjutnya'), - // ), - // ); } : null, style: ElevatedButton.styleFrom( diff --git a/lib/features/home/presentation/screen/home_screen.dart b/lib/features/home/presentation/screen/home_screen.dart index 5bf538f..b5ce7df 100644 --- a/lib/features/home/presentation/screen/home_screen.dart +++ b/lib/features/home/presentation/screen/home_screen.dart @@ -1,5 +1,4 @@ // ignore_for_file: use_build_context_synchronously - import 'dart:math' as math; import 'package:custom_refresh_indicator/custom_refresh_indicator.dart'; @@ -71,16 +70,25 @@ class _HomeScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - IconButton(onPressed: ()=>router.push('/trashview'), icon: Icon( - Iconsax.notification_copy, - color: primaryColor, - ),), - // Icon( - // Iconsax.notification_copy, - // color: primaryColor, - // ), + IconButton( + onPressed: () => router.push('/trashview'), + icon: Icon( + Iconsax.notification_copy, + color: primaryColor, + ), + ), + Gap(10), - Icon(Iconsax.message_copy, color: primaryColor), + + IconButton( + onPressed: () { + debugPrint('message tapped'); + }, + icon: Icon( + Iconsax.message_copy, + color: primaryColor, + ), + ), ], ), ], @@ -108,9 +116,8 @@ class _HomeScreenState extends State { CustomModalDialog.show( context: context, variant: ModalVariant.textVersion, - title: 'Hapus Akun', - content: - 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.', + title: 'Belum Tersedia', + content: 'Maaf, fitur ini belum tersedia', buttonCount: 2, button1: CardButtonOne( textButton: "Ya, Hapus", @@ -181,7 +188,6 @@ class _HomeScreenState extends State { ArticleScreen(), ], ), - // Gap(20), ], ), ), diff --git a/lib/features/launch/screen/splash_screen.dart b/lib/features/launch/screen/splash_screen.dart index c43505f..4d0a28e 100644 --- a/lib/features/launch/screen/splash_screen.dart +++ b/lib/features/launch/screen/splash_screen.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:rijig_mobile/core/network/network_info.dart'; +import 'package:rijig_mobile/core/network/network_aware_widgets.dart'; +import 'package:rijig_mobile/core/network/network_service.dart'; import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/storage/expired_token.dart'; import 'package:rijig_mobile/core/utils/guide.dart'; @@ -9,21 +13,48 @@ class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @override - SplashScreenState createState() => SplashScreenState(); + UpdatedSplashScreenState createState() => UpdatedSplashScreenState(); } -class SplashScreenState extends State { +class UpdatedSplashScreenState extends State + with SingleTickerProviderStateMixin { bool _isCheckingConnection = true; + late AnimationController _animationController; + late Animation _fadeAnimation; + final NetworkService _networkService = NetworkService(); @override void initState() { super.initState(); - _checkNetworkConnection(); - _checkLoginStatus(); + _initializeAnimations(); + _startAppInitialization(); } - Future _checkNetworkConnection() async { - bool isConnected = await NetworkInfo().checkConnection(); + void _initializeAnimations() { + _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + _animationController.forward(); + } + + Future _startAppInitialization() async { + // Initialize network service + await _networkService.initialize(); + + await Future.delayed(const Duration(milliseconds: 3000)); + await _checkNetworkAndProceed(); + } + + Future _checkNetworkAndProceed() async { + setState(() { + _isCheckingConnection = true; + }); + + bool isConnected = await _networkService.checkConnection(); setState(() { _isCheckingConnection = false; @@ -33,59 +64,87 @@ class SplashScreenState extends State { _showNoInternetDialog(); return; } + + await _checkLoginStatus(); } Future _checkLoginStatus() async { - bool expired = await isTokenExpired(); - if (expired) { - debugPrint("tets expired"); + try { + bool expired = await isTokenExpired(); + if (expired) { + debugPrint("Token expired - redirecting to onboarding"); + router.go("/onboarding"); + } else { + debugPrint("Token valid - redirecting to navigation"); + router.go("/navigasi"); + } + } catch (e) { + debugPrint("Login status check error: $e"); router.go("/onboarding"); - } else { - debugPrint("test not expired"); - router.go("/navigasi"); } } void _showNoInternetDialog() { - showDialog( + NetworkDialogManager.showNoInternetDialog( context: context, - builder: - (context) => AlertDialog( - title: Text('No Internet'), - content: Text('Mohon periksa koneksi internet anda, dan coba lagi'), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text('OK'), - ), - ], - ), + onRetry: () { + Navigator.of(context).pop(); + _checkNetworkAndProceed(); + }, + onExit: () { + SystemNavigator.pop(); + }, ); } @override - Widget build(BuildContext context) { - const String assetName = 'assets/icon/logorijig.svg'; - return Scaffold( - backgroundColor: primaryColor, - body: Center( - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: SvgPicture.asset(assetName, height: 120), - ), + void dispose() { + _animationController.dispose(); + super.dispose(); + } - if (_isCheckingConnection) - Align( - alignment: Alignment.bottomCenter, - child: CircularProgressIndicator(color: whiteColor,), + @override + Widget build(BuildContext context) { + return NetworkStatusIndicator( + child: Scaffold( + backgroundColor: primaryColor, + body: AnimatedBuilder( + animation: _fadeAnimation, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset('assets/icon/logorijig.svg', height: 120), + const SizedBox(height: 60), + if (_isCheckingConnection) ...[ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: whiteColor, + strokeWidth: 2.5, + ), + ), + const SizedBox(height: 16), + Text( + 'Memeriksa koneksi...', + style: TextStyle( + color: whiteColor, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ], + ], + ), ), - ], + ); + }, ), ), ); } -} +} \ No newline at end of file diff --git a/lib/features/profil/components/logout_button.dart b/lib/features/profil/components/logout_button.dart index 67e9dc3..921bf64 100644 --- a/lib/features/profil/components/logout_button.dart +++ b/lib/features/profil/components/logout_button.dart @@ -1,12 +1,10 @@ -// ignore_for_file: use_build_context_synchronously - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/utils/guide.dart'; import 'package:rijig_mobile/features/auth/presentation/viewmodel/logout_vmod.dart'; import 'package:rijig_mobile/widget/buttoncard.dart'; -import 'package:rijig_mobile/widget/custom_bottom_sheet.dart'; +import 'package:toastification/toastification.dart'; class ButtonLogout extends StatefulWidget { const ButtonLogout({super.key}); @@ -26,58 +24,25 @@ class _ButtonLogoutState extends State { textButton: viewModel.isLoading ? 'Logging out...' : 'Logout', fontSized: 16, colorText: whiteColor, - color: redColor, + color: primaryColor, borderRadius: 10, horizontal: double.infinity, vertical: 50, - onTap: - () => CustomBottomSheet.show( - context: context, - title: "Logout Sekarang?", - content: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Yakin ingin logout dari akun ini?"), - // tambahan konten - ], - ), - button1: CardButtonOne( - textButton: "Logout", - onTap: () {}, - fontSized: 14, - colorText: Colors.white, - color: Colors.red, - borderRadius: 10, - horizontal: double.infinity, - vertical: 50, - loadingTrue: false, - usingRow: false, - ), - button2: CardButtonOne( - textButton: "Batal", - onTap: () => router.pop(), - fontSized: 14, - colorText: Colors.red, - color: Colors.white, - borderRadius: 10, - horizontal: double.infinity, - vertical: 50, - loadingTrue: false, - usingRow: false, - ), - ), - // onTap: () async { - // await viewModel.logout(); + onTap: () async { + await viewModel.logout(); - // if (viewModel.errorMessage == null) { - // router.go("/login"); - // } else { - // ScaffoldMessenger.of(context).showSnackBar( - // SnackBar(content: Text(viewModel.errorMessage!)), - // ); - // } - // }, + if (viewModel.errorMessage == null) { + router.go("/login"); + } else { + toastification.show( + type: ToastificationType.error, + title: Text("Belum berhsail logout"), + autoCloseDuration: const Duration(seconds: 3), + showProgressBar: true, + ); + } + }, loadingTrue: viewModel.isLoading, usingRow: false, ), diff --git a/lib/features/profil/components/profile_menu_option.dart b/lib/features/profil/components/profile_menu_option.dart index 3bf93e4..d9a2d95 100644 --- a/lib/features/profil/components/profile_menu_option.dart +++ b/lib/features/profil/components/profile_menu_option.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:iconsax_flutter/iconsax_flutter.dart'; +import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/utils/guide.dart'; import 'package:rijig_mobile/features/profil/components/profile_list_tile.dart'; +import 'package:rijig_mobile/widget/buttoncard.dart'; +import 'package:rijig_mobile/widget/custom_bottom_sheet.dart'; class ProfileMenuOptions extends StatelessWidget { const ProfileMenuOptions({super.key}); @@ -9,11 +12,11 @@ class ProfileMenuOptions extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: PaddingCustom().paddingAll(7), + padding: PaddingCustom().paddingAll(10), decoration: BoxDecoration( color: whiteColor, border: Border.all(color: greyColor), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(10), ), child: Column( children: [ @@ -21,7 +24,9 @@ class ProfileMenuOptions extends StatelessWidget { title: 'Ubah Pin', iconColor: primaryColor, icon: Iconsax.wallet, - onTap: () {}, + onTap: () { + router.push('/pinsecureinput'); + }, ), Divider(thickness: 0.7, color: greyColor), ProfileListTile( @@ -44,6 +49,48 @@ class ProfileMenuOptions extends StatelessWidget { iconColor: primaryColor, onTap: () {}, ), + Divider(thickness: 0.7, color: greyColor), + ProfileListTile( + title: 'Keluar', + icon: Iconsax.logout, + iconColor: redColor, + onTap: + () => CustomBottomSheet.show( + context: context, + title: "Logout Sekarang?", + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Yakin ingin logout dari akun ini?"), + // tambahan konten + ], + ), + button1: CardButtonOne( + textButton: "Logout", + onTap: () {}, + fontSized: 14, + colorText: Colors.white, + color: Colors.red, + borderRadius: 10, + horizontal: double.infinity, + vertical: 50, + loadingTrue: false, + usingRow: false, + ), + button2: CardButtonOne( + textButton: "Batal", + onTap: () => router.pop(), + fontSized: 14, + colorText: Colors.red, + color: Colors.white, + borderRadius: 10, + horizontal: double.infinity, + vertical: 50, + loadingTrue: false, + usingRow: false, + ), + ), + ), ], ), ); diff --git a/lib/features/profil/components/secure_pin_input.dart b/lib/features/profil/components/secure_pin_input.dart new file mode 100644 index 0000000..f826bf1 --- /dev/null +++ b/lib/features/profil/components/secure_pin_input.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:pin_code_fields/pin_code_fields.dart'; +import 'package:rijig_mobile/core/utils/guide.dart'; + +class SecurityCodeScreen extends StatefulWidget { + const SecurityCodeScreen({super.key}); + + @override + State createState() => _SecurityCodeScreenState(); +} + +class _SecurityCodeScreenState extends State { + TextEditingController textEditingController = TextEditingController(); + String currentText = ""; + + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + void _onKeypadTap(String value) { + if (value == "delete") { + if (currentText.isNotEmpty) { + currentText = currentText.substring(0, currentText.length - 1); + textEditingController.text = currentText; + } + } else if (currentText.length < 6) { + currentText += value; + textEditingController.text = currentText; + } + + if (currentText.length == 6) { + _validatePin(); + } + } + + void _validatePin() { + debugPrint("PIN entered: $currentText"); + + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("PIN: $currentText"))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [secondaryColor, primaryColor], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + children: [ + const SizedBox(height: 60), + + Text( + 'Masukkan Security Code Kamu', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: whiteColor, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 60), + + PinCodeTextField( + appContext: context, + length: 6, + controller: textEditingController, + readOnly: true, + obscureText: true, + + obscuringCharacter: '.', + animationType: AnimationType.fade, + pinTheme: PinTheme( + shape: PinCodeFieldShape.circle, + borderRadius: BorderRadius.circular(25), + fieldHeight: 20, + fieldWidth: 20, + activeFillColor: whiteColor, + inactiveFillColor: whiteColor.withValues(alpha: 0.3), + selectedFillColor: whiteColor.withValues(alpha: 0.7), + activeColor: Colors.transparent, + inactiveColor: Colors.transparent, + selectedColor: Colors.transparent, + ), + animationDuration: const Duration(milliseconds: 300), + backgroundColor: Colors.transparent, + enableActiveFill: true, + onCompleted: (v) { + _validatePin(); + }, + onChanged: (value) { + currentText = value; + }, + ), + + const SizedBox(height: 40), + + Text( + 'Lupa Security Code', + style: TextStyle( + fontSize: 16, + color: whiteColor, + decoration: TextDecoration.underline, + decorationColor: whiteColor, + ), + ), + + const Spacer(), + + _buildKeypad(), + + const SizedBox(height: 40), + ], + ), + ), + ), + ), + ); + } + + Widget _buildKeypad() { + return Container( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildKeypadButton('1'), + _buildKeypadButton('2'), + _buildKeypadButton('3'), + ], + ), + const SizedBox(height: 20), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildKeypadButton('4'), + _buildKeypadButton('5'), + _buildKeypadButton('6'), + ], + ), + const SizedBox(height: 20), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildKeypadButton('7'), + _buildKeypadButton('8'), + _buildKeypadButton('9'), + ], + ), + const SizedBox(height: 20), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 80), + _buildKeypadButton('0'), + _buildDeleteButton(), + ], + ), + ], + ), + ); + } + + Widget _buildKeypadButton(String number) { + return GestureDetector( + onTap: () => _onKeypadTap(number), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: whiteColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + border: Border.all( + color: whiteColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Center( + child: Text( + number, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w400, + color: whiteColor, + ), + ), + ), + ), + ); + } + + Widget _buildDeleteButton() { + return GestureDetector( + onTap: () => _onKeypadTap('delete'), + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: whiteColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + border: Border.all( + color: whiteColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Center( + child: Icon(Icons.backspace_outlined, color: whiteColor, size: 24), + ), + ), + ); + } +} diff --git a/lib/features/profil/presentation/screen/profil_screen.dart b/lib/features/profil/presentation/screen/profil_screen.dart index 0318519..2b9eaec 100644 --- a/lib/features/profil/presentation/screen/profil_screen.dart +++ b/lib/features/profil/presentation/screen/profil_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; +import 'package:rijig_mobile/core/router.dart'; import 'package:rijig_mobile/core/utils/guide.dart'; -import 'package:rijig_mobile/features/profil/components/clip_path.dart'; import 'package:rijig_mobile/features/profil/components/logout_button.dart'; -import 'package:rijig_mobile/features/profil/components/profile_menu_option.dart'; +import 'package:rijig_mobile/widget/buttoncard.dart'; +import 'package:rijig_mobile/widget/custom_bottom_sheet.dart'; class ProfilScreen extends StatefulWidget { const ProfilScreen({super.key}); @@ -17,80 +18,306 @@ class _ProfilScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: whiteColor, - body: SafeArea( - child: Stack( + body: SingleChildScrollView( + child: Column( children: [ - ClipPath( - clipper: ClipPathClass(), - child: Container( - height: 180, - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - primaryColor, - secondaryColor, - ], // Ganti dengan warna gradien pilihan Anda - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - ), - ), - Positioned( - top: 60, - left: 20, - right: 20, - child: Container( - padding: EdgeInsets.all(20), - decoration: BoxDecoration( - color: whiteColor, - borderRadius: BorderRadius.circular(15), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.3), - spreadRadius: 1, - blurRadius: 8, - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Fahmi Kurniawan'), - SizedBox(height: 10), - Text('+62878774527342'), - SizedBox(height: 20), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Tervalidasi', - style: TextStyle(color: Colors.green), + Gap(50), + Container( + width: double.infinity, + color: whiteColor, + padding: PaddingCustom().paddingHorizontalVertical(20, 30), + child: Column( + children: [ + Stack( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.grey.shade200, + width: 3, + ), ), - Text('edit', style: TextStyle(color: Colors.blue)), - ], + child: CircleAvatar( + backgroundColor: primaryColor, + radius: 47, + backgroundImage: NetworkImage( + 'https://plus.unsplash.com/premium_vector-1731922571914-9d0161b5e7b7?q=80&w=1760&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + ), + ), + ), + // Positioned( + // bottom: 5, + // right: 5, + // child: Container( + // width: 24, + // height: 24, + // decoration: const BoxDecoration( + // color: Color(0xFF3B82F6), + // shape: BoxShape.circle, + // ), + // child: Icon(Icons.check, color: whiteColor, size: 14), + // ), + // ), + ], + ), + Gap(16), + + Text( + 'Fahmi Kurniawan', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: Colors.black, ), - ], - ), + ), + Gap(4), + + Text( + '+6287874527342', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], ), ), - SingleChildScrollView( - child: Container( - margin: EdgeInsets.only(top: 245), - child: Padding( - padding: PaddingCustom().paddingHorizontal(20), - child: Column( - children: [ProfileMenuOptions(), Gap(30), ButtonLogout()], - ), - ), + Gap(20), + + _buildMenuSection([ + _MenuItemData( + icon: Icons.person, + iconColor: const Color(0xFF3B82F6), + title: 'Profil', + subtitle: 'Edit profil, dan lain lain..', ), - ), + _MenuItemData( + icon: Icons.pin, + iconColor: const Color(0xFF10B981), + title: 'Ubah Pin', + subtitle: 'Ubah pin anda disini', + ), + _MenuItemData( + icon: Icons.location_pin, + iconColor: const Color(0xFF3B82F6), + title: 'Alamat', + subtitle: 'kelola daftar alamat anda disini', + ), + _MenuItemData( + icon: Icons.help_center, + iconColor: const Color(0xFF3B82F6), + title: 'Bantuan', + subtitle: 'Butuh bantuan tentang aplikasi?', + ), + _MenuItemData( + icon: Icons.thumb_up, + iconColor: const Color(0xFF6B7280), + title: 'Ulasan', + subtitle: 'Penilaian anda berarti bagi kami', + ), + _MenuItemData( + icon: Icons.logout, + iconColor: const Color(0xFF3B82F6), + title: 'Keluar', + subtitle: 'Keluar aplikasi atau ganti akun', + ), + ]), + Gap(100), ], ), ), ); } + + Widget _buildMenuSection(List<_MenuItemData> items) { + return Container( + margin: const EdgeInsets.only(top: 20, bottom: 80, left: 20, right: 20), + padding: PaddingCustom().paddingAll(7), + decoration: BoxDecoration( + color: whiteColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withValues(alpha: 0.1), + spreadRadius: 0, + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: + items.asMap().entries.map((entry) { + int index = entry.key; + _MenuItemData item = entry.value; + bool isLast = index == items.length - 1; + + return _buildMenuItem( + item.icon, + item.iconColor, + item.title, + item.subtitle, + showDivider: !isLast, + onTap: () => _handleMenuTap(item.title), + ); + }).toList(), + ), + ); + } + + Widget _buildMenuItem( + IconData icon, + Color iconColor, + String title, + String subtitle, { + bool showDivider = true, + VoidCallback? onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + + child: Column( + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: iconColor, size: 22), + ), + const SizedBox(width: 16), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + Gap(4), + Text( + subtitle, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + height: 1.3, + ), + ), + ], + ), + ), + + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Colors.grey.shade400, + ), + ], + ), + + if (showDivider) ...[ + Gap(20), + Divider(height: 1, color: Colors.grey.shade200), + ], + ], + ), + ), + ); + } + + void _handleMenuTap(String menuTitle) { + debugPrint('Tapped on: $menuTitle'); + + switch (menuTitle) { + case 'Profil': + debugPrint('Profil'); + break; + + case 'Ubah Pin': + debugPrint('Uabh Pin'); + break; + + case 'Alamat': + debugPrint('Alamat'); + break; + + case 'Bantuan': + debugPrint('Bantuan'); + break; + + case 'Ulasan': + debugPrint('Ulasan'); + break; + + case 'Keluar': + // _showLogoutDialog(); + CustomBottomSheet.show( + context: context, + title: "Logout Sekarang?", + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Yakin ingin logout dari akun ini?"), + // tambahan konten + ], + ), + button1: ButtonLogout(), + // button1: CardButtonOne( + // textButton: "Logout", + // onTap: () {}, + // fontSized: 14, + // colorText: Colors.white, + // color: Colors.red, + // borderRadius: 10, + // horizontal: double.infinity, + // vertical: 50, + // loadingTrue: false, + // usingRow: false, + // ), + button2: CardButtonOne( + textButton: "Gak jadi..", + onTap: () => router.pop(), + fontSized: 14, + colorText: primaryColor, + color: whiteColor, + borderRadius: 10, + horizontal: double.infinity, + vertical: 50, + loadingTrue: false, + usingRow: false, + ), + ); + break; + + default: + debugPrint('Routing tidak dikenali: $menuTitle'); + } + } +} + +class _MenuItemData { + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + + _MenuItemData({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + }); } diff --git a/lib/widget/showmodal.dart b/lib/widget/showmodal.dart index f0a491c..9a409ed 100644 --- a/lib/widget/showmodal.dart +++ b/lib/widget/showmodal.dart @@ -15,6 +15,9 @@ class CustomModalDialog extends StatelessWidget { final int buttonCount; final Widget? button1; final Widget? button2; + + // Parameter boolean untuk mengontrol tampilan close icon + final bool showCloseIcon; const CustomModalDialog({ super.key, @@ -25,6 +28,7 @@ class CustomModalDialog extends StatelessWidget { this.buttonCount = 0, this.button1, this.button2, + this.showCloseIcon = true, // Default true untuk backward compatibility }); static void show({ @@ -36,6 +40,7 @@ class CustomModalDialog extends StatelessWidget { int buttonCount = 0, Widget? button1, Widget? button2, + bool showCloseIcon = true, // Parameter baru di static method }) { showGeneralDialog( context: context, @@ -64,6 +69,7 @@ class CustomModalDialog extends StatelessWidget { buttonCount: buttonCount, button1: button1, button2: button2, + showCloseIcon: showCloseIcon, // Pass parameter ke constructor ), ), ), @@ -122,19 +128,21 @@ class CustomModalDialog extends StatelessWidget { clipBehavior: Clip.none, children: [ modalContent, - Positioned( - top: -15, - right: 5, - child: GestureDetector( - onTap: () => router.pop(), - child: CircleAvatar( - radius: 18, - backgroundColor: whiteColor, - child: Icon(Icons.close, color: blackNavyColor), + // Kondisional untuk menampilkan close icon + if (showCloseIcon) + Positioned( + top: -15, + right: 5, + child: GestureDetector( + onTap: () => router.pop(), + child: CircleAvatar( + radius: 18, + backgroundColor: whiteColor, + child: Icon(Icons.close, color: blackNavyColor), + ), ), ), - ), ], ); } -} +} \ No newline at end of file