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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// File generated by FlutterFire CLI.
|
||||
// ignore_for_file: type=lint
|
||||
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, kIsWeb, TargetPlatform;
|
||||
|
||||
/// Default [FirebaseOptions] for use with your Firebase apps.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// import 'firebase_options.dart';
|
||||
/// // ...
|
||||
/// await Firebase.initializeApp(
|
||||
/// options: DefaultFirebaseOptions.currentPlatform,
|
||||
/// );
|
||||
/// ```
|
||||
class DefaultFirebaseOptions {
|
||||
static FirebaseOptions get currentPlatform {
|
||||
if (kIsWeb) {
|
||||
return web;
|
||||
}
|
||||
switch (defaultTargetPlatform) {
|
||||
case TargetPlatform.android:
|
||||
return android;
|
||||
case TargetPlatform.iOS:
|
||||
return ios;
|
||||
case TargetPlatform.macOS:
|
||||
return macos;
|
||||
case TargetPlatform.windows:
|
||||
return windows;
|
||||
case TargetPlatform.linux:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions have not been configured for linux - '
|
||||
'you can reconfigure this by running the FlutterFire CLI again.',
|
||||
);
|
||||
default:
|
||||
throw UnsupportedError(
|
||||
'DefaultFirebaseOptions are not supported for this platform.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static const FirebaseOptions web = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDmli9KyCL26sofvC8OFiyLVnXA49RDNp4',
|
||||
appId: '1:128426171433:web:5b27ec0fdfceee78fe5038',
|
||||
messagingSenderId: '128426171433',
|
||||
projectId: 'onsol-go',
|
||||
authDomain: 'onsol-go.firebaseapp.com',
|
||||
storageBucket: 'onsol-go.firebasestorage.app',
|
||||
measurementId: 'G-36GJY6P96Y',
|
||||
);
|
||||
|
||||
static const FirebaseOptions android = FirebaseOptions(
|
||||
apiKey: 'AIzaSyAoS2uuI3uX4XwA0oPVyX94j-HV1MeLlOw',
|
||||
appId: '1:128426171433:android:618cd582a847a9a5fe5038',
|
||||
messagingSenderId: '128426171433',
|
||||
projectId: 'onsol-go',
|
||||
storageBucket: 'onsol-go.firebasestorage.app',
|
||||
);
|
||||
|
||||
static const FirebaseOptions ios = FirebaseOptions(
|
||||
apiKey: 'AIzaSyCwMHcvC54_YDFKjhuSWHeZ4kGRpfwok_o',
|
||||
appId: '1:128426171433:ios:c9499633c66d705ffe5038',
|
||||
messagingSenderId: '128426171433',
|
||||
projectId: 'onsol-go',
|
||||
storageBucket: 'onsol-go.firebasestorage.app',
|
||||
iosBundleId: 'com.stnebula.onsolgo.onsolgo',
|
||||
);
|
||||
|
||||
static const FirebaseOptions macos = FirebaseOptions(
|
||||
apiKey: 'AIzaSyCwMHcvC54_YDFKjhuSWHeZ4kGRpfwok_o',
|
||||
appId: '1:128426171433:ios:c9499633c66d705ffe5038',
|
||||
messagingSenderId: '128426171433',
|
||||
projectId: 'onsol-go',
|
||||
storageBucket: 'onsol-go.firebasestorage.app',
|
||||
iosBundleId: 'com.stnebula.onsolgo.onsolgo',
|
||||
);
|
||||
|
||||
static const FirebaseOptions windows = FirebaseOptions(
|
||||
apiKey: 'AIzaSyDmli9KyCL26sofvC8OFiyLVnXA49RDNp4',
|
||||
appId: '1:128426171433:web:efaf8cf4fb14cd7ffe5038',
|
||||
messagingSenderId: '128426171433',
|
||||
projectId: 'onsol-go',
|
||||
authDomain: 'onsol-go.firebaseapp.com',
|
||||
storageBucket: 'onsol-go.firebasestorage.app',
|
||||
measurementId: 'G-CT9672C0JV',
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:onsolgo/navigation_hub.dart';
|
||||
import 'package:onsolgo/screens/auth/login_screen.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'firebase_options.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (!kIsWeb) {
|
||||
// 1. INITIALIZE AD ENGINE
|
||||
MobileAds.instance.initialize();
|
||||
|
||||
// 2. CONFIGURE TEST DEVICE (Mandatory for Pixel 10 Pro)
|
||||
// This ID comes directly from your console logs
|
||||
RequestConfiguration configuration = RequestConfiguration(
|
||||
testDeviceIds: ["949E2F2F6403B732392FF2648C5D9B85"]);
|
||||
MobileAds.instance.updateRequestConfiguration(configuration);
|
||||
}
|
||||
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 300 * 1024 * 1024;
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
FirebaseFirestore.instance.settings = const Settings(persistenceEnabled: true);
|
||||
|
||||
runApp(const OnsolGoApp());
|
||||
}
|
||||
|
||||
class OnsolGoApp extends StatelessWidget {
|
||||
const OnsolGoApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'ONSOL-GO!',
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
canvasColor: Colors.black,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: kOnsolGold,
|
||||
brightness: Brightness.dark,
|
||||
surface: Colors.black,
|
||||
surfaceTint: Colors.transparent,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(backgroundColor: Colors.black, surfaceTintColor: Colors.transparent, elevation: 0),
|
||||
cardTheme: const CardThemeData(color: Colors.black, surfaceTintColor: Colors.transparent),
|
||||
),
|
||||
home: StreamBuilder<User?>(
|
||||
stream: FirebaseAuth.instance.authStateChanges(),
|
||||
builder: (context, authSnapshot) {
|
||||
if (authSnapshot.hasData) {
|
||||
return StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(authSnapshot.data!.uid).snapshots(),
|
||||
builder: (context, userSnapshot) {
|
||||
if (!userSnapshot.hasData) return const Scaffold(body: Center(child: CircularProgressIndicator(color: kOnsolGold)));
|
||||
if (!userSnapshot.data!.exists) return const NavigationHub();
|
||||
var userData = userSnapshot.data!.data() as Map<String, dynamic>;
|
||||
if (userData['isBanned'] == true) {
|
||||
return const Scaffold(
|
||||
body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.gavel, color: Colors.red, size: 80),
|
||||
Text("EXILED", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 32, letterSpacing: 10)),
|
||||
Text("REMOVED FROM THE ORDER", style: TextStyle(color: Colors.white54, fontSize: 10)),
|
||||
])),
|
||||
);
|
||||
}
|
||||
return const NavigationHub();
|
||||
},
|
||||
);
|
||||
}
|
||||
return const LoginScreen();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/core/streak_manager.dart';
|
||||
import 'package:onsolgo/screens/library/library_grid.dart';
|
||||
import 'package:onsolgo/screens/library/trending_list.dart';
|
||||
import 'package:onsolgo/screens/library/coming_soon.dart';
|
||||
import 'package:onsolgo/screens/market/market_hub.dart';
|
||||
import 'package:onsolgo/screens/social/social_feed.dart';
|
||||
import 'package:onsolgo/screens/profile/profile_view.dart';
|
||||
|
||||
class NavigationHub extends StatefulWidget {
|
||||
const NavigationHub({super.key});
|
||||
@override
|
||||
State<NavigationHub> createState() => _NavigationHubState();
|
||||
}
|
||||
|
||||
class _NavigationHubState extends State<NavigationHub> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
// REMOVED 'const' from the list to avoid the Constant Expression error
|
||||
final List<Widget> _tabs = [
|
||||
const LibraryGrid(),
|
||||
const TrendingList(),
|
||||
const ComingSoon(),
|
||||
const MarketHub(),
|
||||
const SocialFeed(), // Ensure this only exists in social_feed.dart
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final String uid = FirebaseAuth.instance.currentUser?.uid ?? "";
|
||||
if (uid.isNotEmpty) StreakManager.updateStreak(uid);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String uid = FirebaseAuth.instance.currentUser?.uid ?? "";
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
IndexedStack(index: _currentIndex, children: _tabs),
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 10,
|
||||
right: 20,
|
||||
child: SizedBox(
|
||||
width: 45, height: 45,
|
||||
child: StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
int rank = 1; String? pfp;
|
||||
if (snapshot.hasData && snapshot.data!.exists) {
|
||||
var data = snapshot.data!.data() as Map<String, dynamic>;
|
||||
rank = safeInt(data['rankLevel']);
|
||||
pfp = data.containsKey('pfpUrl') ? data['pfpUrl'] : null;
|
||||
}
|
||||
return IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
// REMOVED 'const' here to fix the build error
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (c) => ProfileView())),
|
||||
icon: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: getRankColor(rank), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: getRankColor(rank).withValues(alpha: 0.3), blurRadius: 10)],
|
||||
),
|
||||
child: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: Colors.black,
|
||||
child: (pfp != null && pfp.isNotEmpty)
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: pfp,
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (context, url, error) => const Icon(Icons.person, size: 20, color: Colors.white),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.person, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: (index) => setState(() => _currentIndex = index),
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.black,
|
||||
selectedItemColor: kOnsolGold,
|
||||
unselectedItemColor: Colors.grey,
|
||||
selectedFontSize: 10,
|
||||
unselectedFontSize: 10,
|
||||
items: const [
|
||||
BottomNavigationBarItem(icon: Icon(Icons.grid_view_rounded), label: 'Library'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.local_fire_department), label: 'Hot'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.auto_awesome), label: 'Upcoming'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.token), label: 'Vault'),
|
||||
BottomNavigationBarItem(icon: Icon(Icons.forum_rounded), label: 'Social'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'dart:math';
|
||||
|
||||
class AdminDashboard extends StatelessWidget {
|
||||
const AdminDashboard({super.key});
|
||||
|
||||
// --- LOGIC: SYSTEM BROADCAST ---
|
||||
void _editAnnouncement(BuildContext context) async {
|
||||
final tC = TextEditingController();
|
||||
final mC = TextEditingController();
|
||||
bool active = true;
|
||||
|
||||
try {
|
||||
var existing = await FirebaseFirestore.instance.collection('system').doc('announcement').get();
|
||||
if (existing.exists) {
|
||||
final map = existing.data();
|
||||
if (map != null) {
|
||||
tC.text = map['title']?.toString() ?? '';
|
||||
mC.text = map['message']?.toString() ?? '';
|
||||
active = map['isActive'] as bool? ?? true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("Load Error: $e");
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setS) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: const Text("SYSTEM BROADCAST", style: TextStyle(color: kOnsolGold, fontSize: 14, fontWeight: FontWeight.bold)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(controller: tC, decoration: const InputDecoration(hintText: "Headline")),
|
||||
const SizedBox(height: 10),
|
||||
TextField(controller: mC, maxLines: 3, decoration: const InputDecoration(hintText: "Message")),
|
||||
SwitchListTile(
|
||||
title: const Text("Show Popup", style: TextStyle(fontSize: 12)),
|
||||
value: active,
|
||||
activeThumbColor: kOnsolGold,
|
||||
onChanged: (v) => setS(() => active = v)
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("CANCEL", style: TextStyle(color: Colors.grey))),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await FirebaseFirestore.instance.collection('system').doc('announcement').set({
|
||||
'title': tC.text.trim(),
|
||||
'message': mC.text.trim(),
|
||||
'isActive': active,
|
||||
'updatedAt': FieldValue.serverTimestamp()
|
||||
});
|
||||
if (context.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("TRANSMIT"),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 4,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.red[900],
|
||||
title: const Text("COMMAND CENTER", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.podcasts), onPressed: () => _editAnnouncement(context))
|
||||
],
|
||||
bottom: const TabBar(
|
||||
labelStyle: TextStyle(fontSize: 8, fontWeight: FontWeight.bold),
|
||||
indicatorColor: Colors.white,
|
||||
tabs: [
|
||||
Tab(text: "SERIES", icon: Icon(Icons.library_books, size: 18)),
|
||||
Tab(text: "MERCH", icon: Icon(Icons.shopping_bag, size: 18)),
|
||||
Tab(text: "CITIZENS", icon: Icon(Icons.people, size: 18)),
|
||||
Tab(text: "INVITES", icon: Icon(Icons.vpn_key, size: 18)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: const TabBarView(
|
||||
children: [
|
||||
_ManageSeriesTab(),
|
||||
_ManageMerchTab(),
|
||||
_ManageUsersTab(),
|
||||
_ManageInvitesTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- TAB 3: CITIZEN MODERATION (Energy & Ranks) ---
|
||||
class _ManageUsersTab extends StatelessWidget {
|
||||
const _ManageUsersTab();
|
||||
|
||||
void _showModeration(BuildContext context, DocumentSnapshot user) {
|
||||
var d = user.data() as Map<String, dynamic>;
|
||||
int rank = safeInt(d['rankLevel'] ?? 1);
|
||||
bool ver = d['isVerified'] ?? (rank == 5);
|
||||
bool ban = d['isBanned'] ?? false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setS) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: Text("MODERATING: ${d['username'] ?? 'Citizen'}"),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButton<int>(
|
||||
value: rank,
|
||||
isExpanded: true,
|
||||
dropdownColor: Colors.black,
|
||||
items: [1, 2, 3, 4, 5].map((v) => DropdownMenuItem(value: v, child: Text(getRankName(v)))).toList(),
|
||||
onChanged: (v) => setS(() {
|
||||
rank = v!;
|
||||
if (rank == 5) ver = true;
|
||||
}),
|
||||
),
|
||||
SwitchListTile(title: const Text("Verified"), value: ver, activeThumbColor: kOnsolGold, onChanged: (v) => setS(() => ver = v)),
|
||||
SwitchListTile(title: const Text("Exiled"), value: ban, activeThumbColor: Colors.red, onChanged: (v) => setS(() => ban = v)),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// --- ENERGY REFILL BUTTON (2/2) ---
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.orangeAccent, foregroundColor: Colors.black, minimumSize: const Size(double.infinity, 40)),
|
||||
onPressed: () async {
|
||||
await user.reference.update({
|
||||
'energy': 2,
|
||||
'lastEnergyRefill': FieldValue.serverTimestamp()
|
||||
});
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("REFILL ENERGY (2/2)", style: TextStyle(fontWeight: FontWeight.bold))
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("CANCEL")),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await user.reference.update({'rankLevel': rank, 'isVerified': ver, 'isBanned': ban});
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text("APPLY"),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(15),
|
||||
children: snapshot.data!.docs.map((u) {
|
||||
var d = u.data() as Map<String, dynamic>;
|
||||
int r = safeInt(d['rankLevel'] ?? 1);
|
||||
bool isBan = d['isBanned'] ?? false;
|
||||
return Card(
|
||||
color: Colors.grey[900],
|
||||
child: ListTile(
|
||||
leading: Icon(getRankIcon(r), color: isBan ? Colors.red : getRankColor(r)),
|
||||
title: Row(children: [
|
||||
Text(d['username'] ?? 'Anonymous', style: TextStyle(color: isBan ? Colors.red : Colors.white)),
|
||||
verifiedBadge(d['isVerified'] ?? (r == 5))
|
||||
]),
|
||||
subtitle: Text(isBan ? "EXILED" : getRankName(r)),
|
||||
trailing: IconButton(icon: const Icon(Icons.shield, color: Colors.amber), onPressed: () => _showModeration(context, u)),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- OTHER TABS ---
|
||||
|
||||
class _ManageSeriesTab extends StatelessWidget {
|
||||
const _ManageSeriesTab();
|
||||
void _add(BuildContext context) {
|
||||
final t = TextEditingController();
|
||||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900], title: const Text("New Series"),
|
||||
content: TextField(controller: t, decoration: const InputDecoration(hintText: "Series Title")),
|
||||
actions: [ElevatedButton(onPressed: () { FirebaseFirestore.instance.collection('manga').add({'title': t.text, 'reads': 0, 'readingMode': 'RL', 'author': 'Artist', 'coverUrl': ''}); Navigator.pop(ctx); }, child: const Text("ADD"))],
|
||||
));
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(backgroundColor: Colors.transparent, floatingActionButton: FloatingActionButton(backgroundColor: Colors.white, child: const Icon(Icons.add, color: Colors.black), onPressed: () => _add(context)), body: StreamBuilder<QuerySnapshot>(stream: FirebaseFirestore.instance.collection('manga').snapshots(), builder: (context, snapshot) { if (!snapshot.hasData) return const CircularProgressIndicator(); return ListView(padding: const EdgeInsets.all(15), children: snapshot.data!.docs.map((doc) => Card(color: Colors.grey[900], child: ListTile(title: Text(doc['title'] ?? 'Untitled'), trailing: IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () => doc.reference.delete())))).toList()); }));
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageMerchTab extends StatelessWidget {
|
||||
const _ManageMerchTab();
|
||||
void _add(BuildContext context) {
|
||||
final n = TextEditingController(); final p = TextEditingController(); final u = TextEditingController(); String cat = 'vault';
|
||||
showDialog(context: context, builder: (ctx) => StatefulBuilder(builder: (context, setS) => AlertDialog(backgroundColor: Colors.grey[900], title: const Text("New Item"),
|
||||
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [TextField(controller: n, decoration: const InputDecoration(hintText: "Name")), TextField(controller: p, decoration: const InputDecoration(hintText: "Price")), TextField(controller: u, decoration: const InputDecoration(hintText: "Buy URL")), DropdownButton<String>(value: cat, isExpanded: true, dropdownColor: Colors.black, items: ['vault', 'prints', 'merch'].map((s) => DropdownMenuItem(value: s, child: Text(s.toUpperCase()))).toList(), onChanged: (v) => setS(() => cat = v!))])),
|
||||
actions: [ElevatedButton(onPressed: () { FirebaseFirestore.instance.collection('marketplace').add({'name': n.text, 'price': p.text, 'buyUrl': u.text, 'category': cat, 'imageUrl': ''}); Navigator.pop(ctx); }, child: const Text("ADD"))],
|
||||
)));
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(backgroundColor: Colors.transparent, floatingActionButton: FloatingActionButton(backgroundColor: kOnsolGold, child: const Icon(Icons.add_shopping_cart, color: Colors.black), onPressed: () => _add(context)), body: StreamBuilder<QuerySnapshot>(stream: FirebaseFirestore.instance.collection('marketplace').snapshots(), builder: (context, snapshot) { if (!snapshot.hasData) return const CircularProgressIndicator(); return ListView(padding: const EdgeInsets.all(15), children: snapshot.data!.docs.map((doc) => Card(color: Colors.grey[900], child: ListTile(title: Text(doc['name'] ?? 'Item'), subtitle: Text(doc['price'] ?? ''), trailing: IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red), onPressed: () => doc.reference.delete())))).toList()); }));
|
||||
}
|
||||
}
|
||||
|
||||
class _ManageInvitesTab extends StatelessWidget {
|
||||
const _ManageInvitesTab();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(children: [
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.black), onPressed: () {
|
||||
String code = List.generate(6, (i) => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[Random().nextInt(30)]).join();
|
||||
FirebaseFirestore.instance.collection('invite_codes').doc(code).set({'isUsed': false, 'createdAt': FieldValue.serverTimestamp()});
|
||||
}, child: const Text("GENERATE INVITE")),
|
||||
Expanded(child: StreamBuilder<QuerySnapshot>(stream: FirebaseFirestore.instance.collection('invite_codes').snapshots(), builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
|
||||
var docs = snapshot.data!.docs;
|
||||
docs.sort((a,b) {
|
||||
var at = (a.data() as Map)['createdAt'] as Timestamp? ?? Timestamp.now();
|
||||
var bt = (b.data() as Map)['createdAt'] as Timestamp? ?? Timestamp.now();
|
||||
return bt.compareTo(at);
|
||||
});
|
||||
return ListView(padding: const EdgeInsets.all(20), children: docs.map((doc) {
|
||||
var d = doc.data() as Map<String, dynamic>;
|
||||
return ListTile(title: Text(doc.id, style: const TextStyle(letterSpacing: 4, fontWeight: FontWeight.bold, color: Colors.white)), trailing: (d['isUsed'] ?? false) ? const Icon(Icons.check_circle, color: Colors.green) : const Icon(Icons.hourglass_empty, color: Colors.amber));
|
||||
}).toList());
|
||||
}))
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/screens/artist/artist_series_manage_screen.dart';
|
||||
import 'package:onsolgo/screens/artist/artist_upcoming_screen.dart';
|
||||
|
||||
class ArtistHub extends StatefulWidget {
|
||||
const ArtistHub({super.key});
|
||||
@override
|
||||
State<ArtistHub> createState() => _ArtistHubState();
|
||||
}
|
||||
|
||||
class _ArtistHubState extends State<ArtistHub> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String uid = FirebaseAuth.instance.currentUser?.uid ?? "";
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.amber[900],
|
||||
title: const Text("ARTIST HUB"),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Upcoming releases',
|
||||
icon: const Icon(Icons.event_available),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(builder: (_) => const ArtistUpcomingScreen()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('manga').where('authorId', isEqualTo: uid).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
|
||||
final docs = snapshot.data!.docs;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Card(
|
||||
margin: const EdgeInsets.fromLTRB(15, 15, 15, 8),
|
||||
color: Colors.grey.shade900,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.event_available, color: kOnsolGold),
|
||||
title: const Text('MANAGE UPCOMING RELEASES'),
|
||||
subtitle: const Text('Add, edit, or remove Soon tab teasers', style: TextStyle(fontSize: 11)),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white54),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(builder: (_) => const ArtistUpcomingScreen()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (docs.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No series assigned to your account yet.',
|
||||
style: TextStyle(color: Colors.grey[500]),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(15, 0, 15, 15),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => Card(
|
||||
color: Colors.grey[900],
|
||||
child: ListTile(
|
||||
title: Text(docs[index]['title'] ?? 'Untitled'),
|
||||
subtitle: const Text('Manage portfolio'),
|
||||
onTap: () => _showOptions(context, docs[index]),
|
||||
),
|
||||
),
|
||||
childCount: docs.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showOptions(BuildContext context, DocumentSnapshot series) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.grey[900],
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (context) => SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.auto_stories, color: kOnsolGold),
|
||||
title: const Text('SERIES & CHAPTERS'),
|
||||
subtitle: const Text('Banner, library cover, chapters', style: TextStyle(fontSize: 11)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(builder: (_) => ArtistSeriesManageScreen(series: series)),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.event_available, color: kOnsolGold),
|
||||
title: const Text('UPCOMING RELEASES'),
|
||||
subtitle: const Text('Manage Soon tab announcements', style: TextStyle(fontSize: 11)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(context, MaterialPageRoute<void>(builder: (_) => const ArtistUpcomingScreen()));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('EDIT PORTFOLIO INFO'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_editPortfolio(context, series);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _editPortfolio(BuildContext context, DocumentSnapshot series) {
|
||||
final d = series.data() as Map<String, dynamic>;
|
||||
final s = d['socials'] ?? {};
|
||||
final sC = TextEditingController(text: d['synopsis'] ?? "");
|
||||
final bC = TextEditingController(text: d['aboutArtist'] ?? "");
|
||||
final fbC = TextEditingController(text: s['facebook'] ?? "");
|
||||
final igC = TextEditingController(text: s['instagram'] ?? "");
|
||||
final xC = TextEditingController(text: s['twitter'] ?? s['x'] ?? "");
|
||||
final webC = TextEditingController(text: s['website'] ?? "");
|
||||
|
||||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900], title: const Text("Edit Portfolio"),
|
||||
content: SingleChildScrollView(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(controller: sC, maxLines: 3, decoration: const InputDecoration(hintText: "Synopsis")),
|
||||
TextField(controller: bC, maxLines: 3, decoration: const InputDecoration(hintText: "Artist Bio")),
|
||||
TextField(controller: fbC, decoration: const InputDecoration(hintText: "Facebook URL")),
|
||||
TextField(controller: igC, decoration: const InputDecoration(hintText: "Instagram URL")),
|
||||
TextField(controller: xC, decoration: const InputDecoration(hintText: "X (Twitter) URL")),
|
||||
TextField(controller: webC, decoration: const InputDecoration(hintText: "Personal website URL")),
|
||||
])),
|
||||
actions: [ElevatedButton(onPressed: () {
|
||||
series.reference.update({
|
||||
'synopsis': sC.text,
|
||||
'aboutArtist': bC.text,
|
||||
'socials': {
|
||||
'facebook': fbC.text,
|
||||
'instagram': igC.text,
|
||||
'twitter': xC.text,
|
||||
'website': webC.text,
|
||||
},
|
||||
});
|
||||
Navigator.pop(ctx);
|
||||
}, child: const Text("SAVE"))],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
/// Series portfolio editor: cover (same view as chapter list context) and chapter removal.
|
||||
class ArtistSeriesManageScreen extends StatefulWidget {
|
||||
final DocumentSnapshot series;
|
||||
|
||||
const ArtistSeriesManageScreen({super.key, required this.series});
|
||||
|
||||
@override
|
||||
State<ArtistSeriesManageScreen> createState() => _ArtistSeriesManageScreenState();
|
||||
}
|
||||
|
||||
class _ArtistSeriesManageScreenState extends State<ArtistSeriesManageScreen> {
|
||||
/// null | 'cover' | 'banner'
|
||||
String? _uploadingKind;
|
||||
|
||||
Future<void> _uploadSeriesCover() async {
|
||||
final picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70);
|
||||
if (image == null) return;
|
||||
setState(() => _uploadingKind = 'cover');
|
||||
try {
|
||||
final ref = FirebaseStorage.instance.ref().child('series_covers').child('${widget.series.id}.jpg');
|
||||
await ref.putData(await image.readAsBytes(), SettableMetadata(contentType: 'image/jpeg'));
|
||||
final url = await ref.getDownloadURL();
|
||||
await widget.series.reference.update({'coverUrl': url});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Cover updated')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cover upload failed: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingKind = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadSeriesBanner() async {
|
||||
final picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(source: ImageSource.gallery, imageQuality: 72);
|
||||
if (image == null) return;
|
||||
setState(() => _uploadingKind = 'banner');
|
||||
try {
|
||||
final ref = FirebaseStorage.instance.ref().child('series_banners').child('${widget.series.id}.jpg');
|
||||
await ref.putData(await image.readAsBytes(), SettableMetadata(contentType: 'image/jpeg'));
|
||||
final url = await ref.getDownloadURL();
|
||||
await widget.series.reference.update({'bannerUrl': url});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Banner updated')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Banner upload failed: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingKind = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearBanner() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: const Text('Remove banner?', style: TextStyle(color: Colors.white)),
|
||||
content: const Text(
|
||||
'The series page will use the tall cover image at the top until you add a banner again.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(c, true),
|
||||
child: const Text('Remove', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
try {
|
||||
await widget.series.reference.update({'bannerUrl': FieldValue.delete()});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Banner removed')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not remove banner: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmDeleteChapter(DocumentSnapshot ch) async {
|
||||
final cd = ch.data() as Map<String, dynamic>;
|
||||
final numLabel = cd['chapterNumber']?.toString() ?? ch.id;
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: const Text('Remove chapter?', style: TextStyle(color: Colors.white)),
|
||||
content: Text(
|
||||
'Delete chapter $numLabel from Firestore? This cannot be undone.',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(c, true),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
try {
|
||||
await ch.reference.delete();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chapter $numLabel removed')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not delete: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final d = widget.series.data() as Map<String, dynamic>;
|
||||
final title = d['title']?.toString() ?? 'Series';
|
||||
final cover = d['coverUrl']?.toString() ?? '';
|
||||
final banner = d['bannerUrl']?.toString() ?? '';
|
||||
final busy = _uploadingKind != null;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.amber[900],
|
||||
title: Text(title.toUpperCase(), style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
StreamBuilder<QuerySnapshot>(
|
||||
stream: widget.series.reference.collection('chapters').orderBy('chapterNumber', descending: true).snapshots(),
|
||||
builder: (context, chSnap) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'DETAIL PAGE BANNER',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Wide image at the top when readers open this series (library grid still uses the cover below).',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600], height: 1.3),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: banner.isEmpty
|
||||
? Container(
|
||||
color: Colors.grey[850],
|
||||
alignment: Alignment.center,
|
||||
child: const Text('No banner yet', style: TextStyle(color: Colors.white38)),
|
||||
)
|
||||
: CachedNetworkImage(imageUrl: banner, fit: BoxFit.cover, alignment: Alignment.center),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: busy ? null : _uploadSeriesBanner,
|
||||
icon: const Icon(Icons.panorama_wide_angle_outlined, color: kOnsolGold),
|
||||
label: const Text('UPDATE BANNER', style: TextStyle(color: kOnsolGold)),
|
||||
style: OutlinedButton.styleFrom(side: const BorderSide(color: kOnsolGold)),
|
||||
),
|
||||
),
|
||||
if (banner.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Remove banner',
|
||||
onPressed: busy ? null : _clearBanner,
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'LIBRARY COVER',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey, fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: cover.isEmpty
|
||||
? Container(
|
||||
color: Colors.grey[850],
|
||||
alignment: Alignment.center,
|
||||
child: const Text('No cover', style: TextStyle(color: Colors.white38)),
|
||||
)
|
||||
: CachedNetworkImage(imageUrl: cover, fit: BoxFit.cover, alignment: Alignment.topCenter),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: busy ? null : _uploadSeriesCover,
|
||||
icon: const Icon(Icons.photo_library_outlined, color: kOnsolGold),
|
||||
label: const Text('UPDATE SERIES COVER', style: TextStyle(color: kOnsolGold)),
|
||||
style: OutlinedButton.styleFrom(side: const BorderSide(color: kOnsolGold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||
child: Text('CHAPTERS', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
if (chSnap.hasError)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('Error: ${chSnap.error}', style: const TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
)
|
||||
else if (!chSnap.hasData)
|
||||
const SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: CircularProgressIndicator(color: kOnsolGold)),
|
||||
)
|
||||
else if (chSnap.data!.docs.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Center(child: Text('No chapters yet.', style: TextStyle(color: Colors.grey[500]))),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final ch = chSnap.data!.docs[i];
|
||||
final cd = ch.data() as Map<String, dynamic>;
|
||||
final thumb = cd['chapterCoverUrl']?.toString() ?? cover;
|
||||
return Card(
|
||||
color: Colors.grey[900],
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: thumb.isEmpty
|
||||
? const SizedBox(width: 50, height: 50, child: ColoredBox(color: Colors.white10))
|
||||
: CachedNetworkImage(imageUrl: thumb, width: 50, height: 50, fit: BoxFit.cover),
|
||||
),
|
||||
title: Text('Ch ${cd['chapterNumber']}', style: const TextStyle(color: Colors.white)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||
onPressed: () => _confirmDeleteChapter(ch),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: chSnap.data!.docs.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_uploadingKind != null)
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: LinearProgressIndicator(minHeight: 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
const String _kUpcomingCollection = 'upcoming';
|
||||
const String _kindNewSeries = 'new_series';
|
||||
const String _kindChapterDrop = 'chapter_drop';
|
||||
|
||||
/// Sentinel end date for Firestore ordering when "date TBD" (sorts after real dates).
|
||||
final DateTime _kTbdSortDate = DateTime(2099, 12, 31, 23, 59);
|
||||
|
||||
/// Artists manage teasers shown on the Soon tab.
|
||||
class ArtistUpcomingScreen extends StatelessWidget {
|
||||
const ArtistUpcomingScreen({super.key});
|
||||
|
||||
String _fmtDate(DateTime d) => '${d.month.toString().padLeft(2, '0')}/${d.day.toString().padLeft(2, '0')}/${d.year}';
|
||||
|
||||
Future<void> _openEditor(
|
||||
BuildContext context, {
|
||||
DocumentSnapshot? existing,
|
||||
}) async {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (uid == null) return;
|
||||
|
||||
final userSnap = await FirebaseFirestore.instance.collection('users').doc(uid).get();
|
||||
final userName = (userSnap.data()?['username'] as String?)?.trim() ?? 'Artist';
|
||||
|
||||
final mangaSnap = await FirebaseFirestore.instance.collection('manga').where('authorId', isEqualTo: uid).get();
|
||||
final mangaDocs = mangaSnap.docs;
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
String kind = existing?.get('kind') as String? ?? _kindNewSeries;
|
||||
final titleCtrl = TextEditingController(text: existing?.get('seriesTitle') as String? ?? '');
|
||||
final descCtrl = TextEditingController(text: existing?.get('description') as String? ?? '');
|
||||
final chCtrl = TextEditingController(
|
||||
text: existing != null && existing.get('chapterNumber') != null ? '${existing.get('chapterNumber')}' : '',
|
||||
);
|
||||
String? mangaId = existing?.get('mangaId') as String?;
|
||||
final existingTs = existing?.get('targetDate') as Timestamp?;
|
||||
DateTime target = existingTs?.toDate() ?? DateTime.now().add(const Duration(days: 7));
|
||||
if (existing != null && (existing.get('dateTbd') as bool? ?? false)) {
|
||||
target = DateTime.now().add(const Duration(days: 7));
|
||||
}
|
||||
bool dateTbd = existing?.get('dateTbd') as bool? ?? false;
|
||||
bool featured = existing?.get('featured') as bool? ?? false;
|
||||
XFile? pickedCover;
|
||||
final existingCoverUrl = existing?.get('teaserCoverUrl') as String?;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setS) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: Text(existing == null ? 'Announce release' : 'Edit announcement', style: const TextStyle(color: kOnsolGold, fontSize: 16)),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Type', style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
dropdownColor: Colors.black87,
|
||||
value: kind,
|
||||
items: const [
|
||||
DropdownMenuItem(value: _kindNewSeries, child: Text('New series (coming soon)', style: TextStyle(color: Colors.white))),
|
||||
DropdownMenuItem(value: _kindChapterDrop, child: Text('New chapter (existing series)', style: TextStyle(color: Colors.white))),
|
||||
],
|
||||
onChanged: (v) => setS(() {
|
||||
kind = v ?? _kindNewSeries;
|
||||
if (kind == _kindNewSeries) mangaId = null;
|
||||
}),
|
||||
),
|
||||
if (kind == _kindChapterDrop) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text('Series', style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButton<String>(
|
||||
isExpanded: true,
|
||||
dropdownColor: Colors.black87,
|
||||
value: mangaId != null && mangaDocs.any((d) => d.id == mangaId) ? mangaId : null,
|
||||
hint: const Text('Select series', style: TextStyle(color: Colors.white54)),
|
||||
items: mangaDocs
|
||||
.map((d) => DropdownMenuItem(value: d.id, child: Text(d['title'] ?? d.id, style: const TextStyle(color: Colors.white))))
|
||||
.toList(),
|
||||
onChanged: (v) => setS(() {
|
||||
mangaId = v;
|
||||
try {
|
||||
final m = mangaDocs.firstWhere((d) => d.id == v);
|
||||
titleCtrl.text = m['title'] ?? '';
|
||||
} catch (_) {}
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: chCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Chapter # (optional)',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Series title',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
hintText: 'Working title',
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
maxLines: 4,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (Soon tab)',
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
hintText: 'Teaser, synopsis, or hook for readers',
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Teaser cover', style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 80);
|
||||
if (image != null) setS(() => pickedCover = image);
|
||||
},
|
||||
icon: const Icon(Icons.add_photo_alternate_outlined, color: kOnsolGold, size: 20),
|
||||
label: const Text('Choose image', style: TextStyle(color: kOnsolGold, fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (pickedCover != null || (existingCoverUrl != null && existingCoverUrl.isNotEmpty)) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: pickedCover != null
|
||||
? FutureBuilder(
|
||||
future: pickedCover!.readAsBytes(),
|
||||
builder: (context, snap) {
|
||||
if (!snap.hasData) return const ColoredBox(color: Colors.white10);
|
||||
return Image.memory(snap.data!, fit: BoxFit.cover);
|
||||
},
|
||||
)
|
||||
: CachedNetworkImage(imageUrl: existingCoverUrl!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Highlight on Soon tab', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
subtitle: const Text('Featured styling for this announcement', style: TextStyle(color: Colors.white38, fontSize: 11)),
|
||||
value: featured,
|
||||
activeThumbColor: kOnsolGold,
|
||||
onChanged: (v) => setS(() => featured = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Release date TBD', style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
subtitle: const Text('No specific day yet — shows as “Date TBD”', style: TextStyle(color: Colors.white38, fontSize: 11)),
|
||||
value: dateTbd,
|
||||
activeThumbColor: kOnsolGold,
|
||||
onChanged: (v) => setS(() => dateTbd = v),
|
||||
),
|
||||
if (!dateTbd)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Release date', style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
subtitle: Text(_fmtDate(target), style: const TextStyle(color: kOnsolGold, fontWeight: FontWeight.bold)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.calendar_month, color: kOnsolGold),
|
||||
onPressed: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: ctx,
|
||||
initialDate: target,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 1)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365 * 3)),
|
||||
);
|
||||
if (picked != null) setS(() => target = picked);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel', style: TextStyle(color: Colors.grey))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: kOnsolGold, foregroundColor: Colors.black),
|
||||
onPressed: () async {
|
||||
final seriesTitle = titleCtrl.text.trim();
|
||||
if (seriesTitle.isEmpty) return;
|
||||
if (kind == _kindChapterDrop && (mangaId == null || mangaId!.isEmpty)) return;
|
||||
|
||||
num? chNum = num.tryParse(chCtrl.text.trim());
|
||||
final targetTs = dateTbd
|
||||
? Timestamp.fromDate(_kTbdSortDate)
|
||||
: Timestamp.fromDate(DateTime(target.year, target.month, target.day, 23, 59));
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'kind': kind,
|
||||
'authorId': uid,
|
||||
'authorName': userName,
|
||||
'seriesTitle': seriesTitle,
|
||||
'mangaId': kind == _kindChapterDrop ? mangaId : null,
|
||||
'chapterNumber': chNum,
|
||||
'targetDate': targetTs,
|
||||
'dateTbd': dateTbd,
|
||||
'featured': featured,
|
||||
'description': descCtrl.text.trim(),
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
};
|
||||
|
||||
if (pickedCover == null && existingCoverUrl != null && existingCoverUrl.isNotEmpty) {
|
||||
payload['teaserCoverUrl'] = existingCoverUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existing == null) {
|
||||
payload['createdAt'] = FieldValue.serverTimestamp();
|
||||
final docRef = FirebaseFirestore.instance.collection(_kUpcomingCollection).doc();
|
||||
await docRef.set(payload);
|
||||
if (pickedCover != null) {
|
||||
final ref = FirebaseStorage.instance.ref().child('upcoming_covers').child('${docRef.id}.jpg');
|
||||
await ref.putData(await pickedCover!.readAsBytes(), SettableMetadata(contentType: 'image/jpeg'));
|
||||
final url = await ref.getDownloadURL();
|
||||
await docRef.update({'teaserCoverUrl': url});
|
||||
}
|
||||
} else {
|
||||
await existing.reference.update(payload);
|
||||
if (pickedCover != null) {
|
||||
final ref = FirebaseStorage.instance.ref().child('upcoming_covers').child('${existing.id}.jpg');
|
||||
await ref.putData(await pickedCover!.readAsBytes(), SettableMetadata(contentType: 'image/jpeg'));
|
||||
final url = await ref.getDownloadURL();
|
||||
await existing.reference.update({'teaserCoverUrl': url});
|
||||
}
|
||||
}
|
||||
if (ctx.mounted) Navigator.pop(ctx);
|
||||
} catch (e) {
|
||||
if (ctx.mounted) {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(existing == null ? 'Publish' : 'Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.amber[900],
|
||||
title: const Text('UPCOMING RELEASES'),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: kOnsolGold,
|
||||
foregroundColor: Colors.black,
|
||||
onPressed: uid.isEmpty ? null : () => _openEditor(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: uid.isEmpty
|
||||
? const Center(child: Text('Sign in', style: TextStyle(color: Colors.white54)))
|
||||
: StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection(_kUpcomingCollection).where('authorId', isEqualTo: uid).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}', style: const TextStyle(color: Colors.redAccent)));
|
||||
}
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator(color: kOnsolGold));
|
||||
final docs = snapshot.data!.docs.toList()
|
||||
..sort((a, b) {
|
||||
final ad = a.data() as Map<String, dynamic>;
|
||||
final bd = b.data() as Map<String, dynamic>;
|
||||
final af = ad['featured'] == true;
|
||||
final bf = bd['featured'] == true;
|
||||
if (af != bf) return af ? -1 : 1;
|
||||
final atbd = ad['dateTbd'] == true;
|
||||
final btbd = bd['dateTbd'] == true;
|
||||
if (atbd != btbd) return atbd ? 1 : -1;
|
||||
final ta = (ad['targetDate'] as Timestamp?)?.millisecondsSinceEpoch ?? 0;
|
||||
final tb = (bd['targetDate'] as Timestamp?)?.millisecondsSinceEpoch ?? 0;
|
||||
return ta.compareTo(tb);
|
||||
});
|
||||
if (docs.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No announcements yet.\nTap + to add a new series teaser or a chapter drop.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey[500], height: 1.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: docs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final doc = docs[i];
|
||||
final d = doc.data() as Map<String, dynamic>;
|
||||
final kind = d['kind'] as String? ?? '';
|
||||
final title = d['seriesTitle'] ?? '';
|
||||
final dateTbd = d['dateTbd'] as bool? ?? false;
|
||||
final ts = d['targetDate'] as Timestamp?;
|
||||
final dateStr = dateTbd ? 'Date TBD' : (ts != null ? _fmtDate(ts.toDate()) : '—');
|
||||
final featured = d['featured'] == true;
|
||||
final coverUrl = d['teaserCoverUrl'] as String?;
|
||||
final subtitle = kind == _kindNewSeries
|
||||
? 'New series · $dateStr'
|
||||
: 'New chapter · ${d['chapterNumber'] != null ? 'Ch ${d['chapterNumber']} · ' : ''}$dateStr';
|
||||
return Card(
|
||||
color: Colors.grey[900],
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: featured ? kOnsolGold : Colors.white12,
|
||||
width: featured ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
isThreeLine: true,
|
||||
leading: coverUrl != null && coverUrl.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: CachedNetworkImage(imageUrl: coverUrl, width: 56, height: 80, fit: BoxFit.cover),
|
||||
)
|
||||
: const Icon(Icons.image_outlined, color: Colors.white24),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white))),
|
||||
if (featured)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: kOnsolGold.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('FEATURED', style: TextStyle(color: kOnsolGold, fontSize: 9, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(subtitle, style: TextStyle(color: Colors.grey[400], fontSize: 12)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: kOnsolGold, size: 20),
|
||||
onPressed: () => _openEditor(context, existing: doc),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: const Text('Remove?', style: TextStyle(color: Colors.white)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('Delete', style: TextStyle(color: Colors.red))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) await doc.reference.delete();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
class InviteSignupScreen extends StatefulWidget {
|
||||
const InviteSignupScreen({super.key});
|
||||
@override
|
||||
State<InviteSignupScreen> createState() => _InviteSignupScreenState();
|
||||
}
|
||||
|
||||
class _InviteSignupScreenState extends State<InviteSignupScreen> {
|
||||
final _code = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
|
||||
void _processSignup() async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final nav = Navigator.of(context);
|
||||
// 1. Check if Invite Code exists and is not used
|
||||
var codeDoc = await FirebaseFirestore.instance.collection('invite_codes').doc(_code.text.trim()).get();
|
||||
|
||||
final codeData = codeDoc.data();
|
||||
if (codeDoc.exists && codeData != null && codeData['isUsed'] == false) {
|
||||
try {
|
||||
// 2. Create the User
|
||||
UserCredential cred = await FirebaseAuth.instance.createUserWithEmailAndPassword(
|
||||
email: _email.text.trim(), password: _pass.text.trim());
|
||||
|
||||
// 3. Create Firestore Profile
|
||||
await FirebaseFirestore.instance.collection('users').doc(cred.user!.uid).set({
|
||||
'username': _user.text.trim(),
|
||||
'role': 'reader',
|
||||
'rankLevel': 1,
|
||||
'uid': cred.user!.uid,
|
||||
});
|
||||
|
||||
// 4. Burn the code
|
||||
await codeDoc.reference.update({'isUsed': true});
|
||||
|
||||
if (context.mounted) nav.pop();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
} else {
|
||||
messenger.showSnackBar(const SnackBar(content: Text("Invalid or Used Code")));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("REDEEM INVITATION")),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
children: [
|
||||
TextField(controller: _code, decoration: const InputDecoration(labelText: "6-Digit Invite Code")),
|
||||
TextField(controller: _user, decoration: const InputDecoration(labelText: "Choose Username")),
|
||||
TextField(controller: _email, decoration: const InputDecoration(labelText: "Email")),
|
||||
TextField(controller: _pass, decoration: const InputDecoration(labelText: "Password"), obscureText: true),
|
||||
const SizedBox(height: 30),
|
||||
ElevatedButton(onPressed: _processSignup, child: const Text("JOIN THE ORDER")),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/core/achievement_manager.dart';
|
||||
import 'package:onsolgo/screens/auth/tier_comparison_screen.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _email = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _invite = TextEditingController();
|
||||
final _user = TextEditingController();
|
||||
bool _isSigningUp = false;
|
||||
bool _loading = false;
|
||||
|
||||
void _handleAuth() async {
|
||||
setState(() => _loading = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
if (_isSigningUp) {
|
||||
var codeDoc = await FirebaseFirestore.instance.collection('invite_codes').doc(_invite.text.trim()).get();
|
||||
if (!codeDoc.exists || codeDoc['isUsed'] == true) throw "Invalid or Used Invite Code";
|
||||
|
||||
UserCredential cred = await FirebaseAuth.instance.createUserWithEmailAndPassword(
|
||||
email: _email.text.trim(), password: _pass.text.trim());
|
||||
|
||||
await FirebaseFirestore.instance.collection('users').doc(cred.user!.uid).set({
|
||||
'username': _user.text.trim(),
|
||||
'role': 'reader',
|
||||
'tier': 'free', // DEFAULT TIER
|
||||
'rankLevel': 1,
|
||||
'uid': cred.user!.uid,
|
||||
'streak': 1,
|
||||
'lastVisit': FieldValue.serverTimestamp(),
|
||||
'pagesRead': 0,
|
||||
'chaptersRead': 0,
|
||||
'energy': 2,
|
||||
'lastEnergyRefill': FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
await codeDoc.reference.update({'isUsed': true});
|
||||
AchievementManager.unlock(cred.user!.uid, "initiate");
|
||||
} else {
|
||||
await FirebaseAuth.instance.signInWithEmailAndPassword(email: _email.text.trim(), password: _pass.text.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
CachedNetworkImage(imageUrl: kOnsolBanner, height: 80),
|
||||
const SizedBox(height: 40),
|
||||
if (_isSigningUp) TextField(controller: _invite, decoration: const InputDecoration(labelText: "INVITE CODE", labelStyle: TextStyle(color: kOnsolGold))),
|
||||
if (_isSigningUp) TextField(controller: _user, decoration: const InputDecoration(labelText: "CHOOSE USERNAME")),
|
||||
TextField(controller: _email, decoration: const InputDecoration(labelText: "EMAIL")),
|
||||
TextField(controller: _pass, decoration: const InputDecoration(labelText: "PASSWORD"), obscureText: true),
|
||||
const SizedBox(height: 30),
|
||||
_loading ? const CircularProgressIndicator(color: kOnsolGold) : ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 50), backgroundColor: kOnsolGold, foregroundColor: Colors.black),
|
||||
onPressed: _handleAuth,
|
||||
child: Text(_isSigningUp ? "JOIN THE ORDER" : "ENTER THE ORDER")
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// NEW TIER COMPARISON LINK
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (c) => const TierComparisonScreen())),
|
||||
child: const Text("View Tier Benefits & Features", style: TextStyle(color: kOnsolGold, fontSize: 12, decoration: TextDecoration.underline))
|
||||
),
|
||||
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isSigningUp = !_isSigningUp),
|
||||
child: Text(_isSigningUp ? "Already a Citizen? Login" : "Redeem Invite", style: const TextStyle(color: Colors.white70))
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
class TierComparisonScreen extends StatelessWidget {
|
||||
const TierComparisonScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
elevation: 0,
|
||||
title: const Text("ORDER TIERS", style: TextStyle(letterSpacing: 3, fontSize: 14)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text("CHOOSE YOUR STATUS",
|
||||
style: TextStyle(color: kOnsolGold, fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: 4)),
|
||||
const SizedBox(height: 30),
|
||||
|
||||
_buildTierCard(
|
||||
context,
|
||||
name: "READER",
|
||||
price: "FREE",
|
||||
color: Colors.grey,
|
||||
features: [
|
||||
"Read current ManaA issues",
|
||||
"Engage with Our Artists on the Social Feed",
|
||||
"Shop the Merch Vault",
|
||||
"Unlock Citizen Milestones",
|
||||
],
|
||||
limitations: ["Standard Access (With Ads)", "Viewing only (No social posting)"],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTierCard(
|
||||
context,
|
||||
name: "OGO+",
|
||||
price: "\$2.99 / mo",
|
||||
color: Colors.amber,
|
||||
features: [
|
||||
"Ad-Free Access to ManaA issues",
|
||||
"Post to the Social Feed: A Voice in the Community",
|
||||
"Download your Favorite Chapters for Offline Reading",
|
||||
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildTierCard(
|
||||
context,
|
||||
name: "OGO+ MAX",
|
||||
price: "\$4.99 / mo",
|
||||
color: kOnsolGold,
|
||||
features: [
|
||||
"Everything in OGO+ tier",
|
||||
"The Archive (Access to back issues)",
|
||||
"Exclusive Video Content",
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTierCard(BuildContext context, {
|
||||
required String name, required String price, required Color color,
|
||||
required List<String> features, List<String> limitations = const [],
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4), width: 1),
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(name, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 20, letterSpacing: 2)),
|
||||
Text(price, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white10, height: 30),
|
||||
...features.map((f) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(children: [
|
||||
Icon(Icons.check_circle, color: color, size: 16),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(f, style: const TextStyle(fontSize: 12, color: Colors.white70))),
|
||||
]),
|
||||
)),
|
||||
...limitations.map((l) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.info_outline, color: Colors.white24, size: 16),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(l, style: const TextStyle(fontSize: 12, color: Colors.white24))),
|
||||
]),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
/// Public feed of artist-scheduled releases (`upcoming` collection).
|
||||
class ComingSoon extends StatelessWidget {
|
||||
const ComingSoon({super.key});
|
||||
|
||||
String _fmtDate(DateTime d) =>
|
||||
'${d.month.toString().padLeft(2, '0')}/${d.day.toString().padLeft(2, '0')}/${d.year}';
|
||||
|
||||
String _countdown(Timestamp? ts, {required bool dateTbd}) {
|
||||
if (dateTbd) return 'TBD';
|
||||
if (ts == null) return '';
|
||||
final t = ts.toDate();
|
||||
final now = DateTime.now();
|
||||
final endOfToday = DateTime(now.year, now.month, now.day, 23, 59, 59);
|
||||
if (t.isBefore(endOfToday) && t.year == now.year && t.month == now.month && t.day == now.day) {
|
||||
return 'Today';
|
||||
}
|
||||
if (t.isBefore(now)) return 'Out now';
|
||||
final diff = t.difference(now);
|
||||
if (diff.inDays >= 1) return 'in ${diff.inDays}d';
|
||||
if (diff.inHours >= 1) return 'in ${diff.inHours}h';
|
||||
return 'in ${diff.inMinutes}m';
|
||||
}
|
||||
|
||||
int _sortUpcoming(DocumentSnapshot a, DocumentSnapshot b) {
|
||||
final ad = a.data() as Map<String, dynamic>;
|
||||
final bd = b.data() as Map<String, dynamic>;
|
||||
final af = ad['featured'] == true;
|
||||
final bf = bd['featured'] == true;
|
||||
if (af != bf) return af ? -1 : 1;
|
||||
final atbd = ad['dateTbd'] == true;
|
||||
final btbd = bd['dateTbd'] == true;
|
||||
if (atbd != btbd) return atbd ? 1 : -1;
|
||||
final ta = (ad['targetDate'] as Timestamp?)?.millisecondsSinceEpoch ?? 0;
|
||||
final tb = (bd['targetDate'] as Timestamp?)?.millisecondsSinceEpoch ?? 0;
|
||||
return ta.compareTo(tb);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 56, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, size: 40, color: kOnsolGold),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'THE SUN IS RISING',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, letterSpacing: 3),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'New series and chapter drops from ManaA artists.',
|
||||
style: TextStyle(color: Colors.grey[400], letterSpacing: 0.5, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('upcoming').limit(80).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(child: Text('Could not load schedule.\n${snapshot.error}', textAlign: TextAlign.center, style: const TextStyle(color: Colors.white54))),
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return const SliverFillRemaining(child: Center(child: CircularProgressIndicator(color: kOnsolGold)));
|
||||
}
|
||||
final docs = snapshot.data!.docs.toList()..sort(_sortUpcoming);
|
||||
if (docs.isEmpty) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Text('Check back soon for announcements.', style: TextStyle(color: Colors.grey[500])),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) {
|
||||
final doc = docs[i];
|
||||
final d = doc.data() as Map<String, dynamic>;
|
||||
final kind = d['kind'] as String? ?? '';
|
||||
final seriesTitle = d['seriesTitle'] ?? 'Untitled';
|
||||
final artist = d['authorName'] ?? 'Artist';
|
||||
final dateTbd = d['dateTbd'] as bool? ?? false;
|
||||
final ts = d['targetDate'] as Timestamp?;
|
||||
final dateStr = dateTbd ? 'Date TBD' : (ts != null ? _fmtDate(ts.toDate()) : '—');
|
||||
final cd = _countdown(ts, dateTbd: dateTbd);
|
||||
final ch = d['chapterNumber'];
|
||||
final featured = d['featured'] == true;
|
||||
final description = (d['description'] as String?)?.trim() ?? '';
|
||||
final coverUrl = (d['teaserCoverUrl'] as String?)?.trim() ?? '';
|
||||
|
||||
final headline = kind == 'new_series'
|
||||
? '$seriesTitle · $artist'
|
||||
: 'New chapter · $seriesTitle${ch != null ? ' (Ch $ch)' : ''}';
|
||||
|
||||
final borderColor = featured ? kOnsolGold : kOnsolGold.withValues(alpha: 0.25);
|
||||
final borderWidth = featured ? 2.5 : 1.0;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
boxShadow: featured
|
||||
? [
|
||||
BoxShadow(
|
||||
color: kOnsolGold.withValues(alpha: 0.18),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Card(
|
||||
color: featured ? const Color(0xFF1A1708) : Colors.grey[900],
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: featured ? 4 : 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (featured)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.star_rounded, color: kOnsolGold, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'FEATURED DROP',
|
||||
style: TextStyle(
|
||||
color: kOnsolGold,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (coverUrl.isNotEmpty) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
width: 92,
|
||||
height: 130,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
headline,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, height: 1.3, fontSize: 15),
|
||||
),
|
||||
if (kind != 'new_series') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(artist, style: TextStyle(color: Colors.grey[500], fontSize: 12)),
|
||||
],
|
||||
if (description.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: Colors.grey[300], fontSize: 13, height: 1.35),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.calendar_today, size: 14, color: Colors.grey[500]),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
dateTbd ? 'Target: to be announced' : 'Target: $dateStr',
|
||||
style: TextStyle(color: Colors.grey[400], fontSize: 12),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!dateTbd && cd.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: kOnsolGold.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(cd, style: const TextStyle(color: kOnsolGold, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
)
|
||||
else if (dateTbd)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text('SOON', style: TextStyle(color: Colors.white70, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: docs.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/screens/library/series_detail.dart';
|
||||
|
||||
class LibraryGrid extends StatelessWidget {
|
||||
const LibraryGrid({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// THE BRAND BANNER
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
padding: const EdgeInsets.only(top: 50, bottom: 10),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: kOnsolBanner,
|
||||
height: 100,
|
||||
fit: BoxFit.contain
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// THE MANAA GRID
|
||||
StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('manga').snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return const SliverToBoxAdapter(child: Center(child: Text("Error Connection")));
|
||||
if (!snapshot.hasData) return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
|
||||
|
||||
final docs = snapshot.data!.docs;
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 0.65,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 20
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _MangaGridCard(manga: docs[index]),
|
||||
childCount: docs.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MangaGridCard extends StatelessWidget {
|
||||
final QueryDocumentSnapshot manga;
|
||||
const _MangaGridCard({required this.manga});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mData = manga.data() as Map<String, dynamic>;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => SeriesDetail(manga: manga))
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: mData['coverUrl'] ?? '',
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
alignment: Alignment.topCenter,
|
||||
// Fix for desaturation: Ensure no filters are applied
|
||||
placeholder: (context, url) => Container(color: Colors.grey[900]),
|
||||
),
|
||||
),
|
||||
// READ COUNT OVERLAY
|
||||
Positioned(
|
||||
bottom: 8, left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(4)
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.remove_red_eye, size: 12, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
"${safeInt(mData['reads'])}",
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
mData['title'].toString().toUpperCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white)
|
||||
),
|
||||
Text(
|
||||
mData['author'] ?? 'Artist',
|
||||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/screens/reader/reader_view.dart';
|
||||
import 'package:onsolgo/widgets/energy_user_chip.dart';
|
||||
import 'package:onsolgo/screens/auth/tier_comparison_screen.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class SeriesDetail extends StatefulWidget {
|
||||
final DocumentSnapshot manga;
|
||||
const SeriesDetail({super.key, required this.manga});
|
||||
@override
|
||||
State<SeriesDetail> createState() => _SeriesDetailState();
|
||||
}
|
||||
|
||||
class _SeriesDetailState extends State<SeriesDetail> {
|
||||
InterstitialAd? _interstitialAd;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!kIsWeb) _loadInterstitial();
|
||||
}
|
||||
|
||||
void _loadInterstitial() {
|
||||
InterstitialAd.load(adUnitId: "ca-app-pub-3940256099942544/1033173712", request: const AdRequest(), adLoadCallback: InterstitialAdLoadCallback(onAdLoaded: (ad) => setState(() => _interstitialAd = ad), onAdFailedToLoad: (e) => debugPrint('$e')));
|
||||
}
|
||||
|
||||
Future<void> _handleAccess(DocumentSnapshot ch) async {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid; if (uid == null) return;
|
||||
final userDoc = await FirebaseFirestore.instance.collection('users').doc(uid).get();
|
||||
final uData = userDoc.data() ?? <String, dynamic>{};
|
||||
|
||||
// ELITE BYPASS: Rank 5 or Paid Tier
|
||||
if ((uData['tier'] ?? 'free') != 'free' || safeInt(uData['rankLevel']) == 5 || (uData['isVerified'] ?? false)) {
|
||||
_openReader(ch); return;
|
||||
}
|
||||
|
||||
int energy = safeInt(uData['energy'] ?? 2);
|
||||
if (energy > 0) {
|
||||
await userDoc.reference.update({'energy': energy - 1, 'lastEnergyRefill': FieldValue.serverTimestamp()});
|
||||
if (_interstitialAd != null && !kIsWeb) {
|
||||
_interstitialAd!.fullScreenContentCallback = FullScreenContentCallback(onAdDismissedFullScreenContent: (ad) { ad.dispose(); _openReader(ch); _loadInterstitial(); });
|
||||
_interstitialAd!.show(); _interstitialAd = null;
|
||||
} else { _openReader(ch); }
|
||||
} else { _showExhausted(); }
|
||||
}
|
||||
|
||||
void _openReader(DocumentSnapshot ch) { Navigator.push(context, MaterialPageRoute(builder: (c) => ReaderView(manga: widget.manga, chapter: ch))); }
|
||||
void _showExhausted() { showDialog(context: context, builder: (ctx) => AlertDialog(backgroundColor: Colors.grey[900], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15), side: const BorderSide(color: Colors.red)), title: const Text("ENERGY DEPLETED"), content: const Text("Recharge in 24 hours or upgrade to OGO+."), actions: [ElevatedButton(onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (c) => const TierComparisonScreen())), child: const Text("UPGRADE"))])); }
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final md = widget.manga.data() as Map<String, dynamic>;
|
||||
final String cover = (md['coverUrl'] as String?)?.trim() ?? '';
|
||||
final String banner = (md['bannerUrl'] as String?)?.trim() ?? '';
|
||||
final bool hasBanner = banner.isNotEmpty;
|
||||
final String heroUrl = hasBanner ? banner : cover;
|
||||
final String uid = FirebaseAuth.instance.currentUser?.uid ?? "";
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black, extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0, actions: [IconButton(
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
onPressed: () => SharePlus.instance.share(
|
||||
ShareParams(text: 'Check out ${md['title']} on ONSOL-GO!\n\n$kOnsolAppWebUrl'),
|
||||
),
|
||||
)]),
|
||||
body: SingleChildScrollView(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Stack(children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: heroUrl,
|
||||
width: double.infinity,
|
||||
height: hasBanner ? 300 : 380,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
placeholder: (context, url) => Container(
|
||||
height: hasBanner ? 300 : 380,
|
||||
color: Colors.grey[900],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: hasBanner ? 301 : 381,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.9), Colors.black],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (hasBanner)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(md['title'] ?? '', style: const TextStyle(fontSize: 26, fontWeight: FontWeight.bold)),
|
||||
Text(md['author'] ?? '', style: const TextStyle(fontSize: 17, color: kOnsolGold)),
|
||||
],
|
||||
)
|
||||
else ...[
|
||||
Text(md['title'] ?? '', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
|
||||
Text(md['author'] ?? '', style: const TextStyle(fontSize: 18, color: kOnsolGold)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Align(alignment: Alignment.centerLeft, child: EnergyUserChip(uid: uid)),
|
||||
const SizedBox(height: 16),
|
||||
const Text("SYNOPSIS", style: TextStyle(fontSize: 10, color: Colors.grey, fontWeight: FontWeight.bold, letterSpacing: 2)),
|
||||
Text(md['synopsis'] ?? "No transmission recorded.", style: const TextStyle(color: Colors.white70, height: 1.4)),
|
||||
const SizedBox(height: 30),
|
||||
const Text("CHAPTERS", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
StreamBuilder<QuerySnapshot>(
|
||||
stream: widget.manga.reference.collection('chapters').orderBy('chapterNumber', descending: true).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const CircularProgressIndicator();
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.only(top: 10), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: snapshot.data!.docs.length,
|
||||
itemBuilder: (c, i) => _ChapterTile(ch: snapshot.data!.docs[i], manga: widget.manga, uid: uid, onTap: () => _handleAccess(snapshot.data!.docs[i])),
|
||||
);
|
||||
},
|
||||
)
|
||||
]),
|
||||
),
|
||||
])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChapterTile extends StatelessWidget {
|
||||
final DocumentSnapshot ch; final DocumentSnapshot manga; final String uid; final VoidCallback onTap;
|
||||
const _ChapterTile({required this.ch, required this.manga, required this.uid, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cd = ch.data() as Map<String, dynamic>;
|
||||
return StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).collection('progress').doc(manga.id).snapshots(),
|
||||
builder: (context, prog) {
|
||||
double p = (prog.hasData && prog.data!.exists && prog.data!['lastChapter'] == cd['chapterNumber']) ? prog.data!['percent'] : 0.0;
|
||||
return Card(
|
||||
color: Colors.grey[900], margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ListTile(
|
||||
onTap: onTap,
|
||||
leading: ClipRRect(borderRadius: BorderRadius.circular(4), child: CachedNetworkImage(imageUrl: cd['chapterCoverUrl'] ?? (manga.data() as Map)['coverUrl'], width: 50, height: 50, fit: BoxFit.cover)),
|
||||
title: Text("Ch ${cd['chapterNumber']}"),
|
||||
subtitle: LinearProgressIndicator(value: p, color: kOnsolGold, backgroundColor: Colors.white10, minHeight: 2),
|
||||
trailing: const Icon(Icons.bolt, color: Colors.orangeAccent, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/screens/library/series_detail.dart';
|
||||
|
||||
class TrendingList extends StatelessWidget {
|
||||
const TrendingList({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("🔥 WHAT'S HOT", style: TextStyle(letterSpacing: 2, fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.black,
|
||||
centerTitle: true,
|
||||
),
|
||||
body: StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('manga').orderBy('reads', descending: true).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
|
||||
final docs = snapshot.data!.docs;
|
||||
return ListView.builder(
|
||||
itemCount: docs.length,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
final m = docs[index];
|
||||
Color medalColor = (index == 0) ? kOnsolGold : (index == 1) ? const Color(0xFFC0C0C0) : (index == 2) ? const Color(0xFFCD7F32) : Colors.transparent;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: medalColor != Colors.transparent ? Border.all(color: medalColor, width: 2) : null
|
||||
),
|
||||
child: Card(
|
||||
color: Colors.grey[900],
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
leading: ClipRRect(borderRadius: BorderRadius.circular(4), child: CachedNetworkImage(imageUrl: m['coverUrl'], width: 50, height: 70, fit: BoxFit.cover, alignment: Alignment.topCenter)),
|
||||
title: Text(m['title'] ?? '', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text("${m['reads'] ?? 0} Readers"),
|
||||
trailing: Text("#${index + 1}", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: medalColor != Colors.transparent ? medalColor : Colors.white)),
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => SeriesDetail(manga: m))),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/core/achievement_manager.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MarketHub extends StatelessWidget {
|
||||
const MarketHub({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
title: const Text("ONSOL MARKET", style: TextStyle(letterSpacing: 2, fontWeight: FontWeight.bold)),
|
||||
centerTitle: true,
|
||||
bottom: const TabBar(
|
||||
indicatorColor: kOnsolGold, labelColor: kOnsolGold, unselectedLabelColor: Colors.grey,
|
||||
tabs: [Tab(text: "VAULT"), Tab(text: "PRINTS"), Tab(text: "MERCH")],
|
||||
),
|
||||
),
|
||||
body: const TabBarView(
|
||||
children: [
|
||||
_MarketCategoryView(categoryFilter: "vault"),
|
||||
_MarketCategoryView(categoryFilter: "prints"),
|
||||
_MarketCategoryView(categoryFilter: "merch"),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarketCategoryView extends StatelessWidget {
|
||||
final String categoryFilter;
|
||||
const _MarketCategoryView({required this.categoryFilter});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('marketplace').snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
|
||||
final filtered = snapshot.data!.docs.where((d) => d['category'] == categoryFilter).toList();
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2, childAspectRatio: 0.7, crossAxisSpacing: 16, mainAxisSpacing: 16),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
var p = filtered[index].data() as Map<String, dynamic>;
|
||||
return _ProductCard(name: p['name'], price: p['price'], imageUrl: p['imageUrl'], url: p['buyUrl']);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductCard extends StatelessWidget {
|
||||
final String name, price, imageUrl, url;
|
||||
const _ProductCard({required this.name, required this.price, required this.imageUrl, required this.url});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(color: Colors.grey[900], borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(children: [
|
||||
Expanded(child: ClipRRect(borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.cover))),
|
||||
Padding(padding: const EdgeInsets.all(8.0), child: Column(children: [
|
||||
Text(name.toUpperCase(), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 10)),
|
||||
Text(price, style: const TextStyle(color: kOnsolGold, fontSize: 12)),
|
||||
const SizedBox(height: 5),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.black, minimumSize: const Size(double.infinity, 30)),
|
||||
onPressed: () {
|
||||
launchUrl(Uri.parse(url));
|
||||
AchievementManager.unlock(FirebaseAuth.instance.currentUser!.uid, "first_acquisition");
|
||||
},
|
||||
child: const Text("ACQUIRE", style: TextStyle(fontSize: 10)),
|
||||
)
|
||||
]))
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
class AchievementsView extends StatelessWidget {
|
||||
const AchievementsView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String uid = FirebaseAuth.instance.currentUser?.uid ?? "";
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text("CITIZEN ARCHIVE",
|
||||
style: TextStyle(letterSpacing: 3, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).collection('achievements').snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator(color: kOnsolGold));
|
||||
final unlocked = snapshot.data!.docs.map((d) => d.id).toList();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
_buildCategory("ONBOARDING", "ACCOUNT", unlocked),
|
||||
_buildCategory("READING PROGRESSION", "READING", unlocked),
|
||||
_buildCategory("COMMUNITY & ENGAGEMENT", "COMMUNITY", unlocked),
|
||||
_buildCategory("THE VAULT", "MARKET", unlocked),
|
||||
_buildCategory("SUPPORT & INVESTMENT", "INVEST", unlocked),
|
||||
_buildCategory("CONSISTENCY", "CONSISTENCY", unlocked),
|
||||
_buildCategory("DISCOVERY", "DISCOVERY", unlocked),
|
||||
_buildCategory("RARE / HIDDEN", "RARE", unlocked),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategory(String title, String cat, List<String> unlocked) {
|
||||
final items = kAllAchievements.where((a) => a.category == cat).toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
child: Text(title,
|
||||
style: const TextStyle(color: kOnsolGold, fontWeight: FontWeight.bold, fontSize: 11, letterSpacing: 1.5))
|
||||
),
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 2.1,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (c, i) {
|
||||
final ach = items[i];
|
||||
bool done = unlocked.contains(ach.id);
|
||||
|
||||
// NO CONST HERE: Colors are calculated at runtime
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: done ? kOnsolGold.withValues(alpha: 0.1) : Colors.grey[900],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: done ? kOnsolGold : Colors.transparent, width: 0.5)
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(ach.icon, color: done ? kOnsolGold : Colors.grey, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(ach.title,
|
||||
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: done ? Colors.white : Colors.grey)),
|
||||
Text(ach.desc,
|
||||
style: const TextStyle(fontSize: 7, color: Colors.white38), maxLines: 2),
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/screens/library/series_detail.dart';
|
||||
|
||||
class CollectionView extends StatelessWidget {
|
||||
const CollectionView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(title: const Text("YOUR VAULT"), backgroundColor: Colors.black),
|
||||
body: StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).collection('user_collection').snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator());
|
||||
final items = snapshot.data!.docs;
|
||||
if (items.isEmpty) return const Center(child: Text("Favorite a series to see it here.", style: TextStyle(color: Colors.grey)));
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2, childAspectRatio: 0.65, crossAxisSpacing: 16, mainAxisSpacing: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final doc = items[index];
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
DocumentSnapshot fullManga = await FirebaseFirestore.instance.collection('manga').doc(doc.id).get();
|
||||
if (context.mounted) Navigator.push(context, MaterialPageRoute(builder: (c) => SeriesDetail(manga: fullManga)));
|
||||
},
|
||||
child: Column(children: [
|
||||
Expanded(child: ClipRRect(borderRadius: BorderRadius.circular(10), child: CachedNetworkImage(imageUrl: doc['coverUrl'], fit: BoxFit.cover))),
|
||||
const SizedBox(height: 8),
|
||||
Text(doc['title'].toString().toUpperCase(), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/screens/admin/admin_dashboard.dart';
|
||||
import 'package:onsolgo/screens/profile/collection_view.dart';
|
||||
import 'package:onsolgo/screens/profile/achievements_view.dart';
|
||||
import 'package:onsolgo/screens/artist/artist_hub.dart';
|
||||
|
||||
class ProfileView extends StatefulWidget {
|
||||
const ProfileView({super.key});
|
||||
@override
|
||||
State<ProfileView> createState() => _ProfileViewState();
|
||||
}
|
||||
|
||||
class _ProfileViewState extends State<ProfileView> {
|
||||
bool _isUploading = false;
|
||||
Uint8List? _localBytes;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
Future<void> _pickAndUpload(String uid) async {
|
||||
try {
|
||||
final XFile? image = await _picker.pickImage(source: ImageSource.gallery, imageQuality: 40, maxWidth: 500);
|
||||
if (image == null) return;
|
||||
final bytes = await image.readAsBytes();
|
||||
setState(() { _localBytes = bytes; _isUploading = true; });
|
||||
|
||||
final ref = FirebaseStorage.instance.ref().child('avatars').child('$uid.jpg');
|
||||
await ref.putData(bytes, SettableMetadata(contentType: 'image/jpeg'));
|
||||
|
||||
final downloadUrl = await ref.getDownloadURL();
|
||||
final sep = downloadUrl.contains('?') ? '&' : '?';
|
||||
final hashedUrl = '$downloadUrl${sep}t=${DateTime.now().millisecondsSinceEpoch}';
|
||||
await FirebaseFirestore.instance.collection('users').doc(uid).update({'pfpUrl': hashedUrl});
|
||||
} finally { if (mounted) setState(() => _isUploading = false); }
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final String uid = FirebaseAuth.instance.currentUser?.uid ?? "";
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(backgroundColor: Colors.black, elevation: 0, title: const Text("PROFILE")),
|
||||
body: SafeArea(
|
||||
child: StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || !snapshot.data!.exists) return const Center(child: CircularProgressIndicator());
|
||||
|
||||
var data = snapshot.data!.data() as Map<String, dynamic>;
|
||||
|
||||
// --- WEB-SAFE DATA PARSING ---
|
||||
int rank = safeInt(data['rankLevel']);
|
||||
final int energy = safeInt(data['energy'] ?? 2);
|
||||
String role = data['role']?.toString().toLowerCase() ?? "reader";
|
||||
String tier = data['tier']?.toString().toLowerCase() ?? "free";
|
||||
String? pfp = data['pfpUrl'];
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
// AVATAR
|
||||
Center(
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
Container(width: 120, height: 120, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: getRankColor(rank), width: 3)),
|
||||
child: ClipOval(
|
||||
child: _localBytes != null
|
||||
? Image.memory(_localBytes!, fit: BoxFit.cover)
|
||||
: (pfp != null && pfp.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: pfp,
|
||||
key: ValueKey(pfp),
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
errorWidget: (context, url, error) => const Icon(Icons.person, size: 60),
|
||||
)
|
||||
: const Icon(Icons.person, size: 60)),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: Material(color: Colors.transparent, child: InkWell(borderRadius: BorderRadius.circular(100), onTap: () => _pickAndUpload(uid)))),
|
||||
if (_isUploading)
|
||||
const Positioned.fill(
|
||||
child: Center(child: SizedBox(width: 40, height: 40, child: CircularProgressIndicator(strokeWidth: 2, color: kOnsolGold))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(data['username']?.toString().toUpperCase() ?? "CITIZEN", style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||
|
||||
// --- ENERGY BAR ---
|
||||
const SizedBox(height: 8),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.local_fire_department, color: Colors.orange, size: 18),
|
||||
Text("${data['streak'] ?? 0} DAY STREAK", style: const TextStyle(color: Colors.orange, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
const SizedBox(width: 15),
|
||||
const Icon(Icons.bolt, color: Colors.orangeAccent, size: 18),
|
||||
Text((tier != 'free' || rank == 5) ? "ENERGY: MAX" : "ENERGY: $energy/2",
|
||||
style: const TextStyle(color: Colors.orangeAccent, fontWeight: FontWeight.bold, fontSize: 12)),
|
||||
]),
|
||||
|
||||
Text(getRankName(rank), style: TextStyle(color: getRankColor(rank), letterSpacing: 2, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
_MenuTile(icon: Icons.inventory_2_outlined, label: "VIEW VAULT", onTap: () => Navigator.push(context, MaterialPageRoute(builder: (c) => const CollectionView()))),
|
||||
_MenuTile(icon: Icons.emoji_events_outlined, label: "CITIZEN ARCHIVE", onTap: () => Navigator.push(context, MaterialPageRoute(builder: (c) => const AchievementsView()))),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// --- HUB BUTTONS (Stricter checks for Web) ---
|
||||
if (rank == 5 || role == 'artist')
|
||||
_HubBtn(label: "ARTIST HUB", color: Colors.amber[900]!, icon: Icons.palette, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (c) => const ArtistHub()))),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
if (role == 'admin')
|
||||
_HubBtn(label: "COMMAND CENTER", color: Colors.red[900]!, icon: Icons.admin_panel_settings, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (c) => const AdminDashboard()))),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
TextButton(onPressed: () => FirebaseAuth.instance.signOut(), child: const Text("TERMINATE SESSION", style: TextStyle(color: Colors.grey))),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HubBtn extends StatelessWidget {
|
||||
final String label; final Color color; final IconData icon; final VoidCallback onTap;
|
||||
const _HubBtn({required this.label, required this.color, required this.icon, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(onTap: onTap, tileColor: color.withValues(alpha: 0.15), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), leading: Icon(icon, color: color), title: Text(label, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12)));
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuTile extends StatelessWidget {
|
||||
final IconData icon; final String label; final VoidCallback onTap;
|
||||
const _MenuTile({required this.icon, required this.label, required this.onTap});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(leading: Icon(icon, color: Colors.white, size: 22), title: Text(label, style: const TextStyle(fontSize: 13)), trailing: const Icon(Icons.chevron_right, color: Colors.grey, size: 18), onTap: onTap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/core/share_helper.dart';
|
||||
import 'package:onsolgo/widgets/energy_user_chip.dart';
|
||||
import 'package:onsolgo/widgets/comments_sheet.dart';
|
||||
|
||||
class ReaderView extends StatefulWidget {
|
||||
final DocumentSnapshot manga;
|
||||
final DocumentSnapshot chapter;
|
||||
const ReaderView({super.key, required this.manga, required this.chapter});
|
||||
@override
|
||||
State<ReaderView> createState() => _ReaderViewState();
|
||||
}
|
||||
|
||||
class _ReaderViewState extends State<ReaderView> {
|
||||
final GlobalKey _readerKey = GlobalKey();
|
||||
bool _showUI = true;
|
||||
int _curIdx = 0;
|
||||
|
||||
DocumentReference _pageInteractionRef() {
|
||||
return widget.manga.reference
|
||||
.collection('chapters')
|
||||
.doc(widget.chapter.id)
|
||||
.collection('page_interactions')
|
||||
.doc('${_curIdx + 1}');
|
||||
}
|
||||
|
||||
Future<void> _togglePageLike() async {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (uid == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Sign in to react to pages')));
|
||||
return;
|
||||
}
|
||||
final pageRef = _pageInteractionRef();
|
||||
try {
|
||||
await FirebaseFirestore.instance.runTransaction((txn) async {
|
||||
final snap = await txn.get(pageRef);
|
||||
List<String> liked = [];
|
||||
final raw = snap.data() as Map<String, dynamic>?;
|
||||
if (snap.exists && raw != null) {
|
||||
liked = List<String>.from((raw['likedBy'] as List?)?.map((e) => e.toString()) ?? []);
|
||||
}
|
||||
final set = liked.toSet();
|
||||
if (set.contains(uid)) {
|
||||
set.remove(uid);
|
||||
} else {
|
||||
set.add(uid);
|
||||
}
|
||||
final next = set.toList();
|
||||
final payload = <String, dynamic>{'likedBy': next, 'likes': next.length};
|
||||
if (snap.exists) {
|
||||
txn.update(pageRef, payload);
|
||||
} else {
|
||||
txn.set(pageRef, payload);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not update: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openPageComments() {
|
||||
CommentsSheet.show(
|
||||
context,
|
||||
parent: _pageInteractionRef(),
|
||||
title: 'Page ${_curIdx + 1} · Comments',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cD = widget.chapter.data() as Map<String, dynamic>;
|
||||
final mD = widget.manga.data() as Map<String, dynamic>;
|
||||
final total = safeInt(cD['pageCount']);
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid ?? '';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: GestureDetector(
|
||||
onTap: () => setState(() => _showUI = !_showUI),
|
||||
child: Stack(children: [
|
||||
RepaintBoundary(
|
||||
key: _readerKey,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: PageView.builder(
|
||||
reverse: mD['readingMode'] == "RL",
|
||||
itemCount: total,
|
||||
onPageChanged: (idx) => setState(() => _curIdx = idx),
|
||||
itemBuilder: (context, index) {
|
||||
final url = "${cD['baseUrl']}PG-${(index + 1).toString().padLeft(3, '0')}.webp";
|
||||
return InteractiveViewer(
|
||||
child: CachedNetworkImage(imageUrl: url, fit: BoxFit.contain),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_showUI)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AppBar(
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.7),
|
||||
title: Text("PG ${_curIdx + 1}"),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4, top: 4, bottom: 4),
|
||||
child: EnergyUserChip(uid: FirebaseAuth.instance.currentUser?.uid ?? ''),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share, color: kOnsolGold),
|
||||
onPressed: () => OnsolShare.share(
|
||||
_readerKey,
|
||||
"Check out ${mD['title']} Ch ${cD['chapterNumber']} on ONSOL-GO!",
|
||||
link: kOnsolShareChapterUrl(widget.manga.id, cD['chapterNumber']),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (_showUI)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Material(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: StreamBuilder<DocumentSnapshot>(
|
||||
stream: _pageInteractionRef().snapshots(),
|
||||
builder: (context, snap) {
|
||||
final data = snap.data?.data() as Map<String, dynamic>?;
|
||||
final likedBy = List<String>.from(
|
||||
(data?['likedBy'] as List?)?.map((e) => e.toString()) ?? [],
|
||||
);
|
||||
final liked = uid.isNotEmpty && likedBy.contains(uid);
|
||||
final count = likedBy.length;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
liked ? Icons.favorite : Icons.favorite_border,
|
||||
color: liked ? Colors.redAccent : Colors.white70,
|
||||
),
|
||||
tooltip: 'Like this page',
|
||||
onPressed: _togglePageLike,
|
||||
),
|
||||
Text('$count', style: const TextStyle(color: Colors.white70, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline, color: Colors.white70),
|
||||
tooltip: 'Comments on this page',
|
||||
onPressed: _openPageComments,
|
||||
),
|
||||
const Text('Comment', style: TextStyle(color: Colors.white54, fontSize: 13)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${mD['title'] ?? ''} · Ch ${cD['chapterNumber']}',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 11),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
import 'package:onsolgo/core/share_helper.dart';
|
||||
import 'package:onsolgo/widgets/comments_sheet.dart';
|
||||
|
||||
class SocialFeed extends StatelessWidget {
|
||||
const SocialFeed({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(title: const Text("MANAA SOCIAL", style: TextStyle(letterSpacing: 2, fontWeight: FontWeight.bold)), centerTitle: true),
|
||||
body: StreamBuilder<QuerySnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('social_posts').orderBy('timestamp', descending: true).snapshots(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const Center(child: CircularProgressIndicator(color: kOnsolGold));
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data!.docs.length,
|
||||
itemBuilder: (context, index) => _SocialPostCard(post: snapshot.data!.docs[index]),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: kOnsolGold, child: const Icon(Icons.add_a_photo, color: Colors.black),
|
||||
onPressed: () => showModalBottomSheet(context: context, isScrollControlled: true, backgroundColor: Colors.grey[900], builder: (c) => const _CreatePostSheet()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SocialPostCard extends StatefulWidget {
|
||||
final QueryDocumentSnapshot post;
|
||||
const _SocialPostCard({required this.post});
|
||||
|
||||
@override
|
||||
State<_SocialPostCard> createState() => _SocialPostCardState();
|
||||
}
|
||||
|
||||
class _SocialPostCardState extends State<_SocialPostCard> {
|
||||
final GlobalKey _postKey = GlobalKey();
|
||||
|
||||
Future<void> _confirmDeletePost() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (c) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: const Text('Delete post?', style: TextStyle(color: Colors.white)),
|
||||
content: const Text('This cannot be undone.', style: TextStyle(color: Colors.white70)),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(c, true), child: const Text('Delete', style: TextStyle(color: Colors.redAccent))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !mounted) return;
|
||||
try {
|
||||
await widget.post.reference.delete();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Post removed')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not delete: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleLike() async {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (uid == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Sign in to like posts')));
|
||||
return;
|
||||
}
|
||||
final ref = widget.post.reference;
|
||||
try {
|
||||
await FirebaseFirestore.instance.runTransaction((txn) async {
|
||||
final snap = await txn.get(ref);
|
||||
final data = snap.data() as Map<String, dynamic>?;
|
||||
final liked = List<String>.from((data?['likedBy'] as List?)?.map((e) => e.toString()) ?? []);
|
||||
final set = liked.toSet();
|
||||
if (set.contains(uid)) {
|
||||
set.remove(uid);
|
||||
} else {
|
||||
set.add(uid);
|
||||
}
|
||||
final next = set.toList();
|
||||
txn.update(ref, {'likedBy': next, 'likes': next.length});
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Like failed: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final post = widget.post;
|
||||
final d = post.data() as Map<String, dynamic>;
|
||||
final bool isVer = (d['isVerified'] ?? false) || (d['authorRank'] == "VERIFIED ManaA ARTIST");
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid ?? '';
|
||||
final likedBy = List<String>.from((d['likedBy'] as List?)?.map((e) => e.toString()) ?? []);
|
||||
final liked = uid.isNotEmpty && likedBy.contains(uid);
|
||||
final likeCount = likedBy.length;
|
||||
final authorId = (d['authorId'] as String?) ?? '';
|
||||
|
||||
Widget card({required bool canDelete}) {
|
||||
return RepaintBoundary(
|
||||
key: _postKey,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.grey[900], borderRadius: BorderRadius.circular(15)),
|
||||
child: Column(children: [
|
||||
StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(d['authorId']).snapshots(),
|
||||
builder: (context, userSnap) {
|
||||
String? livePfp;
|
||||
if (userSnap.hasData && userSnap.data!.exists) {
|
||||
var uD = userSnap.data!.data() as Map;
|
||||
livePfp = uD.containsKey('pfpUrl') ? uD['pfpUrl'] : null;
|
||||
}
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.black,
|
||||
backgroundImage: livePfp != null ? NetworkImage(livePfp) : null,
|
||||
child: livePfp == null ? const Icon(Icons.person, color: Colors.white24) : null),
|
||||
title: Row(children: [
|
||||
Text(d['authorName'] ?? 'Citizen', style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white)),
|
||||
verifiedBadge(isVer, size: 18),
|
||||
]),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canDelete)
|
||||
IconButton(
|
||||
tooltip: 'Delete post',
|
||||
icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent),
|
||||
onPressed: _confirmDeletePost,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined, size: 18, color: Colors.grey),
|
||||
onPressed: () {
|
||||
final raw = (d['content'] as String?)?.trim() ?? '';
|
||||
final caption = raw.isNotEmpty
|
||||
? raw
|
||||
: 'Transmission from ${d['authorName'] ?? 'ONSOL-GO'}';
|
||||
OnsolShare.share(
|
||||
_postKey,
|
||||
caption,
|
||||
link: kOnsolSharePostUrl(post.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (d['imageUrl'] != null) CachedNetworkImage(imageUrl: d['imageUrl'], width: double.infinity, fit: BoxFit.cover),
|
||||
if (d['content'] != "") Padding(padding: const EdgeInsets.all(16), child: Text(d['content'] ?? '')),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(liked ? Icons.favorite : Icons.favorite_border, color: liked ? Colors.redAccent : Colors.white70, size: 22),
|
||||
onPressed: _toggleLike,
|
||||
tooltip: 'Like',
|
||||
),
|
||||
Text('$likeCount', style: const TextStyle(color: Colors.white70, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline, color: Colors.white70, size: 22),
|
||||
onPressed: () => CommentsSheet.show(context, parent: post.reference, title: 'Comments'),
|
||||
tooltip: 'Comments',
|
||||
),
|
||||
const Text('Comment', style: TextStyle(color: Colors.white54, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (uid.isEmpty) {
|
||||
return card(canDelete: false);
|
||||
}
|
||||
|
||||
return StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).snapshots(),
|
||||
builder: (context, meSnap) {
|
||||
String? role;
|
||||
if (meSnap.hasData && meSnap.data!.exists) {
|
||||
role = (meSnap.data!.data() as Map?)?['role'] as String?;
|
||||
}
|
||||
final canDelete = uid == authorId || role == 'admin';
|
||||
return card(canDelete: canDelete);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CreatePostSheet extends StatefulWidget {
|
||||
const _CreatePostSheet();
|
||||
@override
|
||||
State<_CreatePostSheet> createState() => _CreatePostSheetState();
|
||||
}
|
||||
|
||||
class _CreatePostSheetState extends State<_CreatePostSheet> {
|
||||
final _textC = TextEditingController(); Uint8List? _webImage; bool _posting = false;
|
||||
Future<void> _pick() async {
|
||||
final picked = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 60);
|
||||
if (picked != null) { final bytes = await picked.readAsBytes(); setState(() => _webImage = bytes); }
|
||||
}
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(child: Padding(padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 20, left: 20, right: 20, top: 20), child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Text("NEW ANNOUNCEMENT", style: TextStyle(fontWeight: FontWeight.bold, color: kOnsolGold)),
|
||||
if (_webImage != null) Image.memory(_webImage!, height: 100),
|
||||
TextField(controller: _textC, maxLines: 3, style: const TextStyle(color: Colors.white), decoration: const InputDecoration(hintText: "Speak to the Order...")),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
IconButton(onPressed: _pick, icon: const Icon(Icons.image, color: Colors.amber)),
|
||||
_posting ? const CircularProgressIndicator() : ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: kOnsolGold, foregroundColor: Colors.black),
|
||||
onPressed: () async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
setState(() => _posting = true);
|
||||
try {
|
||||
final u = FirebaseAuth.instance.currentUser;
|
||||
final uSnap = await FirebaseFirestore.instance.collection('users').doc(u?.uid).get();
|
||||
final raw = uSnap.data();
|
||||
if (u == null || !uSnap.exists || raw == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Profile not found. Try signing in again.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final uMap = Map<String, dynamic>.from(raw as Map);
|
||||
String? url;
|
||||
if (_webImage != null) {
|
||||
final ref = FirebaseStorage.instance.ref().child('social').child('${DateTime.now().millisecondsSinceEpoch}.jpg');
|
||||
await ref.putData(_webImage!, SettableMetadata(contentType: 'image/jpeg'));
|
||||
url = await ref.getDownloadURL();
|
||||
}
|
||||
await FirebaseFirestore.instance.collection('social_posts').add({
|
||||
'authorId': u.uid,
|
||||
'authorName': uMap['username'],
|
||||
'authorPfp': uMap['pfpUrl'],
|
||||
'authorRank': getRankName(safeInt(uMap['rankLevel'])),
|
||||
'isVerified': safeInt(uMap['rankLevel']) == 5 || (uMap['isVerified'] ?? false),
|
||||
'content': _textC.text,
|
||||
'imageUrl': url,
|
||||
'likes': 0,
|
||||
'likedBy': [],
|
||||
'timestamp': FieldValue.serverTimestamp(),
|
||||
});
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text('Post failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _posting = false);
|
||||
}
|
||||
},
|
||||
child: const Text("POST")),
|
||||
]),
|
||||
])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
/// Bottom sheet: list comments under [parent] (`parent.collection('comments')`).
|
||||
class CommentsSheet extends StatefulWidget {
|
||||
final DocumentReference parent;
|
||||
final String title;
|
||||
|
||||
const CommentsSheet({
|
||||
super.key,
|
||||
required this.parent,
|
||||
this.title = 'Comments',
|
||||
});
|
||||
|
||||
static Future<void> show(BuildContext context, {required DocumentReference parent, String title = 'Comments'}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.grey[900],
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (ctx) => CommentsSheet(parent: parent, title: title),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<CommentsSheet> createState() => _CommentsSheetState();
|
||||
}
|
||||
|
||||
class _CommentsSheetState extends State<CommentsSheet> {
|
||||
final _textC = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_textC.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (uid == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Sign in to comment')));
|
||||
return;
|
||||
}
|
||||
final text = _textC.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
final uSnap = await FirebaseFirestore.instance.collection('users').doc(uid).get();
|
||||
final name = (uSnap.data()?['username'] as String?)?.trim() ?? 'Citizen';
|
||||
await widget.parent.collection('comments').add({
|
||||
'uid': uid,
|
||||
'authorName': name,
|
||||
'text': text,
|
||||
'timestamp': FieldValue.serverTimestamp(),
|
||||
});
|
||||
_textC.clear();
|
||||
if (mounted) FocusScope.of(context).unfocus();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not post: $e')));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uid = FirebaseAuth.instance.currentUser?.uid ?? '';
|
||||
final padBottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: padBottom),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.55,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(widget.title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const Spacer(),
|
||||
IconButton(icon: const Icon(Icons.close, color: Colors.white54), onPressed: () => Navigator.pop(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<QuerySnapshot>(
|
||||
stream: widget.parent.collection('comments').orderBy('timestamp', descending: false).snapshots(),
|
||||
builder: (context, snap) {
|
||||
if (snap.hasError) {
|
||||
return Center(child: Text('Error: ${snap.error}', style: const TextStyle(color: Colors.redAccent)));
|
||||
}
|
||||
if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: kOnsolGold));
|
||||
final docs = snap.data!.docs;
|
||||
if (docs.isEmpty) {
|
||||
return Center(child: Text('No comments yet.', style: TextStyle(color: Colors.grey[500])));
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: docs.length,
|
||||
itemBuilder: (context, i) {
|
||||
final c = docs[i].data() as Map<String, dynamic>;
|
||||
final author = c['authorName'] ?? 'Citizen';
|
||||
final text = c['text'] ?? '';
|
||||
final own = c['uid'] == uid;
|
||||
return Card(
|
||||
color: Colors.black54,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: Text(author, style: const TextStyle(color: kOnsolGold, fontSize: 12, fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(text, style: const TextStyle(color: Colors.white70, fontSize: 14)),
|
||||
trailing: own
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await docs[i].reference.delete();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textC,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Add a comment…',
|
||||
hintStyle: TextStyle(color: Colors.grey[600]),
|
||||
filled: true,
|
||||
fillColor: Colors.black38,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_sending
|
||||
? const Padding(padding: EdgeInsets.all(12), child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)))
|
||||
: IconButton.filled(
|
||||
style: IconButton.styleFrom(backgroundColor: kOnsolGold, foregroundColor: Colors.black),
|
||||
onPressed: _send,
|
||||
icon: const Icon(Icons.send),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:onsolgo/core/constants.dart';
|
||||
|
||||
/// Shows current reader energy (or MAX when unlimited) from `users/{uid}`.
|
||||
class EnergyUserChip extends StatelessWidget {
|
||||
final String uid;
|
||||
const EnergyUserChip({super.key, required this.uid});
|
||||
|
||||
static bool _unlimited(Map<String, dynamic> u) {
|
||||
return (u['tier'] ?? 'free') != 'free' ||
|
||||
safeInt(u['rankLevel']) == 5 ||
|
||||
(u['isVerified'] ?? false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (uid.isEmpty) return const SizedBox.shrink();
|
||||
return StreamBuilder<DocumentSnapshot>(
|
||||
stream: FirebaseFirestore.instance.collection('users').doc(uid).snapshots(),
|
||||
builder: (context, snap) {
|
||||
if (!snap.hasData || !snap.data!.exists) return const SizedBox.shrink();
|
||||
final u = snap.data!.data() as Map<String, dynamic>;
|
||||
final unlimited = _unlimited(u);
|
||||
final e = safeInt(u['energy'] ?? 2);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.orangeAccent.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.bolt, color: unlimited ? kOnsolGold : Colors.orangeAccent, size: 18),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
unlimited ? 'ENERGY: MAX' : 'ENERGY: $e/2',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user