Initial commit
This commit is contained in:
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user