initial commit
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
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]}.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:convert';
|
||||
import '../models/film.dart';
|
||||
import '../widgets/schedule_selection_widget.dart';
|
||||
|
||||
class DetailPage extends StatelessWidget {
|
||||
final Film film;
|
||||
final String username;
|
||||
final bool isGuest;
|
||||
|
||||
const DetailPage({
|
||||
super.key,
|
||||
required this.film,
|
||||
required this.username,
|
||||
required this.isGuest,
|
||||
});
|
||||
|
||||
Widget _buildFilmImage(String? imageUrl,
|
||||
{required double width, required double height}) {
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
return Center(
|
||||
child: Icon(Icons.movie, size: 100, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
// Base64 image
|
||||
try {
|
||||
final base64String = imageUrl.split(',')[1];
|
||||
final bytes = base64Decode(base64String);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.memory(
|
||||
bytes,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Icon(Icons.movie, size: 100, color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return Center(
|
||||
child: Icon(Icons.movie, size: 100, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Network image (URL)
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Icon(Icons.movie, size: 100, color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isComingSoon = film.releaseDate.isAfter(DateTime.now());
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text('Detail Film'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(username),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 300,
|
||||
height: 450,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildFilmImage(film.imageUrl, width: 300, height: 450),
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
film.ageRating,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 32),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
film.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 36, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.star,
|
||||
size: 28, color: Color(0xFFFBBF24)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
film.rating.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Text(film.genre,
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
const SizedBox(width: 24),
|
||||
const Icon(Icons.access_time,
|
||||
size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${film.duration} menit',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildInfoRow('Sutradara', film.director),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow('Pemain', film.cast.join(', ')),
|
||||
const SizedBox(height: 8),
|
||||
_buildInfoRow(
|
||||
'Tanggal Rilis',
|
||||
DateFormat('dd MMMM yyyy').format(film.releaseDate),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Sinopsis',
|
||||
style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
film.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, color: Colors.grey, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
if (isComingSoon)
|
||||
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(
|
||||
children: [
|
||||
const Icon(Icons.schedule,
|
||||
size: 48, color: Color(0xFFFBBF24)),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Film ini akan segera tayang',
|
||||
style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Mulai ${DateFormat('dd MMMM yyyy').format(film.releaseDate)}',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFBBF24), fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
ScheduleSelectionWidget(
|
||||
film: film,
|
||||
username: username,
|
||||
isGuest: isGuest,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
const Text(': ', style: TextStyle(color: Colors.grey)),
|
||||
Expanded(
|
||||
child:
|
||||
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:convert';
|
||||
import '../data/app_data.dart';
|
||||
import '../models/film.dart';
|
||||
import 'login_page.dart';
|
||||
import 'detail_page.dart';
|
||||
import 'my_bookings_page.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
final String username;
|
||||
final bool isGuest;
|
||||
|
||||
const HomePage({super.key, required this.username, required this.isGuest});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
Widget _buildFilmImage(String? imageUrl,
|
||||
{required double width, required double height}) {
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
return Center(
|
||||
child: Icon(Icons.movie, size: 80, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
// Base64 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 Center(
|
||||
child: Icon(Icons.movie, size: 80, color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
return Center(
|
||||
child: Icon(Icons.movie, size: 80, color: Colors.grey),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Network image (URL)
|
||||
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 Center(
|
||||
child: Icon(Icons.movie, size: 80, color: Colors.grey),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.movie, color: Color(0xFFFBBF24)),
|
||||
SizedBox(width: 8),
|
||||
Text('CineSim'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!widget.isGuest)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.confirmation_number_outlined),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MyBookingsPage(username: widget.username),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Tiket Saya',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(widget.username),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const LoginPage()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _selectedIndex == 0 ? _buildNowShowingTab() : _buildComingSoonTab(),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _selectedIndex,
|
||||
onTap: (index) => setState(() => _selectedIndex = index),
|
||||
backgroundColor: const Color(0xFF1F2937),
|
||||
selectedItemColor: const Color(0xFFFBBF24),
|
||||
unselectedItemColor: Colors.grey,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.play_circle_outline),
|
||||
label: 'Sedang Tayang',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.upcoming),
|
||||
label: 'Coming Soon',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNowShowingTab() {
|
||||
final nowShowingFilms = AppData.films
|
||||
.where((f) => f.releaseDate.isBefore(DateTime.now()))
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Film Sedang Tayang',
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.isGuest
|
||||
? 'Login untuk memesan tiket'
|
||||
: 'Pilih film favorit Anda',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFilmGrid(nowShowingFilms),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildComingSoonTab() {
|
||||
final comingSoonFilms = AppData.films
|
||||
.where((f) => f.releaseDate.isAfter(DateTime.now()))
|
||||
.toList();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Coming Soon',
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Film yang akan segera tayang',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFilmGrid(comingSoonFilms),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilmGrid(List<Film> films) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
int crossAxisCount = 4;
|
||||
if (constraints.maxWidth < 1200) crossAxisCount = 3;
|
||||
if (constraints.maxWidth < 900) crossAxisCount = 2;
|
||||
if (constraints.maxWidth < 600) crossAxisCount = 1;
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
childAspectRatio: 0.65,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: films.length,
|
||||
itemBuilder: (context, index) {
|
||||
final film = films[index];
|
||||
final isComingSoon = film.releaseDate.isAfter(DateTime.now());
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => DetailPage(
|
||||
film: film,
|
||||
username: widget.username,
|
||||
isGuest: widget.isGuest,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: 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: 300,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[800],
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _buildFilmImage(
|
||||
film.imageUrl,
|
||||
width: double.infinity,
|
||||
height: 300,
|
||||
),
|
||||
),
|
||||
if (isComingSoon)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFBBF24),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'COMING SOON',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
film.ageRating,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
film.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
film.genre,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.star,
|
||||
size: 16, color: Color(0xFFFBBF24)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
film.rating.toString(),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFBBF24),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Icon(Icons.access_time,
|
||||
size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${film.duration} min',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isComingSoon) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tayang: ${DateFormat('dd MMM yyyy').format(film.releaseDate)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFFFBBF24),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'home_page.dart';
|
||||
import 'admin_dashboard.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
String _role = 'user';
|
||||
|
||||
void _login() {
|
||||
final username = _usernameController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
if (_role == 'admin' && username == 'admin' && password == 'admin123') {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AdminDashboard()),
|
||||
);
|
||||
} else if (_role == 'user' && username.isNotEmpty && password.isNotEmpty) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => HomePage(username: username, isGuest: false),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Username atau password salah!')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _guestMode() {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const HomePage(username: 'Tamu', isGuest: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F2937),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.movie, size: 64, color: Color(0xFFFBBF24)),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'CineSim',
|
||||
style: TextStyle(fontSize: 36, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Simulasi Pemesanan Tiket Bioskop',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _role,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Role',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF374151),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'user', child: Text('User')),
|
||||
DropdownMenuItem(value: 'admin', child: Text('Admin')),
|
||||
],
|
||||
onChanged: (value) => setState(() => _role = value!),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _usernameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Username',
|
||||
hintText: _role == 'admin' ? 'admin' : 'Masukkan username',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF374151),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText:
|
||||
_role == 'admin' ? 'admin123' : 'Masukkan password',
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF374151),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _login,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFBBF24),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text(
|
||||
'Login',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: _guestMode,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
side: const BorderSide(color: Color(0xFF374151)),
|
||||
),
|
||||
child: const Text('Lanjutkan sebagai Tamu'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Admin: admin / admin123',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../data/app_data.dart';
|
||||
import 'ticket_page.dart';
|
||||
|
||||
class MyBookingsPage extends StatelessWidget {
|
||||
final String username;
|
||||
|
||||
const MyBookingsPage({super.key, required this.username});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userBookings = AppData.bookings
|
||||
.where((b) => b.user == username)
|
||||
.toList()
|
||||
.reversed
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text('Tiket Saya'),
|
||||
),
|
||||
body: userBookings.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.confirmation_number_outlined,
|
||||
size: 100,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'Belum ada tiket',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Pesan tiket sekarang!',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFBBF24),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
child: const Text('Lihat Film'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(24),
|
||||
itemCount: userBookings.length,
|
||||
itemBuilder: (context, index) {
|
||||
final booking = userBookings[index];
|
||||
final bookingDate = DateTime.parse(booking.date);
|
||||
final isPast = bookingDate.isBefore(DateTime.now());
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F2937),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => TicketPage(booking: booking),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isPast
|
||||
? Colors.grey.shade800
|
||||
: const Color(
|
||||
0xFFFBBF24,
|
||||
).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isPast
|
||||
? Colors.grey
|
||||
: const Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isPast ? 'SELESAI' : 'AKTIF',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isPast
|
||||
? Colors.grey
|
||||
: const Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
booking.code,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
booking.film,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
DateFormat(
|
||||
'EEE, dd MMM yyyy',
|
||||
).format(bookingDate),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
const Icon(
|
||||
Icons.access_time,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
booking.schedule,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.location_on,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
booking.cinema,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.event_seat,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
booking.seats.join(', '),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(color: Color(0xFF374151)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Total Pembayaran',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'Rp ${_formatPrice(booking.price)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPrice(int price) {
|
||||
return price.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(m) => '${m[1]}.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math';
|
||||
import '../models/film.dart';
|
||||
import '../models/booking.dart';
|
||||
import '../data/app_data.dart';
|
||||
import 'ticket_page.dart';
|
||||
|
||||
class SeatSelectionPage extends StatefulWidget {
|
||||
final Film film;
|
||||
final String date;
|
||||
final String schedule;
|
||||
final String username;
|
||||
final String cinema;
|
||||
|
||||
const SeatSelectionPage({
|
||||
super.key,
|
||||
required this.film,
|
||||
required this.date,
|
||||
required this.schedule,
|
||||
required this.username,
|
||||
required this.cinema,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SeatSelectionPage> createState() => _SeatSelectionPageState();
|
||||
}
|
||||
|
||||
class _SeatSelectionPageState extends State<SeatSelectionPage> {
|
||||
final List<String> _selectedSeats = [];
|
||||
final rows = ['A', 'B', 'C', 'D', 'E'];
|
||||
String _selectedPayment = 'QRIS';
|
||||
|
||||
void _toggleSeat(int row, int col) {
|
||||
final seats =
|
||||
widget.film.schedules[widget.cinema]![widget.date]![widget.schedule]!;
|
||||
final seatId = '$row-$col';
|
||||
if (seats[row][col] == 'booked') return;
|
||||
|
||||
setState(() {
|
||||
if (_selectedSeats.contains(seatId)) {
|
||||
_selectedSeats.remove(seatId);
|
||||
} else {
|
||||
_selectedSeats.add(seatId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _confirmBooking() {
|
||||
if (_selectedSeats.isEmpty) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1F2937),
|
||||
title: const Text('Konfirmasi Pemesanan'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildConfirmRow('Film', widget.film.title),
|
||||
_buildConfirmRow(
|
||||
'Tanggal',
|
||||
DateFormat('dd MMM yyyy').format(DateTime.parse(widget.date)),
|
||||
),
|
||||
_buildConfirmRow('Jam', widget.schedule),
|
||||
_buildConfirmRow('Bioskop', widget.cinema),
|
||||
_buildConfirmRow(
|
||||
'Kursi',
|
||||
_selectedSeats.map((s) {
|
||||
final parts = s.split('-');
|
||||
return '${rows[int.parse(parts[0])]}${int.parse(parts[1]) + 1}';
|
||||
}).join(', '),
|
||||
),
|
||||
_buildConfirmRow('Pembayaran', _selectedPayment),
|
||||
const Divider(color: Color(0xFF374151)),
|
||||
_buildConfirmRow(
|
||||
'Total',
|
||||
'Rp ${_formatPrice(widget.film.price * _selectedSeats.length)}',
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Batal'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
_processBooking();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFBBF24),
|
||||
foregroundColor: Colors.black,
|
||||
),
|
||||
child: const Text('Bayar'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _processBooking() {
|
||||
final bookingCode =
|
||||
'BK${Random().nextInt(1000000).toString().padLeft(6, '0')}';
|
||||
final seatNames = _selectedSeats.map((s) {
|
||||
final parts = s.split('-');
|
||||
return '${rows[int.parse(parts[0])]}${int.parse(parts[1]) + 1}';
|
||||
}).toList();
|
||||
|
||||
final booking = Booking(
|
||||
code: bookingCode,
|
||||
film: widget.film.title,
|
||||
date: widget.date,
|
||||
schedule: widget.schedule,
|
||||
seats: seatNames,
|
||||
price: widget.film.price * _selectedSeats.length,
|
||||
user: widget.username,
|
||||
cinema: widget.cinema,
|
||||
bookingTime: DateTime.now(),
|
||||
paymentMethod: _selectedPayment,
|
||||
);
|
||||
|
||||
final seats =
|
||||
widget.film.schedules[widget.cinema]![widget.date]![widget.schedule]!;
|
||||
for (var seat in _selectedSeats) {
|
||||
final parts = seat.split('-');
|
||||
seats[int.parse(parts[0])][int.parse(parts[1])] = 'booked';
|
||||
}
|
||||
|
||||
AppData.bookings.add(booking);
|
||||
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => TicketPage(booking: booking)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmRow(String label, String value, {bool isTotal = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isTotal ? Colors.white : Colors.grey,
|
||||
fontWeight: isTotal ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isTotal ? const Color(0xFFFBBF24) : Colors.white,
|
||||
fontSize: isTotal ? 18 : 14,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final seats =
|
||||
widget.film.schedules[widget.cinema]![widget.date]![widget.schedule]!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
title: const Text('Pilih Kursi'),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.person, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(widget.username),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 900),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F2937),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
widget.film.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${DateFormat('EEE, dd MMM yyyy').format(DateTime.parse(widget.date))} • ${widget.schedule}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.cinema,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFBBF24),
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Color(0xFFFBBF24),
|
||||
Colors.transparent
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'LAYAR',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
letterSpacing: 4),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
...List.generate(5, (rowIndex) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32,
|
||||
child: Text(
|
||||
rows[rowIndex],
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
...List.generate(8, (colIndex) {
|
||||
final status = seats[rowIndex][colIndex];
|
||||
final seatId = '$rowIndex-$colIndex';
|
||||
final isSelected =
|
||||
_selectedSeats.contains(seatId);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
_toggleSeat(rowIndex, colIndex),
|
||||
child: Container(
|
||||
width: 45,
|
||||
height: 45,
|
||||
decoration: BoxDecoration(
|
||||
color: status == 'booked'
|
||||
? Colors.red.shade700
|
||||
: isSelected
|
||||
? const Color(0xFFFBBF24)
|
||||
: const Color(0xFF374151),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(
|
||||
top: Radius.circular(10),
|
||||
),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFFFBBF24)
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${rows[rowIndex]}${colIndex + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: status == 'booked'
|
||||
? Colors.white54
|
||||
: isSelected
|
||||
? Colors.black
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildLegend(const Color(0xFF374151), 'Tersedia'),
|
||||
const SizedBox(width: 24),
|
||||
_buildLegend(const Color(0xFFFBBF24), 'Dipilih'),
|
||||
const SizedBox(width: 24),
|
||||
_buildLegend(Colors.red.shade700, 'Terisi'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
SizedBox(
|
||||
width: 350,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F2937),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Metode Pembayaran',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...['QRIS', 'GoPay', 'OVO', 'Dana', 'Kartu Kredit']
|
||||
.map((method) {
|
||||
return RadioListTile<String>(
|
||||
value: method,
|
||||
groupValue: _selectedPayment,
|
||||
onChanged: (value) {
|
||||
setState(() => _selectedPayment = value!);
|
||||
},
|
||||
title: Text(method),
|
||||
activeColor: const Color(0xFFFBBF24),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF111827),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Kursi Dipilih',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
Text(
|
||||
_selectedSeats.isEmpty
|
||||
? '-'
|
||||
: _selectedSeats.map((s) {
|
||||
final parts = s.split('-');
|
||||
return '${rows[int.parse(parts[0])]}${int.parse(parts[1]) + 1}';
|
||||
}).join(', '),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Harga per Tiket',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
Text(
|
||||
'Rp ${_formatPrice(widget.film.price)}',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Total Pembayaran',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
'Rp ${_formatPrice(widget.film.price * _selectedSeats.length)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _selectedSeats.isEmpty
|
||||
? null
|
||||
: _confirmBooking,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFBBF24),
|
||||
foregroundColor: Colors.black,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16),
|
||||
disabledBackgroundColor: Colors.grey.shade800,
|
||||
),
|
||||
child: const Text(
|
||||
'Lanjutkan',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegend(Color color, String label) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color, borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPrice(int price) {
|
||||
return price.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(m) => '${m[1]}.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/booking.dart';
|
||||
import 'home_page.dart';
|
||||
|
||||
class TicketPage extends StatelessWidget {
|
||||
final Booking booking;
|
||||
|
||||
const TicketPage({super.key, required this.booking});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bookingDate = DateTime.parse(booking.date);
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1F2937),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: const Icon(Icons.check, color: Colors.white, size: 40),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Pemesanan Berhasil!',
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'E-Ticket Anda',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF111827),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF374151)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.confirmation_number,
|
||||
size: 48,
|
||||
color: Color(0xFFFBBF24),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildTicketRow(
|
||||
'Kode Booking',
|
||||
booking.code,
|
||||
isHighlight: true,
|
||||
),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
_buildTicketRow('Film', booking.film),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
_buildTicketRow('Bioskop', booking.cinema),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
_buildTicketRow(
|
||||
'Tanggal',
|
||||
DateFormat('EEE, dd MMM yyyy').format(bookingDate),
|
||||
),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
_buildTicketRow('Jam Tayang', booking.schedule),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
_buildTicketRow('Kursi', booking.seats.join(', ')),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
_buildTicketRow('Pembayaran', booking.paymentMethod),
|
||||
const Divider(color: Color(0xFF374151), height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Total Pembayaran',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
Text(
|
||||
'Rp ${_formatPrice(booking.price)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFFFBBF24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade900.withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue.shade700),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.blue, size: 20),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tunjukkan e-ticket ini di loket bioskop',
|
||||
style: TextStyle(fontSize: 12, color: Colors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
HomePage(username: booking.user, isGuest: false),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFBBF24),
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text(
|
||||
'Kembali ke Beranda',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
HomePage(username: booking.user, isGuest: false),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
side: const BorderSide(color: Color(0xFF374151)),
|
||||
),
|
||||
child: const Text('Pesan Tiket Lagi'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTicketRow(
|
||||
String label,
|
||||
String value, {
|
||||
bool isHighlight = false,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(color: Colors.grey, fontSize: 14)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: isHighlight ? const Color(0xFFFBBF24) : Colors.white,
|
||||
fontWeight: isHighlight ? FontWeight.bold : FontWeight.w600,
|
||||
fontSize: isHighlight ? 18 : 14,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatPrice(int price) {
|
||||
return price.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(m) => '${m[1]}.',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user