UAS/lib/pages/admin_dashboard.dart
2025-12-13 11:36:09 +07:00

809 lines
34 KiB
Dart

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:convert';
import 'dart:typed_data';
import '../data/app_data.dart';
import '../models/film.dart';
import 'login_page.dart';
class AdminDashboard extends StatefulWidget {
const AdminDashboard({super.key});
@override
State<AdminDashboard> createState() => _AdminDashboardState();
}
class _AdminDashboardState extends State<AdminDashboard> {
final _titleController = TextEditingController();
final _genreController = TextEditingController();
final _ratingController = TextEditingController();
final _descriptionController = TextEditingController();
final _priceController = TextEditingController();
final _durationController = TextEditingController();
final _directorController = TextEditingController();
final _castController = TextEditingController();
final _ageRatingController = TextEditingController();
final ImagePicker _imagePicker = ImagePicker();
Uint8List? _selectedImageBytes;
String? _selectedImageBase64;
Film? _editingFilm;
Future<void> _pickImage() async {
try {
final XFile? image = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 1200,
maxHeight: 1800,
imageQuality: 85,
);
if (image != null) {
final bytes = await image.readAsBytes();
setState(() {
_selectedImageBytes = bytes;
_selectedImageBase64 = base64Encode(bytes);
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memilih gambar: $e')),
);
}
}
}
Widget _buildFilmImage(String? imageUrl,
{required double width, required double height}) {
if (imageUrl == null || imageUrl.isEmpty) {
return const Center(
child: Icon(Icons.movie, size: 60, color: Colors.grey));
}
if (imageUrl.startsWith('data:image')) {
try {
final base64String = imageUrl.split(',')[1];
final bytes = base64Decode(base64String);
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Image.memory(
bytes,
width: width,
height: height,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.movie, size: 60, color: Colors.grey));
},
),
);
} catch (e) {
return const Center(
child: Icon(Icons.movie, size: 60, color: Colors.grey));
}
} else {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: Image.network(
imageUrl,
width: width,
height: height,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.movie, size: 60, color: Colors.grey));
},
),
);
}
}
void _showAddEditFilmDialog({Film? film}) {
_editingFilm = film;
_selectedImageBytes = null;
_selectedImageBase64 = null;
if (film != null) {
_titleController.text = film.title;
_genreController.text = film.genre;
_ratingController.text = film.rating.toString();
_descriptionController.text = film.description;
_priceController.text = film.price.toString();
_durationController.text = film.duration.toString();
_directorController.text = film.director;
_castController.text = film.cast.join(', ');
_ageRatingController.text = film.ageRating;
if (film.imageUrl != null && film.imageUrl!.startsWith('data:image')) {
try {
final base64String = film.imageUrl!.split(',')[1];
_selectedImageBase64 = base64String;
_selectedImageBytes = base64Decode(base64String);
} catch (e) {}
}
} else {
_titleController.clear();
_genreController.clear();
_ratingController.clear();
_descriptionController.clear();
_priceController.clear();
_durationController.clear();
_directorController.clear();
_castController.clear();
_ageRatingController.text = 'R 13+';
}
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => Dialog(
backgroundColor: const Color(0xFF1F2937),
child: SingleChildScrollView(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(film == null ? 'Tambah Film Baru' : 'Edit Film',
style: const TextStyle(
fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
const Text('Poster Film',
style:
TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
const SizedBox(height: 8),
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: const Color(0xFF374151),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF4B5563)),
),
child: _selectedImageBytes != null
? Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_selectedImageBytes!,
width: double.infinity,
height: 200,
fit: BoxFit.cover),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setDialogState(() {
_selectedImageBytes = null;
_selectedImageBase64 = null;
});
},
style: IconButton.styleFrom(
backgroundColor: Colors.black87,
foregroundColor: Colors.white),
),
),
],
)
: InkWell(
onTap: () async {
await _pickImage();
setDialogState(() {});
},
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate,
size: 48, color: Colors.grey),
SizedBox(height: 8),
Text('Klik untuk upload poster',
style: TextStyle(color: Colors.grey)),
SizedBox(height: 4),
Text('Format: JPG, PNG (Max 5MB)',
style: TextStyle(
color: Colors.grey, fontSize: 12)),
],
),
),
),
const SizedBox(height: 24),
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Judul Film *',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder())),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _genreController,
decoration: const InputDecoration(
labelText: 'Genre *',
hintText: 'Action, Drama',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder()))),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _ageRatingController,
decoration: const InputDecoration(
labelText: 'Rating Usia *',
hintText: 'R 13+, SU, D 17+',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder()))),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _ratingController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Rating (0-10) *',
hintText: '8.5',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder()))),
const SizedBox(width: 16),
Expanded(
child: TextField(
controller: _durationController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Durasi (menit) *',
hintText: '120',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder()))),
],
),
const SizedBox(height: 16),
TextField(
controller: _directorController,
decoration: const InputDecoration(
labelText: 'Sutradara *',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder())),
const SizedBox(height: 16),
TextField(
controller: _castController,
decoration: const InputDecoration(
labelText: 'Pemain *',
hintText: 'Actor 1, Actor 2, Actor 3',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder())),
const SizedBox(height: 16),
TextField(
controller: _descriptionController,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Deskripsi *',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder())),
const SizedBox(height: 16),
TextField(
controller: _priceController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Harga (Rp) *',
hintText: '50000',
filled: true,
fillColor: Color(0xFF374151),
border: OutlineInputBorder())),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal')),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () {
if (film == null) {
_addFilm();
} else {
_updateFilm(film);
}
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFBBF24),
foregroundColor: Colors.black),
child:
Text(film == null ? 'Tambah Film' : 'Update Film'),
),
],
),
],
),
),
),
),
),
);
}
void _addFilm() {
if (_titleController.text.isEmpty ||
_genreController.text.isEmpty ||
_ratingController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Mohon lengkapi data film yang wajib diisi!')));
return;
}
final now = DateTime.now();
String? imageDataUrl;
if (_selectedImageBase64 != null) {
imageDataUrl = 'data:image/jpeg;base64,$_selectedImageBase64';
}
final newFilm = Film(
id: AppData.films.length + 1,
title: _titleController.text,
genre: _genreController.text,
rating: double.tryParse(_ratingController.text) ?? 7.0,
description: _descriptionController.text.isEmpty
? 'Deskripsi film akan segera hadir.'
: _descriptionController.text,
duration: int.tryParse(_durationController.text) ?? 120,
ageRating: _ageRatingController.text.isEmpty
? 'R 13+'
: _ageRatingController.text,
director: _directorController.text.isEmpty
? 'Unknown Director'
: _directorController.text,
cast: _castController.text.isEmpty
? ['Cast 1', 'Cast 2']
: _castController.text.split(',').map((e) => e.trim()).toList(),
releaseDate: now,
endDate: now.add(const Duration(days: 30)),
schedules: AppData.generateSchedules(),
price: int.tryParse(_priceController.text) ?? 50000,
imageUrl: imageDataUrl,
);
setState(() => AppData.films.add(newFilm));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Film berhasil ditambahkan!')));
}
void _updateFilm(Film oldFilm) {
final index = AppData.films.indexWhere((f) => f.id == oldFilm.id);
if (index == -1) return;
String? imageDataUrl = oldFilm.imageUrl;
if (_selectedImageBase64 != null) {
imageDataUrl = 'data:image/jpeg;base64,$_selectedImageBase64';
}
final updatedFilm = oldFilm.copyWith(
title: _titleController.text,
genre: _genreController.text,
rating: double.tryParse(_ratingController.text) ?? oldFilm.rating,
description: _descriptionController.text,
duration: int.tryParse(_durationController.text) ?? oldFilm.duration,
ageRating: _ageRatingController.text,
director: _directorController.text,
cast: _castController.text.split(',').map((e) => e.trim()).toList(),
price: int.tryParse(_priceController.text) ?? oldFilm.price,
imageUrl: imageDataUrl,
);
setState(() => AppData.films[index] = updatedFilm);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Film berhasil diupdate!')));
}
void _deleteFilm(Film film) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1F2937),
title: const Text('Hapus Film'),
content: Text('Apakah Anda yakin ingin menghapus "${film.title}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Batal')),
ElevatedButton(
onPressed: () {
setState(() => AppData.films.removeWhere((f) => f.id == film.id));
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Film berhasil dihapus!')));
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Hapus'),
),
],
),
);
}
Map<String, int> _calculateRevenue() {
final revenue = <String, int>{};
for (var booking in AppData.bookings) {
revenue[booking.film] = (revenue[booking.film] ?? 0) + booking.price;
}
return revenue;
}
@override
Widget build(BuildContext context) {
final revenue = _calculateRevenue();
final totalRevenue = revenue.values.fold(0, (sum, val) => sum + val);
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
automaticallyImplyLeading: false,
title: const Row(children: [
Icon(Icons.movie, color: Color(0xFFFBBF24)),
SizedBox(width: 8),
Text('CineSim Admin')
]),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => Navigator.pushReplacement(context,
MaterialPageRoute(builder: (_) => const LoginPage())))
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Dashboard Admin',
style:
TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
ElevatedButton.icon(
onPressed: () => _showAddEditFilmDialog(),
icon: const Icon(Icons.add),
label: const Text('Tambah Film'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFBBF24),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 16)),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _buildStatCard(Icons.movie,
AppData.films.length.toString(), 'Total Film')),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(Icons.confirmation_number,
AppData.bookings.length.toString(), 'Total Transaksi')),
const SizedBox(width: 16),
Expanded(
child: _buildStatCard(
Icons.attach_money,
'${(totalRevenue / 1000000).toStringAsFixed(1)}M',
'Total Pendapatan')),
],
),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1F2937),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF374151))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Laporan Pendapatan per Film',
style:
TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
if (revenue.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(48),
child: Column(children: [
Icon(Icons.attach_money,
size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Belum ada transaksi',
style: TextStyle(color: Colors.grey))
])))
else
Column(
children: [
...revenue.entries.map((entry) {
final filmBookings = AppData.bookings
.where((b) => b.film == entry.key)
.toList();
final ticketCount = filmBookings.fold(
0, (sum, b) => sum + b.seats.length);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF111827),
borderRadius: BorderRadius.circular(8),
border:
Border.all(color: const Color(0xFF374151))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(entry.key,
style: const TextStyle(
fontWeight: FontWeight.w600))),
Text('Rp ${_formatPrice(entry.value)}',
style: const TextStyle(
color: Color(0xFFFBBF24),
fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text('${filmBookings.length} transaksi',
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
Text('$ticketCount tiket',
style: const TextStyle(
color: Colors.grey, fontSize: 12)),
],
),
],
),
);
}),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFBBF24).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFBBF24), width: 2)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total Keseluruhan',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16)),
Text('Rp ${_formatPrice(totalRevenue)}',
style: const TextStyle(
color: Color(0xFFFBBF24),
fontWeight: FontWeight.bold,
fontSize: 20)),
],
),
),
],
),
],
),
),
const SizedBox(height: 32),
const Text('Daftar Film',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
LayoutBuilder(
builder: (context, constraints) {
int crossAxisCount = 4;
if (constraints.maxWidth < 1200) crossAxisCount = 3;
if (constraints.maxWidth < 900) crossAxisCount = 2;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 0.65,
crossAxisSpacing: 16,
mainAxisSpacing: 16),
itemCount: AppData.films.length,
itemBuilder: (context, index) {
final film = AppData.films[index];
int totalSeats = 0;
int bookedSeats = 0;
film.schedules.forEach((cinema, dates) {
dates.forEach((date, times) {
times.forEach((time, seats) {
totalSeats += seats.expand((row) => row).length;
bookedSeats += seats
.expand((row) => row)
.where((s) => s == 'booked')
.length;
});
});
});
final occupancy = totalSeats > 0
? ((bookedSeats / totalSeats) * 100).toInt()
: 0;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF1F2937),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF374151))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Container(
height: 250,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12))),
child: _buildFilmImage(film.imageUrl,
width: double.infinity, height: 250),
),
Positioned(
top: 8,
right: 8,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: () =>
_showAddEditFilmDialog(film: film),
style: IconButton.styleFrom(
backgroundColor:
const Color(0xFFFBBF24),
foregroundColor: Colors.black)),
const SizedBox(width: 4),
IconButton(
icon:
const Icon(Icons.delete, size: 20),
onPressed: () => _deleteFilm(film),
style: IconButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white)),
],
),
),
],
),
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(film.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 4),
Text(film.genre,
style: const TextStyle(
color: Colors.grey, fontSize: 11)),
const SizedBox(height: 8),
Row(children: [
const Icon(Icons.star,
size: 14, color: Color(0xFFFBBF24)),
const SizedBox(width: 4),
Text(film.rating.toString(),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontWeight: FontWeight.bold,
fontSize: 12))
]),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF111827),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF374151))),
child: Column(
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text('Okupansi',
style: TextStyle(
color: Colors.grey,
fontSize: 10)),
Text('$occupancy%',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 10))
]),
const SizedBox(height: 4),
ClipRRect(
borderRadius:
BorderRadius.circular(4),
child: LinearProgressIndicator(
value: occupancy / 100,
backgroundColor:
const Color(0xFF374151),
valueColor:
const AlwaysStoppedAnimation<
Color>(Color(0xFFFBBF24)),
minHeight: 6)),
],
),
),
],
),
),
],
),
);
},
);
},
),
],
),
),
);
}
Widget _buildStatCard(IconData icon, String value, String label) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1F2937),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF374151))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Icon(icon, color: const Color(0xFFFBBF24), size: 32),
Text(value,
style:
const TextStyle(fontSize: 28, fontWeight: FontWeight.bold))
]),
const SizedBox(height: 8),
Text(label, style: const TextStyle(color: Colors.grey)),
],
),
);
}
String _formatPrice(int price) {
return price.toString().replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]}.');
}
}