Initial commit

This commit is contained in:
St. Nebula
2026-04-23 23:58:59 -05:00
commit 47b9e3c159
257 changed files with 18913 additions and 0 deletions
+34
View File
@@ -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");
}
}
}
+42
View File
@@ -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;
}
}
+165
View File
@@ -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),
];
+31
View File
@@ -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(),
});
}
}
+104
View File
@@ -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 (_) {}
}
}
}
+34
View File
@@ -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");
}
}
}
+89
View File
@@ -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',
);
}
+84
View File
@@ -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();
},
),
);
}
}
+114
View File
@@ -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'),
],
),
);
}
}
+259
View File
@@ -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());
}))
]);
}
}
+180
View File
@@ -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")),
],
),
);
}
}
+101
View File
@@ -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))),
]),
)),
],
),
);
}
}
+247
View File
@@ -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,
),
),
);
},
),
],
),
);
}
}
+130
View File
@@ -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),
),
],
),
);
}
}
+161
View File
@@ -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),
),
);
}
);
}
}
+54
View File
@@ -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))),
),
),
);
},
);
},
),
);
}
}
+90
View File
@@ -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)),
)
]))
]),
);
}
}
+102
View File
@@ -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),
]
)
),
]
),
);
},
),
]
);
}
}
+48
View File
@@ -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)),
]),
);
},
);
},
),
);
}
}
+154
View File
@@ -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);
}
}
+186
View File
@@ -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,
),
],
),
);
},
),
),
),
),
]),
),
);
}
}
+275
View File
@@ -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")),
]),
])));
}
}
+177
View File
@@ -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),
),
],
),
),
],
),
),
);
}
}
+53
View File
@@ -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,
),
),
],
),
);
},
);
}
}