Rewrite ProfileScreen: professional UI & navigation; add EditProfile and ChangePassword screens

This commit is contained in:
micko samawa 2026-02-22 20:46:44 +07:00
parent 5fe31899db
commit 5886d148ab
22 changed files with 2963 additions and 835 deletions

View File

@ -3,9 +3,12 @@ class AppConfig {
// Windows Desktop: http://localhost:8000
// Android Emulator: http://10.0.2.2:8000
// iOS Simulator: http://localhost:8000
// Real Device: http://192.168.x.x:8000 (IP komputer Anda)
// Real Device: http://192.168.18.16:8000 (IP komputer Anda)
// CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda
// Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac)
static const String baseUrl = 'http://192.168.18.16:8000/api';
static const String storageUrl = 'http://192.168.18.16:8000/storage';
// Timeouts
static const Duration connectionTimeout = Duration(seconds: 10);

View File

@ -4,11 +4,12 @@ class Booking {
final int kontrakanId;
final DateTime tanggalMulai;
final DateTime tanggalSelesai;
final int durasiBulan;
final double totalBiaya;
final String status;
final String? catatan;
final dynamic kontrakan; // Can be Map or Kontrakan object
final String paymentStatus;
final String? paymentProof;
Booking({
required this.id,
@ -16,25 +17,33 @@ class Booking {
required this.kontrakanId,
required this.tanggalMulai,
required this.tanggalSelesai,
required this.durasiBulan,
required this.totalBiaya,
required this.status,
this.catatan,
this.kontrakan,
this.paymentStatus = 'unpaid',
this.paymentProof,
});
factory Booking.fromJson(Map<String, dynamic> json) {
// Support both old field names and actual DB column names
final startDate = json['start_date'] ?? json['tanggal_mulai'];
final endDate = json['end_date'] ?? json['tanggal_selesai'];
final amount = json['amount'] ?? json['total_biaya'];
final notes = json['notes'] ?? json['catatan'];
return Booking(
id: json['id'] ?? 0,
userId: json['user_id'] ?? 0,
kontrakanId: json['kontrakan_id'] ?? 0,
tanggalMulai: DateTime.parse(json['tanggal_mulai']),
tanggalSelesai: DateTime.parse(json['tanggal_selesai']),
durasiBulan: json['durasi_bulan'] ?? 0,
totalBiaya: double.tryParse(json['total_biaya']?.toString() ?? '0') ?? 0,
id: int.tryParse(json['id']?.toString() ?? '0') ?? 0,
userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0,
kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0,
tanggalMulai: DateTime.parse(startDate),
tanggalSelesai: DateTime.parse(endDate),
totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0,
status: json['status'] ?? 'pending',
catatan: json['catatan'],
catatan: notes,
kontrakan: json['kontrakan'],
paymentStatus: json['payment_status'] ?? 'unpaid',
paymentProof: json['payment_proof'],
);
}
@ -43,6 +52,14 @@ class Booking {
return 'Rp ${totalBiaya.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
// Computed duration in months from start and end date
int get durasiBulan {
return ((tanggalSelesai.year - tanggalMulai.year) * 12 +
tanggalSelesai.month -
tanggalMulai.month)
.clamp(1, 99);
}
// Status badge color
String get statusColor {
switch (status.toLowerCase()) {

View File

@ -42,7 +42,7 @@ class Kontrakan {
// Backend uses 'jarak' column, convert meters to km
jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000;
}
return Kontrakan(
id: json['id'] ?? 0,
nama: json['nama'] ?? '',

View File

@ -1,5 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:intl/intl.dart' as intl;
import 'package:intl/date_symbol_data_local.dart';
import 'package:image_picker/image_picker.dart';
import '../models/kontrakan.dart';
import '../services/booking_service.dart';
import '../services/auth_service.dart';
@ -17,12 +20,14 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
final _bookingService = BookingService();
final _authService = AuthService();
final _catatanController = TextEditingController();
final _imagePicker = ImagePicker();
DateTime? _tanggalMulai;
int _durasiBulan = 1;
bool _isSubmitting = false;
File? _paymentProofImage;
final _currencyFormat = NumberFormat.currency(
final _currencyFormat = intl.NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
@ -30,6 +35,15 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
double get _totalBiaya => widget.kontrakan.harga * _durasiBulan;
@override
void initState() {
super.initState();
// Initialize Indonesian locale for date formatting
initializeDateFormatting('id_ID', null).catchError((_) {
// Ignore if already initialized
});
}
@override
void dispose() {
_catatanController.dispose();
@ -63,6 +77,59 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
}
}
Future<void> _pickPaymentProof() async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Unggah Bukti Pembayaran',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Pilih foto struk transfer atau bukti pembayaran',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.photo_library, color: Color(0xFF667eea)),
title: const Text('Pilih dari Galeri'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt, color: Color(0xFF667eea)),
title: const Text('Ambil Foto'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
],
),
),
),
);
if (source == null) return;
final picked = await _imagePicker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (picked != null) {
setState(() => _paymentProofImage = File(picked.path));
}
}
Future<void> _submitBooking() async {
if (_tanggalMulai == null) {
ScaffoldMessenger.of(context).showSnackBar(
@ -74,6 +141,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
return;
}
if (_paymentProofImage == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bukti pembayaran wajib diunggah'),
backgroundColor: Colors.red,
),
);
return;
}
if (!_authService.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@ -102,15 +179,23 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
children: [
_buildConfirmRow('Kontrakan', widget.kontrakan.nama),
const SizedBox(height: 8),
_buildConfirmRow('Tanggal Mulai', DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!)),
_buildConfirmRow(
'Tanggal Mulai',
intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!),
),
const SizedBox(height: 8),
_buildConfirmRow('Durasi', '$_durasiBulan bulan'),
const SizedBox(height: 8),
_buildConfirmRow('Total Biaya', _currencyFormat.format(_totalBiaya)),
_buildConfirmRow(
'Total Biaya',
_currencyFormat.format(_totalBiaya),
),
if (_catatanController.text.isNotEmpty) ...[
const SizedBox(height: 8),
_buildConfirmRow('Catatan', _catatanController.text),
],
const SizedBox(height: 8),
_buildConfirmRow('Bukti Bayar', 'Foto terlampir'),
],
),
actions: [
@ -123,7 +208,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Konfirmasi'),
),
@ -139,7 +226,10 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
kontrakanId: widget.kontrakan.id,
tanggalMulai: _tanggalMulai!,
durasiBulan: _durasiBulan,
catatan: _catatanController.text.isNotEmpty ? _catatanController.text : null,
catatan: _catatanController.text.isNotEmpty
? _catatanController.text
: null,
paymentProof: _paymentProofImage,
);
setState(() => _isSubmitting = false);
@ -151,7 +241,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -162,7 +254,11 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
color: Colors.green.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.check_circle, color: Colors.green, size: 64),
child: const Icon(
Icons.check_circle,
color: Colors.green,
size: 64,
),
),
const SizedBox(height: 16),
const Text(
@ -190,7 +286,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('OK'),
),
@ -214,10 +312,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
children: [
SizedBox(
width: 100,
child: Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[600])),
child: Text(
label,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
),
Expanded(
child: Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
child: Text(
value,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
],
);
@ -261,19 +365,29 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.location_on, size: 16, color: Colors.white70),
const Icon(
Icons.location_on,
size: 16,
color: Colors.white70,
),
const SizedBox(width: 4),
Expanded(
child: Text(
widget.kontrakan.alamat,
style: const TextStyle(fontSize: 13, color: Colors.white70),
style: const TextStyle(
fontSize: 13,
color: Colors.white70,
),
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
@ -305,27 +419,43 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _tanggalMulai != null ? const Color(0xFF667eea) : Colors.grey[300]!),
border: Border.all(
color: _tanggalMulai != null
? const Color(0xFF667eea)
: Colors.grey[300]!,
),
),
child: Row(
children: [
Icon(
Icons.date_range,
color: _tanggalMulai != null ? const Color(0xFF667eea) : Colors.grey[400],
color: _tanggalMulai != null
? const Color(0xFF667eea)
: Colors.grey[400],
),
const SizedBox(width: 12),
Text(
_tanggalMulai != null
? DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!)
? intl.DateFormat(
'dd MMMM yyyy',
'id_ID',
).format(_tanggalMulai!)
: 'Pilih tanggal...',
style: TextStyle(
fontSize: 15,
color: _tanggalMulai != null ? Colors.black87 : Colors.grey[500],
fontWeight: _tanggalMulai != null ? FontWeight.w600 : FontWeight.normal,
color: _tanggalMulai != null
? Colors.black87
: Colors.grey[500],
fontWeight: _tanggalMulai != null
? FontWeight.w600
: FontWeight.normal,
),
),
],
@ -346,21 +476,31 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF667eea).withOpacity(0.3)),
border: Border.all(
color: const Color(0xFF667eea).withOpacity(0.3),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _durasiBulan,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF667eea)),
style: const TextStyle(fontSize: 15, color: Colors.black87, fontWeight: FontWeight.w600),
icon: const Icon(
Icons.arrow_drop_down,
color: Color(0xFF667eea),
),
style: const TextStyle(
fontSize: 15,
color: Colors.black87,
fontWeight: FontWeight.w600,
),
items: List.generate(12, (i) => i + 1).map((bulan) {
return DropdownMenuItem<int>(
value: bulan,
child: Text('$bulan bulan'),
);
}).toList(),
onChanged: (val) => setState(() => _durasiBulan = val!),
onChanged: (val) =>
setState(() => _durasiBulan = val!),
),
),
),
@ -378,7 +518,10 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
maxLines: 3,
decoration: InputDecoration(
hintText: 'Contoh: Saya mahasiswa Polije semester 4...',
hintStyle: TextStyle(fontSize: 13, color: Colors.grey[400]),
hintStyle: TextStyle(
fontSize: 13,
color: Colors.grey[400],
),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
@ -391,7 +534,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF667eea)),
borderSide: const BorderSide(
color: Color(0xFF667eea),
),
),
contentPadding: const EdgeInsets.all(14),
),
@ -400,36 +545,126 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
const SizedBox(height: 16),
// Bukti Pembayaran
_buildFormCard(
icon: Icons.receipt,
title: 'Bukti Pembayaran',
subtitle: 'Upload foto struk/bukti transfer pembayaran (Wajib)',
child: Column(
children: [
if (_paymentProofImage != null) ...[
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
_paymentProofImage!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
),
Positioned(
top: 8,
right: 8,
child: InkWell(
onTap: () => setState(() => _paymentProofImage = null),
child: Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(Icons.close, color: Colors.white, size: 18),
),
),
),
],
),
const SizedBox(height: 12),
],
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _pickPaymentProof,
icon: Icon(
_paymentProofImage != null ? Icons.change_circle : Icons.upload_file,
color: const Color(0xFF667eea),
),
label: Text(
_paymentProofImage != null ? 'Ganti Foto' : 'Pilih Foto Bukti Pembayaran',
style: const TextStyle(color: Color(0xFF667eea)),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFF667eea)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
),
const SizedBox(height: 16),
// Ringkasan Biaya
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [const Color(0xFF667eea).withOpacity(0.05), Colors.white],
colors: [
const Color(0xFF667eea).withOpacity(0.05),
Colors.white,
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF667eea).withOpacity(0.2)),
border: Border.all(
color: const Color(0xFF667eea).withOpacity(0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.receipt_long, size: 20, color: const Color(0xFF667eea)),
Icon(
Icons.receipt_long,
size: 20,
color: const Color(0xFF667eea),
),
const SizedBox(width: 8),
Text('Ringkasan Biaya', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
Text(
'Ringkasan Biaya',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 12),
_buildBiayaRow('Harga per bulan', widget.kontrakan.formattedHarga),
_buildBiayaRow(
'Harga per bulan',
widget.kontrakan.formattedHarga,
),
const SizedBox(height: 6),
_buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'),
const Divider(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total Biaya', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const Text(
'Total Biaya',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
_currencyFormat.format(_totalBiaya),
style: const TextStyle(
@ -456,16 +691,31 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
backgroundColor: const Color(0xFF667eea),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey[400],
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 3,
),
child: _isSubmitting
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)),
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
SizedBox(width: 12),
Text('Memproses...', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text(
'Memproses...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
)
: const Row(
@ -473,7 +723,13 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
children: [
Icon(Icons.bookmark_add, size: 22),
SizedBox(width: 8),
Text('Booking Sekarang', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text(
'Booking Sekarang',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
@ -501,7 +757,13 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.08), blurRadius: 8, offset: const Offset(0, 2))],
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -510,11 +772,21 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
children: [
Icon(icon, size: 20, color: const Color(0xFF667eea)),
const SizedBox(width: 8),
Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])),
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 4),
Text(subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
Text(
subtitle,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
const SizedBox(height: 12),
child,
],
@ -527,7 +799,10 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
Text(
value,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
],
);
}

View File

@ -1,4 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../config/app_config.dart';
import '../services/booking_service.dart';
import '../models/booking.dart';
@ -12,11 +15,13 @@ class BookingHistoryScreen extends StatefulWidget {
class _BookingHistoryScreenState extends State<BookingHistoryScreen>
with SingleTickerProviderStateMixin {
final _bookingService = BookingService();
final _imagePicker = ImagePicker();
late TabController _tabController;
List<Booking> _activeBookings = [];
List<Booking> _pastBookings = [];
bool _isLoading = true;
int? _uploadingBookingId;
@override
void initState() {
@ -45,6 +50,114 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
});
}
Future<void> _cancelBooking(int bookingId) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Batalkan Booking'),
content: const Text('Apakah Anda yakin ingin membatalkan booking ini?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Tidak'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Ya, Batalkan',
style: TextStyle(color: Colors.red),
),
),
],
),
);
if (confirm != true) return;
final result = await _bookingService.cancelBooking(bookingId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? ''),
backgroundColor: result['success'] == true
? Colors.green
: Colors.red,
),
);
if (result['success'] == true) _loadBookings();
}
}
Future<void> _uploadPaymentProof(Booking booking) async {
// Show source dialog
final source = await showModalBottomSheet<ImageSource>(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Unggah Bukti Pembayaran',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Pilih foto struk transfer atau bukti pembayaran lainnya',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(
Icons.photo_library,
color: Color(0xFF4CAF50),
),
title: const Text('Pilih dari Galeri'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt, color: Color(0xFF4CAF50)),
title: const Text('Ambil Foto'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
],
),
),
),
);
if (source == null) return;
final picked = await _imagePicker.pickImage(
source: source,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 85,
);
if (picked == null) return;
setState(() => _uploadingBookingId = booking.id);
final result = await _bookingService.uploadPaymentProof(
booking.id,
File(picked.path),
);
if (mounted) {
setState(() => _uploadingBookingId = null);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? ''),
backgroundColor: result['success'] == true
? Colors.green
: Colors.red,
duration: const Duration(seconds: 3),
),
);
if (result['success'] == true) _loadBookings();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -330,14 +443,94 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
],
),
const SizedBox(height: 8),
// Payment status row
Row(
children: [
Icon(
booking.paymentStatus == 'paid'
? Icons.check_circle
: Icons.payment,
size: 16,
color: booking.paymentStatus == 'paid'
? Colors.green
: Colors.orange,
),
const SizedBox(width: 6),
Text(
booking.paymentStatus == 'paid'
? 'Pembayaran: Lunas'
: 'Pembayaran: Belum Dibayar',
style: TextStyle(
fontSize: 13,
color: booking.paymentStatus == 'paid'
? Colors.green
: Colors.orange,
fontWeight: FontWeight.w600,
),
),
],
),
// Proof already uploaded notice
if (booking.paymentProof != null) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () {
final url = '${AppConfig.storageUrl}/${booking.paymentProof}';
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppBar(
title: const Text('Bukti Pembayaran'),
backgroundColor: const Color(0xFF4CAF50),
foregroundColor: Colors.white,
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(ctx),
),
],
),
Image.network(url, fit: BoxFit.contain),
],
),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.shade200),
),
child: Row(
children: [
const Icon(Icons.image, size: 18, color: Colors.green),
const SizedBox(width: 8),
const Text(
'Bukti pembayaran sudah diunggah — Tap untuk lihat',
style: TextStyle(fontSize: 13, color: Colors.green),
),
],
),
),
),
],
// Actions
if (booking.status == 'pending') ...[
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
// Cancel booking
},
onPressed: () => _cancelBooking(booking.id),
icon: const Icon(Icons.cancel),
label: const Text('Batalkan Booking'),
style: ElevatedButton.styleFrom(
@ -351,6 +544,37 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
),
),
],
if (booking.status == 'confirmed' &&
booking.paymentStatus == 'unpaid') ...[
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: _uploadingBookingId == booking.id
? const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: CircularProgressIndicator(),
),
)
: ElevatedButton.icon(
onPressed: () => _uploadPaymentProof(booking),
icon: const Icon(Icons.upload_file),
label: Text(
booking.paymentProof == null
? 'Upload Bukti Pembayaran'
: 'Ganti Bukti Pembayaran',
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF4CAF50),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
),

View File

@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class ChangePasswordScreen extends StatefulWidget {
const ChangePasswordScreen({super.key});
@override
State<ChangePasswordScreen> createState() => _ChangePasswordScreenState();
}
class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
final _authService = AuthService();
final _formKey = GlobalKey<FormState>();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirm = true;
@override
void dispose() {
_passwordController.dispose();
_confirmController.dispose();
super.dispose();
}
Future<void> _changePassword() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final result = await _authService.changePassword(
password: _passwordController.text,
passwordConfirmation: _confirmController.text,
);
setState(() => _isLoading = false);
if (!mounted) return;
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password berhasil diubah'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal mengubah password'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
appBar: AppBar(
title: const Text('Ubah Password'),
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
child: Column(
children: [
// Header
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1565C0), Color(0xFF1976D2)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
shape: BoxShape.circle,
),
child: const Icon(Icons.lock_outline,
size: 48, color: Colors.white),
),
const SizedBox(height: 12),
const Text(
'Buat password baru',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
],
),
),
// Form
Padding(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
validator: (v) {
if (v == null || v.isEmpty) {
return 'Password wajib diisi';
}
if (v.length < 6) {
return 'Password minimal 6 karakter';
}
return null;
},
decoration: InputDecoration(
labelText: 'Password Baru',
prefixIcon: const Icon(Icons.lock_outline,
color: Color(0xFF1565C0)),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_off
: Icons.visibility,
color: Colors.grey,
),
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(
color: Color(0xFF1565C0), width: 2),
),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _confirmController,
obscureText: _obscureConfirm,
validator: (v) {
if (v == null || v.isEmpty) {
return 'Konfirmasi password wajib diisi';
}
if (v != _passwordController.text) {
return 'Password tidak sama';
}
return null;
},
decoration: InputDecoration(
labelText: 'Konfirmasi Password',
prefixIcon: const Icon(Icons.lock_outline,
color: Color(0xFF1565C0)),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirm
? Icons.visibility_off
: Icons.visibility,
color: Colors.grey,
),
onPressed: () =>
setState(() => _obscureConfirm = !_obscureConfirm),
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(
color: Color(0xFF1565C0), width: 2),
),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _isLoading ? null : _changePassword,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Ubah Password',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
],
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../models/user.dart';
class EditProfileScreen extends StatefulWidget {
final User user;
const EditProfileScreen({super.key, required this.user});
@override
State<EditProfileScreen> createState() => _EditProfileScreenState();
}
class _EditProfileScreenState extends State<EditProfileScreen> {
final _authService = AuthService();
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _emailController;
late TextEditingController _phoneController;
bool _isLoading = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.user.name);
_emailController = TextEditingController(text: widget.user.email);
_phoneController = TextEditingController(text: widget.user.phone ?? '');
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_phoneController.dispose();
super.dispose();
}
Future<void> _saveProfile() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
final result = await _authService.updateProfile(
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim(),
);
setState(() => _isLoading = false);
if (!mounted) return;
if (result['success'] == true) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil berhasil diperbarui'),
backgroundColor: Colors.green,
),
);
Navigator.pop(context, true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Gagal memperbarui profil'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FA),
appBar: AppBar(
title: const Text('Edit Profil'),
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
child: Column(
children: [
// Header
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1565C0), Color(0xFF1976D2)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
child: CircleAvatar(
radius: 45,
backgroundColor: Colors.white,
child: Text(
widget.user.name.substring(0, 1).toUpperCase(),
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
),
),
],
),
),
// Form
Padding(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
children: [
_buildField(
controller: _nameController,
label: 'Nama Lengkap',
icon: Icons.person_outline,
validator: (v) =>
v == null || v.isEmpty ? 'Nama wajib diisi' : null,
),
const SizedBox(height: 16),
_buildField(
controller: _emailController,
label: 'Email',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
validator: (v) {
if (v == null || v.isEmpty) return 'Email wajib diisi';
if (!v.contains('@')) return 'Email tidak valid';
return null;
},
),
const SizedBox(height: 16),
_buildField(
controller: _phoneController,
label: 'No. HP (Opsional)',
icon: Icons.phone_outlined,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _isLoading ? null : _saveProfile,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 2,
),
child: _isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Simpan Perubahan',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
],
),
),
),
],
),
),
);
}
Widget _buildField({
required TextEditingController controller,
required String label,
required IconData icon,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: const Color(0xFF1565C0)),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF1565C0), width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
);
}
}

View File

@ -94,12 +94,16 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Icons.directions_walk,
'${widget.kontrakan.jarakKampus} km dari kampus',
),
_buildInfoRow(Icons.bed, '${widget.kontrakan.jumlahKamar} Kamar'),
_buildInfoRow(
Icons.bed,
'${widget.kontrakan.jumlahKamar} Kamar',
),
const SizedBox(height: 20),
// Location Detection Card
if (widget.kontrakan.latitude != null && widget.kontrakan.longitude != null)
if (widget.kontrakan.latitude != null &&
widget.kontrakan.longitude != null)
_buildLocationCard(),
const SizedBox(height: 20),
@ -169,7 +173,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
if (!authService.isAuthenticated) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Silakan login terlebih dahulu untuk booking'),
content: Text(
'Silakan login terlebih dahulu untuk booking',
),
backgroundColor: Colors.red,
),
);
@ -178,7 +184,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BookingFormScreen(kontrakan: widget.kontrakan),
builder: (_) =>
BookingFormScreen(kontrakan: widget.kontrakan),
),
);
}
@ -196,7 +203,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
),
),
child: Text(
widget.kontrakan.isAvailable ? 'Booking Sekarang' : 'Kontrakan Tidak Tersedia',
widget.kontrakan.isAvailable
? 'Booking Sekarang'
: 'Kontrakan Tidak Tersedia',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
@ -242,11 +251,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
children: [
Row(
children: [
Icon(
Icons.location_on,
color: Colors.purple,
size: 24,
),
Icon(Icons.location_on, color: Colors.purple, size: 24),
const SizedBox(width: 12),
Text(
'Deteksi Lokasi Saya',
@ -286,10 +291,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
children: [
Text(
'Jarak dari Lokasi Saya',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Text(
distance! < 1
@ -320,10 +322,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
Expanded(
child: Text(
locationError!,
style: const TextStyle(
fontSize: 13,
color: Colors.red,
),
style: const TextStyle(fontSize: 13, color: Colors.red),
),
),
],

View File

@ -90,7 +90,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
),
const SizedBox(width: 6),
Text(
widget.laundry.rating.toStringAsFixed(1),
widget.laundry.rating.toStringAsFixed(
1,
),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
@ -119,12 +121,13 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Status Card
// Status Card
_buildStatusCard(),
const SizedBox(height: 16),
// Location Detection Card
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
if (widget.laundry.latitude != null &&
widget.laundry.longitude != null)
_buildLocationCard(),
const SizedBox(height: 16),
@ -184,7 +187,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: () => _launchWhatsApp(widget.laundry.noWhatsapp!),
onPressed: () =>
_launchWhatsApp(widget.laundry.noWhatsapp!),
icon: const Icon(Icons.message, size: 24),
label: const Text(
'Hubungi via WhatsApp',
@ -205,13 +209,16 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
const SizedBox(height: 12),
],
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
if (widget.laundry.latitude != null &&
widget.laundry.longitude != null)
SizedBox(
width: double.infinity,
height: 50,
child: OutlinedButton.icon(
onPressed: () =>
_launchMaps(widget.laundry.latitude!, widget.laundry.longitude!),
onPressed: () => _launchMaps(
widget.laundry.latitude!,
widget.laundry.longitude!,
),
icon: const Icon(Icons.map, size: 24),
label: const Text(
'Lihat di Maps',
@ -243,7 +250,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
}
Widget _buildStatusCard() {
Color statusColor = widget.laundry.status == 'buka' ? Colors.green : Colors.red;
Color statusColor = widget.laundry.status == 'buka'
? Colors.green
: Colors.red;
if (widget.laundry.status == 'buka' && !widget.laundry.isOpen) {
statusColor = Colors.orange;
}
@ -310,11 +319,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
children: [
Row(
children: [
Icon(
Icons.location_on,
color: Colors.purple,
size: 24,
),
Icon(Icons.location_on, color: Colors.purple, size: 24),
const SizedBox(width: 12),
Text(
'Deteksi Lokasi Saya',
@ -354,10 +359,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
children: [
Text(
'Jarak dari Lokasi Saya',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
Text(
distance! < 1
@ -388,10 +390,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
Expanded(
child: Text(
locationError!,
style: const TextStyle(
fontSize: 13,
color: Colors.red,
),
style: const TextStyle(fontSize: 13, color: Colors.red),
),
),
],

View File

@ -57,10 +57,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
],
colors: [Color(0xFF1565C0), Color(0xFF0D47A1)],
),
),
child: SafeArea(
@ -150,10 +147,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
const SizedBox(height: 8),
Text(
'Temukan rekomendasi terbaik untuk kebutuhan Anda',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
@ -191,15 +185,14 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RecommendationScreen(
category: 'kontrakan',
),
builder: (context) =>
const RecommendationScreen(category: 'kontrakan'),
),
);
},
),
const SizedBox(height: 16),
// Laundry Card
_buildCategoryCard(
title: 'Layanan Laundry',
@ -210,9 +203,8 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RecommendationScreen(
category: 'laundry',
),
builder: (context) =>
const RecommendationScreen(category: 'laundry'),
),
);
},
@ -243,11 +235,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
children: [
Row(
children: [
Icon(
Icons.search,
color: Colors.grey[600],
size: 20,
),
Icon(Icons.search, color: Colors.grey[600], size: 20),
const SizedBox(width: 8),
Text(
'Pencarian Cepat',
@ -272,11 +260,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
),
child: Row(
children: [
Icon(
Icons.search,
color: Colors.grey[400],
size: 20,
),
Icon(Icons.search, color: Colors.grey[400], size: 20),
const SizedBox(width: 12),
Text(
'Cari kontrakan atau laundry...',
@ -329,11 +313,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 32,
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(width: 16),
Expanded(
@ -351,10 +331,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
],
),
@ -396,14 +373,8 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
selectedFontSize: 12,
unselectedFontSize: 11,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Cari',
),
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Beranda'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Cari'),
BottomNavigationBarItem(
icon: Icon(Icons.bookmark),
label: 'Booking',
@ -448,4 +419,4 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
}
}
}
}
}

View File

@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../models/user.dart';
import '../login.dart';
import 'edit_profile_screen.dart';
import 'change_password_screen.dart';
import 'booking_history_screen.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@ -33,175 +36,252 @@ class _ProfileScreenState extends State<ProfileScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
backgroundColor: const Color(0xFFF8F9FA),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SafeArea(
? const Center(
child: CircularProgressIndicator(color: Color(0xFF1565C0)))
: RefreshIndicator(
onRefresh: _loadUser,
color: const Color(0xFF1565C0),
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Header with Profile
// --- Profile Header ---
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1565C0), Color(0xFF1976D2)],
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF0D47A1), Color(0xFF1976D2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(32),
bottomRight: Radius.circular(32),
),
),
child: Column(
children: [
// Avatar
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
child: CircleAvatar(
radius: 50,
backgroundColor: Colors.white,
child: Text(
_currentUser?.name.substring(0, 1).toUpperCase() ?? 'U',
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 32),
child: Column(
children: [
// Title
const Row(
children: [
Text(
'Profil Saya',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
const SizedBox(height: 24),
// Avatar & Info
Row(
children: [
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white, width: 2.5),
),
child: CircleAvatar(
radius: 38,
backgroundColor:
Colors.white.withOpacity(0.95),
child: Text(
_currentUser?.name
.substring(0, 1)
.toUpperCase() ??
'U',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Color(0xFF0D47A1),
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
_currentUser?.name ?? 'User',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.email_outlined,
size: 14,
color: Colors.white
.withOpacity(0.8)),
const SizedBox(width: 6),
Expanded(
child: Text(
_currentUser?.email ?? '',
style: TextStyle(
fontSize: 13,
color: Colors.white
.withOpacity(0.85),
),
maxLines: 1,
overflow:
TextOverflow.ellipsis,
),
),
],
),
if (_currentUser?.phone != null &&
_currentUser!.phone!.isNotEmpty) ...[
const SizedBox(height: 2),
Row(
children: [
Icon(Icons.phone_outlined,
size: 14,
color: Colors.white
.withOpacity(0.8)),
const SizedBox(width: 6),
Text(
_currentUser!.phone!,
style: TextStyle(
fontSize: 13,
color: Colors.white
.withOpacity(0.85),
),
),
],
),
],
],
),
),
],
),
],
),
const SizedBox(height: 16),
Text(
_currentUser?.name ?? 'User',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
_currentUser?.email ?? '',
style: TextStyle(
fontSize: 16,
color: Colors.white.withValues(alpha: 0.9),
),
),
],
),
),
),
),
// Menu Items
// --- Menu Sections ---
SliverPadding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildSection('Akun'),
_buildMenuItem(
icon: Icons.person,
title: 'Edit Profil',
subtitle: 'Ubah informasi profil Anda',
onTap: () {
// TODO: Navigate to edit profile
},
),
_buildMenuItem(
icon: Icons.lock,
title: 'Ubah Password',
subtitle: 'Ganti password akun Anda',
onTap: () {
// TODO: Navigate to change password
},
),
const SizedBox(height: 16),
_buildSection('Aktivitas'),
_buildMenuItem(
icon: Icons.bookmark,
title: 'Booking Saya',
subtitle: 'Lihat riwayat booking Anda',
onTap: () {
// Already on tabs, maybe switch tab?
},
),
_buildMenuItem(
icon: Icons.favorite,
title: 'Favorit',
subtitle: 'Kontrakan yang Anda favoritkan',
onTap: () {
// TODO: Navigate to favorites
},
),
_buildMenuItem(
icon: Icons.rate_review,
title: 'Review Saya',
subtitle: 'Lihat semua review Anda',
onTap: () {
// TODO: Navigate to my reviews
},
),
const SizedBox(height: 16),
_buildSection('Pengaturan'),
_buildMenuItem(
icon: Icons.notifications,
title: 'Notifikasi',
subtitle: 'Atur preferensi notifikasi',
onTap: () {
// TODO: Navigate to notifications settings
},
),
_buildMenuItem(
icon: Icons.language,
title: 'Bahasa',
subtitle: 'Pilih bahasa aplikasi',
trailing: const Text('Indonesia', style: TextStyle(color: Colors.grey)),
),
const SizedBox(height: 16),
_buildSection('Bantuan'),
_buildMenuItem(
icon: Icons.help,
title: 'Pusat Bantuan',
subtitle: 'FAQ dan panduan penggunaan',
onTap: () {
// TODO: Navigate to help center
},
),
_buildMenuItem(
icon: Icons.info,
title: 'Tentang Aplikasi',
subtitle: 'Versi 1.0.0',
onTap: () {
_showAboutDialog();
},
),
const SizedBox(height: 24),
// Logout Button
// Akun section
_sectionLabel('Akun'),
const SizedBox(height: 8),
_menuCard([
_menuTile(
icon: Icons.person_outline,
title: 'Edit Profil',
subtitle: 'Ubah nama, email, dan no. HP',
onTap: () async {
if (_currentUser == null) return;
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
EditProfileScreen(user: _currentUser!),
),
);
if (result == true) _loadUser();
},
),
_divider(),
_menuTile(
icon: Icons.lock_outline,
title: 'Ubah Password',
subtitle: 'Ganti password akun Anda',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
const ChangePasswordScreen(),
),
);
},
),
]),
const SizedBox(height: 20),
// Aktivitas section
_sectionLabel('Aktivitas'),
const SizedBox(height: 8),
_menuCard([
_menuTile(
icon: Icons.receipt_long_outlined,
title: 'Booking Saya',
subtitle: 'Lihat riwayat booking Anda',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
const BookingHistoryScreen(),
),
);
},
),
]),
const SizedBox(height: 20),
// Informasi section
_sectionLabel('Informasi'),
const SizedBox(height: 8),
_menuCard([
_menuTile(
icon: Icons.info_outline,
title: 'Tentang Aplikasi',
subtitle: 'SPK Kontrakan v1.0.0',
onTap: _showAboutDialog,
),
]),
const SizedBox(height: 28),
// Logout
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
height: 52,
child: OutlinedButton.icon(
onPressed: _handleLogout,
icon: const Icon(Icons.logout),
label: const Text('Logout'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
icon: const Icon(Icons.logout, size: 20),
label: const Text(
'Keluar dari Akun',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w600),
),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade600,
side: BorderSide(color: Colors.red.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(14),
),
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 32),
]),
),
),
@ -211,128 +291,145 @@ class _ProfileScreenState extends State<ProfileScreen> {
);
}
Widget _buildSection(String title) {
// --- Helper Widgets ---
Widget _sectionLabel(String text) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
padding: const EdgeInsets.only(left: 4),
child: Text(
title,
text.toUpperCase(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey[700],
letterSpacing: 0.5,
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.grey.shade500,
letterSpacing: 1.2,
),
),
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
required String subtitle,
VoidCallback? onTap,
Widget? trailing,
}) {
Widget _menuCard(List<Widget> children) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: ListTile(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Column(children: children),
),
);
}
Widget _menuTile({
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: const Color(0xFF1565C0), size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A2E),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 22),
],
),
child: Icon(icon, color: const Color(0xFF1565C0), size: 24),
),
title: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
subtitle: Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
trailing: trailing ??
(onTap != null
? const Icon(Icons.chevron_right, color: Colors.grey)
: null),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
);
}
Widget _divider() {
return Divider(
height: 1, thickness: 0.5, indent: 72, color: Colors.grey.shade200);
}
// --- Dialogs ---
void _showAboutDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.home_work,
color: Color(0xFF1565C0),
size: 32,
),
),
const SizedBox(width: 12),
const Text('SPK Kontrakan'),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Sistem Pendukung Keputusan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.08),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.home_work,
color: Color(0xFF1565C0), size: 40),
),
const SizedBox(height: 8),
const SizedBox(height: 16),
const Text(
'SPK Kontrakan',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'Aplikasi untuk membantu mahasiswa menemukan kontrakan terbaik dengan metode SAW (Simple Additive Weighting)',
'Versi 1.0.0',
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
),
const SizedBox(height: 16),
Text(
'Sistem Pendukung Keputusan untuk membantu mahasiswa menemukan kontrakan terbaik menggunakan metode SAW.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
fontSize: 14, color: Colors.grey.shade600, height: 1.5),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
_buildInfoRow('Versi', '1.0.0'),
_buildInfoRow('Developer', 'SPK Team'),
_buildInfoRow('Tahun', '2026'),
_aboutRow('Developer', 'SPK Team'),
_aboutRow('Tahun', '2026'),
],
),
actions: [
@ -345,27 +442,19 @@ class _ProfileScreenState extends State<ProfileScreen> {
);
}
Widget _buildInfoRow(String label, String value) {
Widget _aboutRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
Text(
value,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
Text(label,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
Text(value,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87)),
],
),
);
@ -375,32 +464,30 @@ class _ProfileScreenState extends State<ProfileScreen> {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Row(
children: [
Icon(Icons.logout, color: Colors.red[700]),
const SizedBox(width: 12),
const Text('Konfirmasi Logout'),
Icon(Icons.logout, color: Colors.red.shade600, size: 24),
const SizedBox(width: 10),
const Text('Keluar', style: TextStyle(fontSize: 18)),
],
),
content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'),
content: const Text('Apakah Anda yakin ingin keluar dari akun?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
child:
Text('Batal', style: TextStyle(color: Colors.grey.shade600)),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
backgroundColor: Colors.red.shade600,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
borderRadius: BorderRadius.circular(10)),
),
child: const Text('Logout'),
child: const Text('Keluar'),
),
],
),
@ -411,7 +498,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
if (mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
}

File diff suppressed because it is too large Load Diff

View File

@ -173,4 +173,82 @@ class AuthService {
return null;
}
}
// Update profile
Future<Map<String, dynamic>> updateProfile({
required String name,
required String email,
String? phone,
}) async {
try {
final response = await http.put(
Uri.parse('${AppConfig.baseUrl}/profile/update'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'name': name,
'email': email,
'phone': phone,
}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
final user = User.fromJson(data['data']);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
_currentUser = user;
return {'success': true, 'message': data['message']};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal update profil',
'errors': data['errors'],
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
// Change password
Future<Map<String, dynamic>> changePassword({
required String password,
required String passwordConfirmation,
}) async {
try {
final response = await http.put(
Uri.parse('${AppConfig.baseUrl}/profile/update'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $_token',
},
body: jsonEncode({
'name': _currentUser?.name ?? '',
'email': _currentUser?.email ?? '',
'password': password,
'password_confirmation': passwordConfirmation,
}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
return {'success': true, 'message': 'Password berhasil diubah'};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal mengubah password',
'errors': data['errors'],
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
}

View File

@ -1,5 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as path;
import '../config/app_config.dart';
import '../models/booking.dart';
import 'auth_service.dart';
@ -42,25 +44,43 @@ class BookingService {
}
}
// Create booking
// Create booking (with required payment proof image)
Future<Map<String, dynamic>> createBooking({
required int kontrakanId,
required DateTime tanggalMulai,
required int durasiBulan,
String? catatan,
File? paymentProof,
}) async {
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/bookings'),
headers: _headers,
body: jsonEncode({
'kontrakan_id': kontrakanId,
'tanggal_mulai': tanggalMulai.toIso8601String().split('T')[0],
'durasi_bulan': durasiBulan,
'catatan': catatan,
}),
// Validate payment proof is provided
if (paymentProof == null) {
return {
'success': false,
'message': 'Bukti pembayaran wajib diunggah',
};
}
// Use multipart request with payment proof
final uri = Uri.parse('${AppConfig.baseUrl}/bookings');
final request = http.MultipartRequest('POST', uri);
if (_authService.token != null) {
request.headers['Authorization'] = 'Bearer ${_authService.token}';
request.headers['Accept'] = 'application/json';
}
request.fields['kontrakan_id'] = kontrakanId.toString();
request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split('T')[0];
request.fields['durasi_bulan'] = durasiBulan.toString();
if (catatan != null) request.fields['catatan'] = catatan;
request.files.add(
await http.MultipartFile.fromPath('payment_proof', paymentProof.path),
);
final streamedResponse = await request.send().timeout(const Duration(seconds: 30));
final response = await http.Response.fromStream(streamedResponse);
final data = jsonDecode(response.body);
if (response.statusCode == 201 && data['success'] == true) {
@ -124,4 +144,49 @@ class BookingService {
return null;
}
}
// Upload payment proof image
Future<Map<String, dynamic>> uploadPaymentProof(
int bookingId,
File imageFile,
) async {
try {
final uri = Uri.parse(
'${AppConfig.baseUrl}/bookings/$bookingId/payment-proof',
);
final request = http.MultipartRequest('POST', uri);
// Add auth header
if (_authService.token != null) {
request.headers['Authorization'] = 'Bearer ${_authService.token}';
request.headers['Accept'] = 'application/json';
}
// Attach image file
request.files.add(
await http.MultipartFile.fromPath('payment_proof', imageFile.path),
);
final streamedResponse = await request.send().timeout(
const Duration(seconds: 30),
);
final response = await http.Response.fromStream(streamedResponse);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
return {
'success': true,
'message': data['message'] ?? 'Bukti pembayaran berhasil diunggah',
'booking': Booking.fromJson(data['data']),
};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal mengunggah bukti pembayaran',
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
}

View File

@ -3,24 +3,24 @@ import 'dart:math';
class LocationService {
static final LocationService _instance = LocationService._internal();
factory LocationService() => _instance;
LocationService._internal();
// Request location permission
Future<bool> requestLocationPermission() async {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
final result = await Geolocator.requestPermission();
return result == LocationPermission.whileInUse ||
result == LocationPermission.always;
return result == LocationPermission.whileInUse ||
result == LocationPermission.always;
} else if (permission == LocationPermission.deniedForever) {
// Permission permanently denied, open app settings
await Geolocator.openLocationSettings();
return false;
}
return true;
}
@ -35,7 +35,7 @@ class LocationService {
desiredAccuracy: LocationAccuracy.high,
timeLimit: const Duration(seconds: 10),
);
return position;
} catch (e) {
print('Error getting location: $e');
@ -45,9 +45,9 @@ class LocationService {
// Calculate distance between two coordinates (in km)
static double calculateDistance(
double lat1,
double lon1,
double lat2,
double lat1,
double lon1,
double lat2,
double lon2,
) {
const earthRadius = 6371; // km
@ -55,7 +55,8 @@ class LocationService {
final dLat = _degreesToRadians(lat2 - lat1);
final dLon = _degreesToRadians(lon2 - lon1);
final a = sin(dLat / 2) * sin(dLat / 2) +
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(_degreesToRadians(lat1)) *
cos(_degreesToRadians(lat2)) *
sin(dLon / 2) *

View File

@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@ -5,12 +5,14 @@
import FlutterMacOS
import Foundation
import file_selector_macos
import geolocator_apple
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@ -113,6 +121,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
url: "https://pub.dev"
source: hosted
version: "0.9.4"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
url: "https://pub.dev"
source: hosted
version: "0.9.5"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@ -142,6 +182,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
url: "https://pub.dev"
source: hosted
version: "2.0.33"
flutter_spinkit:
dependency: "direct main"
description:
@ -240,6 +288,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156
url: "https://pub.dev"
source: hosted
version: "0.8.13+14"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev"
source: hosted
version: "0.8.13+6"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: "direct main"
description:
@ -312,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:

View File

@ -46,6 +46,7 @@ dependencies:
# Image picker & display
cached_network_image: ^3.3.1
image_picker: ^1.1.2
# State management
provider: ^6.1.1

View File

@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <geolocator_windows/geolocator_windows.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
geolocator_windows
url_launcher_windows
)