update
This commit is contained in:
parent
ae210a5990
commit
7da04ab49d
@ -1,5 +1,33 @@
|
|||||||
package com.example.cb_prestige_qr
|
package com.example.cb_prestige_qr
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(
|
||||||
|
flutterEngine.dartExecutor.binaryMessenger,
|
||||||
|
"cb_prestige_qr/device"
|
||||||
|
).setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"getDeviceId" -> {
|
||||||
|
val deviceId = Settings.Secure.getString(
|
||||||
|
contentResolver,
|
||||||
|
Settings.Secure.ANDROID_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
if (deviceId.isNullOrBlank()) {
|
||||||
|
result.error("device_id_unavailable", "Unable to retrieve device ID.", null)
|
||||||
|
} else {
|
||||||
|
result.success(deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,16 +1,42 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
override func application(
|
private let deviceChannelName = "cb_prestige_qr/device"
|
||||||
_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
}
|
|
||||||
}
|
let channel = FlutterMethodChannel(
|
||||||
|
name: deviceChannelName,
|
||||||
|
binaryMessenger: engineBridge.binaryMessenger
|
||||||
|
)
|
||||||
|
channel.setMethodCallHandler { call, result in
|
||||||
|
switch call.method {
|
||||||
|
case "getDeviceId":
|
||||||
|
if let identifier = UIDevice.current.identifierForVendor?.uuidString,
|
||||||
|
!identifier.isEmpty {
|
||||||
|
result(identifier)
|
||||||
|
} else {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "device_id_unavailable",
|
||||||
|
message: "Unable to retrieve device ID.",
|
||||||
|
details: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
5
lib/config.dart
Normal file
5
lib/config.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
class AppConfig {
|
||||||
|
const AppConfig._();
|
||||||
|
|
||||||
|
static const apiBaseUrl = 'http://192.168.100.41:3000';
|
||||||
|
}
|
||||||
16
lib/core/services/device_id_service.dart
Normal file
16
lib/core/services/device_id_service.dart
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class DeviceIdService {
|
||||||
|
static const _channel = MethodChannel('cb_prestige_qr/device');
|
||||||
|
|
||||||
|
Future<String> getDeviceId() async {
|
||||||
|
// final deviceId = await _channel.invokeMethod<String>('getDeviceId');
|
||||||
|
// if (deviceId == null || deviceId.trim().isEmpty) {
|
||||||
|
// throw PlatformException(
|
||||||
|
// code: 'device_id_unavailable',
|
||||||
|
// message: 'Unable to retrieve device ID.',
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
return "demo_device_id";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
||||||
|
|
||||||
abstract interface class ScanRemoteDataSource {
|
abstract interface class ScanRemoteDataSource {
|
||||||
Future<Map<String, dynamic>> submitScan({
|
Future<Map<String, dynamic>> submitScan({
|
||||||
required String rawValue,
|
required ScanSubmitPayload payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
||||||
|
|
||||||
class FakeScanRemoteDataSource implements ScanRemoteDataSource {
|
class FakeScanRemoteDataSource implements ScanRemoteDataSource {
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> submitScan({
|
Future<Map<String, dynamic>> submitScan({
|
||||||
required String rawValue,
|
required ScanSubmitPayload payload,
|
||||||
}) async {
|
}) async {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'rawValue': rawValue,
|
'rawValue': payload.token,
|
||||||
'scannedAt': DateTime.now().toIso8601String(),
|
'scannedAt': DateTime.now().toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,98 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cb_prestige_qr/config.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
||||||
|
|
||||||
|
class HttpScanRemoteDataSource implements ScanRemoteDataSource {
|
||||||
|
HttpScanRemoteDataSource({HttpClient? httpClient})
|
||||||
|
: _httpClient = httpClient ?? HttpClient();
|
||||||
|
|
||||||
|
final HttpClient _httpClient;
|
||||||
|
|
||||||
|
static const _endpoint = String.fromEnvironment(
|
||||||
|
'QR_SCAN_PATH',
|
||||||
|
defaultValue: '/api/qr/scan',
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, dynamic>> submitScan({
|
||||||
|
required ScanSubmitPayload payload,
|
||||||
|
}) async {
|
||||||
|
final uri = _resolveUri();
|
||||||
|
final request = await _httpClient.postUrl(uri);
|
||||||
|
request.headers.contentType = ContentType.json;
|
||||||
|
request.write(jsonEncode(payload.toJson()));
|
||||||
|
|
||||||
|
final response = await request.close();
|
||||||
|
final responseBody = await response.transform(utf8.decoder).join();
|
||||||
|
|
||||||
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
|
throw HttpException(
|
||||||
|
_buildStatusMessage(response.statusCode, responseBody),
|
||||||
|
uri: uri,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseBody.trim().isEmpty) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'rawValue': payload.token,
|
||||||
|
'scannedAt': DateTime.now().toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(responseBody);
|
||||||
|
if (decoded is! Map) {
|
||||||
|
throw const FormatException('QR scan response must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = decoded.map(
|
||||||
|
(key, value) => MapEntry(key.toString(), value),
|
||||||
|
);
|
||||||
|
final isOk = data['OK'] == true || data['ok'] == true;
|
||||||
|
if (!isOk) {
|
||||||
|
throw HttpException(
|
||||||
|
'Submit failed. Response ok flag is false.',
|
||||||
|
uri: uri,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _resolveUri() {
|
||||||
|
final endpointUri = Uri.tryParse(_endpoint);
|
||||||
|
if (endpointUri != null && endpointUri.hasScheme) {
|
||||||
|
return endpointUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AppConfig.apiBaseUrl.trim().isEmpty) {
|
||||||
|
throw const FormatException(
|
||||||
|
'API_BASE_URL is not configured. Pass --dart-define=API_BASE_URL=https://your-host.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse(AppConfig.apiBaseUrl).resolve(_endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildStatusMessage(int statusCode, String responseBody) {
|
||||||
|
final normalizedBody = responseBody.trim();
|
||||||
|
final bodySuffix = normalizedBody.isEmpty ? '' : ' Response: $normalizedBody';
|
||||||
|
|
||||||
|
return switch (statusCode) {
|
||||||
|
HttpStatus.badRequest =>
|
||||||
|
'Submit failed with status 400 (Bad Request).$bodySuffix',
|
||||||
|
HttpStatus.unauthorized =>
|
||||||
|
'Submit failed with status 401 (Unauthorized).$bodySuffix',
|
||||||
|
HttpStatus.forbidden =>
|
||||||
|
'Submit failed with status 403 (Forbidden).$bodySuffix',
|
||||||
|
HttpStatus.notFound =>
|
||||||
|
'Submit failed with status 404 (Not Found).$bodySuffix',
|
||||||
|
HttpStatus.internalServerError =>
|
||||||
|
'Submit failed with status 500 (Server Error).$bodySuffix',
|
||||||
|
_ =>
|
||||||
|
'Submit failed. Expected status 200 but got $statusCode.$bodySuffix',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/data/models/scanned_qr_model.dart';
|
import 'package:cb_prestige_qr/features/scan/data/models/scanned_qr_model.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
||||||
|
|
||||||
class ScanRepositoryImpl implements ScanRepository {
|
class ScanRepositoryImpl implements ScanRepository {
|
||||||
@ -10,8 +11,17 @@ class ScanRepositoryImpl implements ScanRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ScannedQr> processRawValue(String rawValue) async {
|
Future<ScannedQr> processRawValue(String rawValue) async {
|
||||||
final payload = await _remoteDataSource.submitScan(rawValue: rawValue);
|
return ScannedQr(rawValue: rawValue, scannedAt: DateTime.now());
|
||||||
return ScannedQrModel.fromJson(payload).toEntity();
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ScannedQr> submitScan(String rawValue) async {
|
||||||
|
final request = ScanSubmitPayload.fromRawValue(rawValue: rawValue);
|
||||||
|
await _remoteDataSource.submitScan(payload: request);
|
||||||
|
final normalizedResponse = <String, dynamic>{
|
||||||
|
'rawValue': request.token,
|
||||||
|
'scannedAt': DateTime.now().toIso8601String(),
|
||||||
|
};
|
||||||
|
return ScannedQrModel.fromJson(normalizedResponse).toEntity();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
lib/features/scan/domain/entities/scan_submit_payload.dart
Normal file
53
lib/features/scan/domain/entities/scan_submit_payload.dart
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
class ScanSubmitPayload {
|
||||||
|
const ScanSubmitPayload({
|
||||||
|
required this.token,
|
||||||
|
required this.merchantId,
|
||||||
|
required this.merchantName,
|
||||||
|
required this.merchantBranchId,
|
||||||
|
required this.scannedByDeviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScanSubmitPayload.fromRawValue({
|
||||||
|
required String rawValue,
|
||||||
|
}) {
|
||||||
|
final payload = ScanSubmitPayload(
|
||||||
|
token: rawValue.trim(),
|
||||||
|
merchantId: 'merchant-001',
|
||||||
|
merchantName: 'cashier_001',
|
||||||
|
merchantBranchId: 'branch-001',
|
||||||
|
scannedByDeviceId: 'device-001',
|
||||||
|
);
|
||||||
|
payload._validate();
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final String merchantId;
|
||||||
|
final String merchantName;
|
||||||
|
final String merchantBranchId;
|
||||||
|
final String scannedByDeviceId;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'token': token,
|
||||||
|
'merchantId': merchantId,
|
||||||
|
'merchantName': merchantName,
|
||||||
|
'merchantBranchId': merchantBranchId,
|
||||||
|
'scannedByDeviceId': scannedByDeviceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _validate() {
|
||||||
|
final missing = <String>[
|
||||||
|
if (token.trim().isEmpty) 'token',
|
||||||
|
if (merchantId.trim().isEmpty) 'merchantId',
|
||||||
|
if (merchantName.trim().isEmpty) 'merchantName',
|
||||||
|
if (merchantBranchId.trim().isEmpty) 'merchantBranchId',
|
||||||
|
if (scannedByDeviceId.trim().isEmpty) 'scannedByDeviceId',
|
||||||
|
];
|
||||||
|
if (missing.isEmpty) return;
|
||||||
|
throw FormatException(
|
||||||
|
'Scanned QR is missing required fields: ${missing.join(', ')}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,5 +2,5 @@ import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
|||||||
|
|
||||||
abstract interface class ScanRepository {
|
abstract interface class ScanRepository {
|
||||||
Future<ScannedQr> processRawValue(String rawValue);
|
Future<ScannedQr> processRawValue(String rawValue);
|
||||||
|
Future<ScannedQr> submitScan(String rawValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,12 +7,14 @@ class ScanState {
|
|||||||
const ScanState({
|
const ScanState({
|
||||||
this.isScanning = true,
|
this.isScanning = true,
|
||||||
this.isProcessing = false,
|
this.isProcessing = false,
|
||||||
|
this.isSubmitting = false,
|
||||||
this.scannedValue,
|
this.scannedValue,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isScanning;
|
final bool isScanning;
|
||||||
final bool isProcessing;
|
final bool isProcessing;
|
||||||
|
final bool isSubmitting;
|
||||||
final String? scannedValue;
|
final String? scannedValue;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
@ -21,16 +23,20 @@ class ScanState {
|
|||||||
ScanState copyWith({
|
ScanState copyWith({
|
||||||
bool? isScanning,
|
bool? isScanning,
|
||||||
bool? isProcessing,
|
bool? isProcessing,
|
||||||
|
bool? isSubmitting,
|
||||||
Object? scannedValue = _sentinel,
|
Object? scannedValue = _sentinel,
|
||||||
Object? errorMessage = _sentinel,
|
Object? errorMessage = _sentinel,
|
||||||
}) {
|
}) {
|
||||||
return ScanState(
|
return ScanState(
|
||||||
isScanning: isScanning ?? this.isScanning,
|
isScanning: isScanning ?? this.isScanning,
|
||||||
isProcessing: isProcessing ?? this.isProcessing,
|
isProcessing: isProcessing ?? this.isProcessing,
|
||||||
scannedValue:
|
isSubmitting: isSubmitting ?? this.isSubmitting,
|
||||||
identical(scannedValue, _sentinel) ? this.scannedValue : scannedValue as String?,
|
scannedValue: identical(scannedValue, _sentinel)
|
||||||
errorMessage:
|
? this.scannedValue
|
||||||
identical(errorMessage, _sentinel) ? this.errorMessage : errorMessage as String?,
|
: scannedValue as String?,
|
||||||
|
errorMessage: identical(errorMessage, _sentinel)
|
||||||
|
? this.errorMessage
|
||||||
|
: errorMessage as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,7 +70,30 @@ class ScanController extends _$ScanController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> submitScannedValue(String value) async {
|
||||||
|
if (state.isSubmitting) return;
|
||||||
|
state = state.copyWith(isSubmitting: true, errorMessage: null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(scanRepositoryProvider).submitScan(value);
|
||||||
|
state = state.copyWith(
|
||||||
|
isSubmitting: false,
|
||||||
|
scannedValue: null,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(isSubmitting: false, errorMessage: e.toString());
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void resumeScanning() {
|
void resumeScanning() {
|
||||||
state = const ScanState(isScanning: true, isProcessing: false, scannedValue: null, errorMessage: null);
|
state = const ScanState(
|
||||||
|
isScanning: true,
|
||||||
|
isProcessing: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
scannedValue: null,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:cb_prestige_qr/core/presentation/widgets/start_loading_overlay.dart';
|
import 'package:cb_prestige_qr/core/presentation/widgets/start_loading_overlay.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/presentation/manager/scan_controller.dart';
|
import 'package:cb_prestige_qr/features/scan/presentation/manager/scan_controller.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/presentation/pages/scan_result_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
@ -15,6 +16,7 @@ class ScanPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _ScanPageState extends ConsumerState<ScanPage> {
|
class _ScanPageState extends ConsumerState<ScanPage> {
|
||||||
late final MobileScannerController _scannerController;
|
late final MobileScannerController _scannerController;
|
||||||
|
String? _openedResultValue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -47,6 +49,22 @@ class _ScanPageState extends ConsumerState<ScanPage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ref.listen<String?>(scanControllerProvider.select((s) => s.scannedValue), (
|
||||||
|
previous,
|
||||||
|
next,
|
||||||
|
) {
|
||||||
|
if (next == null || next == _openedResultValue) return;
|
||||||
|
_openedResultValue = next;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => ScanResultPage(rawValue: next)),
|
||||||
|
);
|
||||||
|
_openedResultValue = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
final scanState = ref.watch(scanControllerProvider);
|
final scanState = ref.watch(scanControllerProvider);
|
||||||
|
|
||||||
void onDetect(BarcodeCapture capture) {
|
void onDetect(BarcodeCapture capture) {
|
||||||
@ -85,9 +103,7 @@ class _ScanPageState extends ConsumerState<ScanPage> {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!scanState.isProcessing &&
|
if (!scanState.isProcessing && scanState.errorMessage != null)
|
||||||
(scanState.scannedValue != null ||
|
|
||||||
scanState.errorMessage != null))
|
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: const Color(0x66000000),
|
color: const Color(0x66000000),
|
||||||
@ -115,11 +131,7 @@ class _ScanPageState extends ConsumerState<ScanPage> {
|
|||||||
).textTheme.titleLarge,
|
).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SelectableText(
|
SelectableText(scanState.errorMessage ?? ''),
|
||||||
scanState.errorMessage ??
|
|
||||||
scanState.scannedValue ??
|
|
||||||
'',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
286
lib/features/scan/presentation/pages/scan_result_page.dart
Normal file
286
lib/features/scan/presentation/pages/scan_result_page.dart
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:cb_prestige_qr/core/utils/MainShell.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/presentation/manager/scan_controller.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:jwt_decoder/jwt_decoder.dart';
|
||||||
|
|
||||||
|
class ScanResultPage extends ConsumerWidget {
|
||||||
|
const ScanResultPage({super.key, required this.rawValue});
|
||||||
|
|
||||||
|
final String rawValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final scanState = ref.watch(scanControllerProvider);
|
||||||
|
final isSubmitting = scanState.isSubmitting;
|
||||||
|
final previewValue = _buildPreviewValue(rawValue);
|
||||||
|
|
||||||
|
return PopScope<void>(
|
||||||
|
canPop: !isSubmitting,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop) return;
|
||||||
|
ref.read(scanControllerProvider.notifier).resumeScanning();
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('QR Result'),
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
surfaceTintColor: theme.scaffoldBackgroundColor,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
Icons.qr_code_2_rounded,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Scanned QR',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Review before submitting',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SelectableText(
|
||||||
|
previewValue,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (scanState.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
scanState.errorMessage!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.error,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: isSubmitting
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
ref
|
||||||
|
.read(scanControllerProvider.notifier)
|
||||||
|
.resumeScanning();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: isSubmitting
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
try {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
await ref
|
||||||
|
.read(scanControllerProvider.notifier)
|
||||||
|
.submitScannedValue(rawValue);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('QR submitted successfully.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(navIndexNotifierProvider.notifier)
|
||||||
|
.setIndex(0);
|
||||||
|
ref
|
||||||
|
.read(scanControllerProvider.notifier)
|
||||||
|
.resumeScanning();
|
||||||
|
navigator.pop();
|
||||||
|
navigator.pop();
|
||||||
|
} catch (_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final errorMessage =
|
||||||
|
ref
|
||||||
|
.read(scanControllerProvider)
|
||||||
|
.errorMessage ??
|
||||||
|
'Unable to submit QR.';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
backgroundColor: colorScheme.errorContainer,
|
||||||
|
content: Text(
|
||||||
|
errorMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Submit'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildPreviewValue(String rawValue) {
|
||||||
|
final decodedJson =
|
||||||
|
_tryDecodeJsonObject(rawValue) ??
|
||||||
|
_tryDecodeJwtWithPackage(rawValue) ??
|
||||||
|
_tryExtractAndDecodeJwt(rawValue) ??
|
||||||
|
_tryDecodeFromUri(rawValue) ??
|
||||||
|
_tryDecodeBase64Json(rawValue, urlSafe: true) ??
|
||||||
|
_tryDecodeBase64Json(rawValue, urlSafe: false);
|
||||||
|
if (decodedJson != null) {
|
||||||
|
const encoder = JsonEncoder.withIndent(' ');
|
||||||
|
return encoder.convert(decodedJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _tryDecodeBase64Text(rawValue, urlSafe: true) ??
|
||||||
|
_tryDecodeBase64Text(rawValue, urlSafe: false) ??
|
||||||
|
rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _tryDecodeJsonObject(String value) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(value);
|
||||||
|
if (decoded is! Map) return null;
|
||||||
|
return decoded.map((key, value) => MapEntry(key.toString(), value));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _tryDecodeJwtWithPackage(String value) {
|
||||||
|
try {
|
||||||
|
return JwtDecoder.tryDecode(value.trim());
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _tryExtractAndDecodeJwt(String value) {
|
||||||
|
final match = RegExp(
|
||||||
|
r'([A-Za-z0-9\-_]+=*\.[A-Za-z0-9\-_]+=*\.[A-Za-z0-9\-_]+=*)',
|
||||||
|
).firstMatch(value);
|
||||||
|
if (match == null) return null;
|
||||||
|
return _tryDecodeJwtWithPackage(match.group(1)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _tryDecodeFromUri(String value) {
|
||||||
|
final uri = Uri.tryParse(value.trim());
|
||||||
|
if (uri == null) return null;
|
||||||
|
|
||||||
|
for (final entry in uri.queryParameters.entries) {
|
||||||
|
final decoded =
|
||||||
|
_tryDecodeJwtWithPackage(entry.value) ??
|
||||||
|
_tryExtractAndDecodeJwt(entry.value) ??
|
||||||
|
_tryDecodeJsonObject(entry.value) ??
|
||||||
|
_tryDecodeBase64Json(entry.value, urlSafe: true) ??
|
||||||
|
_tryDecodeBase64Json(entry.value, urlSafe: false);
|
||||||
|
if (decoded != null) return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _tryDecodeBase64Json(String value, {required bool urlSafe}) {
|
||||||
|
try {
|
||||||
|
final normalized = urlSafe ? base64Url.normalize(value) : base64.normalize(value);
|
||||||
|
final bytes = urlSafe ? base64Url.decode(normalized) : base64.decode(normalized);
|
||||||
|
final decoded = utf8.decode(bytes);
|
||||||
|
final payload = jsonDecode(decoded);
|
||||||
|
if (payload is! Map) return null;
|
||||||
|
return payload.map((key, value) => MapEntry(key.toString(), value));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _tryDecodeBase64Text(String value, {required bool urlSafe}) {
|
||||||
|
try {
|
||||||
|
final normalized = urlSafe ? base64Url.normalize(value) : base64.normalize(value);
|
||||||
|
final bytes = urlSafe ? base64Url.decode(normalized) : base64.decode(normalized);
|
||||||
|
final decoded = utf8.decode(bytes).trim();
|
||||||
|
if (decoded.isEmpty || decoded == value) return null;
|
||||||
|
return decoded;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,15 +1,15 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source_fake.dart';
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source_http.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/data/repositories/scan_repository_impl.dart';
|
import 'package:cb_prestige_qr/features/scan/data/repositories/scan_repository_impl.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/use_cases/process_scan_use_case.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/use_cases/process_scan_use_case.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
part 'scan_providers.g.dart';
|
|
||||||
|
|
||||||
|
part 'scan_providers.g.dart';
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
ScanRemoteDataSource scanRemoteDataSource(Ref ref) {
|
ScanRemoteDataSource scanRemoteDataSource(Ref ref) {
|
||||||
return FakeScanRemoteDataSource();
|
return HttpScanRemoteDataSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
|
|||||||
66
pubspec.lock
66
pubspec.lock
@ -278,14 +278,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.7"
|
version: "2.4.7"
|
||||||
flutter_plugin_android_lifecycle:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_plugin_android_lifecycle
|
|
||||||
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.34"
|
|
||||||
flutter_riverpod:
|
flutter_riverpod:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -392,14 +384,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.8.0"
|
||||||
intl:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: intl
|
|
||||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.20.2"
|
|
||||||
io:
|
io:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -416,6 +400,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.11.0"
|
version: "4.11.0"
|
||||||
|
jwt_decoder:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: jwt_decoder
|
||||||
|
sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.1"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -448,46 +440,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.1.0"
|
||||||
local_auth:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: local_auth
|
|
||||||
sha256: ae6f382f638108c6becd134318d7c3f0a93875383a54010f61d7c97ac05d5137
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.1"
|
|
||||||
local_auth_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: local_auth_android
|
|
||||||
sha256: b41970749c2d43791790724b76917eeee1e90de76e6b0eec3edca03a329bf44c
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.7"
|
|
||||||
local_auth_darwin:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: local_auth_darwin
|
|
||||||
sha256: a8c3d4e17454111f7fd31ff72a31222359f6059f7fe956c2dcfe0f88f49826d4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.3"
|
|
||||||
local_auth_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: local_auth_platform_interface
|
|
||||||
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.0"
|
|
||||||
local_auth_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: local_auth_windows
|
|
||||||
sha256: be12c5b8ba5e64896983123655c5f67d2484ecfcc95e367952ad6e3bff94cb16
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.1"
|
|
||||||
logging:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1007,4 +959,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.11.3 <4.0.0"
|
dart: ">=3.11.3 <4.0.0"
|
||||||
flutter: ">=3.38.0"
|
flutter: ">=3.35.0"
|
||||||
|
|||||||
@ -41,6 +41,7 @@ dependencies:
|
|||||||
carousel_slider: ^5.1.2
|
carousel_slider: ^5.1.2
|
||||||
mobile_scanner: ^7.2.0
|
mobile_scanner: ^7.2.0
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
|
jwt_decoder: ^2.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user