Files
Onsol-GO/lib/screens/artist/artist_upcoming_screen.dart
2026-04-23 23:58:59 -05:00

407 lines
20 KiB
Dart

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();
},
),
],
),
),
);
},
);
},
),
);
}
}