Compare commits
41 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
b749ebcc4e | |
|
|
5eed472971 | |
|
|
918e3c0ced | |
|
|
4db92f3d22 | |
|
|
42cd53752b | |
|
|
f4259a785e | |
|
|
6770e70edd | |
|
|
de01c1acce | |
|
|
c87d820a9d | |
|
|
b997980f1c | |
|
|
b988933413 | |
|
|
ce514d0a27 | |
|
|
c214fdaa8a | |
|
|
31bbcabf16 | |
|
|
a832485e86 | |
|
|
972de38377 | |
|
|
63ba97382b | |
|
|
e0060e244d | |
|
|
83e65714ad | |
|
|
0d129218de | |
|
|
4f84abfeee | |
|
|
7d3f129748 | |
|
|
3359304d7a | |
|
|
0cf104c5d9 | |
|
|
b951af1eec | |
|
|
e1f62103b9 | |
|
|
12ecf1c84b | |
|
|
9c61b87c6d | |
|
|
5b94690d02 | |
|
|
1785fbceb7 | |
|
|
cbcb250be3 | |
|
|
71c7e498b6 | |
|
|
b758b98271 | |
|
|
5ac66566d2 | |
|
|
054640e563 | |
|
|
9bbfd4529a | |
|
|
0fbbb807d9 | |
|
|
9f869503b2 | |
|
|
45e35e6e85 | |
|
|
438c95746f | |
|
|
801698262b |
15
.metadata
|
|
@ -18,21 +18,6 @@ migration:
|
|||
- platform: android
|
||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
- platform: ios
|
||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
- platform: linux
|
||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
- platform: macos
|
||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
- platform: web
|
||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
- platform: windows
|
||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||
|
||||
# User provided section
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ plugins {
|
|||
android {
|
||||
namespace = "com.example.rijig_mobile"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
ndkVersion = "28.1.13356709"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<application
|
||||
android:label="rijig_mobile"
|
||||
android:label="Rijig"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
|
@ -43,5 +45,10 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="google.navigation" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -427,7 +427,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
@ -484,7 +484,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
|
|||
|
|
@ -1,122 +1 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 499 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 788 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
|
@ -0,0 +1,18 @@
|
|||
class ApiException implements Exception {
|
||||
final String message;
|
||||
final int statusCode;
|
||||
ApiException(this.message, this.statusCode);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "ApiException: $message (Status Code: $statusCode)";
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkException implements Exception {
|
||||
final String message;
|
||||
NetworkException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkException: $message';
|
||||
}
|
||||
|
|
@ -0,0 +1,402 @@
|
|||
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';
|
||||
|
||||
class Https {
|
||||
static final Https _instance = Https.internal();
|
||||
Https.internal();
|
||||
factory Https() => _instance;
|
||||
|
||||
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<int> _retryStatusCodes = [408, 429, 500, 502, 503, 504];
|
||||
final Duration _baseRetryDelay = const Duration(seconds: 1);
|
||||
|
||||
Future<Map<String, String>> _getHeaders() async {
|
||||
String? token = await _secureStorage.readSecureData('token');
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
return {
|
||||
"Content-type": "application/json; charset=UTF-8",
|
||||
"Accept": "application/json",
|
||||
"x-api-key": _apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
bool isExpired = await isTokenExpired();
|
||||
if (isExpired) {
|
||||
await deleteToken();
|
||||
throw Exception('Session expired, please log in again.');
|
||||
}
|
||||
|
||||
return {
|
||||
"Content-type": "application/json; charset=UTF-8",
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer $token",
|
||||
"x-api-key": _apiKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
Future<dynamic> _request(
|
||||
String method, {
|
||||
required String desturl,
|
||||
Map<String, String> headers = const {},
|
||||
dynamic body,
|
||||
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("Request URL: $url");
|
||||
debugPrint("Network Quality: ${_networkService.currentQuality}");
|
||||
|
||||
final timeout = _networkService.getTimeoutDuration();
|
||||
|
||||
http.Response response;
|
||||
try {
|
||||
if (multipartRequest != null) {
|
||||
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)
|
||||
.timeout(timeout);
|
||||
break;
|
||||
case 'post':
|
||||
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,
|
||||
)
|
||||
.timeout(timeout);
|
||||
break;
|
||||
case 'delete':
|
||||
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,
|
||||
)
|
||||
.timeout(timeout);
|
||||
break;
|
||||
default:
|
||||
throw ApiException('Unsupported HTTP method: $method', 405);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
debugPrint('Request error: $error');
|
||||
|
||||
if (error is ApiException || error is NetworkException) {
|
||||
rethrow;
|
||||
} else {
|
||||
throw ApiException('Network error: $error', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRetry(int statusCode, int retryCount) {
|
||||
return retryCount < _maxRetries && _retryStatusCodes.contains(statusCode);
|
||||
}
|
||||
|
||||
bool _shouldRetryOnNetworkError(int retryCount) {
|
||||
return retryCount < _maxRetries;
|
||||
}
|
||||
|
||||
Future<dynamic> _retryRequest(
|
||||
String method, {
|
||||
required String desturl,
|
||||
Map<String, String> 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<dynamic> get(
|
||||
String desturl, {
|
||||
Map<String, String> headers = const {},
|
||||
String? baseUrl,
|
||||
}) async {
|
||||
return await _request(
|
||||
'get',
|
||||
desturl: desturl,
|
||||
headers: headers,
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> post(
|
||||
String desturl, {
|
||||
Map<String, String> headers = const {},
|
||||
dynamic body,
|
||||
Encoding? encoding,
|
||||
String? baseUrl,
|
||||
}) async {
|
||||
return await _request(
|
||||
'post',
|
||||
desturl: desturl,
|
||||
headers: headers,
|
||||
body: body,
|
||||
encoding: encoding,
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> put(
|
||||
String desturl, {
|
||||
Map<String, String> headers = const {},
|
||||
dynamic body,
|
||||
Encoding? encoding,
|
||||
String? baseUrl,
|
||||
}) async {
|
||||
return await _request(
|
||||
'put',
|
||||
desturl: desturl,
|
||||
headers: headers,
|
||||
body: body,
|
||||
encoding: encoding,
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> delete(
|
||||
String desturl, {
|
||||
Map<String, String> headers = const {},
|
||||
String? baseUrl,
|
||||
body = const {},
|
||||
}) async {
|
||||
return await _request(
|
||||
'delete',
|
||||
desturl: desturl,
|
||||
headers: headers,
|
||||
body: body,
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> patch(
|
||||
String destUrl, {
|
||||
Map<String, String> headers = const {},
|
||||
dynamic body,
|
||||
Encoding? encoding,
|
||||
String? baseUrl,
|
||||
}) async {
|
||||
return await _request(
|
||||
'patch',
|
||||
desturl: destUrl,
|
||||
headers: headers,
|
||||
body: body,
|
||||
encoding: encoding,
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> uploadFormData(
|
||||
String desturl, {
|
||||
required Map<String, dynamic> formData,
|
||||
Map<String, String> headers = const {},
|
||||
String? baseUrl,
|
||||
}) async {
|
||||
var request = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse("${baseUrl ?? _baseUrl}$desturl"),
|
||||
);
|
||||
|
||||
request.headers.addAll(await _getHeaders());
|
||||
|
||||
formData.forEach((key, value) async {
|
||||
if (value is String) {
|
||||
request.fields[key] = value;
|
||||
} else if (value is File) {
|
||||
String fileName = value.uri.pathSegments.last;
|
||||
|
||||
if (value.lengthSync() > 10485760) {
|
||||
throw ApiException('File size exceeds 10MB', 401);
|
||||
}
|
||||
|
||||
request.files.add(
|
||||
await http.MultipartFile.fromPath(
|
||||
key,
|
||||
value.path,
|
||||
filename: fileName,
|
||||
contentType: MediaType('image', 'png'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw ApiException('Unsupported value type for field $key', 401);
|
||||
}
|
||||
});
|
||||
|
||||
return await _request(
|
||||
'post',
|
||||
desturl: desturl,
|
||||
headers: headers,
|
||||
multipartRequest: request,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
class ApiService {
|
||||
final String baseUrl = dotenv.get('BASE_URL');
|
||||
final String apiKey = dotenv.get('API_KEY');
|
||||
final FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||
|
||||
static const Map<String, String> _headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
Future<String?> _getAuthToken() async {
|
||||
return await _secureStorage.read(key: 'token');
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _getHeaders() async {
|
||||
final token = await _getAuthToken();
|
||||
return {
|
||||
..._headers,
|
||||
'x-api-key': apiKey,
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _processResponse(http.Response response) {
|
||||
if (response.body.isEmpty) {
|
||||
throw Exception('Empty response body');
|
||||
}
|
||||
|
||||
try {
|
||||
final responseJson = jsonDecode(response.body);
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 200:
|
||||
return responseJson;
|
||||
case 400:
|
||||
throw BadRequestException(
|
||||
'Bad request. The server could not process your request.',
|
||||
);
|
||||
case 401:
|
||||
throw UnauthorizedException(
|
||||
'Unauthorized. Please check your credentials.',
|
||||
);
|
||||
case 404:
|
||||
throw NotFoundException(
|
||||
'Not found. The requested resource could not be found.',
|
||||
);
|
||||
case 500:
|
||||
throw ServerException(
|
||||
'Internal server error. Please try again later.',
|
||||
);
|
||||
default:
|
||||
throw Exception('Failed with status code: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error parsing response: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> get(String endpoint) async {
|
||||
try {
|
||||
final headers = await _getHeaders();
|
||||
final url = Uri.parse('$baseUrl$endpoint');
|
||||
final response = await http.get(url, headers: headers);
|
||||
|
||||
return _processResponse(response);
|
||||
} catch (e) {
|
||||
throw NetworkException(
|
||||
'Failed to connect to the server. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> post(
|
||||
String endpoint,
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
try {
|
||||
final headers = await _getHeaders();
|
||||
final url = Uri.parse('$baseUrl$endpoint');
|
||||
|
||||
debugPrint('Request URL: $url');
|
||||
debugPrint('Request Body: ${jsonEncode(body)}');
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
debugPrint('Response: ${response.body}');
|
||||
return _processResponse(response);
|
||||
} catch (e) {
|
||||
debugPrint('Error during API request: $e');
|
||||
throw NetworkException(
|
||||
'Failed to connect to the server. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> put(
|
||||
String endpoint,
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
try {
|
||||
final headers = await _getHeaders();
|
||||
final url = Uri.parse('$baseUrl$endpoint');
|
||||
|
||||
debugPrint('Request URL: $url');
|
||||
debugPrint('Request Body: ${jsonEncode(body)}');
|
||||
|
||||
final response = await http.put(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
debugPrint('Response: ${response.body}');
|
||||
return _processResponse(response);
|
||||
} catch (e) {
|
||||
debugPrint('Error during API request: $e');
|
||||
throw NetworkException(
|
||||
'Failed to connect to the server. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> patch(
|
||||
String endpoint,
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
try {
|
||||
final headers = await _getHeaders();
|
||||
final url = Uri.parse('$baseUrl$endpoint');
|
||||
|
||||
debugPrint('Request URL: $url');
|
||||
debugPrint('Request Body: ${jsonEncode(body)}');
|
||||
|
||||
final response = await http.patch(
|
||||
url,
|
||||
headers: headers,
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
debugPrint('Response: ${response.body}');
|
||||
return _processResponse(response);
|
||||
} catch (e) {
|
||||
debugPrint('Error during API request: $e');
|
||||
throw NetworkException(
|
||||
'Failed to connect to the server. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> delete(String endpoint) async {
|
||||
try {
|
||||
final headers = await _getHeaders();
|
||||
final url = Uri.parse('$baseUrl$endpoint');
|
||||
final response = await http.delete(url, headers: headers);
|
||||
|
||||
return _processResponse(response);
|
||||
} catch (e) {
|
||||
throw NetworkException(
|
||||
'Failed to connect to the server. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkException implements Exception {
|
||||
final String message;
|
||||
NetworkException(this.message);
|
||||
}
|
||||
|
||||
class BadRequestException implements Exception {
|
||||
final String message;
|
||||
BadRequestException(this.message);
|
||||
}
|
||||
|
||||
class UnauthorizedException implements Exception {
|
||||
final String message;
|
||||
UnauthorizedException(this.message);
|
||||
}
|
||||
|
||||
class NotFoundException implements Exception {
|
||||
final String message;
|
||||
NotFoundException(this.message);
|
||||
}
|
||||
|
||||
class ServerException implements Exception {
|
||||
final String message;
|
||||
ServerException(this.message);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
export 'package:get_it/get_it.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/viewmodel/login_vmod.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/viewmodel/logout_vmod.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/viewmodel/otp_vmod.dart';
|
||||
export 'package:rijig_mobile/features/auth/repositories/login_repository.dart';
|
||||
export 'package:rijig_mobile/features/auth/repositories/logout_repository.dart';
|
||||
export 'package:rijig_mobile/features/auth/repositories/otp_repository.dart';
|
||||
export 'package:rijig_mobile/features/auth/service/login_service.dart';
|
||||
export 'package:rijig_mobile/features/auth/service/logout_service.dart';
|
||||
export 'package:rijig_mobile/features/auth/service/otp_service.dart';
|
||||
export 'package:rijig_mobile/globaldata/trash/trash_repository.dart';
|
||||
export 'package:rijig_mobile/globaldata/trash/trash_service.dart';
|
||||
export 'package:rijig_mobile/globaldata/trash/trash_viewmodel.dart';
|
||||
export 'package:rijig_mobile/globaldata/about/about_vmod.dart';
|
||||
export 'package:rijig_mobile/globaldata/about/about_repository.dart';
|
||||
export 'package:rijig_mobile/globaldata/about/about_service.dart';
|
||||
export 'package:rijig_mobile/globaldata/article/article_repository.dart';
|
||||
export 'package:rijig_mobile/globaldata/article/article_service.dart';
|
||||
export 'package:rijig_mobile/globaldata/article/article_vmod.dart';
|
||||
|
||||
export 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
|
||||
export 'package:rijig_mobile/features/cart/repositories/trashcart_repo.dart';
|
||||
export 'package:rijig_mobile/features/cart/service/trashcart_service.dart';
|
||||
// export 'package:rijig_mobile/features/cart/presentation/viewmodel/cartitem_vmod.dart';
|
||||
// export 'package:rijig_mobile/features/cart/repositories/cartitem_repo.dart';
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:rijig_mobile/core/container/export_vmod.dart';
|
||||
// import 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
|
||||
// import 'package:rijig_mobile/features/cart/repositories/trashcart_repo.dart';
|
||||
// import 'package:rijig_mobile/features/cart/service/trashcart_service.dart';
|
||||
|
||||
final sl = GetIt.instance;
|
||||
|
||||
void init() {
|
||||
sl.registerFactory(() => LoginViewModel(LoginService(LoginRepository())));
|
||||
sl.registerFactory(() => OtpViewModel(OtpService(OtpRepository())));
|
||||
sl.registerFactory(() => LogoutViewModel(LogoutService(LogoutRepository())));
|
||||
|
||||
sl.registerLazySingleton<ITrashCategoryRepository>(
|
||||
() => TrashCategoryRepository(),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<ITrashCategoryService>(
|
||||
() => TrashCategoryService(sl<ITrashCategoryRepository>()),
|
||||
);
|
||||
|
||||
sl.registerFactory<TrashViewModel>(
|
||||
() => TrashViewModel(sl<ITrashCategoryService>()),
|
||||
);
|
||||
|
||||
sl.registerFactory(() => AboutViewModel(AboutService(AboutRepository())));
|
||||
sl.registerFactory(
|
||||
() => AboutDetailViewModel(AboutService(AboutRepository())),
|
||||
);
|
||||
sl.registerFactory(
|
||||
() => ArticleViewModel(ArticleService(ArticleRepository())),
|
||||
);
|
||||
// sl.registerFactory(() => CartViewModel(CartRepository()));
|
||||
sl.registerLazySingleton<CartRepository>(
|
||||
() => CartRepositoryImpl(),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<CartService>(
|
||||
() => CartServiceImpl(repository: sl<CartRepository>()),
|
||||
);
|
||||
|
||||
sl.registerFactory<CartViewModel>(
|
||||
() => CartViewModel(cartService: sl<CartService>()),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'dart:io';
|
||||
|
||||
Future<String> getDeviceId() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
String deviceID;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
var androidInfo = await deviceInfo.androidInfo;
|
||||
|
||||
deviceID = androidInfo.id;
|
||||
} else if (Platform.isIOS) {
|
||||
var iosInfo = await deviceInfo.iosInfo;
|
||||
|
||||
deviceID = iosInfo.identifierForVendor ?? '';
|
||||
} else {
|
||||
var uuid = Uuid();
|
||||
deviceID = uuid.v4();
|
||||
}
|
||||
|
||||
return deviceID;
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:iconsax_flutter/iconsax_flutter.dart';
|
||||
import 'package:rijig_mobile/core/guide.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/screen/app/activity/activity_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/cart/cart_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/home/home_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/profil/profil_screen.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class NavigationPage extends StatefulWidget {
|
||||
final dynamic data;
|
||||
const NavigationPage({super.key, this.data});
|
||||
|
||||
@override
|
||||
State<NavigationPage> createState() => _NavigationPageState();
|
||||
}
|
||||
|
||||
class _NavigationPageState extends State<NavigationPage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
_loadSelectedIndex() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_selectedIndex = prefs.getInt('last_selected_index') ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
_saveSelectedIndex(int index) async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
prefs.setInt('last_selected_index', index);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSelectedIndex();
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
if (index == 2) {
|
||||
router.push("/requestpickup");
|
||||
} else {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
});
|
||||
_saveSelectedIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
backgroundColor: Colors.white,
|
||||
body: IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: const [
|
||||
HomeScreen(),
|
||||
ActivityScreen(),
|
||||
Text(""),
|
||||
CartScreen(),
|
||||
ProfilScreen(),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
splashFactory: NoSplash.splashFactory,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
child: Visibility(
|
||||
visible: _selectedIndex != 2,
|
||||
child: BottomAppBar(
|
||||
shape: const CircularNotchedRectangle(),
|
||||
padding: PaddingCustom().paddingHorizontal(2),
|
||||
elevation: 0,
|
||||
height: 67,
|
||||
color: Colors.white,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
notchMargin: 3.0,
|
||||
child: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
showSelectedLabels: true,
|
||||
showUnselectedLabels: true,
|
||||
selectedItemColor: Colors.blue,
|
||||
unselectedItemColor: Colors.grey,
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: _onItemTapped,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Iconsax.home_1, color: Colors.grey),
|
||||
activeIcon: Icon(Iconsax.home_1, color: Colors.blue),
|
||||
label: 'Beranda',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Iconsax.home, color: Colors.grey),
|
||||
activeIcon: Icon(Iconsax.home, color: Colors.blue),
|
||||
label: 'Pesan',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: SizedBox.shrink(),
|
||||
label: '',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Iconsax.document, color: Colors.grey),
|
||||
activeIcon: Icon(Iconsax.document, color: Colors.blue),
|
||||
label: 'Tutorial',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Iconsax.home, color: Colors.grey),
|
||||
activeIcon: Icon(Iconsax.home, color: Colors.blue),
|
||||
label: 'Profil',
|
||||
),
|
||||
],
|
||||
selectedLabelStyle: const TextStyle(fontSize: 14),
|
||||
unselectedLabelStyle: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
floatingActionButton: SizedBox(
|
||||
width: 78,
|
||||
height: 78,
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {
|
||||
router.push("/requestpickup");
|
||||
},
|
||||
backgroundColor: Colors.white,
|
||||
shape: const CircleBorder(
|
||||
side: BorderSide(color: Colors.white, width: 4),
|
||||
),
|
||||
elevation: 0,
|
||||
highlightElevation: 0,
|
||||
hoverColor: Colors.blue,
|
||||
splashColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Icon(Iconsax.home, color: primaryColor, size: 30),
|
||||
Text("data", style: TextStyle(color: blackNavyColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
// 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<T extends NetworkAwareWidget>
|
||||
extends State<T> {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
StreamSubscription<NetworkStatus>? _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<T> performNetworkOperation<T>(
|
||||
Future<T> 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.showImage(
|
||||
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.showImage(
|
||||
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.showImage(
|
||||
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.showImage(
|
||||
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<NetworkStatusIndicator> createState() => _NetworkStatusIndicatorState();
|
||||
}
|
||||
|
||||
class _NetworkStatusIndicatorState extends State<NetworkStatusIndicator> {
|
||||
final NetworkService _networkService = NetworkService();
|
||||
StreamSubscription<NetworkStatus>? _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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +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 {
|
||||
Future<bool> checkConnection() async {
|
||||
try {
|
||||
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<NetworkQuality> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NetworkStatus> _networkStatusController =
|
||||
StreamController<NetworkStatus>.broadcast();
|
||||
|
||||
NetworkStatus _currentStatus = NetworkStatus.checking;
|
||||
NetworkQuality _currentQuality = NetworkQuality.none;
|
||||
Timer? _connectionTimer;
|
||||
Timer? _qualityTimer;
|
||||
bool _isInitialized = false;
|
||||
|
||||
Stream<NetworkStatus> get networkStatusStream =>
|
||||
_networkStatusController.stream;
|
||||
NetworkStatus get currentStatus => _currentStatus;
|
||||
NetworkQuality get currentQuality => _currentQuality;
|
||||
bool get isConnected => _currentStatus == NetworkStatus.connected;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
await _checkConnection();
|
||||
_connectivity.onConnectivityChanged.listen(_handleConnectivityChange);
|
||||
_startQualityMonitoring();
|
||||
}
|
||||
|
||||
Future<bool> 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<void> _checkConnection() async {
|
||||
await checkConnection();
|
||||
}
|
||||
|
||||
void _handleConnectivityChange(List<ConnectivityResult> results) {
|
||||
if (results.contains(ConnectivityResult.none) || results.isEmpty) {
|
||||
_updateStatus(NetworkStatus.disconnected);
|
||||
_currentQuality = NetworkQuality.none;
|
||||
} else {
|
||||
Timer(const Duration(seconds: 1), () async {
|
||||
await _checkConnection();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _canReachInternet(int timeoutSeconds) async {
|
||||
final List<String> 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<void> _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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +1,169 @@
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rijig_mobile/core/navigation.dart';
|
||||
import 'package:rijig_mobile/screen/app/activity/activity_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/cart/cart_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/home/home_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/profil/profil_screen.dart';
|
||||
import 'package:rijig_mobile/screen/app/requestpick/requestpickup_screen.dart';
|
||||
import 'package:rijig_mobile/screen/auth/inputpin_screen.dart';
|
||||
import 'package:rijig_mobile/screen/auth/login_screen.dart';
|
||||
import 'package:rijig_mobile/screen/auth/otp_screen.dart';
|
||||
import 'package:rijig_mobile/screen/auth/verifpin_screen.dart';
|
||||
import 'package:rijig_mobile/screen/launch/onboardingpage_screen.dart';
|
||||
import 'package:rijig_mobile/screen/launch/splash_screen.dart';
|
||||
import 'package:rijig_mobile/core/utils/exportimportview.dart';
|
||||
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.dart';
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => SplashScreen()),
|
||||
// GoRoute(
|
||||
// path: '/',
|
||||
// builder: (context, state) => UploadKtpScreen(),
|
||||
// ),
|
||||
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(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding',
|
||||
builder: (context, state) => OnboardingPageScreen(),
|
||||
pageBuilder: (context, state) {
|
||||
var key = state.pageKey;
|
||||
return transisi(OnboardingPageScreen(), key);
|
||||
},
|
||||
),
|
||||
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
|
||||
GoRoute(path: '/clogin', builder: (context, state) => CloginScreen()),
|
||||
GoRoute(
|
||||
path: '/welcomec',
|
||||
builder: (context, state) => WelcomeCollectorScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verifidentity',
|
||||
builder: (context, state) => UploadKtpScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/berandapengepul',
|
||||
builder: (context, state) => ChomeCollectorScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/cpickuphistory',
|
||||
builder: (context, state) => PickupHistoryScreen(),
|
||||
),
|
||||
|
||||
// Rute untuk verifikasi OTP dengan ekstraksi data dari path
|
||||
GoRoute(
|
||||
path: '/verif-otp',
|
||||
builder: (context, state) {
|
||||
final phone = state.extra as String?;
|
||||
return VerifotpScreen(phone: phone!);
|
||||
dynamic phoneNumber = state.extra;
|
||||
return VerifOtpScreen(phoneNumber: phoneNumber);
|
||||
},
|
||||
),
|
||||
GoRoute(path: '/setpin', builder: (context, state) => SetPinScreen()),
|
||||
GoRoute(path: '/verifpin', builder: (context, state) => VerifPinScreen()),
|
||||
GoRoute(
|
||||
path: '/cverif-otp',
|
||||
builder: (context, state) {
|
||||
// dynamic phoneNumber = state.extra;
|
||||
return CverifOtpScreen();
|
||||
},
|
||||
),
|
||||
|
||||
// GoRoute(path: '/setpin', builder: (context, state) => InputPinScreen()),
|
||||
// GoRoute(path: '/verifpin', builder: (context, state) => VerifPinScreen()),
|
||||
|
||||
// Rute dengan parameter dinamis untuk halaman navigasi
|
||||
GoRoute(
|
||||
path: '/navigasi',
|
||||
builder: (context, state) {
|
||||
dynamic data = state.extra;
|
||||
final data = state.extra;
|
||||
return NavigationPage(data: data);
|
||||
},
|
||||
),
|
||||
|
||||
// Rute untuk halaman-halaman utama
|
||||
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
||||
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
|
||||
GoRoute(
|
||||
path: '/requestpickup',
|
||||
builder: (context, state) => RequestPickScreen(),
|
||||
path: '/notifikasi',
|
||||
builder: (context, state) => NotificationScreen(),
|
||||
),
|
||||
GoRoute(path: '/cart', builder: (context, state) => CartScreen()),
|
||||
GoRoute(path: '/chatlist', builder: (context, state) => ChatListScreen()),
|
||||
// Router config
|
||||
GoRoute(
|
||||
path: '/chatroom/:contactId',
|
||||
builder: (context, state) {
|
||||
final contactName = state.uri.queryParameters['name'] ?? 'Unknown';
|
||||
final contactImage = state.uri.queryParameters['image'] ?? '';
|
||||
final isOnline = state.uri.queryParameters['online'] == 'true';
|
||||
|
||||
return ChatRoomScreen(
|
||||
contactName: contactName,
|
||||
contactImage: contactImage,
|
||||
isOnline: isOnline,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/dataperforma',
|
||||
builder: (context, state) => DatavisualizedScreen(),
|
||||
),
|
||||
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
|
||||
GoRoute(path: '/profil', builder: (context, state) => ProfilScreen()),
|
||||
GoRoute(path: '/akunprofil', builder: (context, state) => AccountScreen()),
|
||||
GoRoute(path: '/address', builder: (context, state) => AddressScreen()),
|
||||
GoRoute(
|
||||
path: '/addaddress',
|
||||
builder: (context, state) => AddAddressScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/editaddress',
|
||||
builder: (context, state) {
|
||||
dynamic address = state.extra;
|
||||
return EditAddressScreen(address: address);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/aboutdetail',
|
||||
builder: (context, state) {
|
||||
dynamic data = state.extra;
|
||||
return AboutDetailScreenComp(data: data);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/artikeldetail',
|
||||
builder: (context, state) {
|
||||
dynamic data = state.extra;
|
||||
return ArticleDetailScreen(data: data);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/pickupmethod',
|
||||
builder: (context, state) {
|
||||
dynamic data = state.extra;
|
||||
return PickupScreen(data: data);
|
||||
},
|
||||
),
|
||||
|
||||
GoRoute(
|
||||
path: '/pilihpengepul',
|
||||
builder: (context, state) {
|
||||
return SelectCollectorScreen();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
CustomTransitionPage transisi(Widget page, key) {
|
||||
return CustomTransitionPage(
|
||||
transitionDuration: const Duration(milliseconds: 1200),
|
||||
child: page,
|
||||
key: key,
|
||||
transitionsBuilder: (key, animation, secondaryAnimation, page) {
|
||||
return FadeTransition(
|
||||
opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
|
||||
child: page,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||
import 'package:rijig_mobile/core/storage/secure_storage.dart';
|
||||
|
||||
final SecureStorage _secureStorage = SecureStorage();
|
||||
|
||||
Future<bool> isTokenExpired() async {
|
||||
String? token = await _secureStorage.readSecureData('token');
|
||||
|
||||
if (token == null || token.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, dynamic> decodedToken = JwtDecoder.decode(token);
|
||||
int expirationTime = decodedToken['exp'];
|
||||
int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
return expirationTime < currentTime;
|
||||
} catch (e) {
|
||||
debugPrint("Error decoding token: $e");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Future<bool> isTokenExpired() async {
|
||||
// String? token = await _secureStorage.readSecureData('token');
|
||||
|
||||
// if (token == null || token.isEmpty) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// // Decode the token to get the payload
|
||||
// Map<String, dynamic> decodedToken = JwtDecoder.decode(token);
|
||||
|
||||
// // Retrieve the expiration time in seconds
|
||||
// int expirationTime = decodedToken['exp'];
|
||||
|
||||
// // Get current time in seconds
|
||||
// int currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
|
||||
// // Check if token is expired
|
||||
// return expirationTime < currentTime;
|
||||
// }
|
||||
|
||||
|
||||
Future<void> storeSessionData(
|
||||
String token,
|
||||
String userId,
|
||||
String userRole,
|
||||
) async {
|
||||
await _secureStorage.writeSecureData('token', token);
|
||||
await _secureStorage.writeSecureData('user_id', userId);
|
||||
await _secureStorage.writeSecureData('user_role', userRole);
|
||||
}
|
||||
|
||||
Future<void> deleteToken() async {
|
||||
await _secureStorage.deleteSecureData('token');
|
||||
await _secureStorage.deleteSecureData('user_id');
|
||||
await _secureStorage.deleteSecureData('user_role');
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class SecureStorage {
|
||||
final FlutterSecureStorage _storage = FlutterSecureStorage();
|
||||
|
||||
Future<void> writeSecureData(String key, String value) async {
|
||||
await _storage.write(key: key, value: value);
|
||||
}
|
||||
|
||||
Future<String?> readSecureData(String key) async {
|
||||
return await _storage.read(key: key);
|
||||
}
|
||||
|
||||
Future<void> deleteSecureData(String key) async {
|
||||
await _storage.delete(key: key);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class SharedPrefsHelper {
|
||||
static Future<void> setIsLoggedIn(bool isLoggedIn) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setBool('isLoggedIn', isLoggedIn);
|
||||
}
|
||||
|
||||
static Future<bool> getIsLoggedIn() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool('isLoggedIn') ?? false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
export 'package:flutter/material.dart';
|
||||
export 'package:go_router/go_router.dart';
|
||||
export 'package:rijig_mobile/core/utils/navigation.dart';
|
||||
export 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart';
|
||||
export 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart';
|
||||
export 'package:rijig_mobile/features/home/notification/presentation/screen/notification_screen.dart';
|
||||
export 'package:rijig_mobile/features/home/datavisualized/presentation/screen/datavisualized_screen.dart';
|
||||
export 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart';
|
||||
export 'package:rijig_mobile/features/profil/address/presentation/screen/address_screen.dart';
|
||||
export 'package:rijig_mobile/features/profil/address/presentation/screen/add_address_screen.dart';
|
||||
export 'package:rijig_mobile/features/profil/address/presentation/screen/edit_address_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/inputpin_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/login_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/otp_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/verifpin_screen.dart';
|
||||
export 'package:rijig_mobile/features/launch/screen/onboardingpage_screen.dart';
|
||||
export 'package:rijig_mobile/features/launch/screen/splash_screen.dart';
|
||||
export 'package:rijig_mobile/features/home/presentation/components/about_detail_comp.dart';
|
||||
export 'package:rijig_mobile/features/home/presentation/components/article_content.dart';
|
||||
export 'package:rijig_mobile/features/pickup/presentation/screen/pickup_screen.dart';
|
||||
export 'package:rijig_mobile/features/pickup/presentation/screen/selectcollector_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/collector/identity_validation_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/collector/welcome_collector_screen.dart';
|
||||
export 'package:rijig_mobile/features/home/presentation/screen/collector/chome_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/collector/cotp_screen.dart';
|
||||
export 'package:rijig_mobile/features/auth/presentation/screen/collector/clogin_screen.dart';
|
||||
export 'package:rijig_mobile/features/home/presentation/screen/collector/pickup_history_screen.dart';
|
||||
export 'package:rijig_mobile/features/pickup/presentation/screen/pickup_map_screen.dart';
|
||||
export 'package:rijig_mobile/features/chat/presentation/screen/chatlist_screen.dart';
|
||||
export 'package:rijig_mobile/features/profil/account/presentation/screen/account_screen.dart';
|
||||
|
||||
|
||||
|
||||
// remmovable
|
||||
export 'package:rijig_mobile/features/cart/presentation/screens/cart_test_screen.dart';
|
||||
export 'package:rijig_mobile/features/profil/components/secure_pin_input.dart';
|
||||
export 'package:rijig_mobile/features/requestpick/presentation/screen/trash_testview.dart';
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'dart:io';
|
||||
|
||||
Future<String> getDeviceId() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
String deviceID = '';
|
||||
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
var androidInfo = await deviceInfo.androidInfo;
|
||||
deviceID = androidInfo.id;
|
||||
} else if (Platform.isIOS) {
|
||||
var iosInfo = await deviceInfo.iosInfo;
|
||||
deviceID = iosInfo.identifierForVendor ?? '';
|
||||
} else {
|
||||
var uuid = Uuid();
|
||||
deviceID = uuid.v4();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Error fetching device ID: $e");
|
||||
|
||||
var uuid = Uuid();
|
||||
deviceID = uuid.v4();
|
||||
}
|
||||
|
||||
return deviceID;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
// =====================color schema=====================
|
||||
Color whiteColor = Color(0xffF0F1EA);
|
||||
Color whiteColor = Color(0xffFBFBFB);
|
||||
Color blackNavyColor = Color(0xff101010);
|
||||
Color primaryColor = Color(0xff018558);
|
||||
Color secondaryColor = Color(0xffBDE902);
|
||||
|
|
@ -24,26 +24,38 @@ FontWeight superBold = FontWeight.w900;
|
|||
|
||||
// =====================text behavior=====================
|
||||
class Tulisan {
|
||||
static TextStyle heading({Color? color}) {
|
||||
static TextStyle heading({Color? color, double? fontsize}) {
|
||||
return GoogleFonts.spaceGrotesk(
|
||||
fontSize: 24.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: fontsize?.sp ?? 24.sp,
|
||||
fontWeight: extraBold,
|
||||
color: color ?? blackNavyColor,
|
||||
);
|
||||
}
|
||||
|
||||
static TextStyle body({Color? color}) {
|
||||
static TextStyle body({Color? color, double? fontsize}) {
|
||||
return GoogleFonts.spaceMono(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: fontsize?.sp ?? 16.sp,
|
||||
fontWeight: regular,
|
||||
color: color ?? blackNavyColor,
|
||||
);
|
||||
}
|
||||
|
||||
static TextStyle subheading({Color? color}) {
|
||||
static TextStyle subheading({Color? color, double? fontsize}) {
|
||||
return GoogleFonts.spaceGrotesk(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: fontsize?.sp ?? 18.sp,
|
||||
fontWeight: medium,
|
||||
color: color ?? blackNavyColor,
|
||||
);
|
||||
}
|
||||
|
||||
static TextStyle customText({
|
||||
Color? color,
|
||||
double? fontsize,
|
||||
FontWeight? fontWeight,
|
||||
}) {
|
||||
return GoogleFonts.spaceGrotesk(
|
||||
fontSize: fontsize?.sp ?? 16.sp,
|
||||
fontWeight: fontWeight ?? regular,
|
||||
color: color ?? blackNavyColor,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:iconsax_flutter/iconsax_flutter.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/features/activity/presentation/screen/activity_screen.dart';
|
||||
import 'package:rijig_mobile/features/cart/presentation/screens/cart_test_screen.dart';
|
||||
import 'package:rijig_mobile/features/home/presentation/screen/home_screen.dart';
|
||||
import 'package:rijig_mobile/features/profil/presentation/screen/profil_screen.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class NavigationPage extends StatefulWidget {
|
||||
final dynamic data;
|
||||
const NavigationPage({super.key, this.data});
|
||||
|
||||
@override
|
||||
State<NavigationPage> createState() => _NavigationPageState();
|
||||
}
|
||||
|
||||
class _NavigationPageState extends State<NavigationPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
int _selectedIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(1.0, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
|
||||
_controller.forward();
|
||||
_loadSelectedIndex();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
_loadSelectedIndex() async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_selectedIndex = prefs.getInt('last_selected_index') ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
_saveSelectedIndex(int index) async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
prefs.setInt('last_selected_index', index);
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
if (index == 2) {
|
||||
router.push("/trashview");
|
||||
} else {
|
||||
setState(() => _selectedIndex = index);
|
||||
_saveSelectedIndex(index);
|
||||
}
|
||||
}
|
||||
|
||||
void _onCenterButtonTapped() {
|
||||
router.push("/trashview");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Size size = MediaQuery.of(context).size;
|
||||
|
||||
return Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
extendBody: true,
|
||||
backgroundColor: whiteColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: const [
|
||||
HomeScreen(),
|
||||
ActivityScreen(),
|
||||
Text(""),
|
||||
OrderSummaryScreen(),
|
||||
ProfilScreen(),
|
||||
],
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: SizedBox(
|
||||
width: size.width,
|
||||
height: 65,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size(size.width, 65),
|
||||
painter: CustomBottomNavPainter(
|
||||
backgroundColor: primaryColor,
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
heightFactor: 0.5,
|
||||
child: GestureDetector(
|
||||
onTap: _onCenterButtonTapped,
|
||||
child: Container(
|
||||
width: 65,
|
||||
height: 65,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [secondaryColor, primaryColor],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Iconsax.archive_2,
|
||||
color: whiteColor,
|
||||
size: 26,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
"Mulai",
|
||||
style: TextStyle(
|
||||
color: whiteColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: size.width,
|
||||
height: 65,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildNavItem(
|
||||
icon: Iconsax.home_2,
|
||||
label: 'Beranda',
|
||||
index: 0,
|
||||
),
|
||||
_buildNavItem(
|
||||
icon: Iconsax.note_favorite,
|
||||
label: 'Aktivitas',
|
||||
index: 1,
|
||||
),
|
||||
|
||||
SizedBox(width: size.width * 0.20),
|
||||
_buildNavItem(
|
||||
icon: Iconsax.shopping_cart,
|
||||
label: 'Keranjang',
|
||||
index: 3,
|
||||
),
|
||||
_buildNavItem(
|
||||
icon: Iconsax.user,
|
||||
label: 'Profil',
|
||||
index: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required int index,
|
||||
}) {
|
||||
final isSelected = _selectedIndex == index;
|
||||
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _onItemTapped(index),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 6),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: isSelected ? 26 : 24,
|
||||
color: isSelected ? secondaryColor : whiteColor,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? secondaryColor : whiteColor,
|
||||
fontSize: isSelected ? 14 : 12,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomBottomNavPainter extends CustomPainter {
|
||||
final Color backgroundColor;
|
||||
|
||||
CustomBottomNavPainter({required this.backgroundColor});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint paint =
|
||||
Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Path path = Path();
|
||||
|
||||
path.moveTo(0, 0);
|
||||
path.lineTo(size.width * 0.35, 0);
|
||||
|
||||
path.quadraticBezierTo(size.width * 0.40, 0, size.width * 0.40, 15);
|
||||
|
||||
path.arcToPoint(
|
||||
Offset(size.width * 0.60, 15),
|
||||
radius: const Radius.circular(17.0),
|
||||
clockwise: false,
|
||||
);
|
||||
|
||||
path.quadraticBezierTo(size.width * 0.60, 0, size.width * 0.65, 0);
|
||||
path.lineTo(size.width, 0);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(0, size.height);
|
||||
path.lineTo(0, 0);
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleNavigationPage extends StatefulWidget {
|
||||
final dynamic data;
|
||||
const SimpleNavigationPage({super.key, this.data});
|
||||
|
||||
@override
|
||||
State<SimpleNavigationPage> createState() => _SimpleNavigationPageState();
|
||||
}
|
||||
|
||||
class _SimpleNavigationPageState extends State<SimpleNavigationPage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Size size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
extendBody: true,
|
||||
backgroundColor: whiteColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
IndexedStack(
|
||||
index: _selectedIndex,
|
||||
children: const [
|
||||
HomeScreen(),
|
||||
ActivityScreen(),
|
||||
OrderSummaryScreen(),
|
||||
ProfilScreen(),
|
||||
],
|
||||
),
|
||||
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: SizedBox(
|
||||
width: size.width,
|
||||
height: 60,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size(size.width, 60),
|
||||
painter: PreciseBottomNavPainter(
|
||||
backgroundColor: primaryColor,
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
heightFactor: 0.7,
|
||||
child: GestureDetector(
|
||||
onTap: () => router.push("/trashview"),
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [secondaryColor, primaryColor],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Iconsax.archive_2,
|
||||
color: whiteColor,
|
||||
size: 22,
|
||||
),
|
||||
Text(
|
||||
"Mulai",
|
||||
style: TextStyle(
|
||||
color: whiteColor,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: size.width,
|
||||
height: 60,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_simpleNavItem(Iconsax.home_2, 'Beranda', 0),
|
||||
_simpleNavItem(Iconsax.note_favorite, 'Aktivitas', 1),
|
||||
SizedBox(width: size.width * 0.15),
|
||||
_simpleNavItem(Iconsax.shopping_cart, 'Keranjang', 2),
|
||||
_simpleNavItem(Iconsax.user, 'Profil', 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _simpleNavItem(IconData icon, String label, int index) {
|
||||
final isSelected = _selectedIndex == index;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selectedIndex = index),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: isSelected ? 22 : 18,
|
||||
color: isSelected ? secondaryColor : whiteColor,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? secondaryColor : whiteColor,
|
||||
fontSize: 9,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PreciseBottomNavPainter extends CustomPainter {
|
||||
final Color backgroundColor;
|
||||
|
||||
PreciseBottomNavPainter({required this.backgroundColor});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint paint =
|
||||
Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Path path = Path();
|
||||
|
||||
final double centerX = size.width * 0.5;
|
||||
final double notchRadius = 30.0;
|
||||
final double notchMargin = 4.0;
|
||||
|
||||
path.moveTo(0, 15);
|
||||
|
||||
path.quadraticBezierTo(size.width * 0.15, 0, size.width * 0.35, 0);
|
||||
|
||||
path.quadraticBezierTo(
|
||||
centerX - notchRadius - notchMargin,
|
||||
0,
|
||||
centerX - notchRadius - notchMargin,
|
||||
12,
|
||||
);
|
||||
|
||||
path.arcToPoint(
|
||||
Offset(centerX + notchRadius + notchMargin, 12),
|
||||
radius: Radius.circular(notchRadius + notchMargin),
|
||||
clockwise: false,
|
||||
);
|
||||
|
||||
path.quadraticBezierTo(
|
||||
centerX + notchRadius + notchMargin,
|
||||
0,
|
||||
size.width * 0.65,
|
||||
0,
|
||||
);
|
||||
|
||||
path.quadraticBezierTo(size.width * 0.85, 0, size.width, 15);
|
||||
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(0, size.height);
|
||||
path.lineTo(0, 15);
|
||||
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
class Validation {
|
||||
static bool isValidPhoneNumber(String phone) {
|
||||
final RegExp phoneRegExp = RegExp(r"^\+?1?\d{9,15}$");
|
||||
return phoneRegExp.hasMatch(phone);
|
||||
}
|
||||
|
||||
static bool isValidEmail(String email) {
|
||||
final RegExp emailRegExp = RegExp(
|
||||
r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$",
|
||||
);
|
||||
return emailRegExp.hasMatch(email);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,989 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/widget/tabbar_custom.dart';
|
||||
import 'package:rijig_mobile/widget/unhope_handler.dart';
|
||||
import 'package:timelines_plus/timelines_plus.dart';
|
||||
|
||||
class ActivityScreen extends StatefulWidget {
|
||||
const ActivityScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ActivityScreen> createState() => _ActivityScreenState();
|
||||
}
|
||||
|
||||
class _ActivityScreenState extends State<ActivityScreen> {
|
||||
// Data contoh untuk timeline
|
||||
final List<ActivityItem> processActivities = [
|
||||
ActivityItem(
|
||||
title: 'Pesanan Dikonfirmasi',
|
||||
description: 'Pesanan Anda telah dikonfirmasi dan sedang diproses',
|
||||
time: '10:30',
|
||||
date: '15 Juni 2024',
|
||||
status: ActivityStatus.completed,
|
||||
icon: Icons.check_circle,
|
||||
),
|
||||
ActivityItem(
|
||||
title: 'Sedang Disiapkan',
|
||||
description: 'Tim kami sedang menyiapkan pesanan Anda',
|
||||
time: '11:15',
|
||||
date: '15 Juni 2024',
|
||||
status: ActivityStatus.inProgress,
|
||||
icon: Icons.timer,
|
||||
),
|
||||
ActivityItem(
|
||||
title: 'Siap Dikirim',
|
||||
description: 'Pesanan siap untuk dikirim ke alamat tujuan',
|
||||
time: '',
|
||||
date: '',
|
||||
status: ActivityStatus.pending,
|
||||
icon: Icons.local_shipping,
|
||||
),
|
||||
ActivityItem(
|
||||
title: 'Dalam Perjalanan',
|
||||
description: 'Pesanan sedang dalam perjalanan menuju alamat Anda',
|
||||
time: '',
|
||||
date: '',
|
||||
status: ActivityStatus.pending,
|
||||
icon: Icons.directions_car,
|
||||
),
|
||||
ActivityItem(
|
||||
title: 'Pesanan Sampai',
|
||||
description: 'Pesanan telah sampai di alamat tujuan',
|
||||
time: '',
|
||||
date: '',
|
||||
status: ActivityStatus.pending,
|
||||
icon: Icons.home,
|
||||
),
|
||||
];
|
||||
|
||||
// Data contoh untuk pesanan selesai
|
||||
final List<CompletedOrder> completedOrders = [
|
||||
CompletedOrder(
|
||||
orderId: '#12340',
|
||||
title: 'Paket Makanan Premium',
|
||||
description: '2x Nasi Gudeg, 1x Es Teh Manis',
|
||||
totalAmount: 'Rp 85.000',
|
||||
completedDate: '12 Juni 2024',
|
||||
completedTime: '14:30',
|
||||
rating: 5,
|
||||
customerNote: 'Makanan enak sekali, pengiriman cepat!',
|
||||
),
|
||||
CompletedOrder(
|
||||
orderId: '#12339',
|
||||
title: 'Paket Minuman Segar',
|
||||
description: '3x Jus Jeruk, 2x Smoothie Mangga',
|
||||
totalAmount: 'Rp 65.000',
|
||||
completedDate: '10 Juni 2024',
|
||||
completedTime: '16:45',
|
||||
rating: 4,
|
||||
customerNote: 'Minuman segar, kemasan bagus',
|
||||
),
|
||||
CompletedOrder(
|
||||
orderId: '#12338',
|
||||
title: 'Paket Snack Keluarga',
|
||||
description: '1x Risoles, 2x Pastel, 1x Kopi',
|
||||
totalAmount: 'Rp 45.000',
|
||||
completedDate: '8 Juni 2024',
|
||||
completedTime: '10:15',
|
||||
rating: 5,
|
||||
customerNote: '',
|
||||
),
|
||||
];
|
||||
|
||||
// Data contoh untuk pesanan dibatalkan
|
||||
final List<CancelledOrder> cancelledOrders = [
|
||||
CancelledOrder(
|
||||
orderId: '#12337',
|
||||
title: 'Paket Lunch Box',
|
||||
description: '1x Nasi Ayam Geprek, 1x Es Jeruk',
|
||||
totalAmount: 'Rp 35.000',
|
||||
cancelledDate: '7 Juni 2024',
|
||||
cancelledTime: '13:20',
|
||||
cancelReason: 'Dibatalkan oleh pelanggan',
|
||||
refundStatus: 'Dikembalikan',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
backgroundColor: whiteColor,
|
||||
appBar: AppBar(
|
||||
title: Text('Aktifitas', style: Tulisan.subheading()),
|
||||
centerTitle: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(40),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Container(
|
||||
height: 40,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
color: Colors.green.shade100,
|
||||
),
|
||||
child: TabBar(
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
dividerColor: Colors.transparent,
|
||||
indicator: BoxDecoration(
|
||||
color: primaryColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.black54,
|
||||
tabs: [
|
||||
TabItem(title: 'Proses', count: 6),
|
||||
TabItem(title: 'Selesai', count: 3),
|
||||
TabItem(title: 'Dibatalkan', count: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_buildProcessTab(),
|
||||
_buildCompletedTab(),
|
||||
_buildCancelledTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedTab() {
|
||||
return Container(
|
||||
color: Colors.grey.shade50,
|
||||
child: completedOrders.isEmpty
|
||||
? Center(child: InfoStateWidget(type: InfoStateType.emptyData))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: completedOrders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final order = completedOrders[index];
|
||||
return _buildCompletedOrderCard(order);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedOrderCard(CompletedOrder order) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha:0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header dengan status
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
order.orderId,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Selesai',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Konten pesanan
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.shopping_bag,
|
||||
color: Colors.green.shade600,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
order.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
order.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Selesai pada ${order.completedDate} • ${order.completedTime}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
order.totalAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildRatingStars(order.rating),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Customer note jika ada
|
||||
if (order.customerNote.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade100),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.comment,
|
||||
size: 16,
|
||||
color: Colors.blue.shade600,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Catatan Pelanggan:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.blue.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
order.customerNote,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.blue.shade700,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// Action untuk lihat detail
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.visibility,
|
||||
size: 16,
|
||||
color: primaryColor,
|
||||
),
|
||||
label: Text(
|
||||
'Lihat Detail',
|
||||
style: TextStyle(color: primaryColor),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: primaryColor),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Action untuk pesan lagi
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.refresh,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: const Text(
|
||||
'Pesan Lagi',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCancelledTab() {
|
||||
return Container(
|
||||
color: Colors.grey.shade50,
|
||||
child: cancelledOrders.isEmpty
|
||||
? Center(child: InfoStateWidget(type: InfoStateType.emptyData))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: cancelledOrders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final order = cancelledOrders[index];
|
||||
return _buildCancelledOrderCard(order);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCancelledOrderCard(CancelledOrder order) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.red.shade100),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha:0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header dengan status
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
order.orderId,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.cancel,
|
||||
size: 14,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Dibatalkan',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Konten pesanan
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.shopping_bag_outlined,
|
||||
color: Colors.red.shade600,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
order.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
order.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Dibatalkan pada ${order.cancelledDate} • ${order.cancelledTime}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
order.totalAmount,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade600,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Info pembatalan
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade100),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Alasan Pembatalan:',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
order.cancelReason,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet,
|
||||
size: 16,
|
||||
color: Colors.green.shade600,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Status Refund: ${order.refundStatus}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Action button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// Action untuk pesan lagi
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
size: 16,
|
||||
color: primaryColor,
|
||||
),
|
||||
label: Text(
|
||||
'Pesan Lagi',
|
||||
style: TextStyle(color: primaryColor),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: primaryColor),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRatingStars(int rating) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(5, (index) {
|
||||
return Icon(
|
||||
index < rating ? Icons.star : Icons.star_border,
|
||||
size: 14,
|
||||
color: Colors.orange,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessTab() {
|
||||
return Container(
|
||||
color: Colors.grey.shade50,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Card
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha:0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withValues(alpha:0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.shopping_bag,
|
||||
color: primaryColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Pesanan #12345',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Total: Rp 150.000',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
'Dalam Proses',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Timeline Section
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha:0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Status Pesanan',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTimeline(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimeline() {
|
||||
return Timeline.tileBuilder(
|
||||
theme: TimelineThemeData(
|
||||
nodePosition: 0,
|
||||
color: Colors.grey.shade300,
|
||||
indicatorTheme: const IndicatorThemeData(
|
||||
position: 0,
|
||||
size: 20.0,
|
||||
),
|
||||
connectorTheme: const ConnectorThemeData(
|
||||
thickness: 2.0,
|
||||
),
|
||||
),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
builder: TimelineTileBuilder.connected(
|
||||
connectionDirection: ConnectionDirection.before,
|
||||
itemCount: processActivities.length,
|
||||
contentsBuilder: (context, index) {
|
||||
return _buildTimelineContent(processActivities[index]);
|
||||
},
|
||||
indicatorBuilder: (context, index) {
|
||||
return _buildTimelineIndicator(processActivities[index]);
|
||||
},
|
||||
connectorBuilder: (context, index, type) {
|
||||
return SolidLineConnector(
|
||||
color: index < processActivities.length - 1 &&
|
||||
processActivities[index].status == ActivityStatus.completed
|
||||
? primaryColor
|
||||
: Colors.grey.shade300,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimelineIndicator(ActivityItem item) {
|
||||
Color indicatorColor;
|
||||
Widget indicatorChild;
|
||||
|
||||
switch (item.status) {
|
||||
case ActivityStatus.completed:
|
||||
indicatorColor = primaryColor;
|
||||
indicatorChild = Icon(
|
||||
Icons.check,
|
||||
color: Colors.white,
|
||||
size: 12,
|
||||
);
|
||||
break;
|
||||
case ActivityStatus.inProgress:
|
||||
indicatorColor = Colors.orange;
|
||||
indicatorChild = Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case ActivityStatus.pending:
|
||||
indicatorColor = Colors.grey.shade300;
|
||||
indicatorChild = Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return DotIndicator(
|
||||
size: 20,
|
||||
color: indicatorColor,
|
||||
child: indicatorChild,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimelineContent(ActivityItem item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
item.icon,
|
||||
size: 20,
|
||||
color: item.status == ActivityStatus.completed
|
||||
? primaryColor
|
||||
: item.status == ActivityStatus.inProgress
|
||||
? Colors.orange
|
||||
: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: item.status == ActivityStatus.pending
|
||||
? Colors.grey.shade500
|
||||
: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: item.status == ActivityStatus.pending
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (item.time.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${item.time} • ${item.date}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Model untuk item aktivitas
|
||||
class ActivityItem {
|
||||
final String title;
|
||||
final String description;
|
||||
final String time;
|
||||
final String date;
|
||||
final ActivityStatus status;
|
||||
final IconData icon;
|
||||
|
||||
ActivityItem({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.time,
|
||||
required this.date,
|
||||
required this.status,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
// Model untuk pesanan selesai
|
||||
class CompletedOrder {
|
||||
final String orderId;
|
||||
final String title;
|
||||
final String description;
|
||||
final String totalAmount;
|
||||
final String completedDate;
|
||||
final String completedTime;
|
||||
final int rating;
|
||||
final String customerNote;
|
||||
|
||||
CompletedOrder({
|
||||
required this.orderId,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.totalAmount,
|
||||
required this.completedDate,
|
||||
required this.completedTime,
|
||||
required this.rating,
|
||||
required this.customerNote,
|
||||
});
|
||||
}
|
||||
|
||||
// Model untuk pesanan dibatalkan
|
||||
class CancelledOrder {
|
||||
final String orderId;
|
||||
final String title;
|
||||
final String description;
|
||||
final String totalAmount;
|
||||
final String cancelledDate;
|
||||
final String cancelledTime;
|
||||
final String cancelReason;
|
||||
final String refundStatus;
|
||||
|
||||
CancelledOrder({
|
||||
required this.orderId,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.totalAmount,
|
||||
required this.cancelledDate,
|
||||
required this.cancelledTime,
|
||||
required this.cancelReason,
|
||||
required this.refundStatus,
|
||||
});
|
||||
}
|
||||
|
||||
// Enum untuk status aktivitas
|
||||
enum ActivityStatus {
|
||||
completed,
|
||||
inProgress,
|
||||
pending,
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
class LoginModel {
|
||||
final String phone;
|
||||
|
||||
LoginModel({required this.phone});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'phone': phone};
|
||||
}
|
||||
}
|
||||
|
||||
class LoginResponse {
|
||||
final String message;
|
||||
|
||||
LoginResponse({required this.message});
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
return LoginResponse(message: json['meta']['message']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
class LogoutResponse {
|
||||
final String message;
|
||||
|
||||
LogoutResponse({required this.message});
|
||||
|
||||
factory LogoutResponse.fromJson(Map<String, dynamic> json) {
|
||||
return LogoutResponse(message: json['meta']['message']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
class OtpModel {
|
||||
final String phone;
|
||||
final String otp;
|
||||
final String deviceId;
|
||||
|
||||
OtpModel({required this.phone, required this.otp, required this.deviceId});
|
||||
|
||||
factory OtpModel.fromJson(Map<String, dynamic> json) {
|
||||
return OtpModel(
|
||||
phone: json['phone'],
|
||||
otp: json['otp'],
|
||||
deviceId: json['device_id'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'phone': phone, 'otp': otp, 'device_id': deviceId};
|
||||
}
|
||||
}
|
||||
|
||||
class VerifOkResponse {
|
||||
final String userId;
|
||||
final String userRole;
|
||||
final String token;
|
||||
|
||||
VerifOkResponse({
|
||||
required this.userId,
|
||||
required this.userRole,
|
||||
required this.token,
|
||||
});
|
||||
|
||||
factory VerifOkResponse.fromJson(Map<String, dynamic> json) {
|
||||
return VerifOkResponse(
|
||||
userId: json['data']['user_id'],
|
||||
userRole: json['data']['user_role'],
|
||||
token: json['data']['token'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
class ResponseModel {
|
||||
final bool status;
|
||||
final String message;
|
||||
|
||||
ResponseModel({required this.status, required this.message});
|
||||
|
||||
factory ResponseModel.fromJson(Map<String, dynamic> json) {
|
||||
return ResponseModel(
|
||||
status: json['meta']['status'] == 200,
|
||||
message: json['meta']['message'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserModel {
|
||||
final String userId;
|
||||
final String userRole;
|
||||
final String token;
|
||||
|
||||
UserModel({
|
||||
required this.userId,
|
||||
required this.userRole,
|
||||
required this.token,
|
||||
});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserModel(
|
||||
userId: json['data']['user_id'],
|
||||
userRole: json['data']['user_role'],
|
||||
token: json['data']['token'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
class UserModel {
|
||||
final String userId;
|
||||
final String userRole;
|
||||
final String token;
|
||||
|
||||
UserModel({required this.userId, required this.userRole, required this.token});
|
||||
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserModel(
|
||||
userId: json['data']['user_id'],
|
||||
userRole: json['data']['user_role'],
|
||||
token: json['data']['token'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// import 'package:rijig_mobile/core/api/api_services.dart';
|
||||
// import 'package:rijig_mobile/features/auth/model/response_model.dart';
|
||||
|
||||
// class PinModel {
|
||||
// final ApiService _apiService = ApiService();
|
||||
|
||||
// Future<ResponseModel?> checkPinStatus(String userId) async {
|
||||
// try {
|
||||
// var response = await _apiService.get('/cek-pin-status');
|
||||
// return ResponseModel.fromJson(response);
|
||||
// } catch (e) {
|
||||
// rethrow;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Future<ResponseModel?> setPin(String pin) async {
|
||||
// try {
|
||||
// var response = await _apiService.post('/set-pin', {'userpin': pin});
|
||||
// return ResponseModel.fromJson(response);
|
||||
// } catch (e) {
|
||||
// rethrow;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Future<ResponseModel?> verifyPin(String pin) async {
|
||||
// try {
|
||||
// var response = await _apiService.post('/verif-pin', {'userpin': pin});
|
||||
// return ResponseModel.fromJson(response);
|
||||
// } catch (e) {
|
||||
// rethrow;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||
import 'package:rijig_mobile/widget/formfiled.dart';
|
||||
|
||||
class CloginScreen extends StatefulWidget {
|
||||
const CloginScreen({super.key});
|
||||
|
||||
@override
|
||||
CloginScreenState createState() => CloginScreenState();
|
||||
}
|
||||
|
||||
class CloginScreenState extends State<CloginScreen> {
|
||||
final TextEditingController cPhoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
cPhoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: PaddingCustom().paddingHorizontalVertical(15, 40),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight:
|
||||
mediaQuery.size.height * 1 / 4 -
|
||||
mediaQuery.padding.top -
|
||||
mediaQuery.padding.bottom,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
"Selamat datang di aplikasi",
|
||||
style: Tulisan.subheading(),
|
||||
),
|
||||
Text(
|
||||
"Rijig",
|
||||
style: Tulisan.heading(color: primaryColor),
|
||||
),
|
||||
SizedBox(height: mediaQuery.size.height * 0.1),
|
||||
Image.asset(
|
||||
'assets/image/security.png',
|
||||
width: mediaQuery.size.width * 0.35,
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
FormFieldOne(
|
||||
controllers: cPhoneController,
|
||||
hintText: 'Masukkan nomor whatsapp anda!',
|
||||
placeholder: "cth.62..",
|
||||
isRequired: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.phone,
|
||||
onTap: () {},
|
||||
onChanged: (value) {},
|
||||
fontSize: 14,
|
||||
fontSizeField: 16,
|
||||
onFieldSubmitted: (value) {},
|
||||
readOnly: false,
|
||||
enabled: true,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
CardButtonOne(
|
||||
textButton: "kirim otp",
|
||||
// viewModel.isLoading
|
||||
// ? 'Sending OTP...'
|
||||
// : 'Send OTP',
|
||||
fontSized: 16.sp,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
onTap: () {
|
||||
if (cPhoneController.text.isNotEmpty) {
|
||||
debugPrint("send otp dipencet");
|
||||
router.go(
|
||||
"/cverif-otp",
|
||||
extra: cPhoneController.text,
|
||||
);
|
||||
// await viewModel.loginOrRegister(
|
||||
// cPhoneController.text,
|
||||
// );
|
||||
// if (viewModel.loginResponse != null) {
|
||||
// router.go(
|
||||
// "/verif-otp",
|
||||
// extra: cPhoneController.text,
|
||||
// );
|
||||
// }
|
||||
}
|
||||
},
|
||||
// loadingTrue: viewModel.isLoading,
|
||||
usingRow: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("login sebagai:"),
|
||||
TextButton(
|
||||
onPressed: () => router.push('/login'),
|
||||
child: Text("masyarakat?"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,716 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:nik_validator/nik_validator.dart';
|
||||
|
||||
enum KtpValidationState { initial, imageSelected, processing, success, error }
|
||||
|
||||
enum ProcessingStatus { idle, preprocessing, extracting, validating, completed }
|
||||
|
||||
class ImageProcessingData {
|
||||
final Uint8List imageBytes;
|
||||
final String imagePath;
|
||||
|
||||
ImageProcessingData(this.imageBytes, this.imagePath);
|
||||
}
|
||||
|
||||
class ProcessedImageResult {
|
||||
final String processedPath;
|
||||
final bool success;
|
||||
final String? error;
|
||||
|
||||
ProcessedImageResult({
|
||||
required this.processedPath,
|
||||
required this.success,
|
||||
this.error,
|
||||
});
|
||||
}
|
||||
|
||||
class KtpData {
|
||||
String identificationNumber;
|
||||
String fullName;
|
||||
String bloodType;
|
||||
String hamlet;
|
||||
String village;
|
||||
String neighbourhood;
|
||||
String religion;
|
||||
String maritalStatus;
|
||||
String job;
|
||||
String citizenship;
|
||||
String validUntil;
|
||||
String placeOfBirth;
|
||||
String dateOfBirth;
|
||||
String gender;
|
||||
String province;
|
||||
String district;
|
||||
String subDistrict;
|
||||
String postalCode;
|
||||
|
||||
KtpData({
|
||||
this.identificationNumber = '',
|
||||
this.fullName = '',
|
||||
this.bloodType = '',
|
||||
this.hamlet = '',
|
||||
this.village = '',
|
||||
this.neighbourhood = '',
|
||||
this.religion = '',
|
||||
this.maritalStatus = '',
|
||||
this.job = '',
|
||||
this.citizenship = '',
|
||||
this.validUntil = '',
|
||||
this.placeOfBirth = '',
|
||||
this.dateOfBirth = '',
|
||||
this.gender = '',
|
||||
this.province = '',
|
||||
this.district = '',
|
||||
this.subDistrict = '',
|
||||
this.postalCode = '',
|
||||
});
|
||||
|
||||
void clear() {
|
||||
identificationNumber = '';
|
||||
fullName = '';
|
||||
bloodType = '';
|
||||
hamlet = '';
|
||||
village = '';
|
||||
neighbourhood = '';
|
||||
religion = '';
|
||||
maritalStatus = '';
|
||||
job = '';
|
||||
citizenship = '';
|
||||
validUntil = '';
|
||||
placeOfBirth = '';
|
||||
dateOfBirth = '';
|
||||
gender = '';
|
||||
province = '';
|
||||
district = '';
|
||||
subDistrict = '';
|
||||
postalCode = '';
|
||||
}
|
||||
|
||||
bool get hasData => identificationNumber.isNotEmpty;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'identificationNumber': identificationNumber,
|
||||
'fullName': fullName,
|
||||
'bloodType': bloodType,
|
||||
'hamlet': hamlet,
|
||||
'village': village,
|
||||
'neighbourhood': neighbourhood,
|
||||
'religion': religion,
|
||||
'maritalStatus': maritalStatus,
|
||||
'job': job,
|
||||
'citizenship': citizenship,
|
||||
'validUntil': validUntil,
|
||||
'placeOfBirth': placeOfBirth,
|
||||
'dateOfBirth': dateOfBirth,
|
||||
'gender': gender,
|
||||
'province': province,
|
||||
'district': district,
|
||||
'subDistrict': subDistrict,
|
||||
'postalCode': postalCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class KtpValidatorController extends ChangeNotifier {
|
||||
static const int _maxImageSize = 2048;
|
||||
static const int _imageQuality = 85;
|
||||
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
final KtpData _ktpData = KtpData();
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
|
||||
File? _selectedImage;
|
||||
File? _processedImage;
|
||||
KtpValidationState _state = KtpValidationState.initial;
|
||||
ProcessingStatus _processingStatus = ProcessingStatus.idle;
|
||||
String _errorMessage = '';
|
||||
String _successMessage = '';
|
||||
TextRecognizer? _textRecognizer;
|
||||
|
||||
// Getters tetap sama
|
||||
KtpValidationState get state => _state;
|
||||
ProcessingStatus get processingStatus => _processingStatus;
|
||||
String get errorMessage => _errorMessage;
|
||||
String get successMessage => _successMessage;
|
||||
File? get selectedImage => _selectedImage;
|
||||
KtpData get ktpData => _ktpData;
|
||||
Map<String, TextEditingController> get controllers => _controllers;
|
||||
bool get isProcessing => _state == KtpValidationState.processing;
|
||||
bool get hasData => _ktpData.hasData;
|
||||
bool get canProcessImage => _selectedImage != null && !isProcessing;
|
||||
|
||||
void initialize() {
|
||||
_initializeControllers();
|
||||
_initializeTextRecognizer();
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
final fields = [
|
||||
'nik',
|
||||
'name',
|
||||
'placeOfBirth',
|
||||
'dateOfBirth',
|
||||
'gender',
|
||||
'bloodType',
|
||||
'hamlet',
|
||||
'village',
|
||||
'neighbourhood',
|
||||
'religion',
|
||||
'maritalStatus',
|
||||
'job',
|
||||
'citizenship',
|
||||
'validUntil',
|
||||
'province',
|
||||
'district',
|
||||
'subDistrict',
|
||||
'postalCode',
|
||||
];
|
||||
|
||||
for (String field in fields) {
|
||||
_controllers[field] = TextEditingController();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeTextRecognizer() {
|
||||
_textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
|
||||
}
|
||||
|
||||
Future<void> pickImageFromCamera() async {
|
||||
await _pickImage(ImageSource.camera);
|
||||
}
|
||||
|
||||
Future<void> pickImageFromGallery() async {
|
||||
await _pickImage(ImageSource.gallery);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: _imageQuality,
|
||||
maxWidth: _maxImageSize.toDouble(),
|
||||
maxHeight: _maxImageSize.toDouble(),
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
_cleanupTempFiles();
|
||||
_selectedImage = File(image.path);
|
||||
_processedImage = null;
|
||||
_ktpData.clear();
|
||||
_clearControllers();
|
||||
|
||||
_updateState(KtpValidationState.imageSelected);
|
||||
_clearMessages();
|
||||
}
|
||||
} catch (e) {
|
||||
_handleError('Error saat memilih gambar: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> processImage() async {
|
||||
if (_selectedImage == null || _textRecognizer == null) return;
|
||||
|
||||
_updateState(KtpValidationState.processing);
|
||||
_updateProcessingStatus(ProcessingStatus.preprocessing);
|
||||
_ktpData.clear();
|
||||
_clearControllers();
|
||||
|
||||
try {
|
||||
// Improved image processing
|
||||
final ProcessedImageResult result = await _processImageOptimized(
|
||||
_selectedImage!,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw Exception(result.error ?? 'Image processing failed');
|
||||
}
|
||||
|
||||
_processedImage = File(result.processedPath);
|
||||
_updateProcessingStatus(ProcessingStatus.extracting);
|
||||
|
||||
// Enhanced OCR with multiple attempts
|
||||
final String extractedNIK = await _performMultipleOCRAttempts(
|
||||
_processedImage!,
|
||||
);
|
||||
|
||||
if (extractedNIK.isEmpty) {
|
||||
throw Exception(
|
||||
'NIK tidak ditemukan. Pastikan foto KTP jelas dan tidak buram.',
|
||||
);
|
||||
}
|
||||
|
||||
_updateProcessingStatus(ProcessingStatus.validating);
|
||||
|
||||
await _parseNIK(extractedNIK);
|
||||
|
||||
_updateProcessingStatus(ProcessingStatus.completed);
|
||||
_updateControllers();
|
||||
_updateState(KtpValidationState.success);
|
||||
_setSuccessMessage('NIK berhasil terdeteksi: $extractedNIK');
|
||||
} catch (e) {
|
||||
_handleError('Error saat memproses gambar: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _performMultipleOCRAttempts(File processedImage) async {
|
||||
try {
|
||||
// Attempt 1: Original processed image
|
||||
String nik = await _performEnhancedOCR(processedImage);
|
||||
if (nik.isNotEmpty) {
|
||||
debugPrint('NIK found in attempt 1: $nik');
|
||||
return nik;
|
||||
}
|
||||
|
||||
// Attempt 2: Try with different image processing
|
||||
final File alternativeProcessed = await _createAlternativeProcessedImage(
|
||||
processedImage,
|
||||
);
|
||||
nik = await _performEnhancedOCR(alternativeProcessed);
|
||||
if (nik.isNotEmpty) {
|
||||
debugPrint('NIK found in attempt 2: $nik');
|
||||
return nik;
|
||||
}
|
||||
|
||||
// Attempt 3: Try with original image (no processing)
|
||||
nik = await _performEnhancedOCR(_selectedImage!);
|
||||
if (nik.isNotEmpty) {
|
||||
debugPrint('NIK found in attempt 3 (original): $nik');
|
||||
return nik;
|
||||
}
|
||||
|
||||
debugPrint('NIK not found in any attempt');
|
||||
return '';
|
||||
} catch (e) {
|
||||
debugPrint('Multiple OCR attempts error: $e');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _createAlternativeProcessedImage(File originalProcessed) async {
|
||||
try {
|
||||
final Uint8List imageBytes = await originalProcessed.readAsBytes();
|
||||
final img.Image? image = img.decodeImage(imageBytes);
|
||||
|
||||
if (image == null) return originalProcessed;
|
||||
|
||||
img.Image processed = image;
|
||||
|
||||
// Different processing approach
|
||||
processed = img.adjustColor(processed, contrast: 1.6, brightness: 1.2);
|
||||
processed = img.gaussianBlur(processed, radius: 1);
|
||||
processed = img.adjustColor(processed, contrast: 1.3);
|
||||
|
||||
// Apply threshold for better text recognition
|
||||
processed = _applyThreshold(processed, 128);
|
||||
|
||||
final String altProcessedPath = originalProcessed.path.replaceAll(
|
||||
'_processed.jpg',
|
||||
'_alt_processed.jpg',
|
||||
);
|
||||
final File altProcessedFile = File(altProcessedPath);
|
||||
await altProcessedFile.writeAsBytes(
|
||||
img.encodeJpg(processed, quality: 95),
|
||||
);
|
||||
|
||||
return altProcessedFile;
|
||||
} catch (e) {
|
||||
debugPrint('Alternative processing error: $e');
|
||||
return originalProcessed;
|
||||
}
|
||||
}
|
||||
|
||||
img.Image _applyThreshold(img.Image image, int threshold) {
|
||||
for (int y = 0; y < image.height; y++) {
|
||||
for (int x = 0; x < image.width; x++) {
|
||||
final pixel = image.getPixel(x, y);
|
||||
final gray = img.getLuminance(pixel);
|
||||
final newPixel =
|
||||
gray > threshold
|
||||
? img.ColorRgb8(255, 255, 255)
|
||||
: img.ColorRgb8(0, 0, 0);
|
||||
image.setPixel(x, y, newPixel);
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
Future<ProcessedImageResult> _processImageOptimized(File imageFile) async {
|
||||
try {
|
||||
final Uint8List imageBytes = await imageFile.readAsBytes();
|
||||
return await compute(
|
||||
_processImageInIsolate,
|
||||
ImageProcessingData(imageBytes, imageFile.path),
|
||||
);
|
||||
} catch (e) {
|
||||
return ProcessedImageResult(
|
||||
processedPath: imageFile.path,
|
||||
success: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _performEnhancedOCR(File imageFile) async {
|
||||
try {
|
||||
final InputImage inputImage = InputImage.fromFile(imageFile);
|
||||
final RecognizedText recognizedText = await _textRecognizer!.processImage(
|
||||
inputImage,
|
||||
);
|
||||
|
||||
// Debug: Print all detected text
|
||||
debugPrint('OCR Raw Text: ${recognizedText.text}');
|
||||
|
||||
// Print each text block for debugging
|
||||
for (TextBlock block in recognizedText.blocks) {
|
||||
debugPrint('Text Block: ${block.text}');
|
||||
for (TextLine line in block.lines) {
|
||||
debugPrint('Text Line: ${line.text}');
|
||||
}
|
||||
}
|
||||
|
||||
// Try multiple extraction methods
|
||||
String nik = _extractNIKEnhanced(recognizedText.text);
|
||||
if (nik.isNotEmpty) return nik;
|
||||
|
||||
nik = _extractNIKFromBlocks(recognizedText.blocks);
|
||||
if (nik.isNotEmpty) return nik;
|
||||
|
||||
nik = _extractNIKFallback(recognizedText.text);
|
||||
return nik;
|
||||
} catch (e) {
|
||||
debugPrint('OCR Error: $e');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// IMPROVED: Enhanced NIK extraction with better patterns
|
||||
String _extractNIKEnhanced(String ocrText) {
|
||||
final List<RegExp> nikPatterns = [
|
||||
// Standard 16 digits
|
||||
RegExp(r'\b(\d{16})\b'),
|
||||
// With spaces or separators
|
||||
RegExp(r'(\d{2}[\s\-_]?\d{2}[\s\-_]?\d{2}[\s\-_]?\d{6}[\s\-_]?\d{4})'),
|
||||
RegExp(r'(\d{4}[\s\-_]?\d{4}[\s\-_]?\d{4}[\s\-_]?\d{4})'),
|
||||
// More flexible patterns
|
||||
RegExp(r'(\d{2}\s*\d{2}\s*\d{2}\s*\d{6}\s*\d{4})'),
|
||||
RegExp(r'(\d{6}\s*\d{6}\s*\d{4})'),
|
||||
// Looking for NIK label followed by numbers
|
||||
RegExp(r'(?:NIK|No\.?\s*KTP)[\s:]*(\d{16})', caseSensitive: false),
|
||||
RegExp(
|
||||
r'(?:NIK|No\.?\s*KTP)[\s:]*(\d{2}[\s\-_]?\d{2}[\s\-_]?\d{2}[\s\-_]?\d{6}[\s\-_]?\d{4})',
|
||||
caseSensitive: false,
|
||||
),
|
||||
];
|
||||
|
||||
// Clean text for better matching
|
||||
String cleanText =
|
||||
ocrText
|
||||
.replaceAll(RegExp(r'[^\d\s\-_:]'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
debugPrint('Clean text for NIK extraction: $cleanText');
|
||||
|
||||
for (RegExp pattern in nikPatterns) {
|
||||
final Match? match = pattern.firstMatch(ocrText);
|
||||
if (match != null) {
|
||||
String candidate = match.group(1) ?? match.group(0)!;
|
||||
candidate = candidate.replaceAll(RegExp(r'[\s\-_]+'), '');
|
||||
|
||||
debugPrint('Pattern matched candidate: $candidate');
|
||||
|
||||
if (candidate.length == 16 && _isValidNIKFormat(candidate)) {
|
||||
debugPrint('Valid NIK found: $candidate');
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractNIKFromBlocks(List<TextBlock> blocks) {
|
||||
for (TextBlock block in blocks) {
|
||||
for (TextLine line in block.lines) {
|
||||
String lineText = line.text;
|
||||
debugPrint('Checking line: $lineText');
|
||||
|
||||
// Look for lines that might contain NIK
|
||||
if (lineText.toLowerCase().contains('nik') ||
|
||||
lineText.toLowerCase().contains('ktp') ||
|
||||
RegExp(r'\d{12,}').hasMatch(lineText)) {
|
||||
String nik = _extractNIKEnhanced(lineText);
|
||||
if (nik.isNotEmpty) {
|
||||
debugPrint('NIK found in line: $lineText -> $nik');
|
||||
return nik;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _extractNIKFallback(String ocrText) {
|
||||
final RegExp numberPattern = RegExp(r'\d+');
|
||||
final Iterable<Match> matches = numberPattern.allMatches(ocrText);
|
||||
|
||||
for (Match match in matches) {
|
||||
String candidate = match.group(0)!;
|
||||
|
||||
if (candidate.length == 16 && _isValidNIKFormat(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (candidate.length > 16) {
|
||||
for (int i = 0; i <= candidate.length - 16; i++) {
|
||||
String subCandidate = candidate.substring(i, i + 16);
|
||||
if (_isValidNIKFormat(subCandidate)) {
|
||||
return subCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
bool _isValidNIKFormat(String nik) {
|
||||
if (nik.length != 16) return false;
|
||||
|
||||
int provinceCode = int.tryParse(nik.substring(0, 2)) ?? 0;
|
||||
if (provinceCode < 11 || provinceCode > 94) return false;
|
||||
|
||||
int regencyCode = int.tryParse(nik.substring(2, 4)) ?? 0;
|
||||
if (regencyCode < 1 || regencyCode > 99) return false;
|
||||
|
||||
int districtCode = int.tryParse(nik.substring(4, 6)) ?? 0;
|
||||
if (districtCode < 1 || districtCode > 99) return false;
|
||||
|
||||
String birthDate = nik.substring(6, 12);
|
||||
int day = int.tryParse(birthDate.substring(0, 2)) ?? 0;
|
||||
int month = int.tryParse(birthDate.substring(2, 4)) ?? 0;
|
||||
|
||||
if (day > 40) day -= 40;
|
||||
|
||||
if (day < 1 || day > 31) return false;
|
||||
if (month < 1 || month > 12) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _parseNIK(String nik) async {
|
||||
try {
|
||||
final NIKModel result = await NIKValidator.instance.parse(nik: nik);
|
||||
|
||||
if (result.valid == true) {
|
||||
_ktpData.identificationNumber = result.nik ?? '';
|
||||
_ktpData.gender = result.gender ?? '';
|
||||
_ktpData.dateOfBirth = result.bornDate ?? '';
|
||||
_ktpData.province = result.province ?? '';
|
||||
_ktpData.district = result.city ?? '';
|
||||
_ktpData.subDistrict = result.subdistrict ?? '';
|
||||
_ktpData.postalCode = result.postalCode ?? '';
|
||||
} else {
|
||||
throw Exception('NIK tidak valid: $nik');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error saat memvalidasi NIK: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void updateControllerValue(String key, String value) {
|
||||
if (_controllers.containsKey(key)) {
|
||||
_controllers[key]?.text = value;
|
||||
}
|
||||
}
|
||||
|
||||
void _updateControllers() {
|
||||
_controllers['nik']?.text = _ktpData.identificationNumber;
|
||||
_controllers['name']?.text = _ktpData.fullName;
|
||||
_controllers['placeOfBirth']?.text = _ktpData.placeOfBirth;
|
||||
_controllers['dateOfBirth']?.text = _ktpData.dateOfBirth;
|
||||
_controllers['gender']?.text = _ktpData.gender;
|
||||
_controllers['bloodType']?.text = _ktpData.bloodType;
|
||||
_controllers['hamlet']?.text = _ktpData.hamlet;
|
||||
_controllers['village']?.text = _ktpData.village;
|
||||
_controllers['neighbourhood']?.text = _ktpData.neighbourhood;
|
||||
_controllers['religion']?.text = _ktpData.religion;
|
||||
_controllers['maritalStatus']?.text = _ktpData.maritalStatus;
|
||||
_controllers['job']?.text = _ktpData.job;
|
||||
_controllers['citizenship']?.text = _ktpData.citizenship;
|
||||
_controllers['validUntil']?.text = _ktpData.validUntil;
|
||||
_controllers['province']?.text = _ktpData.province;
|
||||
_controllers['district']?.text = _ktpData.district;
|
||||
_controllers['subDistrict']?.text = _ktpData.subDistrict;
|
||||
_controllers['postalCode']?.text = _ktpData.postalCode;
|
||||
}
|
||||
|
||||
void _clearControllers() {
|
||||
for (var controller in _controllers.values) {
|
||||
controller.clear();
|
||||
}
|
||||
}
|
||||
|
||||
bool validateForm() {
|
||||
final nikValue = _controllers['nik']?.text ?? '';
|
||||
final nameValue = _controllers['name']?.text ?? '';
|
||||
|
||||
if (nikValue.length != 16) {
|
||||
_handleError('NIK harus 16 digit');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nameValue.isEmpty) {
|
||||
_handleError('Nama lengkap harus diisi');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> saveData() async {
|
||||
if (!validateForm()) return false;
|
||||
|
||||
try {
|
||||
_ktpData.identificationNumber = _controllers['nik']?.text ?? '';
|
||||
_ktpData.fullName = _controllers['name']?.text ?? '';
|
||||
_ktpData.placeOfBirth = _controllers['placeOfBirth']?.text ?? '';
|
||||
_ktpData.dateOfBirth = _controllers['dateOfBirth']?.text ?? '';
|
||||
_ktpData.gender = _controllers['gender']?.text ?? '';
|
||||
_ktpData.bloodType = _controllers['bloodType']?.text ?? '';
|
||||
_ktpData.neighbourhood = _controllers['neighbourhood']?.text ?? '';
|
||||
_ktpData.village = _controllers['village']?.text ?? '';
|
||||
_ktpData.religion = _controllers['religion']?.text ?? '';
|
||||
_ktpData.maritalStatus = _controllers['maritalStatus']?.text ?? '';
|
||||
_ktpData.job = _controllers['job']?.text ?? '';
|
||||
_ktpData.citizenship = _controllers['citizenship']?.text ?? '';
|
||||
_ktpData.validUntil = _controllers['validUntil']?.text ?? '';
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
_setSuccessMessage('Data KTP berhasil disimpan!');
|
||||
debugPrint('KTP Data saved: ${_ktpData.toJson()}');
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_handleError('Error saat menyimpan data: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void _updateState(KtpValidationState newState) {
|
||||
_state = newState;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _updateProcessingStatus(ProcessingStatus status) {
|
||||
_processingStatus = status;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleError(String message) {
|
||||
_errorMessage = message;
|
||||
_successMessage = '';
|
||||
_updateState(KtpValidationState.error);
|
||||
debugPrint('KTP Validation Error: $message');
|
||||
}
|
||||
|
||||
void _setSuccessMessage(String message) {
|
||||
_successMessage = message;
|
||||
_errorMessage = '';
|
||||
}
|
||||
|
||||
void _clearMessages() {
|
||||
_errorMessage = '';
|
||||
_successMessage = '';
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_errorMessage = '';
|
||||
if (_state == KtpValidationState.error) {
|
||||
_updateState(
|
||||
_selectedImage != null
|
||||
? KtpValidationState.imageSelected
|
||||
: KtpValidationState.initial,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupTempFiles() {
|
||||
try {
|
||||
_processedImage?.deleteSync();
|
||||
} catch (e) {
|
||||
debugPrint('Error cleaning temp files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textRecognizer?.close();
|
||||
for (var controller in _controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
_cleanupTempFiles();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProcessedImageResult> _processImageInIsolate(
|
||||
ImageProcessingData data,
|
||||
) async {
|
||||
try {
|
||||
img.Image? image = img.decodeImage(data.imageBytes);
|
||||
if (image == null) {
|
||||
return ProcessedImageResult(
|
||||
processedPath: data.imagePath,
|
||||
success: false,
|
||||
error: 'Failed to decode image',
|
||||
);
|
||||
}
|
||||
|
||||
img.Image processed = image;
|
||||
|
||||
processed = img.grayscale(processed);
|
||||
|
||||
processed = img.adjustColor(processed, contrast: 1.4, brightness: 1.1);
|
||||
|
||||
processed = img.convolution(
|
||||
processed,
|
||||
filter: [0, -1, 0, -1, 5, -1, 0, -1, 0],
|
||||
);
|
||||
|
||||
if (processed.width < 1200) {
|
||||
processed = img.copyResize(processed, width: 1200);
|
||||
} else if (processed.width > 1600) {
|
||||
processed = img.copyResize(processed, width: 1600);
|
||||
}
|
||||
|
||||
processed = img.gaussianBlur(processed, radius: 1);
|
||||
|
||||
processed = img.adjustColor(processed, contrast: 1.2);
|
||||
|
||||
processed = img.normalize(processed, min: 0, max: 255);
|
||||
|
||||
final String processedPath = data.imagePath.replaceAll(
|
||||
'.jpg',
|
||||
'_processed.jpg',
|
||||
);
|
||||
final File processedFile = File(processedPath);
|
||||
await processedFile.writeAsBytes(img.encodeJpg(processed, quality: 95));
|
||||
|
||||
return ProcessedImageResult(processedPath: processedPath, success: true);
|
||||
} catch (e) {
|
||||
return ProcessedImageResult(
|
||||
processedPath: data.imagePath,
|
||||
success: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:pin_code_fields/pin_code_fields.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||
|
||||
class CverifOtpScreen extends StatefulWidget {
|
||||
const CverifOtpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CverifOtpScreen> createState() => _CotpScreenState();
|
||||
}
|
||||
|
||||
class _CotpScreenState extends State<CverifOtpScreen> {
|
||||
final TextEditingController _cOtpController = TextEditingController();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: PaddingCustom().paddingHorizontalVertical(15, 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("OTP has been sent to 6287874527xxxx"),
|
||||
SizedBox(height: 20),
|
||||
PinCodeTextField(
|
||||
controller: _cOtpController,
|
||||
appContext: context,
|
||||
length: 4,
|
||||
obscureText: false,
|
||||
animationType: AnimationType.fade,
|
||||
pinTheme: PinTheme(
|
||||
shape: PinCodeFieldShape.box,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
fieldHeight: 50,
|
||||
fieldWidth: 50,
|
||||
activeFillColor: whiteColor,
|
||||
inactiveFillColor: whiteColor,
|
||||
selectedFillColor: whiteColor,
|
||||
activeColor: blackNavyColor,
|
||||
inactiveColor: blackNavyColor,
|
||||
selectedColor: primaryColor,
|
||||
),
|
||||
onChanged: (value) {},
|
||||
onCompleted: (value) {},
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
|
||||
CardButtonOne(
|
||||
textButton: "lanjut",
|
||||
|
||||
fontSized: 16.sp,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
onTap: () {
|
||||
router.go("/verifidentity");
|
||||
},
|
||||
|
||||
usingRow: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,684 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:google_fonts/google_fonts.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/widget/buttoncard.dart';
|
||||
import 'package:rijig_mobile/widget/formfiled.dart';
|
||||
import 'package:rijig_mobile/features/auth/presentation/screen/collector/controller/ktp_validator_controller.dart';
|
||||
|
||||
class IdentityValidationScreen extends StatefulWidget {
|
||||
const IdentityValidationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<IdentityValidationScreen> createState() =>
|
||||
_IdentityValidationScreenState();
|
||||
}
|
||||
|
||||
class _IdentityValidationScreenState extends State<IdentityValidationScreen>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
late KtpValidatorController _controller;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = context.read<KtpValidatorController>();
|
||||
_controller.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: whiteColor,
|
||||
appBar: _buildAppBar(),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar() {
|
||||
return AppBar(
|
||||
title: Text(
|
||||
'Validasi Identitas KTP',
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: bold,
|
||||
color: whiteColor,
|
||||
),
|
||||
),
|
||||
backgroundColor: primaryColor,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back_ios, color: whiteColor),
|
||||
onPressed: () => router.pop(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return Consumer<KtpValidatorController>(
|
||||
builder: (context, controller, child) {
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.all(20.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildImageUploadSection(controller),
|
||||
SizedBox(height: 24.h),
|
||||
if (controller.hasData) _buildFormSection(controller),
|
||||
_buildMessages(controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageUploadSection(KtpValidatorController controller) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(20.w),
|
||||
decoration: BoxDecoration(
|
||||
color: whiteColor,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildUploadHeader(),
|
||||
SizedBox(height: 20.h),
|
||||
if (controller.selectedImage != null)
|
||||
_buildImagePreview(controller.selectedImage!),
|
||||
SizedBox(height: 20.h),
|
||||
_buildImageSourceButtons(controller),
|
||||
SizedBox(height: 20.h),
|
||||
_buildProcessButton(controller),
|
||||
if (controller.isProcessing) _buildProcessingIndicator(controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Icon(Icons.camera_alt, color: primaryColor, size: 24.w),
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Upload Foto KTP',
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: bold,
|
||||
color: blackNavyColor,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'Pastikan foto jelas dan tidak buram',
|
||||
style: GoogleFonts.dmSans(fontSize: 12.sp, color: greyColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePreview(File image) {
|
||||
return Container(
|
||||
height: 180.h,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(color: greyColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: Image.file(image, fit: BoxFit.cover),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageSourceButtons(KtpValidatorController controller) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CardButtonOne(
|
||||
textButton: 'Kamera',
|
||||
fontSized: 14,
|
||||
colorText: primaryColor,
|
||||
borderRadius: 12,
|
||||
horizontal: double.infinity,
|
||||
vertical: 48.h,
|
||||
color: whiteColor,
|
||||
borderAll: Border.all(color: primaryColor, width: 1),
|
||||
onTap: controller.pickImageFromCamera,
|
||||
usingRow: true,
|
||||
child: Icon(Icons.camera_alt, color: primaryColor, size: 18.w),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: CardButtonOne(
|
||||
textButton: 'Galeri',
|
||||
fontSized: 14,
|
||||
colorText: primaryColor,
|
||||
borderRadius: 12,
|
||||
horizontal: double.infinity,
|
||||
vertical: 48.h,
|
||||
color: whiteColor,
|
||||
borderAll: Border.all(color: primaryColor, width: 1),
|
||||
onTap: controller.pickImageFromGallery,
|
||||
usingRow: true,
|
||||
child: Icon(Icons.photo_library, color: primaryColor, size: 18.w),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessButton(KtpValidatorController controller) {
|
||||
return CardButtonOne(
|
||||
textButton: controller.isProcessing ? 'Memproses...' : 'SCAN KTP',
|
||||
fontSized: 16,
|
||||
colorText: whiteColor,
|
||||
borderRadius: 12,
|
||||
horizontal: double.infinity,
|
||||
vertical: 52.h,
|
||||
color: controller.canProcessImage ? primaryColor : greyColor,
|
||||
loadingTrue: controller.isProcessing,
|
||||
onTap: controller.canProcessImage ? controller.processImage : () {},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessingIndicator(KtpValidatorController controller) {
|
||||
String statusText = '';
|
||||
double progress = 0.0;
|
||||
|
||||
switch (controller.processingStatus) {
|
||||
case ProcessingStatus.preprocessing:
|
||||
statusText = 'Memproses gambar...';
|
||||
progress = 0.25;
|
||||
break;
|
||||
case ProcessingStatus.extracting:
|
||||
statusText = 'Membaca teks...';
|
||||
progress = 0.5;
|
||||
break;
|
||||
case ProcessingStatus.validating:
|
||||
statusText = 'Validasi NIK...';
|
||||
progress = 0.75;
|
||||
break;
|
||||
case ProcessingStatus.completed:
|
||||
statusText = 'Selesai!';
|
||||
progress = 1.0;
|
||||
break;
|
||||
default:
|
||||
statusText = 'Memulai...';
|
||||
progress = 0.1;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 16.h),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
statusText,
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: medium,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: greyColor.withValues(alpha: 0.3),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormSection(KtpValidatorController controller) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(20.w),
|
||||
decoration: BoxDecoration(
|
||||
color: whiteColor,
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFormHeader(),
|
||||
SizedBox(height: 24.h),
|
||||
..._buildFormFields(controller),
|
||||
SizedBox(height: 24.h),
|
||||
_buildSaveButton(controller),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Icon(Icons.edit_document, color: Colors.green, size: 24.w),
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Data KTP Terdeteksi',
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: bold,
|
||||
color: blackNavyColor,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
'Silakan periksa dan edit data jika diperlukan',
|
||||
style: GoogleFonts.dmSans(fontSize: 12.sp, color: greyColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildFormFields(KtpValidatorController controller) {
|
||||
return [
|
||||
_buildSectionTitle('Data Utama'),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'NIK',
|
||||
controllers: controller.controllers['nik'],
|
||||
isRequired: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 16,
|
||||
onTap: () {},
|
||||
validator: (value) {
|
||||
if (value == null || value.length != 16) {
|
||||
return 'NIK harus 16 digit';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'Nama Lengkap',
|
||||
controllers: controller.controllers['name'],
|
||||
isRequired: true,
|
||||
onTap: () {},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Nama lengkap harus diisi';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
_buildSectionTitle('Informasi Personal'),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Tempat Lahir',
|
||||
controllers: controller.controllers['placeOfBirth'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Tanggal Lahir',
|
||||
controllers: controller.controllers['dateOfBirth'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'DD-MM-YYYY',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Jenis Kelamin',
|
||||
controllers: controller.controllers['gender'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'LAKI-LAKI / PEREMPUAN',
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Golongan Darah',
|
||||
controllers: controller.controllers['bloodType'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'A, B, AB, O',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
_buildSectionTitle('Alamat'),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'RT/RW',
|
||||
controllers: controller.controllers['neighbourhood'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'Contoh: 001/002',
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Desa/Kelurahan',
|
||||
controllers: controller.controllers['village'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Kecamatan',
|
||||
controllers: controller.controllers['subDistrict'],
|
||||
isRequired: false,
|
||||
enabled: false,
|
||||
inputColor: greyColor.withValues(alpha: 0.1),
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
_buildSectionTitle('Informasi Lainnya'),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'Agama',
|
||||
controllers: controller.controllers['religion'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'Status Perkawinan',
|
||||
controllers: controller.controllers['maritalStatus'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'BELUM KAWIN / KAWIN / CERAI',
|
||||
),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'Pekerjaan',
|
||||
controllers: controller.controllers['job'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Kewarganegaraan',
|
||||
controllers: controller.controllers['citizenship'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'WNI / WNA',
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Berlaku Hingga',
|
||||
controllers: controller.controllers['validUntil'],
|
||||
isRequired: false,
|
||||
onTap: () {},
|
||||
placeholder: 'SEUMUR HIDUP',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 20.h),
|
||||
_buildSectionTitle('Data dari NIK Validator'),
|
||||
SizedBox(height: 12.h),
|
||||
|
||||
FormFieldOne(
|
||||
hintText: 'Provinsi',
|
||||
controllers: controller.controllers['province'],
|
||||
isRequired: false,
|
||||
enabled: false,
|
||||
inputColor: greyColor.withValues(alpha: 0.1),
|
||||
onTap: () {},
|
||||
),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Kabupaten/Kota',
|
||||
controllers: controller.controllers['district'],
|
||||
isRequired: false,
|
||||
enabled: false,
|
||||
inputColor: greyColor.withValues(alpha: 0.1),
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: FormFieldOne(
|
||||
hintText: 'Kode Pos',
|
||||
controllers: controller.controllers['postalCode'],
|
||||
isRequired: false,
|
||||
enabled: false,
|
||||
inputColor: greyColor.withValues(alpha: 0.1),
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: bold,
|
||||
color: blackNavyColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSaveButton(KtpValidatorController controller) {
|
||||
return CardButtonOne(
|
||||
textButton: 'SIMPAN DATA KTP',
|
||||
fontSized: 16,
|
||||
colorText: whiteColor,
|
||||
borderRadius: 12,
|
||||
horizontal: double.infinity,
|
||||
vertical: 52.h,
|
||||
color: primaryColor,
|
||||
onTap: () => _handleSaveData(controller),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessages(KtpValidatorController controller) {
|
||||
if (controller.errorMessage.isNotEmpty) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 16.h),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: redColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(color: redColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: redColor, size: 20.w),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.errorMessage,
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 14.sp,
|
||||
color: redColor,
|
||||
fontWeight: medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: controller.clearError,
|
||||
icon: Icon(Icons.close, color: redColor, size: 20.w),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.successMessage.isNotEmpty) {
|
||||
return Container(
|
||||
margin: EdgeInsets.only(top: 16.h),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(color: Colors.green.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, color: Colors.green, size: 20.w),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.successMessage,
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.green,
|
||||
fontWeight: medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Future<void> _handleSaveData(KtpValidatorController controller) async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final success = await controller.saveData();
|
||||
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: whiteColor),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Data KTP berhasil disimpan!',
|
||||
style: GoogleFonts.dmSans(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: medium,
|
||||
color: whiteColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UploadKtpScreen extends StatelessWidget {
|
||||
const UploadKtpScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (context) => KtpValidatorController(),
|
||||
child: const IdentityValidationScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.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/buttoncard.dart';
|
||||
|
||||
class WelcomeCollectorScreen extends StatefulWidget {
|
||||
const WelcomeCollectorScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WelcomeCollectorScreen> createState() => _WelcomeSeekJobScreenState();
|
||||
}
|
||||
|
||||
class _WelcomeSeekJobScreenState extends State<WelcomeCollectorScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: whiteColor,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: PaddingCustom().paddingHorizontal(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/image/welcome_collector.png',
|
||||
width: mediaQuery.size.width * 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
Gap(20),
|
||||
Text(
|
||||
'Selamat datang pengepul!',
|
||||
style: Tulisan.heading(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Gap(15),
|
||||
Text(
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
style: Tulisan.customText(
|
||||
color: greyAbsolutColor,
|
||||
fontsize: 14.sp,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Spacer(),
|
||||
CardButtonOne(
|
||||
textButton: "Lanjut",
|
||||
fontSized: 16.sp,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
onTap: () {
|
||||
router.push("/clogin");
|
||||
},
|
||||
usingRow: false,
|
||||
),
|
||||
Gap(30),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import 'package:provider/provider.dart';
|
||||
// import 'package:rijig_mobile/core/router.dart';
|
||||
// import 'package:rijig_mobile/features/auth/presentation/viewmodel/userpin_vmod.dart';
|
||||
|
||||
// class InputPinScreen extends StatefulWidget {
|
||||
// const InputPinScreen({super.key});
|
||||
|
||||
// @override
|
||||
// InputPinScreenState createState() => InputPinScreenState();
|
||||
// }
|
||||
|
||||
// class InputPinScreenState extends State<InputPinScreen> {
|
||||
// final _pinController = TextEditingController();
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// final pinViewModel = Provider.of<PinViewModel>(context);
|
||||
|
||||
// return Scaffold(
|
||||
// appBar: AppBar(title: Text("Buat PIN Baru")),
|
||||
// body: Padding(
|
||||
// padding: const EdgeInsets.all(16.0),
|
||||
// child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// children: [
|
||||
// Text("Buat PIN Anda (6 digit)", style: TextStyle(fontSize: 18)),
|
||||
// SizedBox(height: 20),
|
||||
// TextField(
|
||||
// controller: _pinController,
|
||||
// decoration: InputDecoration(labelText: "PIN"),
|
||||
// keyboardType: TextInputType.number,
|
||||
// obscureText: true,
|
||||
// ),
|
||||
// SizedBox(height: 20),
|
||||
// ElevatedButton(
|
||||
// onPressed: () async {
|
||||
// String pin = _pinController.text;
|
||||
|
||||
// await pinViewModel.createPin(pin);
|
||||
// if (pinViewModel.pinExists == true) {
|
||||
// router.go('/navigasi');
|
||||
// } else {
|
||||
// ScaffoldMessenger.of(
|
||||
// context,
|
||||
// ).showSnackBar(SnackBar(content: Text('Gagal membuat PIN')));
|
||||
// }
|
||||
// },
|
||||
// child: Text("Buat PIN"),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
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/login_vmod.dart';
|
||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||
import 'package:rijig_mobile/widget/formfiled.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
LoginScreenState createState() => LoginScreenState();
|
||||
}
|
||||
|
||||
class LoginScreenState extends State<LoginScreen> {
|
||||
final TextEditingController phoneController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
body: Consumer<LoginViewModel>(
|
||||
builder: (context, viewModel, child) {
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: PaddingCustom().paddingHorizontalVertical(15, 40),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight:
|
||||
mediaQuery.size.height * 1 / 4 -
|
||||
mediaQuery.padding.top -
|
||||
mediaQuery.padding.bottom,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
"Selamat datang di aplikasi",
|
||||
style: Tulisan.subheading(),
|
||||
),
|
||||
Text(
|
||||
"Rijig",
|
||||
style: Tulisan.heading(color: primaryColor),
|
||||
),
|
||||
SizedBox(height: mediaQuery.size.height * 0.1),
|
||||
Image.asset(
|
||||
'assets/image/security.png',
|
||||
width: mediaQuery.size.width * 0.35,
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
FormFieldOne(
|
||||
controllers: phoneController,
|
||||
hintText: 'Masukkan nomor whatsapp anda!',
|
||||
placeholder: "cth.62..",
|
||||
isRequired: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.phone,
|
||||
onTap: () {},
|
||||
onChanged: (value) {},
|
||||
fontSize: 14,
|
||||
fontSizeField: 16,
|
||||
onFieldSubmitted: (value) {},
|
||||
readOnly: false,
|
||||
enabled: true,
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
CardButtonOne(
|
||||
textButton:
|
||||
viewModel.isLoading
|
||||
? 'Sending OTP...'
|
||||
: 'Send OTP',
|
||||
fontSized: 16,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
onTap: () async {
|
||||
if (phoneController.text.isNotEmpty) {
|
||||
debugPrint("send otp dipencet");
|
||||
await viewModel.loginOrRegister(
|
||||
phoneController.text,
|
||||
);
|
||||
if (viewModel.loginResponse != null) {
|
||||
router.go(
|
||||
"/verif-otp",
|
||||
extra: phoneController.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
loadingTrue: viewModel.isLoading,
|
||||
usingRow: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("login sebagai:"),
|
||||
TextButton(
|
||||
onPressed: () => router.push('/welcomec'),
|
||||
child: Text("pengepul?"),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => router.push('/navigasi'),
|
||||
child: Text("skip login"),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:pin_code_fields/pin_code_fields.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/otp_vmod.dart';
|
||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||
|
||||
class VerifOtpScreen extends StatefulWidget {
|
||||
final String phoneNumber;
|
||||
|
||||
const VerifOtpScreen({super.key, required this.phoneNumber});
|
||||
|
||||
@override
|
||||
VerifOtpScreenState createState() => VerifOtpScreenState();
|
||||
}
|
||||
|
||||
class VerifOtpScreenState extends State<VerifOtpScreen> {
|
||||
final TextEditingController _otpController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Consumer<OtpViewModel>(
|
||||
builder: (context, viewModel, child) {
|
||||
return SafeArea(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: PaddingCustom().paddingHorizontalVertical(15, 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("OTP has been sent to ${widget.phoneNumber}"),
|
||||
SizedBox(height: 20),
|
||||
|
||||
PinCodeTextField(
|
||||
controller: _otpController,
|
||||
appContext: context,
|
||||
length: 4,
|
||||
obscureText: false,
|
||||
animationType: AnimationType.fade,
|
||||
pinTheme: PinTheme(
|
||||
shape: PinCodeFieldShape.box,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
fieldHeight: 50,
|
||||
fieldWidth: 50,
|
||||
activeFillColor: whiteColor,
|
||||
inactiveFillColor: whiteColor,
|
||||
selectedFillColor: whiteColor,
|
||||
activeColor: blackNavyColor,
|
||||
inactiveColor: blackNavyColor,
|
||||
selectedColor: primaryColor,
|
||||
),
|
||||
onChanged: (value) {},
|
||||
onCompleted: (value) {},
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
|
||||
CardButtonOne(
|
||||
textButton:
|
||||
viewModel.isLoading
|
||||
? 'Verifying OTP...'
|
||||
: 'Verify OTP',
|
||||
fontSized: 16,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
onTap: () async {
|
||||
if (_otpController.text.isNotEmpty) {
|
||||
await viewModel.verifyOtp(
|
||||
widget.phoneNumber,
|
||||
_otpController.text,
|
||||
);
|
||||
if (viewModel.authResponse != null) {
|
||||
router.go("/navigasi");
|
||||
}
|
||||
}
|
||||
},
|
||||
loadingTrue: viewModel.isLoading,
|
||||
usingRow: false,
|
||||
),
|
||||
|
||||
if (viewModel.errorMessage != null)
|
||||
Text(
|
||||
viewModel.errorMessage!,
|
||||
style: TextStyle(color: redColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import 'package:provider/provider.dart';
|
||||
// import 'package:rijig_mobile/core/router.dart';
|
||||
// import 'package:rijig_mobile/features/auth/presentation/viewmodel/userpin_vmod.dart';
|
||||
|
||||
// class VerifPinScreen extends StatefulWidget {
|
||||
// const VerifPinScreen({super.key});
|
||||
|
||||
// @override
|
||||
// VerifPinScreenState createState() => VerifPinScreenState();
|
||||
// }
|
||||
|
||||
// class VerifPinScreenState extends State<VerifPinScreen> {
|
||||
// final _pinController = TextEditingController();
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// final pinViewModel = Provider.of<PinViewModel>(context);
|
||||
|
||||
// return Scaffold(
|
||||
// appBar: AppBar(title: Text("Verifikasi PIN")),
|
||||
// body: Padding(
|
||||
// padding: const EdgeInsets.all(16.0),
|
||||
// child: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// children: [
|
||||
// Text(
|
||||
// "Masukkan PIN yang sudah dibuat",
|
||||
// style: TextStyle(fontSize: 18),
|
||||
// ),
|
||||
// SizedBox(height: 20),
|
||||
// TextField(
|
||||
// controller: _pinController,
|
||||
// decoration: InputDecoration(labelText: "PIN"),
|
||||
// keyboardType: TextInputType.number,
|
||||
// obscureText: true,
|
||||
// ),
|
||||
// SizedBox(height: 20),
|
||||
// ElevatedButton(
|
||||
// onPressed: () async {
|
||||
// String pin = _pinController.text;
|
||||
|
||||
// await pinViewModel.verifyPin(pin);
|
||||
// if (pinViewModel.pinExists == true) {
|
||||
// router.go('/navigasi');
|
||||
// } else {
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(content: Text('PIN yang anda masukkan salah')),
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// child: Text("Verifikasi PIN"),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/features/auth/model/login_model.dart';
|
||||
import 'package:rijig_mobile/features/auth/service/login_service.dart';
|
||||
|
||||
class LoginViewModel extends ChangeNotifier {
|
||||
final LoginService _loginService;
|
||||
|
||||
LoginViewModel(this._loginService);
|
||||
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
LoginResponse? loginResponse;
|
||||
|
||||
Future<void> loginOrRegister(String phone) async {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
loginResponse = await _loginService.loginOrRegister(phone);
|
||||
} catch (e) {
|
||||
errorMessage = "Error: ${e.toString()}";
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/features/auth/service/logout_service.dart';
|
||||
|
||||
class LogoutViewModel extends ChangeNotifier {
|
||||
final LogoutService _logoutService;
|
||||
|
||||
LogoutViewModel(this._logoutService);
|
||||
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
|
||||
Future<void> logout() async {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _logoutService.logout();
|
||||
} catch (e) {
|
||||
errorMessage = "Error: ${e.toString()}";
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/features/auth/model/otp_model.dart';
|
||||
import 'package:rijig_mobile/features/auth/service/otp_service.dart';
|
||||
|
||||
class OtpViewModel extends ChangeNotifier {
|
||||
final OtpService _otpService;
|
||||
|
||||
OtpViewModel(this._otpService);
|
||||
|
||||
bool isLoading = false;
|
||||
String? errorMessage;
|
||||
VerifOkResponse? authResponse;
|
||||
|
||||
Future<void> verifyOtp(String phone, String otp) async {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
String deviceId = await _otpService.getDeviceInfo();
|
||||
|
||||
OtpModel otpModel = OtpModel(phone: phone, otp: otp, deviceId: deviceId);
|
||||
|
||||
authResponse = await _otpService.verifyOtp(otpModel);
|
||||
|
||||
if (authResponse != null) {
|
||||
await _otpService.storeSessionData(
|
||||
authResponse!.token,
|
||||
authResponse!.userId,
|
||||
authResponse!.userRole,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage = "Error: ${e.toString()}";
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
// import 'package:flutter/material.dart';
|
||||
// import 'package:rijig_mobile/features/auth/model/userpin_model.dart';
|
||||
|
||||
// class PinViewModel extends ChangeNotifier {
|
||||
// final PinModel _pinModel = PinModel();
|
||||
// bool? pinExists;
|
||||
|
||||
// Future<void> checkPinStatus(String userId) async {
|
||||
// try {
|
||||
// var response = await _pinModel.checkPinStatus(userId);
|
||||
|
||||
// if (response?.status == 200) {
|
||||
// pinExists = true;
|
||||
// } else {
|
||||
// pinExists = false;
|
||||
// }
|
||||
// notifyListeners();
|
||||
// } catch (e) {
|
||||
// debugPrint('Error checking pin status: $e');
|
||||
// pinExists = false;
|
||||
// notifyListeners();
|
||||
// }
|
||||
// }
|
||||
|
||||
// Future<void> createPin(String pin) async {
|
||||
// try {
|
||||
// var response = await _pinModel.setPin(pin);
|
||||
|
||||
// if (response?.status == 201) {
|
||||
// pinExists = true;
|
||||
// } else {
|
||||
// pinExists = false;
|
||||
// }
|
||||
// notifyListeners();
|
||||
// } catch (e) {
|
||||
// debugPrint('Error creating pin: $e');
|
||||
// pinExists = false;
|
||||
// notifyListeners();
|
||||
// }
|
||||
// }
|
||||
|
||||
// Future<void> verifyPin(String pin) async {
|
||||
// try {
|
||||
// var response = await _pinModel.verifyPin(pin);
|
||||
|
||||
// if (response?.status == 200) {
|
||||
// pinExists = true;
|
||||
// } else {
|
||||
// pinExists = false;
|
||||
// }
|
||||
// notifyListeners();
|
||||
// } catch (e) {
|
||||
// debugPrint('Error verifying pin: $e');
|
||||
// pinExists = false;
|
||||
// notifyListeners();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:rijig_mobile/core/api/api_services.dart';
|
||||
import 'package:rijig_mobile/features/auth/model/login_model.dart';
|
||||
|
||||
class LoginRepository {
|
||||
final Https _https = Https();
|
||||
|
||||
Future<LoginResponse> loginOrRegister(String phone) async {
|
||||
final response = await _https.post(
|
||||
'/authmasyarakat/auth',
|
||||
body: {'phone': phone},
|
||||
);
|
||||
return LoginResponse.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import 'package:rijig_mobile/core/api/api_services.dart';
|
||||
import 'package:rijig_mobile/features/auth/model/logout_model.dart';
|
||||
|
||||
class LogoutRepository {
|
||||
final Https _https = Https();
|
||||
|
||||
Future<LogoutResponse> logout() async {
|
||||
final response = await _https.post('/authmasyarakat/logout');
|
||||
return LogoutResponse.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import 'package:rijig_mobile/core/api/api_services.dart';
|
||||
import 'package:rijig_mobile/features/auth/model/otp_model.dart';
|
||||
|
||||
class OtpRepository {
|
||||
final Https _https = Https();
|
||||
|
||||
Future<VerifOkResponse> verifyOtp(OtpModel otpModel) async {
|
||||
final response = await _https.post(
|
||||
'/authmasyarakat/verify-otp',
|
||||
body: otpModel.toJson(),
|
||||
);
|
||||
return VerifOkResponse.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:rijig_mobile/features/auth/model/login_model.dart';
|
||||
import 'package:rijig_mobile/features/auth/repositories/login_repository.dart';
|
||||
|
||||
class LoginService {
|
||||
final LoginRepository _loginRepository;
|
||||
|
||||
LoginService(this._loginRepository);
|
||||
|
||||
Future<LoginResponse> loginOrRegister(String phone) async {
|
||||
try {
|
||||
return await _loginRepository.loginOrRegister(phone);
|
||||
} catch (e) {
|
||||
throw Exception('Login failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:rijig_mobile/core/storage/secure_storage.dart';
|
||||
import 'package:rijig_mobile/features/auth/repositories/logout_repository.dart';
|
||||
|
||||
class LogoutService {
|
||||
final LogoutRepository _logoutRepository;
|
||||
final _storage = SecureStorage();
|
||||
|
||||
LogoutService(this._logoutRepository);
|
||||
|
||||
Future<void> clearSession() async {
|
||||
await _storage.deleteSecureData('token');
|
||||
await _storage.deleteSecureData('user_id');
|
||||
await _storage.deleteSecureData('user_role');
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _logoutRepository.logout();
|
||||
await clearSession();
|
||||
} catch (e) {
|
||||
throw Exception('Logout failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:rijig_mobile/core/storage/secure_storage.dart';
|
||||
import 'package:rijig_mobile/core/utils/getinfodevice.dart';
|
||||
import 'package:rijig_mobile/features/auth/model/otp_model.dart';
|
||||
import 'package:rijig_mobile/features/auth/repositories/otp_repository.dart';
|
||||
|
||||
class OtpService {
|
||||
final OtpRepository _otpRepository;
|
||||
// final SecureStorage _secureStorage = SecureStorage();
|
||||
|
||||
OtpService(this._otpRepository);
|
||||
|
||||
Future<String> getDeviceInfo() async {
|
||||
return await getDeviceId();
|
||||
}
|
||||
|
||||
Future<void> storeSessionData(
|
||||
String token,
|
||||
String userId,
|
||||
String userRole,
|
||||
) async {
|
||||
await SecureStorage().writeSecureData('token', token);
|
||||
await SecureStorage().writeSecureData('user_id', userId);
|
||||
await SecureStorage().writeSecureData('user_role', userRole);
|
||||
}
|
||||
|
||||
Future<VerifOkResponse> verifyOtp(OtpModel otpModel) async {
|
||||
try {
|
||||
return await _otpRepository.verifyOtp(otpModel);
|
||||
} catch (e) {
|
||||
throw Exception('OTP Verification failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
class CartItem {
|
||||
final String id;
|
||||
final String trashId;
|
||||
final String trashName;
|
||||
final String trashIcon;
|
||||
final double trashPrice;
|
||||
final int amount;
|
||||
final double subtotalEstimatedPrice;
|
||||
|
||||
CartItem({
|
||||
required this.id,
|
||||
required this.trashId,
|
||||
required this.trashName,
|
||||
required this.trashIcon,
|
||||
required this.trashPrice,
|
||||
required this.amount,
|
||||
required this.subtotalEstimatedPrice,
|
||||
});
|
||||
|
||||
factory CartItem.fromJson(Map<String, dynamic> json) {
|
||||
return CartItem(
|
||||
id: json['id'] ?? '',
|
||||
trashId: json['trash_id'] ?? '',
|
||||
trashName: json['trash_name'] ?? '',
|
||||
trashIcon: json['trash_icon'] ?? '',
|
||||
trashPrice: (json['trash_price'] ?? 0).toDouble(),
|
||||
amount: json['amount'] ?? 0,
|
||||
subtotalEstimatedPrice:
|
||||
(json['subtotal_estimated_price'] ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'trash_id': trashId,
|
||||
'trash_name': trashName,
|
||||
'trash_icon': trashIcon,
|
||||
'trash_price': trashPrice,
|
||||
'amount': amount,
|
||||
'subtotal_estimated_price': subtotalEstimatedPrice,
|
||||
};
|
||||
}
|
||||
|
||||
CartItem copyWith({
|
||||
String? id,
|
||||
String? trashId,
|
||||
String? trashName,
|
||||
String? trashIcon,
|
||||
double? trashPrice,
|
||||
int? amount,
|
||||
double? subtotalEstimatedPrice,
|
||||
}) {
|
||||
return CartItem(
|
||||
id: id ?? this.id,
|
||||
trashId: trashId ?? this.trashId,
|
||||
trashName: trashName ?? this.trashName,
|
||||
trashIcon: trashIcon ?? this.trashIcon,
|
||||
trashPrice: trashPrice ?? this.trashPrice,
|
||||
amount: amount ?? this.amount,
|
||||
subtotalEstimatedPrice:
|
||||
subtotalEstimatedPrice ?? this.subtotalEstimatedPrice,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CartItem(id: $id, trashId: $trashId, trashName: $trashName, amount: $amount, subtotalEstimatedPrice: $subtotalEstimatedPrice)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is CartItem && other.trashId == trashId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => trashId.hashCode;
|
||||
}
|
||||
|
||||
class Cart {
|
||||
final String id;
|
||||
final String userId;
|
||||
final int totalAmount;
|
||||
final double estimatedTotalPrice;
|
||||
final List<CartItem> cartItems;
|
||||
|
||||
Cart({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.totalAmount,
|
||||
required this.estimatedTotalPrice,
|
||||
required this.cartItems,
|
||||
});
|
||||
|
||||
factory Cart.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] ?? {};
|
||||
return Cart(
|
||||
id: data['id'] ?? '',
|
||||
userId: data['user_id'] ?? '',
|
||||
totalAmount: data['total_amount'] ?? 0,
|
||||
estimatedTotalPrice: (data['estimated_total_price'] ?? 0).toDouble(),
|
||||
cartItems:
|
||||
(data['cart_items'] as List<dynamic>?)
|
||||
?.map((item) => CartItem.fromJson(item))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'data': {
|
||||
'id': id,
|
||||
'user_id': userId,
|
||||
'total_amount': totalAmount,
|
||||
'estimated_total_price': estimatedTotalPrice,
|
||||
'cart_items': cartItems.map((item) => item.toJson()).toList(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Cart copyWith({
|
||||
String? id,
|
||||
String? userId,
|
||||
int? totalAmount,
|
||||
double? estimatedTotalPrice,
|
||||
List<CartItem>? cartItems,
|
||||
}) {
|
||||
return Cart(
|
||||
id: id ?? this.id,
|
||||
userId: userId ?? this.userId,
|
||||
totalAmount: totalAmount ?? this.totalAmount,
|
||||
estimatedTotalPrice: estimatedTotalPrice ?? this.estimatedTotalPrice,
|
||||
cartItems: cartItems ?? this.cartItems,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty => cartItems.isEmpty;
|
||||
bool get isNotEmpty => cartItems.isNotEmpty;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'Cart(id: $id, userId: $userId, totalAmount: $totalAmount, estimatedTotalPrice: $estimatedTotalPrice, cartItems: ${cartItems.length})';
|
||||
}
|
||||
}
|
||||
|
||||
class AddOrUpdateCartRequest {
|
||||
final String trashId;
|
||||
final int amount;
|
||||
|
||||
AddOrUpdateCartRequest({required this.trashId, required this.amount});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'trash_id': trashId, 'amount': amount};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AddOrUpdateCartRequest(trashId: $trashId, amount: $amount)';
|
||||
}
|
||||
}
|
||||
|
||||
class CartApiResponse<T> {
|
||||
final int status;
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
CartApiResponse({required this.status, required this.message, this.data});
|
||||
|
||||
factory CartApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Map<String, dynamic>)? fromJsonT,
|
||||
) {
|
||||
return CartApiResponse<T>(
|
||||
status: json['meta']?['status'] ?? 0,
|
||||
message: json['meta']?['message'] ?? '',
|
||||
data: json['data'] != null && fromJsonT != null ? fromJsonT(json) : null,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isSuccess => status >= 200 && status < 300;
|
||||
}
|
||||
|
|
@ -0,0 +1,913 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:custom_refresh_indicator/custom_refresh_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/features/cart/presentation/viewmodel/trashcart_vmod.dart';
|
||||
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
|
||||
|
||||
class OrderSummaryScreen extends StatefulWidget {
|
||||
const OrderSummaryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OrderSummaryScreen> createState() => _OrderSummaryScreenState();
|
||||
}
|
||||
|
||||
class _OrderSummaryScreenState extends State<OrderSummaryScreen>
|
||||
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||
late CartViewModel _cartViewModel;
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_initializeCart();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
if (state == AppLifecycleState.resumed && mounted) {
|
||||
_refreshCartData();
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshCartData() {
|
||||
if (mounted) {
|
||||
final cartViewModel = context.read<CartViewModel>();
|
||||
cartViewModel.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeCart() async {
|
||||
if (_isInitialized) return;
|
||||
_cartViewModel = context.read<CartViewModel>();
|
||||
|
||||
await _cartViewModel.loadCartItems(showLoading: false);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
if (_isInitialized) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_refreshCartData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeItem(String trashId, String itemName) async {
|
||||
final success = await _cartViewModel.deleteItem(trashId);
|
||||
if (success) {
|
||||
_showSnackbar('$itemName berhasil dihapus');
|
||||
} else {
|
||||
_showSnackbar(_cartViewModel.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAllItems() async {
|
||||
if (_cartViewModel.isEmpty) return;
|
||||
|
||||
final confirmed = await _showConfirmationDialog(
|
||||
title: 'Hapus Semua Item',
|
||||
content: 'Apakah Anda yakin ingin menghapus semua item dari keranjang?',
|
||||
confirmText: 'Hapus Semua',
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final success = await _cartViewModel.clearCart();
|
||||
if (success) {
|
||||
_showSnackbar('Semua item berhasil dihapus');
|
||||
} else {
|
||||
_showSnackbar(_cartViewModel.errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _incrementQuantity(String trashId) async {
|
||||
await _cartViewModel.incrementItemAmount(trashId);
|
||||
if (_cartViewModel.state == CartState.error) {
|
||||
_showSnackbar(_cartViewModel.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _decrementQuantity(String trashId) async {
|
||||
await _cartViewModel.decrementItemAmount(trashId);
|
||||
if (_cartViewModel.state == CartState.error) {
|
||||
_showSnackbar(_cartViewModel.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showQuantityDialog(CartItem item) async {
|
||||
final TextEditingController controller = TextEditingController(
|
||||
text: item.amount.toString(),
|
||||
);
|
||||
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Input Jumlah ${item.trashName}'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Jumlah',
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'kg',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Batal'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final newAmount = int.tryParse(controller.text);
|
||||
if (newAmount != null && newAmount > 0) {
|
||||
Navigator.of(context).pop(newAmount);
|
||||
} else {
|
||||
_showSnackbar('Masukkan angka yang valid (lebih dari 0)');
|
||||
}
|
||||
},
|
||||
child: Text('Simpan'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
final success = await _cartViewModel.addOrUpdateItem(
|
||||
item.trashId,
|
||||
result,
|
||||
);
|
||||
if (!success) {
|
||||
_showSnackbar(_cartViewModel.errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _showConfirmationDialog({
|
||||
required String title,
|
||||
required String content,
|
||||
required String confirmText,
|
||||
}) {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('Batal'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(confirmText),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSnackbar(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), duration: Duration(seconds: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getTrashTypeConfig(String trashName) {
|
||||
final name = trashName.toLowerCase();
|
||||
if (name.contains('plastik')) {
|
||||
return {
|
||||
'icon': Icons.local_drink,
|
||||
'backgroundColor': Colors.blue.shade100,
|
||||
'iconColor': Colors.blue,
|
||||
};
|
||||
} else if (name.contains('kertas')) {
|
||||
return {
|
||||
'icon': Icons.description,
|
||||
'backgroundColor': Colors.orange.shade100,
|
||||
'iconColor': Colors.orange,
|
||||
};
|
||||
} else if (name.contains('logam') || name.contains('metal')) {
|
||||
return {
|
||||
'icon': Icons.build,
|
||||
'backgroundColor': Colors.grey.shade100,
|
||||
'iconColor': Colors.grey.shade700,
|
||||
};
|
||||
} else if (name.contains('kaca')) {
|
||||
return {
|
||||
'icon': Icons.wine_bar,
|
||||
'backgroundColor': Colors.green.shade100,
|
||||
'iconColor': Colors.green,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
'icon': Icons.delete_outline,
|
||||
'backgroundColor': Colors.grey.shade100,
|
||||
'iconColor': Colors.grey.shade600,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
double get totalWeight => _cartViewModel.totalItems.toDouble();
|
||||
int get estimatedEarnings => _cartViewModel.totalPrice.round();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade50,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
'Detail Pesanan',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
Consumer<CartViewModel>(
|
||||
builder: (context, cartVM, child) {
|
||||
if (cartVM.isNotEmpty) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh, color: Colors.black),
|
||||
onPressed: () => _refreshCartData(),
|
||||
tooltip: 'Refresh Data',
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(Icons.more_vert, color: Colors.black),
|
||||
onSelected: (value) {
|
||||
if (value == 'clear_all') {
|
||||
_clearAllItems();
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'clear_all',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.clear_all,
|
||||
color: Colors.red,
|
||||
size: 20,
|
||||
),
|
||||
Gap(8),
|
||||
Text(
|
||||
'Hapus Semua',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<CartViewModel>(
|
||||
builder: (context, cartVM, child) {
|
||||
if (cartVM.state == CartState.loading) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
Gap(16),
|
||||
Text('Memuat keranjang...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (cartVM.state == CartState.error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||
Gap(16),
|
||||
Text(
|
||||
'Terjadi kesalahan',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Gap(8),
|
||||
Text(
|
||||
cartVM.errorMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
Gap(24),
|
||||
ElevatedButton(
|
||||
onPressed: () => _refreshCartData(),
|
||||
child: Text('Coba Lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (cartVM.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return CustomMaterialIndicator(
|
||||
onRefresh: () async {
|
||||
await _cartViewModel.loadCartItems(showLoading: false);
|
||||
},
|
||||
backgroundColor: Colors.white,
|
||||
indicatorBuilder: (context, controller) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.blue,
|
||||
value: controller.state.isLoading
|
||||
? null
|
||||
: math.min(controller.value, 1.0),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
physics: AlwaysScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildItemsSection(cartVM),
|
||||
Gap(20),
|
||||
_buildEarningsSection(cartVM),
|
||||
Gap(20),
|
||||
_buildBottomButton(cartVM),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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.shopping_cart_outlined,
|
||||
size: 48,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
Gap(16),
|
||||
Text(
|
||||
'Keranjang kosong',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Gap(8),
|
||||
Text(
|
||||
'Tambahkan item sampah untuk melanjutkan',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||
),
|
||||
Gap(24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.add),
|
||||
label: Text('Tambah Item'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemsSection(CartViewModel cartVM) {
|
||||
return 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: [
|
||||
_buildSectionHeader(),
|
||||
Gap(16),
|
||||
...cartVM.cartItems.map((item) => _buildItemCard(item)),
|
||||
Gap(16),
|
||||
_buildTotalWeight(cartVM),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: () => Navigator.of(context).pop(),
|
||||
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(CartItem item) {
|
||||
final config = _getTrashTypeConfig(item.trashName);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Consumer<CartViewModel>(
|
||||
builder: (context, cartVM, child) {
|
||||
return Slidable(
|
||||
key: ValueKey('${item.trashId}_${item.id}'),
|
||||
endActionPane: ActionPane(
|
||||
motion: ScrollMotion(),
|
||||
children: [
|
||||
SlidableAction(
|
||||
onPressed: (context) => _removeItem(item.trashId, item.trashName),
|
||||
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: [
|
||||
_buildItemIcon(config),
|
||||
Gap(12),
|
||||
_buildItemInfo(item),
|
||||
_buildQuantityControls(item, cartVM),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemIcon(Map<String, dynamic> config) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: config['backgroundColor'],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(config['icon'], color: config['iconColor'], size: 20),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemInfo(CartItem item) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.trashName,
|
||||
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 ${_formatCurrency(item.trashPrice.round())}/kg',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuantityControls(CartItem item, CartViewModel cartVM) {
|
||||
return Row(
|
||||
children: [
|
||||
_buildQuantityButton(
|
||||
icon: Icons.remove,
|
||||
onTap: cartVM.isOperationInProgress
|
||||
? null
|
||||
: () => _decrementQuantity(item.trashId),
|
||||
backgroundColor: Colors.white,
|
||||
iconColor: Colors.grey.shade600,
|
||||
),
|
||||
Gap(8),
|
||||
GestureDetector(
|
||||
onTap: cartVM.isOperationInProgress
|
||||
? null
|
||||
: () => _showQuantityDialog(item),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Text(
|
||||
'${item.amount} kg',
|
||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
Gap(8),
|
||||
_buildQuantityButton(
|
||||
icon: Icons.add,
|
||||
onTap: cartVM.isOperationInProgress
|
||||
? null
|
||||
: () => _incrementQuantity(item.trashId),
|
||||
backgroundColor: Colors.blue,
|
||||
iconColor: Colors.white,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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: onTap == null ? Colors.grey.shade300 : backgroundColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: backgroundColor == Colors.white
|
||||
? Border.all(color: Colors.grey.shade300)
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: onTap == null ? Colors.grey.shade500 : iconColor,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalWeight(CartViewModel cartVM) {
|
||||
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(
|
||||
'${cartVM.totalItems} kg',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEarningsSection(CartViewModel cartVM) {
|
||||
return 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),
|
||||
),
|
||||
Gap(12),
|
||||
Text(
|
||||
'Rincian Perhitungan',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(16),
|
||||
// Detail per item
|
||||
...cartVM.cartItems.map((item) => _buildItemCalculation(item)),
|
||||
Gap(12),
|
||||
// Divider
|
||||
Divider(color: Colors.grey.shade300),
|
||||
Gap(8),
|
||||
// Total
|
||||
_buildTotalCalculation(cartVM),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemCalculation(CartItem item) {
|
||||
final subtotal = (item.amount * item.trashPrice).round();
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.receipt_outlined, color: Colors.blue, size: 12),
|
||||
),
|
||||
Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.trashName,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
Gap(2),
|
||||
Text(
|
||||
'${item.amount} kg × Rp ${_formatCurrency(item.trashPrice.round())}/kg',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Rp ${_formatCurrency(subtotal)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTotalCalculation(CartViewModel cartVM) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.account_balance_wallet, color: Colors.green, size: 16),
|
||||
),
|
||||
Gap(12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Total Estimasi Pendapatan',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Rp ${_formatCurrency(estimatedEarnings)}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomButton(CartViewModel cartVM) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Consumer<CartViewModel>(
|
||||
builder: (context, cartVM, child) {
|
||||
final isLoading = cartVM.isOperationInProgress;
|
||||
final hasItems = cartVM.isNotEmpty;
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: hasItems
|
||||
? () {
|
||||
_showSnackbar('Lanjut ke proses selanjutnya');
|
||||
router.push('/pickupmethod');
|
||||
}
|
||||
: () => Navigator.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: hasItems ? Colors.blue : Colors.grey.shade400,
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
hasItems ? 'Lanjut' : 'Tambah Item',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCurrency(int amount) {
|
||||
return amount.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]}.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
|
||||
import 'package:rijig_mobile/features/cart/service/trashcart_service.dart';
|
||||
|
||||
enum CartState { initial, loading, loaded, error, empty, updating }
|
||||
|
||||
class CartViewModel extends ChangeNotifier {
|
||||
final CartService _cartService;
|
||||
|
||||
CartViewModel({CartService? cartService})
|
||||
: _cartService = cartService ?? CartServiceProvider.instance;
|
||||
|
||||
CartState _state = CartState.initial;
|
||||
Cart? _cart;
|
||||
String _errorMessage = '';
|
||||
bool _isOperationInProgress = false;
|
||||
|
||||
CartState get state => _state;
|
||||
Cart? get cart => _cart;
|
||||
String get errorMessage => _errorMessage;
|
||||
bool get isOperationInProgress => _isOperationInProgress;
|
||||
|
||||
List<CartItem> get cartItems => _cart?.cartItems ?? [];
|
||||
int get totalItems => _cart?.totalAmount ?? 0;
|
||||
double get totalPrice => _cart?.estimatedTotalPrice ?? 0.0;
|
||||
bool get isEmpty => cartItems.isEmpty;
|
||||
bool get isNotEmpty => cartItems.isNotEmpty;
|
||||
|
||||
void _setState(CartState newState) {
|
||||
if (_state != newState) {
|
||||
_state = newState;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_errorMessage = message;
|
||||
_setState(CartState.error);
|
||||
}
|
||||
|
||||
void _setOperationInProgress(bool inProgress) {
|
||||
_isOperationInProgress = inProgress;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadCartItems({bool showLoading = true}) async {
|
||||
if (showLoading) {
|
||||
_setState(CartState.loading);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _cartService.getCartItems();
|
||||
|
||||
if (response.isSuccess && response.data != null) {
|
||||
_cart = response.data as Cart;
|
||||
_setState(_cart!.isEmpty ? CartState.empty : CartState.loaded);
|
||||
} else if (response.isUnauthorized) {
|
||||
_setError('Sesi Anda telah berakhir, silakan login kembali');
|
||||
} else {
|
||||
_setError(response.message);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CartViewModel - loadCartItems error: $e');
|
||||
_setError('Terjadi kesalahan tidak terduga');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> addOrUpdateItem(
|
||||
String trashId,
|
||||
int amount, {
|
||||
bool showUpdating = true,
|
||||
}) async {
|
||||
if (showUpdating) {
|
||||
_setOperationInProgress(true);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _cartService.addOrUpdateItem(trashId, amount);
|
||||
|
||||
if (response.isSuccess) {
|
||||
await loadCartItems(showLoading: false);
|
||||
return true;
|
||||
} else {
|
||||
if (response.isUnauthorized) {
|
||||
_setError('Sesi Anda telah berakhir, silakan login kembali');
|
||||
} else {
|
||||
_setError(response.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CartViewModel - addOrUpdateItem error: $e');
|
||||
_setError('Terjadi kesalahan tidak terduga');
|
||||
return false;
|
||||
} finally {
|
||||
if (showUpdating) {
|
||||
_setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> deleteItem(String trashId, {bool showUpdating = true}) async {
|
||||
if (showUpdating) {
|
||||
_setOperationInProgress(true);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _cartService.deleteItem(trashId);
|
||||
|
||||
if (response.isSuccess) {
|
||||
await loadCartItems(showLoading: false);
|
||||
return true;
|
||||
} else {
|
||||
if (response.isUnauthorized) {
|
||||
_setError('Sesi Anda telah berakhir, silakan login kembali');
|
||||
} else {
|
||||
_setError(response.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CartViewModel - deleteItem error: $e');
|
||||
_setError('Terjadi kesalahan tidak terduga');
|
||||
return false;
|
||||
} finally {
|
||||
if (showUpdating) {
|
||||
_setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> clearCart({bool showUpdating = true}) async {
|
||||
if (showUpdating) {
|
||||
_setOperationInProgress(true);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _cartService.clearCart();
|
||||
|
||||
if (response.isSuccess) {
|
||||
await loadCartItems(showLoading: false);
|
||||
return true;
|
||||
} else {
|
||||
if (response.isUnauthorized) {
|
||||
_setError('Sesi Anda telah berakhir, silakan login kembali');
|
||||
} else {
|
||||
_setError(response.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CartViewModel - clearCart error: $e');
|
||||
_setError('Terjadi kesalahan tidak terduga');
|
||||
return false;
|
||||
} finally {
|
||||
if (showUpdating) {
|
||||
_setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> incrementItemAmount(String trashId) async {
|
||||
_setOperationInProgress(true);
|
||||
|
||||
try {
|
||||
final response = await _cartService.incrementItemAmount(trashId);
|
||||
|
||||
if (response.isSuccess) {
|
||||
await loadCartItems(showLoading: false);
|
||||
return true;
|
||||
} else {
|
||||
if (response.isUnauthorized) {
|
||||
_setError('Sesi Anda telah berakhir, silakan login kembali');
|
||||
} else {
|
||||
_setError(response.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CartViewModel - incrementItemAmount error: $e');
|
||||
_setError('Terjadi kesalahan tidak terduga');
|
||||
return false;
|
||||
} finally {
|
||||
_setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> decrementItemAmount(String trashId) async {
|
||||
_setOperationInProgress(true);
|
||||
|
||||
try {
|
||||
final response = await _cartService.decrementItemAmount(trashId);
|
||||
|
||||
if (response.isSuccess) {
|
||||
await loadCartItems(showLoading: false);
|
||||
return true;
|
||||
} else {
|
||||
if (response.isUnauthorized) {
|
||||
_setError('Sesi Anda telah berakhir, silakan login kembali');
|
||||
} else {
|
||||
_setError(response.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CartViewModel - decrementItemAmount error: $e');
|
||||
_setError('Terjadi kesalahan tidak terduga');
|
||||
return false;
|
||||
} finally {
|
||||
_setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
|
||||
CartItem? getItemByTrashId(String trashId) {
|
||||
try {
|
||||
return cartItems.firstWhere((item) => item.trashId == trashId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool isItemInCart(String trashId) {
|
||||
return getItemByTrashId(trashId) != null;
|
||||
}
|
||||
|
||||
int getItemAmount(String trashId) {
|
||||
final item = getItemByTrashId(trashId);
|
||||
return item?.amount ?? 0;
|
||||
}
|
||||
|
||||
double getItemSubtotal(String trashId) {
|
||||
final item = getItemByTrashId(trashId);
|
||||
return item?.subtotalEstimatedPrice ?? 0.0;
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
if (_state == CartState.error) {
|
||||
_errorMessage = '';
|
||||
_setState(
|
||||
_cart == null || _cart!.isEmpty ? CartState.empty : CartState.loaded,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await loadCartItems(showLoading: true);
|
||||
}
|
||||
|
||||
// Future<void> refresh() async {
|
||||
// await loadCartItems(showLoading: false);
|
||||
// notifyListeners();
|
||||
// }
|
||||
|
||||
// @override
|
||||
// void dispose() {
|
||||
// super.dispose();
|
||||
// }
|
||||
}
|
||||
|
||||
extension CartViewModelExtension on CartViewModel {
|
||||
String get formattedTotalPrice {
|
||||
return 'Rp ${totalPrice.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
|
||||
}
|
||||
|
||||
String getFormattedItemPrice(String trashId) {
|
||||
final item = getItemByTrashId(trashId);
|
||||
if (item == null) return 'Rp 0';
|
||||
|
||||
return 'Rp ${item.trashPrice.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
|
||||
}
|
||||
|
||||
String getFormattedItemSubtotal(String trashId) {
|
||||
final item = getItemByTrashId(trashId);
|
||||
if (item == null) return 'Rp 0';
|
||||
|
||||
return 'Rp ${item.subtotalEstimatedPrice.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:rijig_mobile/core/api/api_exception.dart';
|
||||
import 'package:rijig_mobile/core/api/api_services.dart';
|
||||
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
|
||||
|
||||
abstract class CartRepository {
|
||||
Future<CartApiResponse<dynamic>> addOrUpdateCartItem(
|
||||
AddOrUpdateCartRequest request,
|
||||
);
|
||||
Future<Cart> getCartItems();
|
||||
Future<CartApiResponse<dynamic>> deleteCartItem(String trashId);
|
||||
Future<CartApiResponse<dynamic>> clearCart();
|
||||
}
|
||||
|
||||
class CartRepositoryImpl implements CartRepository {
|
||||
final Https _https = Https();
|
||||
|
||||
static const String _cartEndpoint = '/cart';
|
||||
static const String _cartItemEndpoint = '/cart/item';
|
||||
static const String _cartClearEndpoint = '/cart/clear';
|
||||
|
||||
@override
|
||||
Future<CartApiResponse<dynamic>> addOrUpdateCartItem(
|
||||
AddOrUpdateCartRequest request,
|
||||
) async {
|
||||
try {
|
||||
final response = await _https.post(
|
||||
_cartItemEndpoint,
|
||||
body: request.toJson(),
|
||||
);
|
||||
|
||||
return CartApiResponse<dynamic>.fromJson(response, null);
|
||||
} on ApiException catch (e) {
|
||||
throw ApiException(e.message, e.statusCode);
|
||||
} catch (e) {
|
||||
throw ApiException(
|
||||
'Unexpected error occurred while adding/updating cart item',
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Cart> getCartItems() async {
|
||||
try {
|
||||
final response = await _https.get(_cartEndpoint);
|
||||
|
||||
final cartResponse = CartApiResponse<Cart>.fromJson(
|
||||
response,
|
||||
(json) => Cart.fromJson(json),
|
||||
);
|
||||
|
||||
if (!cartResponse.isSuccess) {
|
||||
throw ApiException(cartResponse.message, cartResponse.status);
|
||||
}
|
||||
|
||||
return cartResponse.data ??
|
||||
Cart(
|
||||
id: '',
|
||||
userId: '',
|
||||
totalAmount: 0,
|
||||
estimatedTotalPrice: 0.0,
|
||||
cartItems: [],
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
throw ApiException(e.message, e.statusCode);
|
||||
} catch (e) {
|
||||
throw ApiException(
|
||||
'Unexpected error occurred while fetching cart items',
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartApiResponse<dynamic>> deleteCartItem(String trashId) async {
|
||||
try {
|
||||
final response = await _https.delete('$_cartItemEndpoint/$trashId');
|
||||
|
||||
return CartApiResponse<dynamic>.fromJson(response, null);
|
||||
} on ApiException catch (e) {
|
||||
throw ApiException(e.message, e.statusCode);
|
||||
} catch (e) {
|
||||
throw ApiException(
|
||||
'Unexpected error occurred while deleting cart item',
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartApiResponse<dynamic>> clearCart() async {
|
||||
try {
|
||||
final response = await _https.delete(_cartClearEndpoint);
|
||||
|
||||
return CartApiResponse<dynamic>.fromJson(response, null);
|
||||
} on ApiException catch (e) {
|
||||
throw ApiException(e.message, e.statusCode);
|
||||
} catch (e) {
|
||||
throw ApiException('Unexpected error occurred while clearing cart', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockCartRepository implements CartRepository {
|
||||
static final List<CartItem> _mockCartItems = [];
|
||||
|
||||
@override
|
||||
Future<CartApiResponse<dynamic>> addOrUpdateCartItem(
|
||||
AddOrUpdateCartRequest request,
|
||||
) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
final existingIndex = _mockCartItems.indexWhere(
|
||||
(item) => item.trashId == request.trashId,
|
||||
);
|
||||
|
||||
if (existingIndex != -1) {
|
||||
final existingItem = _mockCartItems[existingIndex];
|
||||
_mockCartItems[existingIndex] = existingItem.copyWith(
|
||||
amount: request.amount,
|
||||
subtotalEstimatedPrice: existingItem.trashPrice * request.amount,
|
||||
);
|
||||
} else {
|
||||
_mockCartItems.add(
|
||||
CartItem(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
trashId: request.trashId,
|
||||
trashName: 'Mock Trash Item',
|
||||
trashIcon: '/mock/icon.png',
|
||||
trashPrice: 1200.0,
|
||||
amount: request.amount,
|
||||
subtotalEstimatedPrice: 1200.0 * request.amount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CartApiResponse<dynamic>(
|
||||
status: 200,
|
||||
message: 'Berhasil menambah/mengubah item keranjang',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Cart> getCartItems() async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
final totalAmount = _mockCartItems.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + item.amount,
|
||||
);
|
||||
|
||||
final estimatedTotalPrice = _mockCartItems.fold<double>(
|
||||
0.0,
|
||||
(sum, item) => sum + item.subtotalEstimatedPrice,
|
||||
);
|
||||
|
||||
return Cart(
|
||||
id: 'mock_cart_id',
|
||||
userId: 'mock_user_id',
|
||||
totalAmount: totalAmount,
|
||||
estimatedTotalPrice: estimatedTotalPrice,
|
||||
cartItems: List.from(_mockCartItems),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartApiResponse<dynamic>> deleteCartItem(String trashId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
_mockCartItems.removeWhere((item) => item.trashId == trashId);
|
||||
|
||||
return CartApiResponse<dynamic>(
|
||||
status: 200,
|
||||
message: 'Berhasil menghapus item dari keranjang',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartApiResponse<dynamic>> clearCart() async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
_mockCartItems.clear();
|
||||
|
||||
return CartApiResponse<dynamic>(
|
||||
status: 200,
|
||||
message: 'Berhasil mengosongkan keranjang',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/core/api/api_exception.dart';
|
||||
import 'package:rijig_mobile/features/cart/model/trashcart_model.dart';
|
||||
import 'package:rijig_mobile/features/cart/repositories/trashcart_repo.dart';
|
||||
|
||||
enum CartOperationResult { success, failed, networkError, unauthorized }
|
||||
|
||||
class CartOperationResponse {
|
||||
final CartOperationResult result;
|
||||
final String message;
|
||||
final dynamic data;
|
||||
|
||||
CartOperationResponse({
|
||||
required this.result,
|
||||
required this.message,
|
||||
this.data,
|
||||
});
|
||||
|
||||
bool get isSuccess => result == CartOperationResult.success;
|
||||
bool get isNetworkError => result == CartOperationResult.networkError;
|
||||
bool get isUnauthorized => result == CartOperationResult.unauthorized;
|
||||
}
|
||||
|
||||
abstract class CartService {
|
||||
Future<CartOperationResponse> addOrUpdateItem(String trashId, int amount);
|
||||
Future<CartOperationResponse> getCartItems();
|
||||
Future<CartOperationResponse> deleteItem(String trashId);
|
||||
Future<CartOperationResponse> clearCart();
|
||||
Future<CartOperationResponse> incrementItemAmount(String trashId);
|
||||
Future<CartOperationResponse> decrementItemAmount(String trashId);
|
||||
}
|
||||
|
||||
class CartServiceImpl implements CartService {
|
||||
final CartRepository _repository;
|
||||
|
||||
CartServiceImpl({CartRepository? repository})
|
||||
: _repository = repository ?? CartRepositoryImpl();
|
||||
|
||||
@override
|
||||
Future<CartOperationResponse> addOrUpdateItem(
|
||||
String trashId,
|
||||
int amount,
|
||||
) async {
|
||||
try {
|
||||
if (amount <= 0) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.failed,
|
||||
message: 'Jumlah item harus lebih dari 0',
|
||||
);
|
||||
}
|
||||
|
||||
final request = AddOrUpdateCartRequest(trashId: trashId, amount: amount);
|
||||
|
||||
final response = await _repository.addOrUpdateCartItem(request);
|
||||
|
||||
if (response.isSuccess) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.success,
|
||||
message: response.message,
|
||||
data: response.data,
|
||||
);
|
||||
} else {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.failed,
|
||||
message: response.message,
|
||||
);
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('CartService - addOrUpdateItem error: ${e.message}');
|
||||
|
||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.unauthorized,
|
||||
message: 'Sesi Anda telah berakhir, silakan login kembali',
|
||||
);
|
||||
}
|
||||
|
||||
return CartOperationResponse(
|
||||
result:
|
||||
e.statusCode >= 500
|
||||
? CartOperationResult.networkError
|
||||
: CartOperationResult.failed,
|
||||
message: e.message,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('CartService - addOrUpdateItem unexpected error: $e');
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.networkError,
|
||||
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartOperationResponse> getCartItems() async {
|
||||
try {
|
||||
final cart = await _repository.getCartItems();
|
||||
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.success,
|
||||
message: 'Berhasil mengambil data keranjang',
|
||||
data: cart,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('CartService - getCartItems error: ${e.message}');
|
||||
|
||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.unauthorized,
|
||||
message: 'Sesi Anda telah berakhir, silakan login kembali',
|
||||
);
|
||||
}
|
||||
|
||||
return CartOperationResponse(
|
||||
result:
|
||||
e.statusCode >= 500
|
||||
? CartOperationResult.networkError
|
||||
: CartOperationResult.failed,
|
||||
message: e.message,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('CartService - getCartItems unexpected error: $e');
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.networkError,
|
||||
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartOperationResponse> deleteItem(String trashId) async {
|
||||
try {
|
||||
final response = await _repository.deleteCartItem(trashId);
|
||||
|
||||
if (response.isSuccess) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.success,
|
||||
message: response.message,
|
||||
);
|
||||
} else {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.failed,
|
||||
message: response.message,
|
||||
);
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('CartService - deleteItem error: ${e.message}');
|
||||
|
||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.unauthorized,
|
||||
message: 'Sesi Anda telah berakhir, silakan login kembali',
|
||||
);
|
||||
}
|
||||
|
||||
return CartOperationResponse(
|
||||
result:
|
||||
e.statusCode >= 500
|
||||
? CartOperationResult.networkError
|
||||
: CartOperationResult.failed,
|
||||
message: e.message,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('CartService - deleteItem unexpected error: $e');
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.networkError,
|
||||
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartOperationResponse> clearCart() async {
|
||||
try {
|
||||
final response = await _repository.clearCart();
|
||||
|
||||
if (response.isSuccess) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.success,
|
||||
message: response.message,
|
||||
);
|
||||
} else {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.failed,
|
||||
message: response.message,
|
||||
);
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('CartService - clearCart error: ${e.message}');
|
||||
|
||||
if (e.statusCode == 401 || e.statusCode == 403) {
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.unauthorized,
|
||||
message: 'Sesi Anda telah berakhir, silakan login kembali',
|
||||
);
|
||||
}
|
||||
|
||||
return CartOperationResponse(
|
||||
result:
|
||||
e.statusCode >= 500
|
||||
? CartOperationResult.networkError
|
||||
: CartOperationResult.failed,
|
||||
message: e.message,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('CartService - clearCart unexpected error: $e');
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.networkError,
|
||||
message: 'Terjadi kesalahan jaringan, silakan coba lagi',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartOperationResponse> incrementItemAmount(String trashId) async {
|
||||
try {
|
||||
final cartResponse = await getCartItems();
|
||||
if (!cartResponse.isSuccess) {
|
||||
return cartResponse;
|
||||
}
|
||||
|
||||
final cart = cartResponse.data as Cart;
|
||||
final item = cart.cartItems.firstWhere(
|
||||
(item) => item.trashId == trashId,
|
||||
orElse: () => throw Exception('Item tidak ditemukan di keranjang'),
|
||||
);
|
||||
|
||||
return await addOrUpdateItem(trashId, item.amount + 1);
|
||||
} catch (e) {
|
||||
debugPrint('CartService - incrementItemAmount error: $e');
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.failed,
|
||||
message: 'Gagal menambah jumlah item',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CartOperationResponse> decrementItemAmount(String trashId) async {
|
||||
try {
|
||||
final cartResponse = await getCartItems();
|
||||
if (!cartResponse.isSuccess) {
|
||||
return cartResponse;
|
||||
}
|
||||
|
||||
final cart = cartResponse.data as Cart;
|
||||
final item = cart.cartItems.firstWhere(
|
||||
(item) => item.trashId == trashId,
|
||||
orElse: () => throw Exception('Item tidak ditemukan di keranjang'),
|
||||
);
|
||||
|
||||
if (item.amount <= 1) {
|
||||
return await deleteItem(trashId);
|
||||
}
|
||||
|
||||
return await addOrUpdateItem(trashId, item.amount - 1);
|
||||
} catch (e) {
|
||||
debugPrint('CartService - decrementItemAmount error: $e');
|
||||
return CartOperationResponse(
|
||||
result: CartOperationResult.failed,
|
||||
message: 'Gagal mengurangi jumlah item',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CartServiceProvider {
|
||||
static CartService? _instance;
|
||||
|
||||
static CartService get instance {
|
||||
_instance ??= CartServiceImpl();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static void setInstance(CartService service) {
|
||||
_instance = service;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
class ChatItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final String profileImage;
|
||||
final String lastMessage;
|
||||
final DateTime lastMessageTime;
|
||||
final int unreadCount;
|
||||
final bool isOnline;
|
||||
|
||||
ChatItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.profileImage,
|
||||
required this.lastMessage,
|
||||
required this.lastMessageTime,
|
||||
this.unreadCount = 0,
|
||||
this.isOnline = false,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.dart';
|
||||
|
||||
class Message {
|
||||
final String id;
|
||||
final String content;
|
||||
final DateTime timestamp;
|
||||
final bool isFromMe;
|
||||
final MessageType type;
|
||||
final MessageStatus status;
|
||||
final String? mediaUrl;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
required this.isFromMe,
|
||||
this.type = MessageType.text,
|
||||
this.status = MessageStatus.sent,
|
||||
this.mediaUrl,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,805 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/features/chat/presentation/model/chatlist_model.dart';
|
||||
|
||||
class ChatListScreen extends StatefulWidget {
|
||||
const ChatListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
class _ChatListScreenState extends State<ChatListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
List<ChatItem> _allChats = [];
|
||||
List<ChatItem> _filteredChats = [];
|
||||
bool _isSearching = false;
|
||||
|
||||
// Selection mode states
|
||||
bool _isSelectionMode = false;
|
||||
Set<String> _selectedChatIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeDummyData();
|
||||
_filteredChats = _allChats;
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeDummyData() {
|
||||
final now = DateTime.now();
|
||||
|
||||
_allChats = [
|
||||
ChatItem(
|
||||
id: '1',
|
||||
name: 'Sarah Johnson',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1494790108755-2616b612b793?w=150',
|
||||
lastMessage: 'Halo! Bagaimana dengan projek yang kemarin?',
|
||||
lastMessageTime: now.subtract(const Duration(minutes: 5)),
|
||||
unreadCount: 3,
|
||||
isOnline: true,
|
||||
),
|
||||
ChatItem(
|
||||
id: '2',
|
||||
name: 'Ahmad Pratama',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150',
|
||||
lastMessage: 'Terima kasih untuk bantuannya!',
|
||||
lastMessageTime: now.subtract(const Duration(minutes: 15)),
|
||||
unreadCount: 1,
|
||||
isOnline: true,
|
||||
),
|
||||
ChatItem(
|
||||
id: '3',
|
||||
name: 'Maria Garcia',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150',
|
||||
lastMessage: 'Sudah sampai rumah dengan selamat',
|
||||
lastMessageTime: now.subtract(const Duration(hours: 1)),
|
||||
unreadCount: 0,
|
||||
isOnline: false,
|
||||
),
|
||||
ChatItem(
|
||||
id: '4',
|
||||
name: 'David Chen',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150',
|
||||
lastMessage: 'Baik, nanti saya kirim file nya',
|
||||
lastMessageTime: now.subtract(const Duration(hours: 2)),
|
||||
unreadCount: 2,
|
||||
isOnline: true,
|
||||
),
|
||||
ChatItem(
|
||||
id: '5',
|
||||
name: 'Jessica Lee',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=150',
|
||||
lastMessage: 'Kapan kita bisa ketemu lagi?',
|
||||
lastMessageTime: now.subtract(const Duration(hours: 4)),
|
||||
unreadCount: 0,
|
||||
isOnline: true,
|
||||
),
|
||||
ChatItem(
|
||||
id: '6',
|
||||
name: 'Michael Rodriguez',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150',
|
||||
lastMessage: 'Jangan lupa meeting besok pagi',
|
||||
lastMessageTime: now.subtract(const Duration(hours: 8)),
|
||||
unreadCount: 4,
|
||||
isOnline: false,
|
||||
),
|
||||
ChatItem(
|
||||
id: '7',
|
||||
name: 'Lisa Wang',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150',
|
||||
lastMessage: 'Foto-fotonya bagus banget!',
|
||||
lastMessageTime: now.subtract(const Duration(days: 1)),
|
||||
unreadCount: 0,
|
||||
isOnline: false,
|
||||
),
|
||||
ChatItem(
|
||||
id: '8',
|
||||
name: 'Ryan Thompson',
|
||||
profileImage:
|
||||
'https://images.unsplash.com/photo-1519244703995-f4e0f30006d5?w=150',
|
||||
lastMessage: 'Oke siap, sampai jumpa!',
|
||||
lastMessageTime: now.subtract(const Duration(days: 2)),
|
||||
unreadCount: 1,
|
||||
isOnline: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
final query = _searchController.text.toLowerCase();
|
||||
setState(() {
|
||||
_isSearching = query.isNotEmpty;
|
||||
if (query.isEmpty) {
|
||||
_filteredChats = _allChats;
|
||||
} else {
|
||||
_filteredChats =
|
||||
_allChats.where((chat) {
|
||||
return chat.name.toLowerCase().contains(query) ||
|
||||
chat.lastMessage.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onChatTap(ChatItem chat) {
|
||||
if (_isSelectionMode) {
|
||||
// Toggle selection in selection mode
|
||||
setState(() {
|
||||
if (_selectedChatIds.contains(chat.id)) {
|
||||
_selectedChatIds.remove(chat.id);
|
||||
} else {
|
||||
_selectedChatIds.add(chat.id);
|
||||
}
|
||||
|
||||
// Exit selection mode if no items selected
|
||||
if (_selectedChatIds.isEmpty) {
|
||||
_isSelectionMode = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Normal navigation
|
||||
// Mark as read (remove unread count)
|
||||
setState(() {
|
||||
final index = _allChats.indexWhere((c) => c.id == chat.id);
|
||||
if (index != -1) {
|
||||
_allChats[index] = ChatItem(
|
||||
id: chat.id,
|
||||
name: chat.name,
|
||||
profileImage: chat.profileImage,
|
||||
lastMessage: chat.lastMessage,
|
||||
lastMessageTime: chat.lastMessageTime,
|
||||
unreadCount: 0, // Mark as read
|
||||
isOnline: chat.isOnline,
|
||||
);
|
||||
_filteredChats = _allChats;
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to chat room with query parameters
|
||||
final encodedName = Uri.encodeComponent(chat.name);
|
||||
final encodedImage = Uri.encodeComponent(chat.profileImage);
|
||||
final onlineStatus = chat.isOnline.toString();
|
||||
|
||||
router.push(
|
||||
'/chatroom/${chat.id}?name=$encodedName&image=$encodedImage&online=$onlineStatus',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onChatLongPress(ChatItem chat) {
|
||||
if (!_isSelectionMode) {
|
||||
setState(() {
|
||||
_isSelectionMode = true;
|
||||
_selectedChatIds.add(chat.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _exitSelectionMode() {
|
||||
setState(() {
|
||||
_isSelectionMode = false;
|
||||
_selectedChatIds.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAllChats() {
|
||||
setState(() {
|
||||
_selectedChatIds = _filteredChats.map((chat) => chat.id).toSet();
|
||||
});
|
||||
}
|
||||
|
||||
void _markSelectedAsRead() {
|
||||
final selectedCount = _selectedChatIds.length;
|
||||
|
||||
setState(() {
|
||||
for (int i = 0; i < _allChats.length; i++) {
|
||||
if (_selectedChatIds.contains(_allChats[i].id)) {
|
||||
_allChats[i] = ChatItem(
|
||||
id: _allChats[i].id,
|
||||
name: _allChats[i].name,
|
||||
profileImage: _allChats[i].profileImage,
|
||||
lastMessage: _allChats[i].lastMessage,
|
||||
lastMessageTime: _allChats[i].lastMessageTime,
|
||||
unreadCount: 0, // Mark as read
|
||||
isOnline: _allChats[i].isOnline,
|
||||
);
|
||||
}
|
||||
}
|
||||
_filteredChats = _allChats;
|
||||
_exitSelectionMode();
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$selectedCount chat ditandai sudah dibaca'),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteSelectedChats() {
|
||||
final selectedCount = _selectedChatIds.length;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.red, size: 24),
|
||||
SizedBox(width: 8),
|
||||
Text('Hapus Chat'),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'Apakah Anda yakin ingin menghapus $selectedCount chat? Tindakan ini tidak dapat dibatalkan.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
'Batal',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_allChats.removeWhere(
|
||||
(chat) => _selectedChatIds.contains(chat.id),
|
||||
);
|
||||
_filteredChats = _allChats;
|
||||
_exitSelectionMode();
|
||||
});
|
||||
|
||||
context.pop();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$selectedCount chat berhasil dihapus'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Hapus', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _markAllAsRead() {
|
||||
setState(() {
|
||||
for (int i = 0; i < _allChats.length; i++) {
|
||||
_allChats[i] = ChatItem(
|
||||
id: _allChats[i].id,
|
||||
name: _allChats[i].name,
|
||||
profileImage: _allChats[i].profileImage,
|
||||
lastMessage: _allChats[i].lastMessage,
|
||||
lastMessageTime: _allChats[i].lastMessageTime,
|
||||
unreadCount: 0, // Mark as read
|
||||
isOnline: _allChats[i].isOnline,
|
||||
);
|
||||
}
|
||||
_filteredChats = _allChats;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Semua chat ditandai sudah dibaca'),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteAllChats() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: Row(
|
||||
children: const [
|
||||
Icon(Icons.warning, color: Colors.red, size: 24),
|
||||
SizedBox(width: 8),
|
||||
Text('Hapus Semua Chat'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Apakah Anda yakin ingin menghapus semua chat? Tindakan ini tidak dapat dibatalkan.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
'Batal',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_allChats.clear();
|
||||
_filteredChats.clear();
|
||||
});
|
||||
|
||||
context.pop();
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Semua chat berhasil dihapus'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'Hapus Semua',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(time);
|
||||
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'Baru saja';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes}m';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours}j';
|
||||
} else if (difference.inDays == 1) {
|
||||
return 'Kemarin';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} hari';
|
||||
} else {
|
||||
return '${time.day}/${time.month}/${time.year}';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalUnread = _allChats.fold<int>(
|
||||
0,
|
||||
(sum, chat) => sum + chat.unreadCount,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade50,
|
||||
appBar: AppBar(
|
||||
backgroundColor: whiteColor,
|
||||
elevation: 0,
|
||||
leading:
|
||||
_isSelectionMode
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _exitSelectionMode,
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title:
|
||||
_isSelectionMode
|
||||
? Text(
|
||||
'${_selectedChatIds.length} dipilih',
|
||||
style: Tulisan.subheading(),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Text('Chat', style: Tulisan.subheading()),
|
||||
if (totalUnread > 0)
|
||||
Text(
|
||||
'$totalUnread pesan belum dibaca',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions:
|
||||
_isSelectionMode
|
||||
? [
|
||||
// Select All Button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_selectedChatIds.length == _filteredChats.length
|
||||
? Icons.deselect
|
||||
: Icons.select_all,
|
||||
),
|
||||
onPressed: () {
|
||||
if (_selectedChatIds.length == _filteredChats.length) {
|
||||
_exitSelectionMode();
|
||||
} else {
|
||||
_selectAllChats();
|
||||
}
|
||||
},
|
||||
tooltip:
|
||||
_selectedChatIds.length == _filteredChats.length
|
||||
? 'Batal pilih semua'
|
||||
: 'Pilih semua',
|
||||
),
|
||||
// Mark as Read Button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mark_email_read),
|
||||
onPressed:
|
||||
_selectedChatIds.isNotEmpty
|
||||
? _markSelectedAsRead
|
||||
: null,
|
||||
tooltip: 'Tandai sudah dibaca',
|
||||
),
|
||||
// Delete Button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed:
|
||||
_selectedChatIds.isNotEmpty
|
||||
? _deleteSelectedChats
|
||||
: null,
|
||||
tooltip: 'Hapus',
|
||||
),
|
||||
]
|
||||
: [
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'mark_all_read':
|
||||
_markAllAsRead();
|
||||
break;
|
||||
case 'delete_all':
|
||||
_deleteAllChats();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder:
|
||||
(BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
value: 'mark_all_read',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.mark_email_read,
|
||||
size: 18,
|
||||
color: Colors.green,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('Baca Semua'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete_all',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.delete_sweep,
|
||||
size: 18,
|
||||
color: Colors.red,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Hapus Semua Chat',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search Bar
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: whiteColor,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari chat atau pesan...',
|
||||
prefixIcon: Icon(Icons.search, color: Colors.grey.shade500),
|
||||
suffixIcon:
|
||||
_isSearching
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Chat List
|
||||
Expanded(
|
||||
child:
|
||||
_filteredChats.isEmpty
|
||||
? _buildEmptyState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: _filteredChats.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildChatItem(_filteredChats[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_isSearching ? 'Tidak ada chat ditemukan' : 'Belum ada chat',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_isSearching ? 'Coba kata kunci lain' : 'Belum ada percakapan',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatItem(ChatItem chat) {
|
||||
final isSelected = _selectedChatIds.contains(chat.id);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? primaryColor.withValues(alpha: 0.1) : whiteColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: isSelected ? Border.all(color: primaryColor, width: 2) : null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _onChatTap(chat),
|
||||
onLongPress: () => _onChatLongPress(chat),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Selection Checkbox (only show in selection mode)
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected ? primaryColor : Colors.grey.shade400,
|
||||
width: 2,
|
||||
),
|
||||
color: isSelected ? primaryColor : Colors.transparent,
|
||||
),
|
||||
child:
|
||||
isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
|
||||
// Profile Picture with Online Status
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
backgroundImage: NetworkImage(chat.profileImage),
|
||||
onBackgroundImageError: (_, __) {},
|
||||
child: Text(
|
||||
chat.name.substring(0, 1).toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (chat.isOnline)
|
||||
Positioned(
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: whiteColor, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Chat Content
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Name and Time
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
chat.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
chat.unreadCount > 0
|
||||
? FontWeight.bold
|
||||
: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatTime(chat.lastMessageTime),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
chat.unreadCount > 0
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color:
|
||||
chat.unreadCount > 0
|
||||
? primaryColor
|
||||
: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Last Message and Unread Count
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
chat.lastMessage,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight:
|
||||
chat.unreadCount > 0
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
color:
|
||||
chat.unreadCount > 0
|
||||
? Colors.black87
|
||||
: Colors.grey.shade600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (chat.unreadCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 20),
|
||||
child: Text(
|
||||
chat.unreadCount > 99
|
||||
? '99+'
|
||||
: '${chat.unreadCount}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,692 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/features/chat/presentation/model/chatroom_model.dart';
|
||||
import 'package:rijig_mobile/widget/custom_bottom_sheet.dart';
|
||||
|
||||
// Model untuk Message
|
||||
/* class Message {
|
||||
final String id;
|
||||
final String content;
|
||||
final DateTime timestamp;
|
||||
final bool isFromMe;
|
||||
final MessageType type;
|
||||
final MessageStatus status;
|
||||
final String? mediaUrl;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.content,
|
||||
required this.timestamp,
|
||||
required this.isFromMe,
|
||||
this.type = MessageType.text,
|
||||
this.status = MessageStatus.sent,
|
||||
this.mediaUrl,
|
||||
});
|
||||
} */
|
||||
|
||||
enum MessageType { text, image, video }
|
||||
|
||||
enum MessageStatus { sending, sent, delivered, read }
|
||||
|
||||
class ChatRoomScreen extends StatefulWidget {
|
||||
final String contactName;
|
||||
final String contactImage;
|
||||
final bool isOnline;
|
||||
|
||||
const ChatRoomScreen({
|
||||
super.key,
|
||||
required this.contactName,
|
||||
required this.contactImage,
|
||||
this.isOnline = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
|
||||
}
|
||||
|
||||
class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final FocusNode _messageFocus = FocusNode();
|
||||
|
||||
List<Message> _messages = [];
|
||||
bool _isTyping = false;
|
||||
bool _isSending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeDummyMessages();
|
||||
_messageController.addListener(_onTypingChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_messageFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeDummyMessages() {
|
||||
final now = DateTime.now();
|
||||
|
||||
_messages = [
|
||||
Message(
|
||||
id: '1',
|
||||
content: 'Halo! Bagaimana kabarnya?',
|
||||
timestamp: now.subtract(const Duration(hours: 2)),
|
||||
isFromMe: false,
|
||||
status: MessageStatus.read,
|
||||
),
|
||||
Message(
|
||||
id: '2',
|
||||
content: 'Halo juga! Baik kok, sedang sibuk proyekan',
|
||||
timestamp: now.subtract(const Duration(hours: 2, minutes: -5)),
|
||||
isFromMe: true,
|
||||
status: MessageStatus.read,
|
||||
),
|
||||
Message(
|
||||
id: '3',
|
||||
content: 'Wah asyik, projek apa emangnya?',
|
||||
timestamp: now.subtract(const Duration(hours: 1, minutes: 50)),
|
||||
isFromMe: false,
|
||||
status: MessageStatus.read,
|
||||
),
|
||||
Message(
|
||||
id: '4',
|
||||
content: 'Projek mobile app untuk client, lumayan challenging sih',
|
||||
timestamp: now.subtract(const Duration(hours: 1, minutes: 45)),
|
||||
isFromMe: true,
|
||||
status: MessageStatus.read,
|
||||
),
|
||||
Message(
|
||||
id: '5',
|
||||
content:
|
||||
'https://images.unsplash.com/photo-1551650975-87deedd944c3?w=400',
|
||||
timestamp: now.subtract(const Duration(hours: 1, minutes: 30)),
|
||||
isFromMe: false,
|
||||
type: MessageType.image,
|
||||
status: MessageStatus.read,
|
||||
mediaUrl:
|
||||
'https://images.unsplash.com/photo-1551650975-87deedd944c3?w=400',
|
||||
),
|
||||
Message(
|
||||
id: '6',
|
||||
content: 'Keren banget! UI nya bagus',
|
||||
timestamp: now.subtract(const Duration(hours: 1, minutes: 25)),
|
||||
isFromMe: true,
|
||||
status: MessageStatus.read,
|
||||
),
|
||||
Message(
|
||||
id: '7',
|
||||
content: 'Terima kasih! Btw kapan kita bisa ketemu lagi?',
|
||||
timestamp: now.subtract(const Duration(minutes: 30)),
|
||||
isFromMe: false,
|
||||
status: MessageStatus.delivered,
|
||||
),
|
||||
Message(
|
||||
id: '8',
|
||||
content: 'Gimana weekend ini? Kita bisa lunch bareng',
|
||||
timestamp: now.subtract(const Duration(minutes: 25)),
|
||||
isFromMe: true,
|
||||
status: MessageStatus.delivered,
|
||||
),
|
||||
Message(
|
||||
id: '9',
|
||||
content: 'Boleh banget! Jam berapa dan dimana?',
|
||||
timestamp: now.subtract(const Duration(minutes: 5)),
|
||||
isFromMe: false,
|
||||
status: MessageStatus.sent,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _onTypingChanged() {
|
||||
final isCurrentlyTyping = _messageController.text.isNotEmpty;
|
||||
if (isCurrentlyTyping != _isTyping) {
|
||||
setState(() {
|
||||
_isTyping = isCurrentlyTyping;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty || _isSending) return;
|
||||
|
||||
setState(() {
|
||||
_isSending = true;
|
||||
});
|
||||
|
||||
final newMessage = Message(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
content: content,
|
||||
timestamp: DateTime.now(),
|
||||
isFromMe: true,
|
||||
status: MessageStatus.sending,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_messages.add(newMessage);
|
||||
_messageController.clear();
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
_scrollToBottom();
|
||||
|
||||
// Simulate sending
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
setState(() {
|
||||
_isSending = false;
|
||||
// Update message status to sent
|
||||
final index = _messages.indexWhere((m) => m.id == newMessage.id);
|
||||
if (index != -1) {
|
||||
_messages[index] = Message(
|
||||
id: newMessage.id,
|
||||
content: newMessage.content,
|
||||
timestamp: newMessage.timestamp,
|
||||
isFromMe: newMessage.isFromMe,
|
||||
status: MessageStatus.sent,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate delivery after 2 seconds
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
setState(() {
|
||||
final index = _messages.indexWhere((m) => m.id == newMessage.id);
|
||||
if (index != -1) {
|
||||
_messages[index] = Message(
|
||||
id: newMessage.id,
|
||||
content: newMessage.content,
|
||||
timestamp: newMessage.timestamp,
|
||||
isFromMe: newMessage.isFromMe,
|
||||
status: MessageStatus.delivered,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showAttachmentOptions() {
|
||||
CustomBottomSheet.show(
|
||||
context: context,
|
||||
title: 'Kirim Media',
|
||||
content: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildAttachmentOption(
|
||||
icon: Icons.camera_alt,
|
||||
label: 'Kamera',
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_takePhoto();
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
icon: Icons.photo_library,
|
||||
label: 'Galeri',
|
||||
color: Colors.green,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickFromGallery();
|
||||
},
|
||||
),
|
||||
_buildAttachmentOption(
|
||||
icon: Icons.videocam,
|
||||
label: 'Video',
|
||||
color: Colors.red,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_pickVideo();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
button1: Container(), // Empty since we have custom buttons
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentOption({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 30),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _takePhoto() {
|
||||
debugPrint('Take photo from camera');
|
||||
// TODO: Implement camera functionality
|
||||
// Example: ImagePicker.pickImage(source: ImageSource.camera)
|
||||
}
|
||||
|
||||
void _pickFromGallery() {
|
||||
debugPrint('Pick image from gallery');
|
||||
// TODO: Implement gallery picker
|
||||
// Example: ImagePicker.pickImage(source: ImageSource.gallery)
|
||||
}
|
||||
|
||||
void _pickVideo() {
|
||||
debugPrint('Pick video from gallery');
|
||||
// TODO: Implement video picker
|
||||
// Example: ImagePicker.pickVideo(source: ImageSource.gallery)
|
||||
}
|
||||
|
||||
String _formatMessageTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final messageDate = DateTime(time.year, time.month, time.day);
|
||||
|
||||
if (messageDate == today) {
|
||||
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
} else if (messageDate == today.subtract(const Duration(days: 1))) {
|
||||
return 'Kemarin ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${time.day}/${time.month} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMessageStatusIcon(MessageStatus status) {
|
||||
switch (status) {
|
||||
case MessageStatus.sending:
|
||||
return const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.grey),
|
||||
);
|
||||
case MessageStatus.sent:
|
||||
return Icon(Icons.check, size: 14, color: Colors.grey.shade500);
|
||||
case MessageStatus.delivered:
|
||||
return Icon(Icons.done_all, size: 14, color: Colors.grey.shade500);
|
||||
case MessageStatus.read:
|
||||
return Icon(Icons.done_all, size: 14, color: primaryColor);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey.shade50,
|
||||
appBar: AppBar(
|
||||
backgroundColor: whiteColor,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
backgroundImage: NetworkImage(widget.contactImage),
|
||||
onBackgroundImageError: (_, __) {},
|
||||
child: Text(
|
||||
widget.contactName.substring(0, 1).toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isOnline)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: whiteColor, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.contactName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.isOnline ? 'Online' : 'Terakhir dilihat baru saja',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
debugPrint('Show chat options');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Messages List
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = _messages[index];
|
||||
final showTimestamp =
|
||||
index == 0 ||
|
||||
_messages[index - 1].timestamp
|
||||
.difference(message.timestamp)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
5;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (showTimestamp)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
_formatMessageTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildMessageBubble(message),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Message Input Area
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: whiteColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
// Attachment Button
|
||||
GestureDetector(
|
||||
onTap: _showAttachmentOptions,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.attach_file,
|
||||
color: Colors.grey.shade600,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Text Input
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
focusNode: _messageFocus,
|
||||
maxLines: null,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ketik pesan...',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
fontSize: 14,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Send Button
|
||||
GestureDetector(
|
||||
onTap: _isTyping && !_isSending ? _sendMessage : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _isTyping ? primaryColor : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child:
|
||||
_isSending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Icons.send,
|
||||
color:
|
||||
_isTyping
|
||||
? Colors.white
|
||||
: Colors.grey.shade500,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(Message message) {
|
||||
final isMe = message.isFromMe;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (!isMe) ...[
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
backgroundImage: NetworkImage(widget.contactImage),
|
||||
onBackgroundImageError: (_, __) {},
|
||||
child: Text(
|
||||
widget.contactName.substring(0, 1).toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
Flexible(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.7,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? primaryColor : whiteColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(18),
|
||||
topRight: const Radius.circular(18),
|
||||
bottomLeft: Radius.circular(isMe ? 18 : 4),
|
||||
bottomRight: Radius.circular(isMe ? 4 : 18),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.type == MessageType.image &&
|
||||
message.mediaUrl != null)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
message.mediaUrl!,
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(_, __, ___) => Container(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.broken_image),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (message.content.isNotEmpty)
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isMe ? Colors.white : Colors.black87,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
|
||||
if (isMe) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatMessageTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_buildMessageStatusIcon(message.status),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatMessageTime(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (isMe) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,755 @@
|
|||
import 'package:charts_painter/chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/widget/appbar.dart';
|
||||
import 'package:el_tooltip/el_tooltip.dart';
|
||||
|
||||
class DatavisualizedScreen extends StatefulWidget {
|
||||
const DatavisualizedScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DatavisualizedScreen> createState() => _DatavisualizedScreenState();
|
||||
}
|
||||
|
||||
class _DatavisualizedScreenState extends State<DatavisualizedScreen> {
|
||||
final List<double> dataSampahTerjual = [
|
||||
15.5,
|
||||
23.2,
|
||||
18.7,
|
||||
31.4,
|
||||
28.9,
|
||||
42.1,
|
||||
35.8,
|
||||
];
|
||||
final List<String> namaHari = [
|
||||
'Sen',
|
||||
'Sel',
|
||||
'Rab',
|
||||
'Kam',
|
||||
'Jum',
|
||||
'Sab',
|
||||
'Min',
|
||||
];
|
||||
|
||||
int? selectedIndex;
|
||||
final List<ElTooltipController> tooltipControllers = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialize tooltip controllers
|
||||
for (int i = 0; i < dataSampahTerjual.length; i++) {
|
||||
tooltipControllers.add(ElTooltipController());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideAllTooltips();
|
||||
for (var controller in tooltipControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Simple method to hide all tooltips
|
||||
void _hideAllTooltips() {
|
||||
for (var controller in tooltipControllers) {
|
||||
try {
|
||||
controller.hide();
|
||||
} catch (e) {
|
||||
// Ignore errors - controller might already be disposed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main selection handler - used by both bar clicks and day name clicks
|
||||
void _handleSelection(int index) {
|
||||
// If same item is selected, deselect it
|
||||
if (selectedIndex == index) {
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all tooltips first
|
||||
_hideAllTooltips();
|
||||
|
||||
// Update selection
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
|
||||
// Show tooltip after a brief delay
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted && selectedIndex == index) {
|
||||
tooltipControllers[index].show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clear selection and hide tooltips
|
||||
void _clearSelection() {
|
||||
_hideAllTooltips();
|
||||
setState(() {
|
||||
selectedIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(judul: "Performa"),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header summary card
|
||||
_buildHeaderCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Chart section
|
||||
_buildChartSection(),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Day labels (clickable)
|
||||
_buildDayLabels(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Selected item info card
|
||||
if (selectedIndex != null) _buildSelectedItemCard(),
|
||||
|
||||
// Statistics cards
|
||||
_buildStatisticsCards(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.blue.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.recycling, color: Colors.blue.shade700, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Sampah Terjual Minggu Ini',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue.shade700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Total: ${dataSampahTerjual.reduce((a, b) => a + b).toStringAsFixed(1)} kg',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Grafik Penjualan Sampah (kg)',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.shade300,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Chart
|
||||
Chart<void>(
|
||||
state: ChartState<void>(
|
||||
behaviour: ChartBehaviour(
|
||||
onItemClicked: (item) {
|
||||
final clickedIndex = dataSampahTerjual.indexWhere(
|
||||
(data) => data == item.item.value,
|
||||
);
|
||||
if (clickedIndex != -1) {
|
||||
_handleSelection(clickedIndex);
|
||||
}
|
||||
},
|
||||
),
|
||||
data: ChartData.fromList(
|
||||
dataSampahTerjual
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) => ChartItem<void>(entry.value))
|
||||
.toList(),
|
||||
),
|
||||
itemOptions: BarItemOptions(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
barItemBuilder: (itemBuilderData) {
|
||||
final isSelected = selectedIndex == itemBuilderData.itemIndex;
|
||||
return BarItem(
|
||||
color: isSelected ? Colors.orange.shade600 : primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
backgroundDecorations: [
|
||||
GridDecoration(
|
||||
showVerticalGrid: false,
|
||||
showHorizontalGrid: true,
|
||||
horizontalAxisStep: 10,
|
||||
gridColor: Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
foregroundDecorations: [
|
||||
SparkLineDecoration(
|
||||
lineColor: Colors.orange.shade400,
|
||||
lineWidth: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 250,
|
||||
),
|
||||
// Tooltips overlay
|
||||
..._buildTooltipOverlays(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTooltipOverlays() {
|
||||
return dataSampahTerjual.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final value = entry.value;
|
||||
final chartWidth = MediaQuery.of(context).size.width - 64;
|
||||
final barWidth = chartWidth / dataSampahTerjual.length;
|
||||
|
||||
return Positioned(
|
||||
left: (index * barWidth) + (barWidth / 2) - 20,
|
||||
top: 50,
|
||||
bottom: 50,
|
||||
width: 40,
|
||||
child: ElTooltip(
|
||||
controller: tooltipControllers[index],
|
||||
position: ElTooltipPosition.topCenter,
|
||||
color: Colors.black87,
|
||||
showArrow: true,
|
||||
showModal: false,
|
||||
showChildAboveOverlay: false,
|
||||
content: _buildTooltipContent(index, value),
|
||||
child: Container(width: 40, color: Colors.transparent),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Widget _buildTooltipContent(int index, double value) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
tooltipControllers[index].hide();
|
||||
_showDetailDialog(index);
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 100),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
namaHari[index],
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${value.toStringAsFixed(1)} kg',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'👆 Tap untuk detail',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDayLabels() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: namaHari.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final isSelected = selectedIndex == index;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _handleSelection(index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? Colors.orange.shade100 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
|
||||
color: isSelected ? Colors.orange.shade700 : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectedItemCard() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Data Terpilih: ${namaHari[selectedIndex!]}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Sampah terjual: ${dataSampahTerjual[selectedIndex!].toStringAsFixed(1)} kg',
|
||||
style: TextStyle(
|
||||
color: Colors.orange.shade600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Close button
|
||||
GestureDetector(
|
||||
onTap: _clearSelection,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade200,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 16,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Detail button
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToDetailPage(selectedIndex!),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade700,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'Detail',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatisticsCards() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Rata-rata',
|
||||
'${(dataSampahTerjual.reduce((a, b) => a + b) / dataSampahTerjual.length).toStringAsFixed(1)} kg',
|
||||
Icons.trending_up,
|
||||
Colors.blue,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Tertinggi',
|
||||
'${dataSampahTerjual.reduce((a, b) => a > b ? a : b).toStringAsFixed(1)} kg',
|
||||
Icons.star,
|
||||
Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Terendah',
|
||||
'${dataSampahTerjual.reduce((a, b) => a < b ? a : b).toStringAsFixed(1)} kg',
|
||||
Icons.trending_down,
|
||||
Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Hari Aktif',
|
||||
'${dataSampahTerjual.where((data) => data > 0).length} hari',
|
||||
Icons.calendar_today,
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String title, String value, IconData icon, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 24),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDetailDialog(int index) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.bar_chart, color: primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text('Detail ${namaHari[index]}'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildDialogInfoRow('Hari:', namaHari[index]),
|
||||
const SizedBox(height: 8),
|
||||
_buildDialogInfoRow(
|
||||
'Jumlah:',
|
||||
'${dataSampahTerjual[index].toStringAsFixed(1)} kg',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDialogInfoRow(
|
||||
'Persentase:',
|
||||
'${((dataSampahTerjual[index] / dataSampahTerjual.reduce((a, b) => a + b)) * 100).toStringAsFixed(1)}%',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDialogInfoRow(
|
||||
'Ranking:',
|
||||
'#${_getRanking(index)} dari ${dataSampahTerjual.length} hari',
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Tutup'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_navigateToDetailPage(index);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('Lihat Detail'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDialogInfoRow(String label, String value) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
int _getRanking(int index) {
|
||||
final sortedData = dataSampahTerjual
|
||||
.asMap()
|
||||
.entries
|
||||
.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
return sortedData.indexWhere((entry) => entry.key == index) + 1;
|
||||
}
|
||||
|
||||
void _navigateToDetailPage(int index) {
|
||||
_hideAllTooltips();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DetailHariScreen(
|
||||
hari: namaHari[index],
|
||||
jumlahSampah: dataSampahTerjual[index],
|
||||
index: index,
|
||||
allData: dataSampahTerjual,
|
||||
allDays: namaHari,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
_clearSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DetailHariScreen extends StatelessWidget {
|
||||
final String hari;
|
||||
final double jumlahSampah;
|
||||
final int index;
|
||||
final List<double> allData;
|
||||
final List<String> allDays;
|
||||
|
||||
const DetailHariScreen({
|
||||
super.key,
|
||||
required this.hari,
|
||||
required this.jumlahSampah,
|
||||
required this.index,
|
||||
required this.allData,
|
||||
required this.allDays,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalSampah = allData.reduce((a, b) => a + b);
|
||||
final persentase = (jumlahSampah / totalSampah) * 100;
|
||||
final ranking = _getRanking();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Detail $hari'),
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [primaryColor.withValues(alpha: 0.1), Colors.white],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.recycling,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Penjualan Sampah',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
hari,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${jumlahSampah.toStringAsFixed(1)} kg',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Persentase: ${persentase.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Ranking: #$ranking dari ${allData.length} hari',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _getRanking() {
|
||||
final sortedData = allData
|
||||
.asMap()
|
||||
.entries
|
||||
.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
return sortedData.indexWhere((entry) => entry.key == index) + 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||
|
||||
class CollectorRequestList extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> allRequests = List.generate(
|
||||
5,
|
||||
(index) => {
|
||||
"name": "Nama ${index + 1}",
|
||||
"phone": "62${81300000000 + index}",
|
||||
"request_trash": [
|
||||
{"trash_name": "Botol Plastik", "amoun_weight": 12 + index},
|
||||
{"trash_name": "Kardus", "amoun_weight": 15 + index},
|
||||
],
|
||||
"address": "Desa Banyuwangi ${index + 1}",
|
||||
"distance_from_you": 12.0 - index,
|
||||
"requestedAt": "${9 + index}:${(index + 1) * 5}".padLeft(2, '0'),
|
||||
},
|
||||
);
|
||||
|
||||
final Map<String, dynamic> myRequest = {
|
||||
"name": "Andi Wijaya",
|
||||
"phone": "6281399991234",
|
||||
"request_trash": [
|
||||
{"trash_name": "Kertas", "amoun_weight": 8},
|
||||
],
|
||||
"address": "Desa Sumberagung",
|
||||
"distance_from_you": 13.7,
|
||||
"requestedAt": "10:15",
|
||||
};
|
||||
|
||||
CollectorRequestList({super.key});
|
||||
|
||||
Widget _buildRequestCard(BuildContext context, Map<String, dynamic> data) {
|
||||
return GestureDetector(
|
||||
onTap: () => _showRequestDetailDialog(context, data),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: whiteColor,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(data['name'], style: Tulisan.subheading()),
|
||||
const SizedBox(height: 4),
|
||||
Text("Telp: ${data['phone']}", style: Tulisan.body(fontsize: 12)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Alamat: ${data['address']}",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Jarak: ${data['distance_from_you'].toStringAsFixed(1)} km",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Waktu Permintaan: ${data['requestedAt']}",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text("Detail Sampah:", style: Tulisan.body(fontsize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
...List.generate((data['request_trash'] as List).length, (i) {
|
||||
final trash = data['request_trash'][i];
|
||||
return Text(
|
||||
"• ${trash['trash_name']} - ${trash['amoun_weight']} kg",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRequestDetailDialog(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> data,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Dialog(
|
||||
backgroundColor: whiteColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(data['name'], style: Tulisan.subheading()),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"Telp: ${data['phone']}",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"Alamat: ${data['address']}",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"Jarak: ${data['distance_from_you'].toStringAsFixed(1)} km",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
"Waktu Permintaan: ${data['requestedAt']}",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text("Detail Sampah:", style: Tulisan.body(fontsize: 13)),
|
||||
const SizedBox(height: 6),
|
||||
...List.generate((data['request_trash'] as List).length, (i) {
|
||||
final trash = data['request_trash'][i];
|
||||
return Text(
|
||||
"• ${trash['trash_name']} - ${trash['amoun_weight']} kg",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 20),
|
||||
CardButtonOne(
|
||||
textButton: "Konfirmasi",
|
||||
fontSized: 14,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 8,
|
||||
horizontal: double.infinity,
|
||||
vertical: 45,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Pickup dikonfirmasi")),
|
||||
);
|
||||
},
|
||||
usingRow: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TabBarView(
|
||||
children: [
|
||||
ListView.builder(
|
||||
itemCount: allRequests.length,
|
||||
itemBuilder:
|
||||
(context, index) =>
|
||||
_buildRequestCard(context, allRequests[index]),
|
||||
),
|
||||
ListView(children: [_buildRequestCard(context, myRequest)]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,592 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
|
||||
class NotificationScreen extends StatefulWidget {
|
||||
const NotificationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationScreen> createState() => _NotificationScreenState();
|
||||
}
|
||||
|
||||
class _NotificationScreenState extends State<NotificationScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
List<NotificationItem> unreadNotifications = [
|
||||
NotificationItem(
|
||||
id: '1',
|
||||
title: 'Pesanan Dikonfirmasi',
|
||||
message: 'Pesanan #12345 telah dikonfirmasi dan sedang diproses',
|
||||
time: DateTime.now().subtract(const Duration(minutes: 15)),
|
||||
type: NotificationType.order,
|
||||
isRead: false,
|
||||
),
|
||||
NotificationItem(
|
||||
id: '2',
|
||||
title: 'Promo Spesial!',
|
||||
message: 'Dapatkan diskon 50% untuk pembelian minimal Rp 100.000',
|
||||
time: DateTime.now().subtract(const Duration(hours: 2)),
|
||||
type: NotificationType.promotion,
|
||||
isRead: false,
|
||||
),
|
||||
NotificationItem(
|
||||
id: '3',
|
||||
title: 'Pengiriman Dalam Perjalanan',
|
||||
message: 'Pesanan #12344 sedang dalam perjalanan menuju alamat Anda',
|
||||
time: DateTime.now().subtract(const Duration(hours: 4)),
|
||||
type: NotificationType.delivery,
|
||||
isRead: false,
|
||||
),
|
||||
NotificationItem(
|
||||
id: '4',
|
||||
title: 'Update Sistem',
|
||||
message: 'Aplikasi telah diperbarui dengan fitur-fitur terbaru',
|
||||
time: DateTime.now().subtract(const Duration(days: 1)),
|
||||
type: NotificationType.system,
|
||||
isRead: false,
|
||||
),
|
||||
NotificationItem(
|
||||
id: '5',
|
||||
title: 'Pembayaran Berhasil',
|
||||
message: 'Pembayaran untuk pesanan #12343 telah berhasil diproses',
|
||||
time: DateTime.now().subtract(const Duration(days: 1, hours: 3)),
|
||||
type: NotificationType.payment,
|
||||
isRead: false,
|
||||
),
|
||||
];
|
||||
|
||||
List<NotificationItem> readNotifications = [
|
||||
NotificationItem(
|
||||
id: '6',
|
||||
title: 'Pesanan Selesai',
|
||||
message:
|
||||
'Pesanan #12342 telah selesai. Berikan rating untuk pelayanan kami!',
|
||||
time: DateTime.now().subtract(const Duration(days: 2)),
|
||||
type: NotificationType.order,
|
||||
isRead: true,
|
||||
),
|
||||
NotificationItem(
|
||||
id: '7',
|
||||
title: 'Cashback Berhasil',
|
||||
message: 'Cashback sebesar Rp 10.000 telah masuk ke saldo Anda',
|
||||
time: DateTime.now().subtract(const Duration(days: 3)),
|
||||
type: NotificationType.payment,
|
||||
isRead: true,
|
||||
),
|
||||
NotificationItem(
|
||||
id: '8',
|
||||
title: 'Selamat Datang!',
|
||||
message: 'Terima kasih telah bergabung dengan aplikasi kami',
|
||||
time: DateTime.now().subtract(const Duration(days: 7)),
|
||||
type: NotificationType.system,
|
||||
isRead: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _markAllAsRead() {
|
||||
setState(() {
|
||||
for (var notification in unreadNotifications) {
|
||||
notification.isRead = true;
|
||||
readNotifications.insert(0, notification);
|
||||
}
|
||||
unreadNotifications.clear();
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Semua notifikasi telah ditandai sebagai dibaca'),
|
||||
backgroundColor: Colors.green,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _markAsRead(NotificationItem notification) {
|
||||
setState(() {
|
||||
notification.isRead = true;
|
||||
unreadNotifications.remove(notification);
|
||||
readNotifications.insert(0, notification);
|
||||
});
|
||||
}
|
||||
|
||||
void _deleteNotification(NotificationItem notification, bool isFromUnread) {
|
||||
setState(() {
|
||||
if (isFromUnread) {
|
||||
unreadNotifications.remove(notification);
|
||||
} else {
|
||||
readNotifications.remove(notification);
|
||||
}
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Notifikasi telah dihapus'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: whiteColor,
|
||||
appBar: AppBar(
|
||||
title: Text('Notifikasi', style: Tulisan.subheading()),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (unreadNotifications.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _markAllAsRead,
|
||||
child: Text(
|
||||
'Baca Semua',
|
||||
style: TextStyle(
|
||||
color: primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(40),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
child: Container(
|
||||
height: 40,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
dividerColor: Colors.transparent,
|
||||
indicator: BoxDecoration(
|
||||
color: primaryColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.black54,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Belum Dibaca'),
|
||||
if (unreadNotifications.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${unreadNotifications.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Dibaca'),
|
||||
if (readNotifications.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade400,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${readNotifications.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [_buildUnreadTab(), _buildReadTab()],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnreadTab() {
|
||||
if (unreadNotifications.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.notifications_off,
|
||||
title: 'Tidak Ada Notifikasi Baru',
|
||||
subtitle: 'Semua notifikasi sudah dibaca',
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: Colors.grey.shade50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: unreadNotifications.length,
|
||||
itemBuilder: (context, index) {
|
||||
final notification = unreadNotifications[index];
|
||||
return _buildNotificationCard(notification, true);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReadTab() {
|
||||
if (readNotifications.isEmpty) {
|
||||
return _buildEmptyState(
|
||||
icon: Icons.mark_email_read,
|
||||
title: 'Belum Ada Notifikasi Dibaca',
|
||||
subtitle: 'Notifikasi yang sudah dibaca akan muncul di sini',
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: Colors.grey.shade50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: readNotifications.length,
|
||||
itemBuilder: (context, index) {
|
||||
final notification = readNotifications[index];
|
||||
return _buildNotificationCard(notification, false);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationCard(NotificationItem notification, bool isUnread) {
|
||||
final notificationIcon = _getNotificationIcon(notification.type);
|
||||
final notificationColor = _getNotificationColor(notification.type);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isUnread ? Colors.white : Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
isUnread
|
||||
? Border.all(
|
||||
color: primaryColor.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
)
|
||||
: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Dismissible(
|
||||
key: Key(notification.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.delete, color: Colors.white, size: 24),
|
||||
),
|
||||
onDismissed: (direction) {
|
||||
_deleteNotification(notification, isUnread);
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: isUnread ? () => _markAsRead(notification) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: notificationColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
notificationIcon,
|
||||
color: notificationColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
notification.title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
isUnread
|
||||
? FontWeight.bold
|
||||
: FontWeight.w600,
|
||||
color:
|
||||
isUnread
|
||||
? Colors.black
|
||||
: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isUnread)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
Text(
|
||||
notification.message,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color:
|
||||
isUnread
|
||||
? Colors.grey.shade700
|
||||
: Colors.grey.shade600,
|
||||
height: 1.3,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(notification.time),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_buildNotificationTypeChip(notification.type),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationTypeChip(NotificationType type) {
|
||||
String label;
|
||||
Color color;
|
||||
|
||||
switch (type) {
|
||||
case NotificationType.order:
|
||||
label = 'Pesanan';
|
||||
color = Colors.blue;
|
||||
break;
|
||||
case NotificationType.delivery:
|
||||
label = 'Pengiriman';
|
||||
color = Colors.green;
|
||||
break;
|
||||
case NotificationType.payment:
|
||||
label = 'Pembayaran';
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case NotificationType.promotion:
|
||||
label = 'Promo';
|
||||
color = Colors.purple;
|
||||
break;
|
||||
case NotificationType.system:
|
||||
label = 'Sistem';
|
||||
color = Colors.grey;
|
||||
break;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
}) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 40, color: Colors.grey.shade400),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getNotificationIcon(NotificationType type) {
|
||||
switch (type) {
|
||||
case NotificationType.order:
|
||||
return Icons.shopping_bag;
|
||||
case NotificationType.delivery:
|
||||
return Icons.local_shipping;
|
||||
case NotificationType.payment:
|
||||
return Icons.payment;
|
||||
case NotificationType.promotion:
|
||||
return Icons.local_offer;
|
||||
case NotificationType.system:
|
||||
return Icons.settings;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getNotificationColor(NotificationType type) {
|
||||
switch (type) {
|
||||
case NotificationType.order:
|
||||
return Colors.blue;
|
||||
case NotificationType.delivery:
|
||||
return Colors.green;
|
||||
case NotificationType.payment:
|
||||
return Colors.orange;
|
||||
case NotificationType.promotion:
|
||||
return Colors.purple;
|
||||
case NotificationType.system:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(time);
|
||||
|
||||
if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} menit lalu';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours} jam lalu';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} hari lalu';
|
||||
} else {
|
||||
return '${time.day}/${time.month}/${time.year}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String message;
|
||||
final DateTime time;
|
||||
final NotificationType type;
|
||||
bool isRead;
|
||||
|
||||
NotificationItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.time,
|
||||
required this.type,
|
||||
required this.isRead,
|
||||
});
|
||||
}
|
||||
|
||||
enum NotificationType { order, delivery, payment, promotion, system }
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:carousel_slider/carousel_slider.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/globaldata/about/about_vmod.dart';
|
||||
import 'package:rijig_mobile/widget/skeletonize.dart';
|
||||
|
||||
class AboutComponent extends StatefulWidget {
|
||||
const AboutComponent({super.key});
|
||||
|
||||
@override
|
||||
AboutComponentState createState() => AboutComponentState();
|
||||
}
|
||||
|
||||
class AboutComponentState extends State<AboutComponent> {
|
||||
int _current = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Provider.of<AboutViewModel>(context, listen: false).getAboutList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String? baseUrl = dotenv.env["BASE_URL"];
|
||||
|
||||
return Consumer<AboutViewModel>(
|
||||
builder: (context, viewModel, child) {
|
||||
if (viewModel.isLoading) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: 1,
|
||||
itemBuilder: (context, index) {
|
||||
return SkeletonCard();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
return Center(child: Text(viewModel.errorMessage!));
|
||||
}
|
||||
|
||||
if (viewModel.aboutList == null || viewModel.aboutList!.isEmpty) {
|
||||
return Center(child: Text("No data available"));
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> imageSliders =
|
||||
viewModel.aboutList!.map((about) {
|
||||
return {
|
||||
"iconPath": "$baseUrl${about.coverImage}",
|
||||
"route": about.id,
|
||||
"title": about.title,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
CarouselSlider(
|
||||
items:
|
||||
imageSliders.map((imageData) {
|
||||
return InkWell(
|
||||
child: Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(imageData["iconPath"]),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
color: blackNavyColor.withValues(alpha: 0.5),
|
||||
child: Text(
|
||||
imageData["title"],
|
||||
style: TextStyle(
|
||||
color: whiteColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
debugPrint("Tapped on ${imageData['route']}");
|
||||
router.push("/aboutdetail", extra: imageData["route"]);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
options: CarouselOptions(
|
||||
autoPlay: true,
|
||||
autoPlayInterval: Duration(seconds: 8),
|
||||
enlargeCenterPage: true,
|
||||
height: 150,
|
||||
onPageChanged: (index, reason) {
|
||||
setState(() {
|
||||
_current = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children:
|
||||
imageSliders.asMap().entries.map((entry) {
|
||||
return GestureDetector(
|
||||
child: Container(
|
||||
width: 8.0,
|
||||
height: 8.0,
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
horizontal: 4.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: (Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? Colors.blue
|
||||
: Colors.blue)
|
||||
.withValues(
|
||||
alpha: _current == entry.key ? 0.9 : 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rijig_mobile/globaldata/about/about_vmod.dart';
|
||||
import 'package:rijig_mobile/widget/appbar.dart';
|
||||
import 'package:rijig_mobile/widget/skeletonize.dart';
|
||||
|
||||
class AboutDetailScreenComp extends StatefulWidget {
|
||||
final String data;
|
||||
const AboutDetailScreenComp({super.key, required this.data});
|
||||
|
||||
@override
|
||||
State<AboutDetailScreenComp> createState() => _AboutDetailScreenCompState();
|
||||
}
|
||||
|
||||
class _AboutDetailScreenCompState extends State<AboutDetailScreenComp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
context.read<AboutDetailViewModel>().getDetail(widget.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String? baseurl = dotenv.env['BASE_URL'];
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(judul: "About Detail"),
|
||||
body: Consumer<AboutDetailViewModel>(
|
||||
builder: (context, vm, _) {
|
||||
if (vm.isLoading) {
|
||||
return ListView.builder(
|
||||
itemCount: 2,
|
||||
itemBuilder: (context, index) {
|
||||
return SkeletonCard();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (vm.errorMessage != null) return Text(vm.errorMessage!);
|
||||
return ListView.builder(
|
||||
itemCount: vm.details.length,
|
||||
itemBuilder: (context, index) {
|
||||
final detail = vm.details[index];
|
||||
return Card(
|
||||
margin: EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Image.network("$baseurl${detail.imageDetail}"),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(detail.description),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/globaldata/article/article_model.dart';
|
||||
import 'package:rijig_mobile/widget/appbar.dart';
|
||||
|
||||
class ArticleDetailScreen extends StatelessWidget {
|
||||
final ArticleModel data;
|
||||
|
||||
const ArticleDetailScreen({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String? baseUrl = dotenv.env["BASE_URL"];
|
||||
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(judul: "detail artikel"),
|
||||
body: SingleChildScrollView(
|
||||
padding: PaddingCustom().paddingAll(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(data.heading, style: Tulisan.heading()),
|
||||
Gap(8),
|
||||
Text(
|
||||
"Oleh ${data.author} • ${data.publishedAt}",
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: greyAbsolutColor,
|
||||
),
|
||||
),
|
||||
Gap(30),
|
||||
if (data.coverImage.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
"$baseUrl${data.coverImage}",
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) =>
|
||||
const Icon(Icons.broken_image),
|
||||
),
|
||||
),
|
||||
Gap(10),
|
||||
Divider(thickness: 1.3, color: blackNavyColor),
|
||||
Gap(60),
|
||||
Text(data.content, style: Tulisan.customText()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:rijig_mobile/core/router.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/globaldata/article/article_vmod.dart';
|
||||
import 'package:rijig_mobile/widget/skeletonize.dart';
|
||||
|
||||
class ArticleScreen extends StatefulWidget {
|
||||
const ArticleScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ArticleScreen> createState() => _ArticleScreenState();
|
||||
}
|
||||
|
||||
class _ArticleScreenState extends State<ArticleScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
Provider.of<ArticleViewModel>(context, listen: false).loadArticles();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String? baseUrl = dotenv.env["BASE_URL"];
|
||||
|
||||
return Consumer<ArticleViewModel>(
|
||||
builder: (context, viewModel, child) {
|
||||
if (viewModel.isLoading) {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: 1,
|
||||
itemBuilder: (context, index) {
|
||||
return SkeletonCard();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
return Center(child: Text("Error: ${viewModel.errorMessage}"));
|
||||
}
|
||||
|
||||
if (viewModel.articles.isEmpty) {
|
||||
return const Center(child: Text("Tidak ada artikel ditemukan."));
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 190,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
itemCount: viewModel.articles.length,
|
||||
separatorBuilder: (_, __) => Gap(12),
|
||||
itemBuilder: (context, index) {
|
||||
final article = viewModel.articles[index];
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
router.push("/artikeldetail", extra: article);
|
||||
},
|
||||
child: Container(
|
||||
padding: PaddingCustom().paddingAll(3),
|
||||
width: 180,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: greyColor),
|
||||
color: whiteColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
child: Image.network(
|
||||
"$baseUrl${article.coverImage}",
|
||||
width: double.infinity,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) =>
|
||||
const Icon(Icons.image_not_supported),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: PaddingCustom().paddingAll(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
article.heading,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"by ${article.author} • ${article.publishedAt}",
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:rijig_mobile/core/guide.dart';
|
||||
import 'package:rijig_mobile/model/product.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/features/home/model/product.dart';
|
||||
|
||||
class ProductCard extends StatelessWidget {
|
||||
const ProductCard({
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
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/home/model/c_request_list_dummymodel.dart';
|
||||
import 'package:rijig_mobile/widget/tabbar_custom.dart';
|
||||
|
||||
class ChomeCollectorScreen extends StatefulWidget {
|
||||
const ChomeCollectorScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChomeCollectorScreen> createState() => _ChomeCollectorScreenState();
|
||||
}
|
||||
|
||||
class _ChomeCollectorScreenState extends State<ChomeCollectorScreen> {
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
backgroundColor: whiteColor,
|
||||
appBar: AppBar(
|
||||
backgroundColor: primaryColor,
|
||||
title: Text('Request', style: Tulisan.subheading(color: whiteColor)),
|
||||
centerTitle: true,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.menu, color: whiteColor, size: 30),
|
||||
onPressed: () {
|
||||
_scaffoldKey.currentState?.openDrawer();
|
||||
},
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(70),
|
||||
child: ClipRRect(
|
||||
child: Container(
|
||||
height: 40,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(color: Colors.green.shade100),
|
||||
child: TabBar(
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
dividerColor: Colors.transparent,
|
||||
indicator: BoxDecoration(
|
||||
color: primaryColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: secondaryColor,
|
||||
width: 2.6,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
),
|
||||
labelColor: whiteColor,
|
||||
unselectedLabelColor: Colors.black54,
|
||||
tabs: [
|
||||
TabItem(title: 'All Request', count: 6),
|
||||
TabItem(title: 'Request to You', count: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
drawer: Drawer(
|
||||
backgroundColor: whiteColor,
|
||||
width: MediaQuery.of(context).size.width / 1.7,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
DrawerHeader(
|
||||
decoration: BoxDecoration(color: primaryColor),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundImage: AssetImage('assets/image/Go_Ride.png'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Nama User',
|
||||
style: Tulisan.body(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Status: Active',
|
||||
style: Tulisan.body(
|
||||
color: Colors.white70,
|
||||
fontsize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Iconsax.user),
|
||||
title: Text('Profil', style: Tulisan.customText(fontsize: 16)),
|
||||
onTap: () {},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Iconsax.document_1),
|
||||
title: Text(
|
||||
'Riwayat Pickup',
|
||||
style: Tulisan.customText(fontsize: 16),
|
||||
),
|
||||
onTap: () => router.push('/cpickuphistory'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Iconsax.setting_2),
|
||||
title: Text(
|
||||
'Pengaturan',
|
||||
style: Tulisan.customText(fontsize: 16),
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Iconsax.logout, color: redColor),
|
||||
title: Text(
|
||||
'Keluar',
|
||||
style: Tulisan.customText(color: redColor, fontsize: 16),
|
||||
),
|
||||
onTap: () => router.go('/login'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: CollectorRequestList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/widget/appbar.dart';
|
||||
|
||||
class PickupHistoryScreen extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> weeklyData = [
|
||||
{"label": "Mon", "amount": 200000, "orders": 10},
|
||||
{"label": "Tue", "amount": 0, "orders": 0},
|
||||
{"label": "Wed", "amount": 100000, "orders": 7},
|
||||
{"label": "Thu", "amount": 0, "orders": 0},
|
||||
{"label": "Fri", "amount": 140000, "orders": 13},
|
||||
{"label": "Sat", "amount": 50000, "orders": 5},
|
||||
{"label": "Sun", "amount": 210000, "orders": 15},
|
||||
];
|
||||
|
||||
PickupHistoryScreen({super.key});
|
||||
|
||||
Widget _dateRangeTab(String label, bool isActive) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? primaryColor : greyColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isActive ? Colors.white : Colors.black87,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalIncome = weeklyData.fold<int>(
|
||||
0,
|
||||
(sum, d) => sum + (d['amount'] as int),
|
||||
);
|
||||
final totalOrders = weeklyData.fold<int>(
|
||||
0,
|
||||
(sum, d) => sum + (d['orders'] as int),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(judul: "Riwayat Pickup"),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(child: Text("Day", style: Tulisan.body())),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text("Week", style: Tulisan.body(color: redColor)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_dateRangeTab("Oct\n5 - 11", false),
|
||||
_dateRangeTab("Oct\n12 - 18", false),
|
||||
_dateRangeTab("Oct\n19 - 25", false),
|
||||
_dateRangeTab("Oct - Nov\n26 - 1", true),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Center(
|
||||
child: Text("Total Income", style: Tulisan.body(fontsize: 14)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(child: Text("Rp$totalIncome", style: Tulisan.heading())),
|
||||
Center(
|
||||
child: Text(
|
||||
"$totalOrders Orders Completed",
|
||||
style: Tulisan.body(fontsize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text("Income Trend", style: Tulisan.subheading()),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: 250000,
|
||||
barTouchData: BarTouchData(enabled: true),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, _) {
|
||||
final label = weeklyData[value.toInt()]["label"];
|
||||
return Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
barGroups:
|
||||
weeklyData
|
||||
.asMap()
|
||||
.map(
|
||||
(index, data) => MapEntry(
|
||||
index,
|
||||
BarChartGroupData(
|
||||
x: index,
|
||||
barRods: [
|
||||
BarChartRodData(
|
||||
toY: data["amount"].toDouble(),
|
||||
width: 16,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.values
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
// ignore_for_file: use_build_context_synchronously
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:custom_refresh_indicator/custom_refresh_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:iconsax_flutter/iconsax_flutter.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/home/presentation/components/about_comp.dart';
|
||||
import 'package:rijig_mobile/features/home/presentation/components/article_list.dart';
|
||||
import 'package:rijig_mobile/globaldata/about/about_vmod.dart';
|
||||
import 'package:rijig_mobile/globaldata/article/article_vmod.dart';
|
||||
import 'package:rijig_mobile/widget/buttoncard.dart';
|
||||
import 'package:rijig_mobile/widget/card_withicon.dart';
|
||||
import 'package:rijig_mobile/widget/formfiled.dart';
|
||||
import 'package:rijig_mobile/widget/showmodal.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: whiteColor,
|
||||
body: CustomMaterialIndicator(
|
||||
onRefresh: () async {
|
||||
await Provider.of<AboutViewModel>(
|
||||
context,
|
||||
listen: false,
|
||||
).getAboutList();
|
||||
await Provider.of<ArticleViewModel>(
|
||||
context,
|
||||
listen: false,
|
||||
).loadArticles();
|
||||
},
|
||||
backgroundColor: whiteColor,
|
||||
indicatorBuilder: (context, controller) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: CircularProgressIndicator(
|
||||
color: primaryColor,
|
||||
value:
|
||||
controller.state.isLoading
|
||||
? null
|
||||
: math.min(controller.value, 1.0),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text("Rijig", style: Tulisan.heading(color: primaryColor)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
// onPressed: () => router.push('/trashview'),
|
||||
onPressed: () => router.push('/notifikasi'),
|
||||
icon: Icon(
|
||||
Iconsax.notification_copy,
|
||||
color: primaryColor,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
debugPrint('message icon tapped');
|
||||
// router.push('/cmapview');
|
||||
router.push('/chatlist');
|
||||
},
|
||||
icon: Icon(Iconsax.message_copy, color: primaryColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
CardWithIcon(
|
||||
icon: Iconsax.trash,
|
||||
text: 'Sampah',
|
||||
number: '245 kg',
|
||||
onTap: () {
|
||||
router.push('/dataperforma');
|
||||
},
|
||||
),
|
||||
CardWithIcon(
|
||||
icon: Iconsax.timer,
|
||||
text: 'Process',
|
||||
number: '1',
|
||||
onTap: () {
|
||||
CustomModalDialog.showWidget(
|
||||
customWidget: FormFieldOne(
|
||||
// controllers: cPhoneController,
|
||||
hintText: 'Masukkan nomor whatsapp anda!',
|
||||
placeholder: "cth.62..",
|
||||
isRequired: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.phone,
|
||||
onTap: () {},
|
||||
onChanged: (value) {},
|
||||
fontSize: 14,
|
||||
fontSizeField: 16,
|
||||
onFieldSubmitted: (value) {},
|
||||
readOnly: false,
|
||||
enabled: true,
|
||||
),
|
||||
context: context,
|
||||
// variant: ModalVariant.textVersion,
|
||||
// title: 'Belum Tersedia',
|
||||
// content: 'Maaf, fitur ini belum tersedia',
|
||||
buttonCount: 2,
|
||||
button1: CardButtonOne(
|
||||
textButton: "oke, deh",
|
||||
onTap: () {},
|
||||
fontSized: 14,
|
||||
colorText: whiteColor,
|
||||
color: primaryColor,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
loadingTrue: false,
|
||||
usingRow: false,
|
||||
),
|
||||
button2: CardButtonOne(
|
||||
textButton: "Batal",
|
||||
onTap: () => router.pop(),
|
||||
fontSized: 14,
|
||||
colorText: primaryColor,
|
||||
color: Colors.transparent,
|
||||
borderRadius: 10,
|
||||
horizontal: double.infinity,
|
||||
vertical: 50,
|
||||
loadingTrue: false,
|
||||
usingRow: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(20),
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Important!",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(15),
|
||||
AboutComponent(),
|
||||
],
|
||||
),
|
||||
Gap(20),
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Artikel",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(15),
|
||||
ArticleScreen(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||
import 'package:rijig_mobile/features/launch/model/onboard_model.dart';
|
||||
|
||||
class OnboardingView extends StatelessWidget {
|
||||
const OnboardingView({super.key, required this.data});
|
||||
|
||||
final OnboardingModel data;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
child: Padding(
|
||||
padding: PaddingCustom().paddingOnly(top: 40),
|
||||
child: Image.asset(data.imagePath, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: PaddingCustom().paddingAll(15),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(data.headline, style: Tulisan.heading()),
|
||||
Padding(
|
||||
padding: PaddingCustom().paddingVertical(15),
|
||||
child: Text(
|
||||
data.description,
|
||||
textAlign: TextAlign.center,
|
||||
style: Tulisan.customText(fontsize: 14.sp),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:rijig_mobile/features/launch/model/onboard_model.dart';
|
||||
|
||||
class OnboardingData {
|
||||
static List<OnboardingModel> items = [
|
||||
OnboardingModel(
|
||||
imagePath: "assets/image/onboard_first.png",
|
||||
headline: 'Sampahmu, Cuanmu',
|
||||
description:
|
||||
'Jual sampah dari rumah tanpa ribet. Bisa dapet duit, bisa bantu lingkungan juga!',
|
||||
),
|
||||
OnboardingModel(
|
||||
imagePath: "assets/image/waiting_oboard_sec.png",
|
||||
headline: 'Pilih Pengepul, Duduk Manis',
|
||||
description:
|
||||
'Tinggal pilih pengepul terdekat, mereka yang datang ke kamu. Praktis banget, kan?',
|
||||
),
|
||||
OnboardingModel(
|
||||
imagePath: "assets/image/onboard_third.png",
|
||||
headline: 'Ayo, Kita Jaga Bumi Bareng',
|
||||
description:
|
||||
'Satu langkah kecil bisa bikin perubahan besar. Yuk mulai sekarang — lanjut masuk dulu, ya!',
|
||||
),
|
||||
];
|
||||
}
|
||||