Compare commits

...

41 Commits

Author SHA1 Message Date
pahmiudahgede b749ebcc4e fix: fixing navaigation bar 2025-06-15 12:01:00 +07:00
pahmiudahgede 5eed472971 feat: UI impelemenatation 2025-06-15 09:09:33 +07:00
pahmiudahgede 918e3c0ced feat: feature cart and integrate with trash data us u can see 2025-05-29 05:28:09 +07:00
pahmiudahgede 4db92f3d22 feat&refact&imp: code modal internet check and prepare UI statement 2025-05-28 18:06:31 +07:00
pahmiudahgede 42cd53752b impl: trying to excecute UI idea 2025-05-28 00:10:20 +07:00
pahmiudahgede f4259a785e fix: fixing pushing FaB when snackbar showed 2025-05-27 10:47:58 +07:00
pahmiudahgede 6770e70edd update: improve splash screen transition to navigation 2025-05-27 10:14:55 +07:00
pahmiudahgede de01c1acce feat: implemenat widget modal and show dialog 2025-05-27 08:36:25 +07:00
pahmiudahgede c87d820a9d feat: add feature bar chart to visualization trash perform 2025-05-27 05:34:18 +07:00
pahmiudahgede b997980f1c refact:modified adding debugPrint in response(data) 2025-05-27 01:37:50 +07:00
pahmiudahgede b988933413 fix: fixing laoyout in login screen 2025-05-24 17:14:08 +07:00
pahmiudahgede ce514d0a27 feat: adding map screen and launch to google maps 2025-05-24 11:02:04 +07:00
pahmiudahgede c214fdaa8a feat 2025-05-23 11:15:25 +07:00
pahmiudahgede 31bbcabf16 feat: initiate ocr tech in upload ktp 2025-05-23 02:59:57 +07:00
pahmiudahgede a832485e86 fixing depedency 2025-05-23 01:30:23 +07:00
pahmiudahgede 972de38377 feat: refactoring and tidy up code and add google_ml_kit 2025-05-22 22:58:15 +07:00
pahmiudahgede 63ba97382b feat: add UI for pickup 2025-05-20 02:40:06 +07:00
pahmiudahgede e0060e244d refact 2025-05-20 01:36:17 +07:00
pahmiudahgede 83e65714ad feat: add feature cart integrate eith API and life cycle 2025-05-19 20:58:32 +07:00
pahmiudahgede 0d129218de refact: refactoring code and style 2025-05-18 00:55:21 +07:00
pahmiudahgede 4f84abfeee feat&refact: improve stle and add feature article(uncompleted) 2025-05-17 21:32:38 +07:00
pahmiudahgede 7d3f129748 update: change var text and icon 2025-05-17 17:16:17 +07:00
pahmiudahgede 3359304d7a refact: add color gradient 2025-05-17 17:11:33 +07:00
pahmiudahgede 0cf104c5d9 feat: implemenat refresh indicator 2025-05-17 16:37:22 +07:00
pahmiudahgede b951af1eec refact: improve counter logic in cart 2025-05-17 15:54:38 +07:00
pahmiudahgede e1f62103b9 feat: add depedency for local storage, modal, and toast 2025-05-17 14:05:58 +07:00
pahmiudahgede 12ecf1c84b feat&fix: add screen for cart and change color bg 2025-05-17 01:17:10 +07:00
pahmiudahgede 9c61b87c6d feat: initiate activity screen with TabBar 2025-05-17 00:35:12 +07:00
pahmiudahgede 5b94690d02 feat: add view of profile page (uncompleted yet) 2025-05-17 00:11:44 +07:00
pahmiudahgede 1785fbceb7 update: improve login and verif otp screen 2025-05-16 22:17:45 +07:00
pahmiudahgede cbcb250be3 update: improve onbaording looks 2025-05-16 21:28:25 +07:00
pahmiudahgede 71c7e498b6 fix:fixing controller and remove app bar 2025-05-16 08:17:52 +07:00
pahmiudahgede b758b98271 update:change splashscreen view 2025-05-16 08:07:43 +07:00
pahmiudahgede 5ac66566d2 feat&fix: add launcher icon and fix model for trash 2025-05-16 08:03:07 +07:00
pahmiudahgede 054640e563 refact: remove pickture in splash screen and centering text 2025-05-15 18:02:44 +07:00
pahmiudahgede 9bbfd4529a feat: add feature about detail and prepare repsponse 2025-05-15 16:27:07 +07:00
pahmiudahgede 0fbbb807d9 feat: add feature get trash category 2025-05-15 02:36:15 +07:00
pahmiudahgede 9f869503b2 fix: unused variable 2025-05-14 20:18:30 +07:00
pahmiudahgede 45e35e6e85 refact:tidy up code 2025-05-14 20:17:38 +07:00
pahmiudahgede 438c95746f feat: logout behavior 2025-05-14 17:00:40 +07:00
pahmiudahgede 801698262b fixing: auth behavior 2025-05-14 14:56:27 +07:00
176 changed files with 19355 additions and 1954 deletions

View File

@ -18,21 +18,6 @@ migration:
- platform: android
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: ios
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: linux
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: macos
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: web
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
- platform: windows
create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf
# User provided section

View File

@ -8,7 +8,7 @@ plugins {
android {
namespace = "com.example.rijig_mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
ndkVersion = "28.1.13356709"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11

View File

@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="rijig_mobile"
android:label="Rijig"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/launcher_icon"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
@ -43,5 +45,10 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
<category android:name="android.intent.category.DEFAULT" />
</intent>
</queries>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -427,7 +427,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";

View File

@ -1,122 +1 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -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';
}

View File

@ -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';
}

View File

@ -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);
}

View File

@ -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';

View File

@ -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>()),
);
}

View File

@ -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;
}

View File

@ -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)),
],
),
),
),
);
}
}

View File

@ -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';
}
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -1,48 +1,169 @@
import 'package:go_router/go_router.dart';
import 'package:rijig_mobile/core/navigation.dart';
import 'package:rijig_mobile/screen/app/activity/activity_screen.dart';
import 'package:rijig_mobile/screen/app/cart/cart_screen.dart';
import 'package:rijig_mobile/screen/app/home/home_screen.dart';
import 'package:rijig_mobile/screen/app/profil/profil_screen.dart';
import 'package:rijig_mobile/screen/app/requestpick/requestpickup_screen.dart';
import 'package:rijig_mobile/screen/auth/inputpin_screen.dart';
import 'package:rijig_mobile/screen/auth/login_screen.dart';
import 'package:rijig_mobile/screen/auth/otp_screen.dart';
import 'package:rijig_mobile/screen/auth/verifpin_screen.dart';
import 'package:rijig_mobile/screen/launch/onboardingpage_screen.dart';
import 'package:rijig_mobile/screen/launch/splash_screen.dart';
import 'package:rijig_mobile/core/utils/exportimportview.dart';
import 'package:rijig_mobile/features/chat/presentation/screen/chatroom_screen.dart';
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => SplashScreen()),
// GoRoute(
// path: '/',
// builder: (context, state) => UploadKtpScreen(),
// ),
GoRoute(
path: '/trashview',
builder: (context, state) => TestRequestPickScreen(),
),
GoRoute(
path: '/ordersumary',
builder: (context, state) => OrderSummaryScreen(),
),
GoRoute(
path: '/pinsecureinput',
builder: (context, state) => SecurityCodeScreen(),
),
GoRoute(
path: '/cmapview',
builder: (context, state) => CollectorRouteMapScreen(),
),
GoRoute(
path: '/onboarding',
builder: (context, state) => OnboardingPageScreen(),
pageBuilder: (context, state) {
var key = state.pageKey;
return transisi(OnboardingPageScreen(), key);
},
),
GoRoute(path: '/login', builder: (context, state) => LoginScreen()),
GoRoute(path: '/clogin', builder: (context, state) => CloginScreen()),
GoRoute(
path: '/welcomec',
builder: (context, state) => WelcomeCollectorScreen(),
),
GoRoute(
path: '/verifidentity',
builder: (context, state) => UploadKtpScreen(),
),
GoRoute(
path: '/berandapengepul',
builder: (context, state) => ChomeCollectorScreen(),
),
GoRoute(
path: '/cpickuphistory',
builder: (context, state) => PickupHistoryScreen(),
),
// Rute untuk verifikasi OTP dengan ekstraksi data dari path
GoRoute(
path: '/verif-otp',
builder: (context, state) {
final phone = state.extra as String?;
return VerifotpScreen(phone: phone!);
dynamic phoneNumber = state.extra;
return VerifOtpScreen(phoneNumber: phoneNumber);
},
),
GoRoute(path: '/setpin', builder: (context, state) => SetPinScreen()),
GoRoute(path: '/verifpin', builder: (context, state) => VerifPinScreen()),
GoRoute(
path: '/cverif-otp',
builder: (context, state) {
// dynamic phoneNumber = state.extra;
return CverifOtpScreen();
},
),
// GoRoute(path: '/setpin', builder: (context, state) => InputPinScreen()),
// GoRoute(path: '/verifpin', builder: (context, state) => VerifPinScreen()),
// Rute dengan parameter dinamis untuk halaman navigasi
GoRoute(
path: '/navigasi',
builder: (context, state) {
dynamic data = state.extra;
final data = state.extra;
return NavigationPage(data: data);
},
),
// Rute untuk halaman-halaman utama
GoRoute(path: '/home', builder: (context, state) => HomeScreen()),
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
GoRoute(
path: '/requestpickup',
builder: (context, state) => RequestPickScreen(),
path: '/notifikasi',
builder: (context, state) => NotificationScreen(),
),
GoRoute(path: '/cart', builder: (context, state) => CartScreen()),
GoRoute(path: '/chatlist', builder: (context, state) => ChatListScreen()),
// Router config
GoRoute(
path: '/chatroom/:contactId',
builder: (context, state) {
final contactName = state.uri.queryParameters['name'] ?? 'Unknown';
final contactImage = state.uri.queryParameters['image'] ?? '';
final isOnline = state.uri.queryParameters['online'] == 'true';
return ChatRoomScreen(
contactName: contactName,
contactImage: contactImage,
isOnline: isOnline,
);
},
),
GoRoute(
path: '/dataperforma',
builder: (context, state) => DatavisualizedScreen(),
),
GoRoute(path: '/activity', builder: (context, state) => ActivityScreen()),
GoRoute(path: '/profil', builder: (context, state) => ProfilScreen()),
GoRoute(path: '/akunprofil', builder: (context, state) => AccountScreen()),
GoRoute(path: '/address', builder: (context, state) => AddressScreen()),
GoRoute(
path: '/addaddress',
builder: (context, state) => AddAddressScreen(),
),
GoRoute(
path: '/editaddress',
builder: (context, state) {
dynamic address = state.extra;
return EditAddressScreen(address: address);
},
),
GoRoute(
path: '/aboutdetail',
builder: (context, state) {
dynamic data = state.extra;
return AboutDetailScreenComp(data: data);
},
),
GoRoute(
path: '/artikeldetail',
builder: (context, state) {
dynamic data = state.extra;
return ArticleDetailScreen(data: data);
},
),
GoRoute(
path: '/pickupmethod',
builder: (context, state) {
dynamic data = state.extra;
return PickupScreen(data: data);
},
),
GoRoute(
path: '/pilihpengepul',
builder: (context, state) {
return SelectCollectorScreen();
},
),
],
);
CustomTransitionPage transisi(Widget page, key) {
return CustomTransitionPage(
transitionDuration: const Duration(milliseconds: 1200),
child: page,
key: key,
transitionsBuilder: (key, animation, secondaryAnimation, page) {
return FadeTransition(
opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
child: page,
);
},
);
}

View File

@ -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');
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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;
}

View File

@ -3,7 +3,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
// =====================color schema=====================
Color whiteColor = Color(0xffF0F1EA);
Color whiteColor = Color(0xffFBFBFB);
Color blackNavyColor = Color(0xff101010);
Color primaryColor = Color(0xff018558);
Color secondaryColor = Color(0xffBDE902);
@ -24,26 +24,38 @@ FontWeight superBold = FontWeight.w900;
// =====================text behavior=====================
class Tulisan {
static TextStyle heading({Color? color}) {
static TextStyle heading({Color? color, double? fontsize}) {
return GoogleFonts.spaceGrotesk(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
fontSize: fontsize?.sp ?? 24.sp,
fontWeight: extraBold,
color: color ?? blackNavyColor,
);
}
static TextStyle body({Color? color}) {
static TextStyle body({Color? color, double? fontsize}) {
return GoogleFonts.spaceMono(
fontSize: 16.sp,
fontWeight: FontWeight.w400,
fontSize: fontsize?.sp ?? 16.sp,
fontWeight: regular,
color: color ?? blackNavyColor,
);
}
static TextStyle subheading({Color? color}) {
static TextStyle subheading({Color? color, double? fontsize}) {
return GoogleFonts.spaceGrotesk(
fontSize: 18.sp,
fontWeight: FontWeight.w500,
fontSize: fontsize?.sp ?? 18.sp,
fontWeight: medium,
color: color ?? blackNavyColor,
);
}
static TextStyle customText({
Color? color,
double? fontsize,
FontWeight? fontWeight,
}) {
return GoogleFonts.spaceGrotesk(
fontSize: fontsize?.sp ?? 16.sp,
fontWeight: fontWeight ?? regular,
color: color ?? blackNavyColor,
);
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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,
}

View File

@ -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']);
}
}

View File

@ -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']);
}
}

View File

@ -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'],
);
}
}

View File

@ -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'],
);
}
}

View File

@ -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'],
);
}
}

View File

@ -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;
// }
// }
// }

View File

@ -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?"),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@ -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(),
);
}
}

View File

@ -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,
),
],
),
),
),
),
);
}
}

View File

@ -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(),
);
}
}

View File

@ -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),
],
),
),
),
);
}
}

View File

@ -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"),
// ),
// ],
// ),
// ),
// );
// }
// }

View File

@ -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"),
),
],
),
],
),
),
),
),
);
},
),
);
}
}

View File

@ -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),
),
],
),
),
),
);
},
),
);
}
}

View File

@ -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"),
// ),
// ],
// ),
// ),
// );
// }
// }

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
// }
// }
// }

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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');
}
}
}

View File

@ -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');
}
}
}

View File

@ -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');
}
}
}

View File

@ -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;
}

View File

@ -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]}.',
);
}
}

View File

@ -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]}.')}';
}
}

View File

@ -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',
);
}
}

View File

@ -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;
}
}

View File

@ -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,
});
}

View File

@ -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,
});
}

View File

@ -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,
),
),
],
],
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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;
}
}

View File

@ -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)]),
],
);
}
}

View File

@ -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 }

View File

@ -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(),
),
],
);
},
);
}
}

View File

@ -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),
),
],
),
);
},
);
},
),
);
}
}

View File

@ -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()),
],
),
),
);
}
}

View File

@ -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,
),
),
],
),
),
],
),
),
);
},
),
);
},
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:rijig_mobile/core/guide.dart';
import 'package:rijig_mobile/model/product.dart';
import 'package:rijig_mobile/core/utils/guide.dart';
import 'package:rijig_mobile/features/home/model/product.dart';
class ProductCard extends StatelessWidget {
const ProductCard({

View File

@ -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(),
),
);
}
}

View File

@ -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(),
),
),
),
),
],
),
);
}
}

View File

@ -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(),
],
),
],
),
),
),
);
}
}

View File

@ -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),
],
);
}
}

View File

@ -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!',
),
];
}

Some files were not shown because too many files have changed in this diff Show More