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