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