adding packages
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'ui/home_page.dart';
|
||||
|
||||
void main() {
|
||||
// Setup Logging
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
if (kDebugMode) {
|
||||
print('${record.level.name}: ${record.time}: ${record.message}');
|
||||
}
|
||||
});
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'PocketBase Sync Example',
|
||||
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
|
||||
home: const SyncHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:dart_mappable/dart_mappable.dart';
|
||||
|
||||
part 'message.mapper.dart';
|
||||
|
||||
@MappableClass()
|
||||
class Message with MessageMappable {
|
||||
final String id;
|
||||
final String message;
|
||||
final String author;
|
||||
|
||||
Message({required this.id, required this.message, required this.author});
|
||||
|
||||
static const fromJson = MessageMapper.fromJson;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// dart format off
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member
|
||||
// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter
|
||||
|
||||
part of 'message.dart';
|
||||
|
||||
class MessageMapper extends ClassMapperBase<Message> {
|
||||
MessageMapper._();
|
||||
|
||||
static MessageMapper? _instance;
|
||||
static MessageMapper ensureInitialized() {
|
||||
if (_instance == null) {
|
||||
MapperContainer.globals.use(_instance = MessageMapper._());
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
@override
|
||||
final String id = 'Message';
|
||||
|
||||
static String _$id(Message v) => v.id;
|
||||
static const Field<Message, String> _f$id = Field('id', _$id);
|
||||
static String _$message(Message v) => v.message;
|
||||
static const Field<Message, String> _f$message = Field('message', _$message);
|
||||
static String _$author(Message v) => v.author;
|
||||
static const Field<Message, String> _f$author = Field('author', _$author);
|
||||
|
||||
@override
|
||||
final MappableFields<Message> fields = const {
|
||||
#id: _f$id,
|
||||
#message: _f$message,
|
||||
#author: _f$author,
|
||||
};
|
||||
|
||||
static Message _instantiate(DecodingData data) {
|
||||
return Message(
|
||||
id: data.dec(_f$id),
|
||||
message: data.dec(_f$message),
|
||||
author: data.dec(_f$author),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
final Function instantiate = _instantiate;
|
||||
|
||||
static Message fromMap(Map<String, dynamic> map) {
|
||||
return ensureInitialized().decodeMap<Message>(map);
|
||||
}
|
||||
|
||||
static Message fromJson(String json) {
|
||||
return ensureInitialized().decodeJson<Message>(json);
|
||||
}
|
||||
}
|
||||
|
||||
mixin MessageMappable {
|
||||
String toJson() {
|
||||
return MessageMapper.ensureInitialized().encodeJson<Message>(
|
||||
this as Message,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return MessageMapper.ensureInitialized().encodeMap<Message>(
|
||||
this as Message,
|
||||
);
|
||||
}
|
||||
|
||||
MessageCopyWith<Message, Message, Message> get copyWith =>
|
||||
_MessageCopyWithImpl<Message, Message>(
|
||||
this as Message,
|
||||
$identity,
|
||||
$identity,
|
||||
);
|
||||
@override
|
||||
String toString() {
|
||||
return MessageMapper.ensureInitialized().stringifyValue(this as Message);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return MessageMapper.ensureInitialized().equalsValue(
|
||||
this as Message,
|
||||
other,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return MessageMapper.ensureInitialized().hashValue(this as Message);
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageValueCopy<$R, $Out> on ObjectCopyWith<$R, Message, $Out> {
|
||||
MessageCopyWith<$R, Message, $Out> get $asMessage =>
|
||||
$base.as((v, t, t2) => _MessageCopyWithImpl<$R, $Out>(v, t, t2));
|
||||
}
|
||||
|
||||
abstract class MessageCopyWith<$R, $In extends Message, $Out>
|
||||
implements ClassCopyWith<$R, $In, $Out> {
|
||||
$R call({String? id, String? message, String? author});
|
||||
MessageCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
|
||||
}
|
||||
|
||||
class _MessageCopyWithImpl<$R, $Out>
|
||||
extends ClassCopyWithBase<$R, Message, $Out>
|
||||
implements MessageCopyWith<$R, Message, $Out> {
|
||||
_MessageCopyWithImpl(super.value, super.then, super.then2);
|
||||
|
||||
@override
|
||||
late final ClassMapperBase<Message> $mapper =
|
||||
MessageMapper.ensureInitialized();
|
||||
@override
|
||||
$R call({String? id, String? message, String? author}) => $apply(
|
||||
FieldCopyWithData({
|
||||
if (id != null) #id: id,
|
||||
if (message != null) #message: message,
|
||||
if (author != null) #author: author,
|
||||
}),
|
||||
);
|
||||
@override
|
||||
Message $make(CopyWithData data) => Message(
|
||||
id: data.get(#id, or: $value.id),
|
||||
message: data.get(#message, or: $value.message),
|
||||
author: data.get(#author, or: $value.author),
|
||||
);
|
||||
|
||||
@override
|
||||
MessageCopyWith<$R2, Message, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t) =>
|
||||
_MessageCopyWithImpl<$R2, $Out2>($value, $cast, t);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class LogService extends ChangeNotifier {
|
||||
static final LogService _instance = LogService._internal();
|
||||
factory LogService() => _instance;
|
||||
LogService._internal();
|
||||
|
||||
final List<LogRecord> _logs = [];
|
||||
List<LogRecord> get logs => List.unmodifiable(_logs);
|
||||
|
||||
void init() {
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
_logs.add(record);
|
||||
// Limit to last 1000 logs to prevent memory issues
|
||||
if (_logs.length > 1000) {
|
||||
_logs.removeAt(0);
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_logs.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fetch_client/fetch_client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
import 'package:pocketbase_sync/pocketbase_sync.dart';
|
||||
import 'package:pocketbase_sync/sync_managers/in_memory.dart';
|
||||
|
||||
import '../models/message.dart';
|
||||
import '../services/log_service.dart' as app_logs;
|
||||
import 'logs_page.dart';
|
||||
|
||||
class SyncHomePage extends StatefulWidget {
|
||||
const SyncHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<SyncHomePage> createState() => _SyncHomePageState();
|
||||
}
|
||||
|
||||
class _SyncHomePageState extends State<SyncHomePage> {
|
||||
late final PocketBase pb;
|
||||
late final InMemoryRepository<Message> repository;
|
||||
late final PocketBaseSyncManager<Message> manager;
|
||||
final _logger = Logger('SyncHomePage');
|
||||
|
||||
bool _isLoading = true; // Initial full-screen load
|
||||
bool _isSyncing = false; // Background sync indicator
|
||||
List<SyncRecord<Message>> _records = [];
|
||||
String? _currentUserEmail;
|
||||
|
||||
// Editing State
|
||||
String? _editingId;
|
||||
late final TextEditingController _editController;
|
||||
|
||||
// New State Variables
|
||||
bool _autoSync = true;
|
||||
bool _isOffline = false;
|
||||
StreamSubscription? _updateSubscription;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
app_logs.LogService().init(); // Initialize Logger
|
||||
|
||||
// Initialize synchronously to avoid LateInitializationError in build()
|
||||
pb = PocketBase(
|
||||
'http://127.0.0.1:8090',
|
||||
httpClientFactory: kIsWeb
|
||||
? () => FetchClient(streamRequests: true)
|
||||
: null,
|
||||
);
|
||||
repository = InMemoryRepository<Message>();
|
||||
manager = PocketBaseSyncManager<Message>(
|
||||
pb: pb,
|
||||
collection: 'messages',
|
||||
fromJson: (j) => MessageMapper.fromMap(j),
|
||||
toJson: (m) => m.toMap(),
|
||||
repository: repository,
|
||||
autoSyncInterval: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
_updateSubscription = manager.onUpdate.listen((_) {
|
||||
_refreshList();
|
||||
});
|
||||
|
||||
_editController = TextEditingController();
|
||||
|
||||
_init();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_editController.dispose();
|
||||
_updateSubscription?.cancel();
|
||||
manager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
// 1. Authenticate (Anonymous/Dummy for demo, or real auth)
|
||||
try {
|
||||
final id = Random().nextInt(10000);
|
||||
final email = 'test_sync_$id}@example.com';
|
||||
final password = 'password123456';
|
||||
final name = 'Test User $id';
|
||||
|
||||
try {
|
||||
await pb
|
||||
.collection('users')
|
||||
.create(
|
||||
body: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
'passwordConfirm': password,
|
||||
'name': name,
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
final auth = await pb
|
||||
.collection('users')
|
||||
.authWithPassword(email, password);
|
||||
_currentUserEmail = auth.record?.getStringValue('email');
|
||||
} catch (e, stack) {
|
||||
_logger.warning('Auth failed', e, stack);
|
||||
}
|
||||
|
||||
// Init Realtime Subscription
|
||||
try {
|
||||
manager.subscribe();
|
||||
} catch (e) {
|
||||
_logger.warning('Realtime subscription failed', e);
|
||||
}
|
||||
|
||||
// Load initial empty state
|
||||
await _refreshList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshList() async {
|
||||
final records = await repository.getAll();
|
||||
setState(() {
|
||||
_records = records.where((r) => !r.isDeleted).toList();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sync() async {
|
||||
if (_isOffline) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSyncing = true); // Show linear progress
|
||||
try {
|
||||
await manager.sync();
|
||||
await _refreshList();
|
||||
} catch (e, stack) {
|
||||
_logger.severe('Sync failed', e, stack);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Sync failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSyncing = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleAutoSync(bool value) {
|
||||
setState(() {
|
||||
_autoSync = value;
|
||||
});
|
||||
|
||||
if (_autoSync) {
|
||||
manager.autoSyncInterval = const Duration(seconds: 5);
|
||||
_sync(); // Initial sync
|
||||
} else {
|
||||
manager.autoSyncInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addMessage() async {
|
||||
final id = manager.generateId();
|
||||
final message = Message(
|
||||
id: id,
|
||||
message: 'Hello at ${DateTime.now().toIso8601String()}',
|
||||
author: pb.authStore.model?.id ?? 'anon',
|
||||
);
|
||||
|
||||
await manager.create(id, message);
|
||||
await _refreshList();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
_debounceTimer?.cancel();
|
||||
_debounceTimer = Timer(const Duration(seconds: 1), _sync);
|
||||
}
|
||||
|
||||
void _handleEdit(SyncRecord<Message> record) {
|
||||
setState(() {
|
||||
_editingId = record.id;
|
||||
_editController.text = record.data.message;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEdit(String id, Message original) async {
|
||||
final newText = _editController.text.trim();
|
||||
if (newText.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Message cannot be empty')));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newText == original.message) {
|
||||
_cancelEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create updated copy
|
||||
final updated = Message(
|
||||
id: original.id,
|
||||
message: newText,
|
||||
author: original.author,
|
||||
);
|
||||
|
||||
await manager.update(id, updated);
|
||||
await _refreshList();
|
||||
_cancelEdit();
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Update failed: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelEdit() {
|
||||
setState(() {
|
||||
_editingId = null;
|
||||
_editController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('PB Sync Example'),
|
||||
actions: [
|
||||
if (_isSyncing || _isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sync),
|
||||
onPressed: (_isLoading || _isOffline) ? null : _sync,
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: Drawer(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
const DrawerHeader(
|
||||
decoration: BoxDecoration(color: Colors.blue),
|
||||
child: Text(
|
||||
'Sync Settings',
|
||||
style: TextStyle(color: Colors.white, fontSize: 24),
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: manager.isConnectedNotifier,
|
||||
builder: (context, isConnected, child) {
|
||||
String modeText;
|
||||
Color statusColor;
|
||||
IconData icon;
|
||||
|
||||
if (isConnected) {
|
||||
modeText = 'Mode: Realtime';
|
||||
statusColor = Colors.green;
|
||||
icon = Icons.bolt;
|
||||
} else if (_autoSync) {
|
||||
modeText = 'Mode: Polling (5s)';
|
||||
statusColor = Colors.orange;
|
||||
icon = Icons.sync;
|
||||
} else {
|
||||
modeText = 'Mode: Manual';
|
||||
statusColor = Colors.grey;
|
||||
icon = Icons.touch_app;
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
modeText,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
leading: Icon(icon, color: statusColor),
|
||||
trailing: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
SwitchListTile(
|
||||
title: const Text('Auto-Sync (5s)'),
|
||||
value: _autoSync,
|
||||
onChanged: _toggleAutoSync,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Offline Mode'),
|
||||
subtitle: const Text('Simulate network disconnect'),
|
||||
value: _isOffline,
|
||||
onChanged: (val) {
|
||||
if (mounted) setState(() => _isOffline = val);
|
||||
if (_isOffline) {
|
||||
manager.unsubscribe();
|
||||
} else {
|
||||
manager.subscribe();
|
||||
_sync();
|
||||
}
|
||||
},
|
||||
secondary: const Icon(Icons.wifi_off),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('View Logs'),
|
||||
leading: const Icon(Icons.bug_report),
|
||||
onTap: () {
|
||||
Navigator.pop(context); // Close Drawer
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LogsPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (_isOffline)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.redAccent,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Text(
|
||||
'OFFLINE MODE ENABLED',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (_currentUserEmail != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text('Logged in as: $_currentUserEmail'),
|
||||
),
|
||||
Expanded(
|
||||
child: _records.isEmpty
|
||||
? const Center(child: Text("No messages. Add one!"))
|
||||
: ListView.builder(
|
||||
itemCount: _records.length,
|
||||
itemBuilder: (context, index) {
|
||||
final record = _records[index];
|
||||
final isEditing = _editingId == record.id;
|
||||
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
record.isDirty
|
||||
? Icons.cloud_upload
|
||||
: Icons.cloud_done,
|
||||
color: record.isDirty ? Colors.orange : Colors.green,
|
||||
),
|
||||
title: isEditing
|
||||
? TextField(
|
||||
controller: _editController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Edit message...',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
)
|
||||
: Text(record.data.message),
|
||||
subtitle: Text('ID: ${record.id}'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isEditing) ...[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.check,
|
||||
color: Colors.green,
|
||||
),
|
||||
onPressed: () =>
|
||||
_saveEdit(record.id, record.data),
|
||||
tooltip: 'Save',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.close,
|
||||
color: Colors.red,
|
||||
),
|
||||
onPressed: _cancelEdit,
|
||||
tooltip: 'Cancel',
|
||||
),
|
||||
] else ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await manager.delete(record.id);
|
||||
await _refreshList();
|
||||
_refresh();
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Delete failed: $e'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
onTap: isEditing ? null : () => _handleEdit(record),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _addMessage,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/log_service.dart';
|
||||
|
||||
class LogsPage extends StatelessWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('App Logs'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
LogService().clear();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AnimatedBuilder(
|
||||
animation: LogService(),
|
||||
builder: (context, child) {
|
||||
final logs = LogService().logs.reversed.toList();
|
||||
if (logs.isEmpty) {
|
||||
return const Center(child: Text('No logs collected yet.'));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = logs[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Text(
|
||||
log.time.toString().split('.').first,
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
title: Text(log.message),
|
||||
subtitle: Text(log.level.name),
|
||||
trailing: log.error != null
|
||||
? const Icon(Icons.error, color: Colors.red)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user