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
|
- platform: android
|
||||||
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
|
||||||
base_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
|
# User provided section
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ plugins {
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.rijig_mobile"
|
namespace = "com.example.rijig_mobile"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = "28.1.13356709"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<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
|
<application
|
||||||
android:label="rijig_mobile"
|
android:label="Rijig"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/launcher_icon"
|
||||||
android:enableOnBackInvokedCallback="true">
|
android:enableOnBackInvokedCallback="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|
@ -43,5 +45,10 @@
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="google.navigation" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</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;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
|
@ -484,7 +484,7 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
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_ANALYZER_NONNULL = YES;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
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":"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"}}
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
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/utils/exportimportview.dart';
|
||||||
import 'package:rijig_mobile/core/navigation.dart';
|
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.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';
|
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => SplashScreen()),
|
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(
|
GoRoute(
|
||||||
path: '/onboarding',
|
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: '/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(
|
GoRoute(
|
||||||
path: '/verif-otp',
|
path: '/verif-otp',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final phone = state.extra as String?;
|
dynamic phoneNumber = state.extra;
|
||||||
return VerifotpScreen(phone: phone!);
|
return VerifOtpScreen(phoneNumber: phoneNumber);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(path: '/setpin', builder: (context, state) => SetPinScreen()),
|
GoRoute(
|
||||||
GoRoute(path: '/verifpin', builder: (context, state) => VerifPinScreen()),
|
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(
|
GoRoute(
|
||||||
path: '/navigasi',
|
path: '/navigasi',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
dynamic data = state.extra;
|
final data = state.extra;
|
||||||
return NavigationPage(data: data);
|
return NavigationPage(data: data);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Rute untuk halaman-halaman utama
|
||||||
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
|
||||||
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/requestpickup',
|
path: '/notifikasi',
|
||||||
builder: (context, state) => RequestPickScreen(),
|
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: '/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';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
// =====================color schema=====================
|
// =====================color schema=====================
|
||||||
Color whiteColor = Color(0xffF0F1EA);
|
Color whiteColor = Color(0xffFBFBFB);
|
||||||
Color blackNavyColor = Color(0xff101010);
|
Color blackNavyColor = Color(0xff101010);
|
||||||
Color primaryColor = Color(0xff018558);
|
Color primaryColor = Color(0xff018558);
|
||||||
Color secondaryColor = Color(0xffBDE902);
|
Color secondaryColor = Color(0xffBDE902);
|
||||||
|
|
@ -24,26 +24,38 @@ FontWeight superBold = FontWeight.w900;
|
||||||
|
|
||||||
// =====================text behavior=====================
|
// =====================text behavior=====================
|
||||||
class Tulisan {
|
class Tulisan {
|
||||||
static TextStyle heading({Color? color}) {
|
static TextStyle heading({Color? color, double? fontsize}) {
|
||||||
return GoogleFonts.spaceGrotesk(
|
return GoogleFonts.spaceGrotesk(
|
||||||
fontSize: 24.sp,
|
fontSize: fontsize?.sp ?? 24.sp,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: extraBold,
|
||||||
color: color ?? blackNavyColor,
|
color: color ?? blackNavyColor,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static TextStyle body({Color? color}) {
|
static TextStyle body({Color? color, double? fontsize}) {
|
||||||
return GoogleFonts.spaceMono(
|
return GoogleFonts.spaceMono(
|
||||||
fontSize: 16.sp,
|
fontSize: fontsize?.sp ?? 16.sp,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: regular,
|
||||||
color: color ?? blackNavyColor,
|
color: color ?? blackNavyColor,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static TextStyle subheading({Color? color}) {
|
static TextStyle subheading({Color? color, double? fontsize}) {
|
||||||
return GoogleFonts.spaceGrotesk(
|
return GoogleFonts.spaceGrotesk(
|
||||||
fontSize: 18.sp,
|
fontSize: fontsize?.sp ?? 18.sp,
|
||||||
fontWeight: FontWeight.w500,
|
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,
|
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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:rijig_mobile/core/guide.dart';
|
import 'package:rijig_mobile/core/utils/guide.dart';
|
||||||
import 'package:rijig_mobile/model/product.dart';
|
import 'package:rijig_mobile/features/home/model/product.dart';
|
||||||
|
|
||||||
class ProductCard extends StatelessWidget {
|
class ProductCard extends StatelessWidget {
|
||||||
const ProductCard({
|
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!',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||