Rewrite ProfileScreen: professional UI & navigation; add EditProfile and ChangePassword screens
This commit is contained in:
parent
5fe31899db
commit
5886d148ab
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,9 +185,8 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
|||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const RecommendationScreen(
|
||||
category: 'kontrakan',
|
||||
),
|
||||
builder: (context) =>
|
||||
const RecommendationScreen(category: 'kontrakan'),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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'};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class LocationService {
|
|||
if (permission == LocationPermission.denied) {
|
||||
final result = await Geolocator.requestPermission();
|
||||
return result == LocationPermission.whileInUse ||
|
||||
result == LocationPermission.always;
|
||||
result == LocationPermission.always;
|
||||
} else if (permission == LocationPermission.deniedForever) {
|
||||
// Permission permanently denied, open app settings
|
||||
await Geolocator.openLocationSettings();
|
||||
|
|
@ -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) *
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
geolocator_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue