feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification
Fixes reported from APK field testing: DO/Product scan mode was inconsistent between the camera drawer and documents screen (now one shared provider, with an orange/green color cue); unconfirmed scans leaked into history with placeholder data before the user tapped confirm (backend now gates GET /documents on a new `confirmed` column, flipped only by PUT); and Product Scan ran the GPU classifier twice, once at upload and again on review (now a single pass at upload, persisted and read directly by the editor). Also removes the unused "Hubungkan ke PO" field and fabricated PO/SO/DO placeholder values from the Product Scan flow, closes out the per-document-polling and save-recovery tasks (6.1/6.3), and splits several touched files to stay under the repo's 256-line guideline. Full detail in docs/iteration-log.md and backend/docs/iteration-log.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2febe0c886
commit
ada6488592
67 files changed
+5705
-1142
No files matched your search
@@ -1,11 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../auth/auth_provider.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
import '../documents/pending_documents_provider.dart';
|
||||
import 'camera_drawer_helpers.dart';
|
||||
import 'camera_drawer_mode_toggle.dart';
|
||||
|
||||
/// Main navigation drawer for the camera screen.
|
||||
///
|
||||
/// Split during task 8.1 (AGENTS.md §3 — 256-line threshold on touched files):
|
||||
/// - [CameraDrawerModeToggle] owns the DO/Product mode toggle pill and
|
||||
/// [scanModeProvider] read/write (including the new doModeColor cue).
|
||||
/// - [camera_drawer_helpers.dart] contains stateless UI helpers
|
||||
/// (section header, drawer item, dialogs).
|
||||
class CameraDrawer extends ConsumerStatefulWidget {
|
||||
const CameraDrawer({super.key});
|
||||
|
||||
@@ -33,19 +42,16 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Drawer(
|
||||
backgroundColor: Colors.white,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Close Button & Profile Row
|
||||
// ── Profile header ─────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// Close Button
|
||||
Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: IconButton(
|
||||
@@ -54,7 +60,6 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Profile Row
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -68,10 +73,7 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
child: ClipOval(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Image.asset(
|
||||
'assets/logo.png',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
child: Image.asset('assets/logo.png', fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -109,136 +111,22 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
const Divider(height: 1, thickness: 1),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Mode Toggle Section (between DO and Product Scan)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mode',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
ref.read(scanModeProvider.notifier).state = 'DO';
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: activeMode == 'DO' ? AppConfig.primaryColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: activeMode == 'DO'
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppConfig.primaryColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
activeMode == 'DO' ? Icons.description : Icons.description_outlined,
|
||||
size: 16,
|
||||
color: activeMode == 'DO' ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'DO Scan',
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: activeMode == 'DO' ? FontWeight.bold : FontWeight.normal,
|
||||
color: activeMode == 'DO' ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
ref.read(scanModeProvider.notifier).state = 'Product';
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: activeMode == 'Product' ? AppConfig.primaryColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: activeMode == 'Product'
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppConfig.primaryColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
activeMode == 'Product' ? Icons.inventory_2 : Icons.inventory_2_outlined,
|
||||
size: 16,
|
||||
color: activeMode == 'Product' ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Product Scan',
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: activeMode == 'Product' ? FontWeight.bold : FontWeight.normal,
|
||||
color: activeMode == 'Product' ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── Mode toggle (DO/Product) ────────────────────────────────
|
||||
const CameraDrawerModeToggle(),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Menu Items List with Grouping Sections
|
||||
// ── Menu items ─────────────────────────────────────────────
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
children: [
|
||||
_buildSectionHeader('DASHBOARD'),
|
||||
_buildDrawerItem(
|
||||
buildDrawerSectionHeader('DASHBOARD'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.home_outlined,
|
||||
title: 'Home',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
_buildDrawerItem(
|
||||
buildDrawerItem(
|
||||
icon: Icons.history_outlined,
|
||||
title: 'History',
|
||||
onTap: () {
|
||||
@@ -247,58 +135,55 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionHeader('USER PROFILE'),
|
||||
_buildDrawerItem(
|
||||
buildDrawerSectionHeader('USER PROFILE'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.person_outline,
|
||||
title: 'Profile',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showNotImplemented('Profile');
|
||||
showDrawerNotImplemented(context, 'Profile');
|
||||
},
|
||||
),
|
||||
_buildDrawerItem(
|
||||
buildDrawerItem(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: 'Notifications',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showNotImplemented('Notifications');
|
||||
showDrawerNotImplemented(context, 'Notifications');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionHeader('SUPPORT & SETTINGS'),
|
||||
_buildDrawerItem(
|
||||
buildDrawerSectionHeader('SUPPORT & SETTINGS'),
|
||||
buildDrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
title: 'Settings',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showNotImplemented('Settings');
|
||||
showDrawerNotImplemented(context, 'Settings');
|
||||
},
|
||||
),
|
||||
_buildDrawerItem(
|
||||
buildDrawerItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Help',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showHelpDialog();
|
||||
showDrawerHelpDialog(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Logout at the bottom
|
||||
// ── Logout ─────────────────────────────────────────────────
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildDrawerItem(
|
||||
child: buildDrawerItem(
|
||||
icon: Icons.logout_outlined,
|
||||
title: 'Logout',
|
||||
textColor: AppConfig.errorColor,
|
||||
iconColor: AppConfig.errorColor,
|
||||
onTap: () {
|
||||
ref.read(authProvider.notifier).logout();
|
||||
context.go('/login');
|
||||
},
|
||||
onTap: _handleLogout,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -307,70 +192,42 @@ class _CameraDrawerState extends ConsumerState<CameraDrawer> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade400,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
/// Warns before logout if the pending-documents queue still has unsynced
|
||||
/// items — a plain logout wipes the local Hive cache and clears the queue
|
||||
/// (see AuthNotifier.logout()), silently discarding any in-flight
|
||||
/// uploads, unreviewed scans, or failed syncs otherwise.
|
||||
Future<void> _handleLogout() async {
|
||||
final pendingCount = ref.read(pendingDocumentsProvider).length;
|
||||
|
||||
Widget _buildDrawerItem({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
Color? textColor,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: iconColor ?? Colors.black87),
|
||||
title: Text(
|
||||
title,
|
||||
style: AppConfig.bodyLarge.copyWith(
|
||||
color: textColor ?? Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
void _showNotImplemented(String featureName) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$featureName feature coming soon!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHelpDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Tentang Prima Mart'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Text(
|
||||
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\nPT. Primafood International\nJl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
|
||||
if (pendingCount > 0) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Ada Dokumen Belum Tersinkron'),
|
||||
content: Text(
|
||||
'Ada $pendingCount dokumen yang belum selesai diunggah atau '
|
||||
'disinkronkan ke server. Jika Anda logout sekarang, dokumen '
|
||||
'tersebut akan hilang.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text('Ya, Logout', style: TextStyle(color: AppConfig.errorColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Tutup'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (!mounted) return;
|
||||
context.go('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
/// Stateless helper functions extracted from [CameraDrawer] to keep that
|
||||
/// file under the AGENTS.md §3 256-line threshold (touched in task 8.1).
|
||||
/// Contains only UI helpers with no side-effects or state.
|
||||
|
||||
Widget buildDrawerSectionHeader(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 8.0, bottom: 8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade400,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
letterSpacing: 1.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDrawerItem({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
Color? textColor,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon, color: iconColor ?? Colors.black87),
|
||||
title: Text(
|
||||
title,
|
||||
style: AppConfig.bodyLarge.copyWith(
|
||||
color: textColor ?? Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerNotImplemented(BuildContext context, String featureName) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(' feature coming soon!'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showDrawerHelpDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Tentang Prima Mart'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Text(
|
||||
'www.primafreshmart.com merupakan sarana belanja online dari toko Prima Mart '
|
||||
'yang sudah melayani konsumen selama 10 tahun dan memiliki hampir 1200 gerai '
|
||||
'di Pulau Jawa, Bali dan Sumatera. Inovasi terbaru dari PT. Primafood '
|
||||
'International sebagai bagian dari PT. Charoen Pokphand Indonesia, Tbk.\n\n'
|
||||
'PT. Primafood International\n'
|
||||
'Jl. Ancol Barat VIII no.1, Ancol, Pademangan, Jakarta Utara, DKI Jakarta.',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('Tutup'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import 'scan_mode_provider.dart';
|
||||
|
||||
/// DO / Product Scan mode toggle pill rendered in the [CameraDrawer].
|
||||
///
|
||||
/// Extracted from [CameraDrawer] to keep that file under the 256-line §3
|
||||
/// threshold (AGENTS.md §3, touched during task 8.1). Reads and writes
|
||||
/// [scanModeProvider] directly — the global source of truth shared with
|
||||
/// [DocumentsScreen] (task 8.1, docs/api-contract-map.md G11).
|
||||
///
|
||||
/// Color convention:
|
||||
/// - DO active → [AppConfig.doModeColor] (orange)
|
||||
/// - Product active → [AppConfig.primaryColor] (green)
|
||||
class CameraDrawerModeToggle extends ConsumerWidget {
|
||||
const CameraDrawerModeToggle({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final activeMode = ref.watch(scanModeProvider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Mode',
|
||||
style: AppConfig.bodySmall.copyWith(
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'DO',
|
||||
label: 'DO Scan',
|
||||
activeIcon: Icons.description,
|
||||
inactiveIcon: Icons.description_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.doModeColor,
|
||||
),
|
||||
_buildSegment(
|
||||
ref: ref,
|
||||
value: 'Product',
|
||||
label: 'Product Scan',
|
||||
activeIcon: Icons.inventory_2,
|
||||
inactiveIcon: Icons.inventory_2_outlined,
|
||||
activeMode: activeMode,
|
||||
activeColor: AppConfig.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegment({
|
||||
required WidgetRef ref,
|
||||
required String value,
|
||||
required String label,
|
||||
required IconData activeIcon,
|
||||
required IconData inactiveIcon,
|
||||
required String activeMode,
|
||||
required Color activeColor,
|
||||
}) {
|
||||
final isActive = activeMode == value;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => ref.read(scanModeProvider.notifier).state = value,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isActive
|
||||
? [
|
||||
BoxShadow(
|
||||
color: activeColor.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
]
|
||||
: [],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isActive ? activeIcon : inactiveIcon,
|
||||
size: 16,
|
||||
color: isActive ? Colors.white : Colors.grey.shade500,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: AppConfig.bodyMedium.copyWith(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? Colors.white : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user