Initial commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
class AchievementManager {
|
||||
static Future<void> unlock(String uid, String achId) async {
|
||||
final ref = FirebaseFirestore.instance.collection('users').doc(uid).collection('achievements').doc(achId);
|
||||
final doc = await ref.get();
|
||||
if (!doc.exists) {
|
||||
await ref.set({'unlockedAt': FieldValue.serverTimestamp()});
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> incrementStat(String uid, String field, {int amount = 1}) async {
|
||||
final userRef = FirebaseFirestore.instance.collection('users').doc(uid);
|
||||
await userRef.update({field: FieldValue.increment(amount)});
|
||||
|
||||
final snap = await userRef.get();
|
||||
final d = snap.data() as Map<String, dynamic>;
|
||||
|
||||
// Threshold Checks
|
||||
if (field == 'pagesRead') {
|
||||
int p = safeInt(d['pagesRead']);
|
||||
if (p >= 10) unlock(uid, "page_turner_1");
|
||||
if (p >= 50) unlock(uid, "page_turner_2");
|
||||
if (p >= 200) unlock(uid, "page_turner_3");
|
||||
}
|
||||
if (field == 'chaptersRead') {
|
||||
int c = safeInt(d['chaptersRead']);
|
||||
if (c >= 1) unlock(uid, "chapter_complete_1");
|
||||
if (c >= 10) unlock(uid, "chapter_complete_2");
|
||||
if (c >= 50) unlock(uid, "chapter_complete_3");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
class AdHandler {
|
||||
static InterstitialAd? _interstitialAd;
|
||||
|
||||
static void loadInterstitialAd() {
|
||||
if (kIsWeb) return; // Skip on PWA
|
||||
|
||||
InterstitialAd.load(
|
||||
adUnitId: kInterstitialAdUnitId,
|
||||
request: const AdRequest(),
|
||||
adLoadCallback: InterstitialAdLoadCallback(
|
||||
onAdLoaded: (ad) => _interstitialAd = ad,
|
||||
onAdFailedToLoad: (error) => _interstitialAd = null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void showInterstitialAd(Function onAdClosed) {
|
||||
if (kIsWeb || _interstitialAd == null) {
|
||||
onAdClosed(); // If web or ad not ready, just open the chapter
|
||||
return;
|
||||
}
|
||||
|
||||
_interstitialAd!.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
loadInterstitialAd(); // Load next one
|
||||
onAdClosed(); // Open the chapter
|
||||
},
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
onAdClosed();
|
||||
},
|
||||
);
|
||||
|
||||
_interstitialAd!.show();
|
||||
_interstitialAd = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// --- BRANDING ---
|
||||
const String kOnsolBanner = "https://files.onsolgo.cloud/manaa/assets/onsol-banner.png";
|
||||
const String kOnsolAppWebUrl = 'https://onsol-go.web.app';
|
||||
const Color kOnsolGold = Color(0xFFFFD700);
|
||||
|
||||
/// Share targets for web (query params; app may resolve these later).
|
||||
String kOnsolSharePostUrl(String postId) => '$kOnsolAppWebUrl?post=$postId';
|
||||
|
||||
String kOnsolShareChapterUrl(String mangaId, Object? chapterNumber) =>
|
||||
'$kOnsolAppWebUrl?manga=$mangaId&ch=$chapterNumber';
|
||||
|
||||
// --- THE 5-TIER RANK LOGIC ---
|
||||
String getRankName(int level) {
|
||||
switch (level) {
|
||||
case 1: return "READER";
|
||||
case 2: return "BACKER";
|
||||
case 3: return "INVESTOR";
|
||||
case 4: return "STEWARD";
|
||||
case 5: return "VERIFIED ManaA ARTIST";
|
||||
default: return "READER";
|
||||
}
|
||||
}
|
||||
|
||||
Color getRankColor(int level) {
|
||||
switch (level) {
|
||||
case 1: return Colors.grey;
|
||||
case 2: return Colors.orangeAccent;
|
||||
case 3: return Colors.cyanAccent;
|
||||
case 4: return Colors.amber;
|
||||
case 5: return kOnsolGold;
|
||||
default: return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
IconData getRankIcon(int level) {
|
||||
switch (level) {
|
||||
case 1: return Icons.person;
|
||||
case 2: return Icons.bolt;
|
||||
case 3: return Icons.token;
|
||||
case 4: return Icons.shield;
|
||||
case 5: return Icons.verified;
|
||||
default: return Icons.person;
|
||||
}
|
||||
}
|
||||
|
||||
Widget verifiedBadge(bool isVerified, {double size = 16}) {
|
||||
if (!isVerified) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: Icon(Icons.verified, color: kOnsolGold, size: size),
|
||||
);
|
||||
}
|
||||
|
||||
int safeInt(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is int) return value;
|
||||
if (value is double) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- MASTER ACHIEVEMENT MODEL ---
|
||||
class AchievementModel {
|
||||
final String id, title, desc, category;
|
||||
final IconData icon;
|
||||
const AchievementModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
required this.category,
|
||||
required this.icon
|
||||
});
|
||||
}
|
||||
|
||||
// --- ADMOB CONFIGURATION ---
|
||||
|
||||
// 1. THIS GOES IN YOUR ANDROID MANIFEST (Real App ID from AdMob)
|
||||
const String kAdMobAppId = "ca-app-pub-7461835411004007~7412162757";
|
||||
|
||||
// 2. THE AD UNIT IDS
|
||||
// For now, these are GOOGLE TEST IDs so you don't get banned.
|
||||
// Replace these with your REAL IDs only when you upload to the Play Store.
|
||||
|
||||
// Unit: Interstitial (Full Screen)
|
||||
const String kInterstitialAdUnitId = "ca-app-pub-3940256099942544/1033173712";
|
||||
|
||||
// Unit: Banner (Small strip)
|
||||
const String kBannerAdUnitId = "ca-app-pub-3940256099942544/6300978111";
|
||||
|
||||
// --- FULL ACHIEVEMENT LIST (FROM PHOTOS) ---
|
||||
const List<AchievementModel> kAllAchievements = [
|
||||
// ACCOUNT & ONBOARDING
|
||||
AchievementModel(id: "initiate", title: "INITIATE", desc: "Account created", category: "ACCOUNT", icon: Icons.flag),
|
||||
AchievementModel(id: "profile_activated", title: "PROFILE ACTIVATED", desc: "Profile completed (bio + avatar)", category: "ACCOUNT", icon: Icons.face),
|
||||
AchievementModel(id: "first_login", title: "FIRST LOGIN", desc: "First successful login session", category: "ACCOUNT", icon: Icons.login),
|
||||
AchievementModel(id: "return_user", title: "RETURN USER", desc: "Logged in on 2 separate days", category: "ACCOUNT", icon: Icons.replay),
|
||||
AchievementModel(id: "verified_access", title: "VERIFIED ACCESS", desc: "Email or phone verified", category: "ACCOUNT", icon: Icons.verified_user),
|
||||
|
||||
// READING PROGRESSION
|
||||
AchievementModel(id: "first_contact", title: "FIRST CONTACT", desc: "Open first chapter", category: "READING", icon: Icons.play_arrow),
|
||||
AchievementModel(id: "page_turner_1", title: "PAGE TURNER I", desc: "Read 10 pages", category: "READING", icon: Icons.auto_stories),
|
||||
AchievementModel(id: "page_turner_2", title: "PAGE TURNER II", desc: "Read 50 pages", category: "READING", icon: Icons.menu_book),
|
||||
AchievementModel(id: "page_turner_3", title: "PAGE TURNER III", desc: "Read 200 pages", category: "READING", icon: Icons.library_books),
|
||||
AchievementModel(id: "chapter_complete_1", title: "CHAPTER COMPLETE I", desc: "Finish 1 chapter", category: "READING", icon: Icons.task_alt),
|
||||
AchievementModel(id: "chapter_complete_2", title: "CHAPTER COMPLETE II", desc: "Finish 10 chapters", category: "READING", icon: Icons.done_all),
|
||||
AchievementModel(id: "chapter_complete_3", title: "CHAPTER COMPLETE III", desc: "Finish 50 chapters", category: "READING", icon: Icons.checklist_rtl),
|
||||
AchievementModel(id: "volume_devourer", title: "VOLUME DEVOURER", desc: "Finish a full volume", category: "READING", icon: Icons.auto_awesome_motion),
|
||||
AchievementModel(id: "binge_reader", title: "BINGE READER", desc: "Read 3 chapters in one session", category: "READING", icon: Icons.flash_on),
|
||||
AchievementModel(id: "locked_in", title: "LOCKED IN", desc: "Read 5 chapters in one session", category: "READING", icon: Icons.lock),
|
||||
|
||||
// COMMUNITY & ENGAGEMENT
|
||||
AchievementModel(id: "voice_activated", title: "VOICE ACTIVATED", desc: "First comment posted", category: "COMMUNITY", icon: Icons.comment),
|
||||
AchievementModel(id: "panel_reactor", title: "PANEL REACTOR", desc: "React to a panel", category: "COMMUNITY", icon: Icons.add_reaction),
|
||||
AchievementModel(id: "conversation_starter", title: "CONV. STARTER", desc: "Start a thread", category: "COMMUNITY", icon: Icons.forum),
|
||||
AchievementModel(id: "community_member", title: "COMMUNITY MEMBER", desc: "5 comments posted", category: "COMMUNITY", icon: Icons.groups),
|
||||
AchievementModel(id: "recognized_voice", title: "RECOGNIZED VOICE", desc: "10 likes on a comment", category: "COMMUNITY", icon: Icons.campaign),
|
||||
AchievementModel(id: "fan_favorite", title: "FAN FAVORITE", desc: "50 likes on a comment", category: "COMMUNITY", icon: Icons.favorite),
|
||||
AchievementModel(id: "creator_noticed", title: "CREATOR NOTICED", desc: "Creator replies to you", category: "COMMUNITY", icon: Icons.star),
|
||||
AchievementModel(id: "debate_ready", title: "DEBATE READY", desc: "Participate in 5 discussions", category: "COMMUNITY", icon: Icons.psychology),
|
||||
|
||||
// MARKETPLACE & COLLECTION
|
||||
AchievementModel(id: "first_acquisition", title: "FIRST ACQUISITION", desc: "First purchase", category: "MARKET", icon: Icons.shopping_bag),
|
||||
AchievementModel(id: "collector_1", title: "COLLECTOR I", desc: "Own 3 items", category: "MARKET", icon: Icons.layers),
|
||||
AchievementModel(id: "collector_2", title: "COLLECTOR II", desc: "Own 10 items", category: "MARKET", icon: Icons.inventory_2),
|
||||
AchievementModel(id: "archivist", title: "ARCHIVIST", desc: "Own a full volume", category: "MARKET", icon: Icons.folder_special),
|
||||
AchievementModel(id: "limited_hunter", title: "LIMITED HUNTER", desc: "Purchase a limited drop", category: "MARKET", icon: Icons.timer),
|
||||
AchievementModel(id: "artifact_holder", title: "ARTIFACT HOLDER", desc: "Purchase premium tier item", category: "MARKET", icon: Icons.token),
|
||||
AchievementModel(id: "vault_builder", title: "VAULT BUILDER", desc: "Own items from 3 creators", category: "MARKET", icon: Icons.account_balance),
|
||||
|
||||
// SUPPORT & INVESTMENT
|
||||
AchievementModel(id: "first_backer", title: "FIRST BACKER", desc: "First financial support", category: "INVEST", icon: Icons.volunteer_activism),
|
||||
AchievementModel(id: "supporter", title: "SUPPORTER", desc: "Subscribe to a creator", category: "INVEST", icon: Icons.card_membership),
|
||||
AchievementModel(id: "series_builder", title: "SERIES BUILDER", desc: "Support 3 creators", category: "INVEST", icon: Icons.build),
|
||||
AchievementModel(id: "investor_1", title: "INVESTOR I", desc: "Spend \$25 total", category: "INVEST", icon: Icons.monetization_on),
|
||||
AchievementModel(id: "investor_2", title: "INVESTOR II", desc: "Spend \$100 total", category: "INVEST", icon: Icons.payments),
|
||||
AchievementModel(id: "investor_3", title: "INVESTOR III", desc: "Spend \$250 total", category: "INVEST", icon: Icons.account_balance_wallet),
|
||||
AchievementModel(id: "day_one", title: "DAY ONE BACKER", desc: "Support series before Chapter 3", category: "INVEST", icon: Icons.first_page),
|
||||
AchievementModel(id: "loyal_patron", title: "LOYAL PATRON", desc: "Maintain subscription for 3 months", category: "INVEST", icon: Icons.loyalty),
|
||||
|
||||
// CONSISTENCY
|
||||
AchievementModel(id: "back_tomorrow", title: "BACK TOMORROW", desc: "2-day streak", category: "CONSISTENCY", icon: Icons.history),
|
||||
AchievementModel(id: "discipline", title: "3-DAY DISCIPLINE", desc: "3-day streak", category: "CONSISTENCY", icon: Icons.self_improvement),
|
||||
AchievementModel(id: "weekly_ritual", title: "WEEKLY RITUAL", desc: "7-day streak", category: "CONSISTENCY", icon: Icons.calendar_month),
|
||||
AchievementModel(id: "monthly_reader", title: "MONTHLY READER", desc: "30-day streak", category: "CONSISTENCY", icon: Icons.calendar_today),
|
||||
AchievementModel(id: "no_days_off", title: "NO DAYS OFF", desc: "60-day streak", category: "CONSISTENCY", icon: Icons.hotel_class),
|
||||
|
||||
// DISCOVERY
|
||||
AchievementModel(id: "explorer_1", title: "EXPLORER I", desc: "Read 3 different series", category: "DISCOVERY", icon: Icons.explore),
|
||||
AchievementModel(id: "explorer_2", title: "EXPLORER II", desc: "Read 5 different series", category: "DISCOVERY", icon: Icons.explore_off),
|
||||
AchievementModel(id: "explorer_3", title: "EXPLORER III", desc: "Read 10 different series", category: "DISCOVERY", icon: Icons.public),
|
||||
AchievementModel(id: "genre_breaker", title: "GENRE BREAKER", desc: "Read outside your usual category", category: "DISCOVERY", icon: Icons.extension),
|
||||
AchievementModel(id: "new_blood", title: "NEW BLOOD", desc: "Read a newly released series", category: "DISCOVERY", icon: Icons.fiber_new),
|
||||
AchievementModel(id: "early_witness", title: "EARLY WITNESS", desc: "Read within 24 hours of drop", category: "DISCOVERY", icon: Icons.access_time),
|
||||
|
||||
// RARE / HIDDEN
|
||||
AchievementModel(id: "night_reader", title: "NIGHT READER", desc: "Read between 2AM-5AM", category: "RARE", icon: Icons.dark_mode),
|
||||
AchievementModel(id: "marathon", title: "MARATHON", desc: "2+ hours in one session", category: "RARE", icon: Icons.timer),
|
||||
AchievementModel(id: "silent_watcher", title: "SILENT WATCHER", desc: "Read 20 chapters with no comments", category: "RARE", icon: Icons.visibility_off),
|
||||
AchievementModel(id: "break_loop", title: "BREAK THE LOOP", desc: "Return after 30 days inactive", category: "RARE", icon: Icons.loop),
|
||||
AchievementModel(id: "og_member", title: "OG MEMBER", desc: "Joined during beta", category: "RARE", icon: Icons.auto_awesome),
|
||||
AchievementModel(id: "founder_circle", title: "FOUNDER'S CIRCLE", desc: "Invited directly", category: "RARE", icon: Icons.stars),
|
||||
AchievementModel(id: "system_maxed", title: "SYSTEM MAXED", desc: "Complete all reading achievements", category: "RARE", icon: Icons.diamond),
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
class VigorManager {
|
||||
static const int maxVigor = 3;
|
||||
static const int rechargeHours = 3;
|
||||
|
||||
static Future<int> getAvailableVigor(Map<String, dynamic> userData) async {
|
||||
int currentVigor = userData['vigor'] ?? maxVigor;
|
||||
Timestamp? lastVigorRefill = userData['lastVigorRefill'];
|
||||
|
||||
if (lastVigorRefill == null) return currentVigor;
|
||||
|
||||
// Calculate time since last refill
|
||||
DateTime lastTime = lastVigorRefill.toDate();
|
||||
Duration diff = DateTime.now().difference(lastTime);
|
||||
|
||||
// If more than 3 hours have passed, full refill
|
||||
if (diff.inHours >= rechargeHours) {
|
||||
return maxVigor;
|
||||
}
|
||||
|
||||
return currentVigor;
|
||||
}
|
||||
|
||||
static Future<void> consumeVigor(String uid, int currentVigor) async {
|
||||
await FirebaseFirestore.instance.collection('users').doc(uid).update({
|
||||
'vigor': currentVigor - 1,
|
||||
'lastVigorRefill': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
/// Captures a [RepaintBoundary] (via [key]) as PNG and shares it with [text] and a [link]
|
||||
/// (defaults to [kOnsolAppWebUrl]). Uses in-memory bytes only (no [dart:io]).
|
||||
class OnsolShare {
|
||||
static Future<void> share(GlobalKey key, String text, {String? link}) =>
|
||||
_shareImpl(key, text, link: link);
|
||||
}
|
||||
|
||||
Future<void> _shareImpl(GlobalKey key, String text, {String? link}) async {
|
||||
final url = (link ?? kOnsolAppWebUrl).trim();
|
||||
String bodyWithLink(String raw) {
|
||||
final caption = raw.trim().isEmpty ? 'ONSOL-GO!' : raw.trim();
|
||||
if (caption.contains(url)) return caption;
|
||||
return '$caption\n\n$url';
|
||||
}
|
||||
|
||||
final fallbackMessage = bodyWithLink(text);
|
||||
|
||||
Future<void> shareText(String value) async {
|
||||
await SharePlus.instance.share(ShareParams(text: value));
|
||||
}
|
||||
|
||||
Future<void> clipboard(String value) async {
|
||||
await Clipboard.setData(ClipboardData(text: value));
|
||||
}
|
||||
|
||||
try {
|
||||
final ctx = key.currentContext;
|
||||
if (ctx == null || !ctx.mounted) {
|
||||
await shareText(fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
|
||||
if (!ctx.mounted) {
|
||||
await shareText(fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
final boundary = ctx.findRenderObject();
|
||||
if (boundary is! RenderRepaintBoundary) {
|
||||
await shareText(fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) {
|
||||
await shareText(fallbackMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
final pngBytes = byteData.buffer.asUint8List();
|
||||
final file = XFile.fromData(
|
||||
pngBytes,
|
||||
mimeType: 'image/png',
|
||||
name: 'onsol_share.png',
|
||||
);
|
||||
|
||||
try {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [file], text: bodyWithLink(text)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('OnsolShare: file+caption share failed ($e), retrying file only');
|
||||
}
|
||||
try {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(files: [file], text: bodyWithLink(text)),
|
||||
);
|
||||
} catch (e2) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('OnsolShare: file-only share failed ($e2), falling back to text');
|
||||
}
|
||||
await shareText(fallbackMessage);
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('OnsolShare error: $e\n$st');
|
||||
}
|
||||
try {
|
||||
await shareText(fallbackMessage);
|
||||
} catch (e3) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('OnsolShare text share failed: $e3');
|
||||
}
|
||||
try {
|
||||
await clipboard(fallbackMessage);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:onsolgo/core/achievement_manager.dart';
|
||||
|
||||
class StreakManager {
|
||||
static Future<void> updateStreak(String uid) async {
|
||||
final docRef = FirebaseFirestore.instance.collection('users').doc(uid);
|
||||
final snap = await docRef.get();
|
||||
if (!snap.exists) return;
|
||||
|
||||
final data = snap.data() as Map<String, dynamic>;
|
||||
final lastVisit = (data['lastVisit'] as Timestamp?)?.toDate();
|
||||
int currentStreak = data['streak'] ?? 0;
|
||||
final today = DateTime.now();
|
||||
|
||||
if (lastVisit != null) {
|
||||
final diff = DateTime(today.year, today.month, today.day).difference(DateTime(lastVisit.year, lastVisit.month, lastVisit.day)).inDays;
|
||||
if (diff == 1) {
|
||||
currentStreak++;
|
||||
await docRef.update({'streak': currentStreak, 'lastVisit': FieldValue.serverTimestamp()});
|
||||
// Streak Unlocks
|
||||
if (currentStreak >= 2) AchievementManager.unlock(uid, "back_tomorrow");
|
||||
if (currentStreak >= 3) AchievementManager.unlock(uid, "3_day_discipline");
|
||||
if (currentStreak >= 7) AchievementManager.unlock(uid, "weekly_ritual");
|
||||
if (currentStreak >= 30) AchievementManager.unlock(uid, "monthly_reader");
|
||||
if (currentStreak >= 60) AchievementManager.unlock(uid, "no_days_off");
|
||||
} else if (diff > 1) {
|
||||
await docRef.update({'streak': 1, 'lastVisit': FieldValue.serverTimestamp()});
|
||||
}
|
||||
} else {
|
||||
await docRef.update({'streak': 1, 'lastVisit': FieldValue.serverTimestamp()});
|
||||
AchievementManager.unlock(uid, "first_login");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user