feat: Add document verification and rejection functionality with remote logging support
This commit is contained in:
parent
a02887845b
commit
6d70afc779
@ -75,6 +75,9 @@ class LoginController extends MyController {
|
|||||||
basicValidator.clearErrors();
|
basicValidator.clearErrors();
|
||||||
} else {
|
} else {
|
||||||
await _handleRememberMe();
|
await _handleRememberMe();
|
||||||
|
// ✅ Enable remote logging after successful login
|
||||||
|
enableRemoteLogging();
|
||||||
|
logSafe("✅ Remote logging enabled after login.");
|
||||||
|
|
||||||
// ✅ Commented out FCM token registration after login
|
// ✅ Commented out FCM token registration after login
|
||||||
/*
|
/*
|
||||||
|
@ -45,6 +45,26 @@ class DocumentDetailsController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify document
|
||||||
|
Future<bool> verifyDocument(String documentId) async {
|
||||||
|
final result =
|
||||||
|
await ApiService.verifyDocumentApi(id: documentId, isVerify: true);
|
||||||
|
if (result) {
|
||||||
|
await fetchDocumentDetails(documentId); // refresh details
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject document
|
||||||
|
Future<bool> rejectDocument(String documentId) async {
|
||||||
|
final result =
|
||||||
|
await ApiService.verifyDocumentApi(id: documentId, isVerify: false);
|
||||||
|
if (result) {
|
||||||
|
await fetchDocumentDetails(documentId); // refresh details
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch Pre-Signed URL for a given version
|
/// Fetch Pre-Signed URL for a given version
|
||||||
Future<String?> fetchPresignedUrl(String versionId) async {
|
Future<String?> fetchPresignedUrl(String versionId) async {
|
||||||
return await ApiService.getPresignedUrlApi(versionId);
|
return await ApiService.getPresignedUrlApi(versionId);
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
class ApiEndpoints {
|
class ApiEndpoints {
|
||||||
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
static const String baseUrl = "https://stageapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
//
|
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
|
|
||||||
// Dashboard Module API Endpoints
|
// Dashboard Module API Endpoints
|
||||||
static const String getDashboardAttendanceOverview =
|
static const String getDashboardAttendanceOverview =
|
||||||
"/dashboard/attendance-overview";
|
"/dashboard/attendance-overview";
|
||||||
@ -85,4 +86,8 @@ class ApiEndpoints {
|
|||||||
static const String getDocumentVersion = "/document/get/version";
|
static const String getDocumentVersion = "/document/get/version";
|
||||||
static const String getDocumentVersions = "/document/list/versions";
|
static const String getDocumentVersions = "/document/list/versions";
|
||||||
static const String editDocument = "/document/edit";
|
static const String editDocument = "/document/edit";
|
||||||
|
static const String verifyDocument = "/document/verify";
|
||||||
|
|
||||||
|
/// Logs Module API Endpoints
|
||||||
|
static const String uploadLogs = "/log";
|
||||||
}
|
}
|
||||||
|
@ -247,6 +247,89 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<bool> postLogsApi(List<Map<String, dynamic>> logs) async {
|
||||||
|
const endpoint = "${ApiEndpoints.uploadLogs}";
|
||||||
|
logSafe("Posting logs... count=${logs.length}");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await _postRequest(endpoint, logs, customTimeout: extendedTimeout);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Post logs failed: null response", level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Post logs response status: ${response.statusCode}");
|
||||||
|
logSafe("Post logs response body: ${response.body}");
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.body.isNotEmpty) {
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Logs posted successfully.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Failed to post logs: ${response.body}", level: LogLevel.warning);
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during postLogsApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify Document API
|
||||||
|
static Future<bool> verifyDocumentApi({
|
||||||
|
required String id,
|
||||||
|
bool isVerify = true,
|
||||||
|
}) async {
|
||||||
|
final endpoint = "${ApiEndpoints.verifyDocument}/$id";
|
||||||
|
final queryParams = {"isVerify": isVerify.toString()};
|
||||||
|
logSafe("Verifying document with id: $id | isVerify: $isVerify");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint")
|
||||||
|
.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
|
String? token = await _getToken();
|
||||||
|
if (token == null) return false;
|
||||||
|
|
||||||
|
final headers = _headers(token);
|
||||||
|
logSafe("POST (verify) $uri\nHeaders: $headers");
|
||||||
|
|
||||||
|
final response =
|
||||||
|
await http.post(uri, headers: headers).timeout(extendedTimeout);
|
||||||
|
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
logSafe("Unauthorized VERIFY. Attempting token refresh...");
|
||||||
|
if (await AuthService.refreshToken()) {
|
||||||
|
return await verifyDocumentApi(id: id, isVerify: isVerify);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Verify document response status: ${response.statusCode}");
|
||||||
|
logSafe("Verify document response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Document verify success: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to verify document: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during verifyDocumentApi: $e", level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get Pre-Signed URL for Old Version
|
/// Get Pre-Signed URL for Old Version
|
||||||
static Future<String?> getPresignedUrlApi(String versionId) async {
|
static Future<String?> getPresignedUrlApi(String versionId) async {
|
||||||
final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId";
|
final endpoint = "${ApiEndpoints.getDocumentVersion}/$versionId";
|
||||||
|
@ -2,16 +2,41 @@ import 'dart:io';
|
|||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
|
||||||
/// Global logger instance
|
/// Global logger instance
|
||||||
late final Logger appLogger;
|
Logger? _appLogger;
|
||||||
late final FileLogOutput fileLogOutput;
|
late final FileLogOutput _fileLogOutput;
|
||||||
|
|
||||||
|
/// Store logs temporarily for API posting
|
||||||
|
final List<Map<String, dynamic>> _logBuffer = [];
|
||||||
|
|
||||||
|
/// Lock flag to prevent concurrent posting
|
||||||
|
bool _isPosting = false;
|
||||||
|
|
||||||
|
/// Flag to allow API posting only after login
|
||||||
|
bool _canPostLogs = false;
|
||||||
|
|
||||||
|
/// Maximum number of logs before triggering API post
|
||||||
|
const int _maxLogsBeforePost = 50;
|
||||||
|
|
||||||
|
/// Maximum logs in memory buffer
|
||||||
|
const int _maxBufferSize = 50;
|
||||||
|
|
||||||
|
/// Enum → logger level mapping
|
||||||
|
const _levelMap = {
|
||||||
|
LogLevel.debug: Level.debug,
|
||||||
|
LogLevel.info: Level.info,
|
||||||
|
LogLevel.warning: Level.warning,
|
||||||
|
LogLevel.error: Level.error,
|
||||||
|
LogLevel.verbose: Level.verbose,
|
||||||
|
};
|
||||||
|
|
||||||
/// Initialize logging
|
/// Initialize logging
|
||||||
Future<void> initLogging() async {
|
Future<void> initLogging() async {
|
||||||
fileLogOutput = FileLogOutput();
|
_fileLogOutput = FileLogOutput();
|
||||||
|
|
||||||
appLogger = Logger(
|
_appLogger = Logger(
|
||||||
printer: PrettyPrinter(
|
printer: PrettyPrinter(
|
||||||
methodCount: 0,
|
methodCount: 0,
|
||||||
printTime: true,
|
printTime: true,
|
||||||
@ -20,12 +45,18 @@ Future<void> initLogging() async {
|
|||||||
),
|
),
|
||||||
output: MultiOutput([
|
output: MultiOutput([
|
||||||
ConsoleOutput(),
|
ConsoleOutput(),
|
||||||
fileLogOutput,
|
_fileLogOutput,
|
||||||
]),
|
]),
|
||||||
level: Level.debug,
|
level: Level.debug,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable API posting after login
|
||||||
|
void enableRemoteLogging() {
|
||||||
|
_canPostLogs = true;
|
||||||
|
_postBufferedLogs(); // flush logs if any
|
||||||
|
}
|
||||||
|
|
||||||
/// Safe logger wrapper
|
/// Safe logger wrapper
|
||||||
void logSafe(
|
void logSafe(
|
||||||
String message, {
|
String message, {
|
||||||
@ -34,27 +65,60 @@ void logSafe(
|
|||||||
StackTrace? stackTrace,
|
StackTrace? stackTrace,
|
||||||
bool sensitive = false,
|
bool sensitive = false,
|
||||||
}) {
|
}) {
|
||||||
if (sensitive) return;
|
if (sensitive || _appLogger == null) return;
|
||||||
|
|
||||||
switch (level) {
|
final loggerLevel = _levelMap[level] ?? Level.info;
|
||||||
case LogLevel.debug:
|
_appLogger!.log(loggerLevel, message, error: error, stackTrace: stackTrace);
|
||||||
appLogger.d(message, error: error, stackTrace: stackTrace);
|
|
||||||
break;
|
// Buffer logs for API posting
|
||||||
case LogLevel.warning:
|
_logBuffer.add({
|
||||||
appLogger.w(message, error: error, stackTrace: stackTrace);
|
"logLevel": level.name,
|
||||||
break;
|
"message": message,
|
||||||
case LogLevel.error:
|
"timeStamp": DateTime.now().toUtc().toIso8601String(),
|
||||||
appLogger.e(message, error: error, stackTrace: stackTrace);
|
"ipAddress": "this is test IP", // TODO: real IP
|
||||||
break;
|
"userAgent": "FlutterApp/1.0", // TODO: device_info_plus
|
||||||
case LogLevel.verbose:
|
"details": error?.toString() ?? stackTrace?.toString(),
|
||||||
appLogger.v(message, error: error, stackTrace: stackTrace);
|
});
|
||||||
break;
|
|
||||||
default:
|
if (_logBuffer.length >= _maxLogsBeforePost) {
|
||||||
appLogger.i(message, error: error, stackTrace: stackTrace);
|
_postBufferedLogs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log output to file (safe path, no permission required)
|
/// Post buffered logs to API
|
||||||
|
Future<void> _postBufferedLogs() async {
|
||||||
|
if (!_canPostLogs) return; // 🚫 skip if not logged in
|
||||||
|
if (_isPosting || _logBuffer.isEmpty) return;
|
||||||
|
|
||||||
|
_isPosting = true;
|
||||||
|
final logsToSend = List<Map<String, dynamic>>.from(_logBuffer);
|
||||||
|
_logBuffer.clear();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final success = await ApiService.postLogsApi(logsToSend);
|
||||||
|
if (!success) {
|
||||||
|
_reinsertLogs(logsToSend, reason: "API call returned false");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_reinsertLogs(logsToSend, reason: "API exception: $e");
|
||||||
|
} finally {
|
||||||
|
_isPosting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reinsert logs into buffer if posting fails
|
||||||
|
void _reinsertLogs(List<Map<String, dynamic>> logs, {required String reason}) {
|
||||||
|
_appLogger?.w("Failed to post logs, re-queuing. Reason: $reason");
|
||||||
|
|
||||||
|
if (_logBuffer.length + logs.length > _maxBufferSize) {
|
||||||
|
_appLogger?.e("Buffer full. Dropping ${logs.length} logs to prevent crash.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logBuffer.insertAll(0, logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File-based log output (safe storage)
|
||||||
class FileLogOutput extends LogOutput {
|
class FileLogOutput extends LogOutput {
|
||||||
File? _logFile;
|
File? _logFile;
|
||||||
|
|
||||||
@ -81,7 +145,6 @@ class FileLogOutput extends LogOutput {
|
|||||||
@override
|
@override
|
||||||
void output(OutputEvent event) async {
|
void output(OutputEvent event) async {
|
||||||
await _init();
|
await _init();
|
||||||
|
|
||||||
if (event.lines.isEmpty) return;
|
if (event.lines.isEmpty) return;
|
||||||
|
|
||||||
final logMessage = event.lines.join('\n') + '\n';
|
final logMessage = event.lines.join('\n') + '\n';
|
||||||
@ -122,22 +185,5 @@ class FileLogOutput extends LogOutput {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple log printer for file output
|
/// Custom log levels
|
||||||
class SimpleFileLogPrinter extends LogPrinter {
|
|
||||||
@override
|
|
||||||
List<String> log(LogEvent event) {
|
|
||||||
final message = event.message.toString();
|
|
||||||
|
|
||||||
if (message.contains('[SENSITIVE]')) return [];
|
|
||||||
|
|
||||||
final timestamp = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
|
|
||||||
final level = event.level.name.toUpperCase();
|
|
||||||
final error = event.error != null ? ' | ERROR: ${event.error}' : '';
|
|
||||||
final stack =
|
|
||||||
event.stackTrace != null ? '\nSTACKTRACE:\n${event.stackTrace}' : '';
|
|
||||||
return ['[$timestamp] [$level] $message$error$stack'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optional enum for log levels
|
|
||||||
enum LogLevel { debug, info, warning, error, verbose }
|
enum LogLevel { debug, info, warning, error, verbose }
|
||||||
|
@ -13,7 +13,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
await initLogging();
|
await initLogging();
|
||||||
logSafe("App starting...");
|
logSafe("App starting...");
|
||||||
|
enableRemoteLogging();
|
||||||
try {
|
try {
|
||||||
await initializeApp();
|
await initializeApp();
|
||||||
logSafe("App initialized successfully.");
|
logSafe("App initialized successfully.");
|
||||||
@ -73,9 +73,11 @@ class _MainWrapperState extends State<MainWrapper> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bool isOffline = _connectivityStatus.contains(ConnectivityResult.none);
|
final bool isOffline =
|
||||||
|
_connectivityStatus.contains(ConnectivityResult.none);
|
||||||
return isOffline
|
return isOffline
|
||||||
? const MaterialApp(debugShowCheckedModeBanner: false, home: OfflineScreen())
|
? const MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false, home: OfflineScreen())
|
||||||
: const MyApp();
|
: const MyApp();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -229,6 +229,81 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
|||||||
_buildDetailRow("Uploaded On", uploadDate),
|
_buildDetailRow("Uploaded On", uploadDate),
|
||||||
if (doc.updatedAt != null)
|
if (doc.updatedAt != null)
|
||||||
_buildDetailRow("Last Updated On", updateDate),
|
_buildDetailRow("Last Updated On", updateDate),
|
||||||
|
MySpacing.height(12),
|
||||||
|
if (permissionController
|
||||||
|
.hasPermission(Permissions.verifyDocument)) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.check, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Verify",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await controller.verifyDocument(doc.id);
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Document verified successfully",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to verify document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Reject",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final success = await controller.rejectDocument(doc.id);
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Rejected",
|
||||||
|
message: "Document rejected successfully",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Error",
|
||||||
|
message: "Failed to reject document",
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user