initial commit

This commit is contained in:
itsgabbe
2025-12-13 11:36:09 +07:00
commit 6528cb622e
140 changed files with 7996 additions and 0 deletions
+808
View File
@@ -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]}.');
}
}
+252
View File
@@ -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)),
),
],
);
}
}
+364
View File
@@ -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,
),
),
],
],
),
),
],
),
),
);
},
);
},
);
}
}
+162
View File
@@ -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),
),
],
),
),
),
),
);
}
}
+236
View File
@@ -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]}.',
);
}
}
+491
View File
@@ -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]}.',
);
}
}
+211
View File
@@ -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]}.',
);
}
}