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
|
// Windows Desktop: http://localhost:8000
|
||||||
// Android Emulator: http://10.0.2.2:8000
|
// Android Emulator: http://10.0.2.2:8000
|
||||||
// iOS Simulator: http://localhost: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 baseUrl = 'http://192.168.18.16:8000/api';
|
||||||
|
static const String storageUrl = 'http://192.168.18.16:8000/storage';
|
||||||
|
|
||||||
// Timeouts
|
// Timeouts
|
||||||
static const Duration connectionTimeout = Duration(seconds: 10);
|
static const Duration connectionTimeout = Duration(seconds: 10);
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ class Booking {
|
||||||
final int kontrakanId;
|
final int kontrakanId;
|
||||||
final DateTime tanggalMulai;
|
final DateTime tanggalMulai;
|
||||||
final DateTime tanggalSelesai;
|
final DateTime tanggalSelesai;
|
||||||
final int durasiBulan;
|
|
||||||
final double totalBiaya;
|
final double totalBiaya;
|
||||||
final String status;
|
final String status;
|
||||||
final String? catatan;
|
final String? catatan;
|
||||||
final dynamic kontrakan; // Can be Map or Kontrakan object
|
final dynamic kontrakan; // Can be Map or Kontrakan object
|
||||||
|
final String paymentStatus;
|
||||||
|
final String? paymentProof;
|
||||||
|
|
||||||
Booking({
|
Booking({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -16,25 +17,33 @@ class Booking {
|
||||||
required this.kontrakanId,
|
required this.kontrakanId,
|
||||||
required this.tanggalMulai,
|
required this.tanggalMulai,
|
||||||
required this.tanggalSelesai,
|
required this.tanggalSelesai,
|
||||||
required this.durasiBulan,
|
|
||||||
required this.totalBiaya,
|
required this.totalBiaya,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.catatan,
|
this.catatan,
|
||||||
this.kontrakan,
|
this.kontrakan,
|
||||||
|
this.paymentStatus = 'unpaid',
|
||||||
|
this.paymentProof,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Booking.fromJson(Map<String, dynamic> json) {
|
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(
|
return Booking(
|
||||||
id: json['id'] ?? 0,
|
id: int.tryParse(json['id']?.toString() ?? '0') ?? 0,
|
||||||
userId: json['user_id'] ?? 0,
|
userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0,
|
||||||
kontrakanId: json['kontrakan_id'] ?? 0,
|
kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0,
|
||||||
tanggalMulai: DateTime.parse(json['tanggal_mulai']),
|
tanggalMulai: DateTime.parse(startDate),
|
||||||
tanggalSelesai: DateTime.parse(json['tanggal_selesai']),
|
tanggalSelesai: DateTime.parse(endDate),
|
||||||
durasiBulan: json['durasi_bulan'] ?? 0,
|
totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0,
|
||||||
totalBiaya: double.tryParse(json['total_biaya']?.toString() ?? '0') ?? 0,
|
|
||||||
status: json['status'] ?? 'pending',
|
status: json['status'] ?? 'pending',
|
||||||
catatan: json['catatan'],
|
catatan: notes,
|
||||||
kontrakan: json['kontrakan'],
|
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]}.')}';
|
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
|
// Status badge color
|
||||||
String get statusColor {
|
String get statusColor {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
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 '../models/kontrakan.dart';
|
||||||
import '../services/booking_service.dart';
|
import '../services/booking_service.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
|
|
@ -17,12 +20,14 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
final _bookingService = BookingService();
|
final _bookingService = BookingService();
|
||||||
final _authService = AuthService();
|
final _authService = AuthService();
|
||||||
final _catatanController = TextEditingController();
|
final _catatanController = TextEditingController();
|
||||||
|
final _imagePicker = ImagePicker();
|
||||||
|
|
||||||
DateTime? _tanggalMulai;
|
DateTime? _tanggalMulai;
|
||||||
int _durasiBulan = 1;
|
int _durasiBulan = 1;
|
||||||
bool _isSubmitting = false;
|
bool _isSubmitting = false;
|
||||||
|
File? _paymentProofImage;
|
||||||
|
|
||||||
final _currencyFormat = NumberFormat.currency(
|
final _currencyFormat = intl.NumberFormat.currency(
|
||||||
locale: 'id_ID',
|
locale: 'id_ID',
|
||||||
symbol: 'Rp ',
|
symbol: 'Rp ',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
|
|
@ -30,6 +35,15 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
|
|
||||||
double get _totalBiaya => widget.kontrakan.harga * _durasiBulan;
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_catatanController.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 {
|
Future<void> _submitBooking() async {
|
||||||
if (_tanggalMulai == null) {
|
if (_tanggalMulai == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -74,6 +141,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_paymentProofImage == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Bukti pembayaran wajib diunggah'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_authService.isAuthenticated) {
|
if (!_authService.isAuthenticated) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
|
|
@ -102,15 +179,23 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
children: [
|
children: [
|
||||||
_buildConfirmRow('Kontrakan', widget.kontrakan.nama),
|
_buildConfirmRow('Kontrakan', widget.kontrakan.nama),
|
||||||
const SizedBox(height: 8),
|
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),
|
const SizedBox(height: 8),
|
||||||
_buildConfirmRow('Durasi', '$_durasiBulan bulan'),
|
_buildConfirmRow('Durasi', '$_durasiBulan bulan'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildConfirmRow('Total Biaya', _currencyFormat.format(_totalBiaya)),
|
_buildConfirmRow(
|
||||||
|
'Total Biaya',
|
||||||
|
_currencyFormat.format(_totalBiaya),
|
||||||
|
),
|
||||||
if (_catatanController.text.isNotEmpty) ...[
|
if (_catatanController.text.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildConfirmRow('Catatan', _catatanController.text),
|
_buildConfirmRow('Catatan', _catatanController.text),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildConfirmRow('Bukti Bayar', 'Foto terlampir'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -123,7 +208,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF667eea),
|
backgroundColor: const Color(0xFF667eea),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Konfirmasi'),
|
child: const Text('Konfirmasi'),
|
||||||
),
|
),
|
||||||
|
|
@ -139,7 +226,10 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
kontrakanId: widget.kontrakan.id,
|
kontrakanId: widget.kontrakan.id,
|
||||||
tanggalMulai: _tanggalMulai!,
|
tanggalMulai: _tanggalMulai!,
|
||||||
durasiBulan: _durasiBulan,
|
durasiBulan: _durasiBulan,
|
||||||
catatan: _catatanController.text.isNotEmpty ? _catatanController.text : null,
|
catatan: _catatanController.text.isNotEmpty
|
||||||
|
? _catatanController.text
|
||||||
|
: null,
|
||||||
|
paymentProof: _paymentProofImage,
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
|
|
@ -151,7 +241,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -162,7 +254,11 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
color: Colors.green.withOpacity(0.1),
|
color: Colors.green.withOpacity(0.1),
|
||||||
shape: BoxShape.circle,
|
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 SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
|
|
@ -190,7 +286,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
backgroundColor: const Color(0xFF667eea),
|
backgroundColor: const Color(0xFF667eea),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Text('OK'),
|
child: const Text('OK'),
|
||||||
),
|
),
|
||||||
|
|
@ -214,10 +312,16 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 100,
|
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(
|
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),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
widget.kontrakan.alamat,
|
widget.kontrakan.alamat,
|
||||||
style: const TextStyle(fontSize: 13, color: Colors.white70),
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.2),
|
color: Colors.white.withOpacity(0.2),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
|
@ -305,27 +419,43 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[100],
|
color: Colors.grey[100],
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.date_range,
|
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),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
_tanggalMulai != null
|
_tanggalMulai != null
|
||||||
? DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!)
|
? intl.DateFormat(
|
||||||
|
'dd MMMM yyyy',
|
||||||
|
'id_ID',
|
||||||
|
).format(_tanggalMulai!)
|
||||||
: 'Pilih tanggal...',
|
: 'Pilih tanggal...',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: _tanggalMulai != null ? Colors.black87 : Colors.grey[500],
|
color: _tanggalMulai != null
|
||||||
fontWeight: _tanggalMulai != null ? FontWeight.w600 : FontWeight.normal,
|
? Colors.black87
|
||||||
|
: Colors.grey[500],
|
||||||
|
fontWeight: _tanggalMulai != null
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -346,21 +476,31 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey[100],
|
color: Colors.grey[100],
|
||||||
borderRadius: BorderRadius.circular(12),
|
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: DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<int>(
|
child: DropdownButton<int>(
|
||||||
value: _durasiBulan,
|
value: _durasiBulan,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF667eea)),
|
icon: const Icon(
|
||||||
style: const TextStyle(fontSize: 15, color: Colors.black87, fontWeight: FontWeight.w600),
|
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) {
|
items: List.generate(12, (i) => i + 1).map((bulan) {
|
||||||
return DropdownMenuItem<int>(
|
return DropdownMenuItem<int>(
|
||||||
value: bulan,
|
value: bulan,
|
||||||
child: Text('$bulan bulan'),
|
child: Text('$bulan bulan'),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (val) => setState(() => _durasiBulan = val!),
|
onChanged: (val) =>
|
||||||
|
setState(() => _durasiBulan = val!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -378,7 +518,10 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Contoh: Saya mahasiswa Polije semester 4...',
|
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,
|
filled: true,
|
||||||
fillColor: Colors.grey[100],
|
fillColor: Colors.grey[100],
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
|
|
@ -391,7 +534,9 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(color: Color(0xFF667eea)),
|
borderSide: const BorderSide(
|
||||||
|
color: Color(0xFF667eea),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.all(14),
|
contentPadding: const EdgeInsets.all(14),
|
||||||
),
|
),
|
||||||
|
|
@ -400,36 +545,126 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
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
|
// Ringkasan Biaya
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [const Color(0xFF667eea).withOpacity(0.05), Colors.white],
|
colors: [
|
||||||
|
const Color(0xFF667eea).withOpacity(0.05),
|
||||||
|
Colors.white,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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),
|
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),
|
const SizedBox(height: 12),
|
||||||
_buildBiayaRow('Harga per bulan', widget.kontrakan.formattedHarga),
|
_buildBiayaRow(
|
||||||
|
'Harga per bulan',
|
||||||
|
widget.kontrakan.formattedHarga,
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
_buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'),
|
_buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'),
|
||||||
const Divider(height: 20),
|
const Divider(height: 20),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text('Total Biaya', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text(
|
||||||
|
'Total Biaya',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
_currencyFormat.format(_totalBiaya),
|
_currencyFormat.format(_totalBiaya),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
|
@ -456,16 +691,31 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
backgroundColor: const Color(0xFF667eea),
|
backgroundColor: const Color(0xFF667eea),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
disabledBackgroundColor: Colors.grey[400],
|
disabledBackgroundColor: Colors.grey[400],
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
),
|
),
|
||||||
child: _isSubmitting
|
child: _isSubmitting
|
||||||
? const Row(
|
? const Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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),
|
SizedBox(width: 12),
|
||||||
Text('Memproses...', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
Text(
|
||||||
|
'Memproses...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const Row(
|
: const Row(
|
||||||
|
|
@ -473,7 +723,13 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.bookmark_add, size: 22),
|
Icon(Icons.bookmark_add, size: 22),
|
||||||
SizedBox(width: 8),
|
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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -510,11 +772,21 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: const Color(0xFF667eea)),
|
Icon(icon, size: 20, color: const Color(0xFF667eea)),
|
||||||
const SizedBox(width: 8),
|
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),
|
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),
|
const SizedBox(height: 12),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
|
|
@ -527,7 +799,10 @@ class _BookingFormScreenState extends State<BookingFormScreen> {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
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:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../config/app_config.dart';
|
||||||
import '../services/booking_service.dart';
|
import '../services/booking_service.dart';
|
||||||
import '../models/booking.dart';
|
import '../models/booking.dart';
|
||||||
|
|
||||||
|
|
@ -12,11 +15,13 @@ class BookingHistoryScreen extends StatefulWidget {
|
||||||
class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
class _BookingHistoryScreenState extends State<BookingHistoryScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
final _bookingService = BookingService();
|
final _bookingService = BookingService();
|
||||||
|
final _imagePicker = ImagePicker();
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
|
||||||
List<Booking> _activeBookings = [];
|
List<Booking> _activeBookings = [];
|
||||||
List<Booking> _pastBookings = [];
|
List<Booking> _pastBookings = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
int? _uploadingBookingId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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') ...[
|
if (booking.status == 'pending') ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () => _cancelBooking(booking.id),
|
||||||
// Cancel booking
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.cancel),
|
icon: const Icon(Icons.cancel),
|
||||||
label: const Text('Batalkan Booking'),
|
label: const Text('Batalkan Booking'),
|
||||||
style: ElevatedButton.styleFrom(
|
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,
|
Icons.directions_walk,
|
||||||
'${widget.kontrakan.jarakKampus} km dari kampus',
|
'${widget.kontrakan.jarakKampus} km dari kampus',
|
||||||
),
|
),
|
||||||
_buildInfoRow(Icons.bed, '${widget.kontrakan.jumlahKamar} Kamar'),
|
_buildInfoRow(
|
||||||
|
Icons.bed,
|
||||||
|
'${widget.kontrakan.jumlahKamar} Kamar',
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Location Detection Card
|
// Location Detection Card
|
||||||
if (widget.kontrakan.latitude != null && widget.kontrakan.longitude != null)
|
if (widget.kontrakan.latitude != null &&
|
||||||
|
widget.kontrakan.longitude != null)
|
||||||
_buildLocationCard(),
|
_buildLocationCard(),
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
@ -169,7 +173,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
if (!authService.isAuthenticated) {
|
if (!authService.isAuthenticated) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Silakan login terlebih dahulu untuk booking'),
|
content: Text(
|
||||||
|
'Silakan login terlebih dahulu untuk booking',
|
||||||
|
),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -178,7 +184,8 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => BookingFormScreen(kontrakan: widget.kontrakan),
|
builder: (_) =>
|
||||||
|
BookingFormScreen(kontrakan: widget.kontrakan),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +203,9 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
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),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -242,11 +251,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.location_on, color: Colors.purple, size: 24),
|
||||||
Icons.location_on,
|
|
||||||
color: Colors.purple,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Deteksi Lokasi Saya',
|
'Deteksi Lokasi Saya',
|
||||||
|
|
@ -286,10 +291,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Jarak dari Lokasi Saya',
|
'Jarak dari Lokasi Saya',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
distance! < 1
|
distance! < 1
|
||||||
|
|
@ -320,10 +322,7 @@ class _KontrakanDetailScreenState extends State<KontrakanDetailScreen> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
locationError!,
|
locationError!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 13, color: Colors.red),
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
widget.laundry.rating.toStringAsFixed(1),
|
widget.laundry.rating.toStringAsFixed(
|
||||||
|
1,
|
||||||
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -119,12 +121,13 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// Status Card
|
// Status Card
|
||||||
_buildStatusCard(),
|
_buildStatusCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Location Detection Card
|
// Location Detection Card
|
||||||
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
|
if (widget.laundry.latitude != null &&
|
||||||
|
widget.laundry.longitude != null)
|
||||||
_buildLocationCard(),
|
_buildLocationCard(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
|
@ -184,7 +187,8 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () => _launchWhatsApp(widget.laundry.noWhatsapp!),
|
onPressed: () =>
|
||||||
|
_launchWhatsApp(widget.laundry.noWhatsapp!),
|
||||||
icon: const Icon(Icons.message, size: 24),
|
icon: const Icon(Icons.message, size: 24),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Hubungi via WhatsApp',
|
'Hubungi via WhatsApp',
|
||||||
|
|
@ -205,13 +209,16 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
|
|
||||||
if (widget.laundry.latitude != null && widget.laundry.longitude != null)
|
if (widget.laundry.latitude != null &&
|
||||||
|
widget.laundry.longitude != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () =>
|
onPressed: () => _launchMaps(
|
||||||
_launchMaps(widget.laundry.latitude!, widget.laundry.longitude!),
|
widget.laundry.latitude!,
|
||||||
|
widget.laundry.longitude!,
|
||||||
|
),
|
||||||
icon: const Icon(Icons.map, size: 24),
|
icon: const Icon(Icons.map, size: 24),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
'Lihat di Maps',
|
'Lihat di Maps',
|
||||||
|
|
@ -243,7 +250,9 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatusCard() {
|
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) {
|
if (widget.laundry.status == 'buka' && !widget.laundry.isOpen) {
|
||||||
statusColor = Colors.orange;
|
statusColor = Colors.orange;
|
||||||
}
|
}
|
||||||
|
|
@ -310,11 +319,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.location_on, color: Colors.purple, size: 24),
|
||||||
Icons.location_on,
|
|
||||||
color: Colors.purple,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Deteksi Lokasi Saya',
|
'Deteksi Lokasi Saya',
|
||||||
|
|
@ -354,10 +359,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Jarak dari Lokasi Saya',
|
'Jarak dari Lokasi Saya',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
distance! < 1
|
distance! < 1
|
||||||
|
|
@ -388,10 +390,7 @@ class _LaundryDetailScreenState extends State<LaundryDetailScreen> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
locationError!,
|
locationError!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 13, color: Colors.red),
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.red,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [Color(0xFF1565C0), Color(0xFF0D47A1)],
|
||||||
Color(0xFF1565C0),
|
|
||||||
Color(0xFF0D47A1),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
|
|
@ -150,10 +147,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Temukan rekomendasi terbaik untuk kebutuhan Anda',
|
'Temukan rekomendasi terbaik untuk kebutuhan Anda',
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
fontSize: 14,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -191,9 +185,8 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const RecommendationScreen(
|
builder: (context) =>
|
||||||
category: 'kontrakan',
|
const RecommendationScreen(category: 'kontrakan'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -210,9 +203,8 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => const RecommendationScreen(
|
builder: (context) =>
|
||||||
category: 'laundry',
|
const RecommendationScreen(category: 'laundry'),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -243,11 +235,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.search, color: Colors.grey[600], size: 20),
|
||||||
Icons.search,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Pencarian Cepat',
|
'Pencarian Cepat',
|
||||||
|
|
@ -272,11 +260,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.search, color: Colors.grey[400], size: 20),
|
||||||
Icons.search,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Cari kontrakan atau laundry...',
|
'Cari kontrakan atau laundry...',
|
||||||
|
|
@ -329,11 +313,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
color: color.withOpacity(0.1),
|
color: color.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(icon, color: color, size: 32),
|
||||||
icon,
|
|
||||||
color: color,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -351,10 +331,7 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -396,14 +373,8 @@ class _MobileHomeScreenState extends State<MobileHomeScreen> {
|
||||||
selectedFontSize: 12,
|
selectedFontSize: 12,
|
||||||
unselectedFontSize: 11,
|
unselectedFontSize: 11,
|
||||||
items: const [
|
items: const [
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Beranda'),
|
||||||
icon: Icon(Icons.home),
|
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Cari'),
|
||||||
label: 'Beranda',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
|
||||||
icon: Icon(Icons.search),
|
|
||||||
label: 'Cari',
|
|
||||||
),
|
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.bookmark),
|
icon: Icon(Icons.bookmark),
|
||||||
label: 'Booking',
|
label: 'Booking',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
import '../services/auth_service.dart';
|
import '../services/auth_service.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../login.dart';
|
import '../login.dart';
|
||||||
|
import 'edit_profile_screen.dart';
|
||||||
|
import 'change_password_screen.dart';
|
||||||
|
import 'booking_history_screen.dart';
|
||||||
|
|
||||||
class ProfileScreen extends StatefulWidget {
|
class ProfileScreen extends StatefulWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
|
|
@ -33,175 +36,252 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: const Color(0xFFF8F9FA),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(
|
||||||
: SafeArea(
|
child: CircularProgressIndicator(color: Color(0xFF1565C0)))
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _loadUser,
|
||||||
|
color: const Color(0xFF1565C0),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
// Header with Profile
|
// --- Profile Header ---
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(24),
|
decoration: const BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
gradient: LinearGradient(
|
||||||
gradient: const LinearGradient(
|
colors: [Color(0xFF0D47A1), Color(0xFF1976D2)],
|
||||||
colors: [Color(0xFF1565C0), Color(0xFF1976D2)],
|
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
borderRadius: BorderRadius.only(
|
||||||
BoxShadow(
|
bottomLeft: Radius.circular(32),
|
||||||
color: Colors.black.withValues(alpha: 0.1),
|
bottomRight: Radius.circular(32),
|
||||||
blurRadius: 8,
|
),
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: SafeArea(
|
||||||
children: [
|
bottom: false,
|
||||||
// Avatar
|
child: Padding(
|
||||||
Container(
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 32),
|
||||||
padding: const EdgeInsets.all(4),
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
shape: BoxShape.circle,
|
// Title
|
||||||
border: Border.all(color: Colors.white, width: 3),
|
const Row(
|
||||||
),
|
children: [
|
||||||
child: CircleAvatar(
|
Text(
|
||||||
radius: 50,
|
'Profil Saya',
|
||||||
backgroundColor: Colors.white,
|
style: TextStyle(
|
||||||
child: Text(
|
fontSize: 22,
|
||||||
_currentUser?.name.substring(0, 1).toUpperCase() ?? 'U',
|
fontWeight: FontWeight.bold,
|
||||||
style: const TextStyle(
|
color: Colors.white,
|
||||||
fontSize: 36,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
),
|
||||||
color: Color(0xFF1565C0),
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
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(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 16),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
_buildSection('Akun'),
|
// Akun section
|
||||||
_buildMenuItem(
|
_sectionLabel('Akun'),
|
||||||
icon: Icons.person,
|
const SizedBox(height: 8),
|
||||||
title: 'Edit Profil',
|
_menuCard([
|
||||||
subtitle: 'Ubah informasi profil Anda',
|
_menuTile(
|
||||||
onTap: () {
|
icon: Icons.person_outline,
|
||||||
// TODO: Navigate to edit profile
|
title: 'Edit Profil',
|
||||||
},
|
subtitle: 'Ubah nama, email, dan no. HP',
|
||||||
),
|
onTap: () async {
|
||||||
_buildMenuItem(
|
if (_currentUser == null) return;
|
||||||
icon: Icons.lock,
|
final result = await Navigator.push(
|
||||||
title: 'Ubah Password',
|
context,
|
||||||
subtitle: 'Ganti password akun Anda',
|
MaterialPageRoute(
|
||||||
onTap: () {
|
builder: (_) =>
|
||||||
// TODO: Navigate to change password
|
EditProfileScreen(user: _currentUser!),
|
||||||
},
|
),
|
||||||
),
|
);
|
||||||
const SizedBox(height: 16),
|
if (result == true) _loadUser();
|
||||||
_buildSection('Aktivitas'),
|
},
|
||||||
_buildMenuItem(
|
),
|
||||||
icon: Icons.bookmark,
|
_divider(),
|
||||||
title: 'Booking Saya',
|
_menuTile(
|
||||||
subtitle: 'Lihat riwayat booking Anda',
|
icon: Icons.lock_outline,
|
||||||
onTap: () {
|
title: 'Ubah Password',
|
||||||
// Already on tabs, maybe switch tab?
|
subtitle: 'Ganti password akun Anda',
|
||||||
},
|
onTap: () {
|
||||||
),
|
Navigator.push(
|
||||||
_buildMenuItem(
|
context,
|
||||||
icon: Icons.favorite,
|
MaterialPageRoute(
|
||||||
title: 'Favorit',
|
builder: (_) =>
|
||||||
subtitle: 'Kontrakan yang Anda favoritkan',
|
const ChangePasswordScreen(),
|
||||||
onTap: () {
|
),
|
||||||
// TODO: Navigate to favorites
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildMenuItem(
|
]),
|
||||||
icon: Icons.rate_review,
|
|
||||||
title: 'Review Saya',
|
const SizedBox(height: 20),
|
||||||
subtitle: 'Lihat semua review Anda',
|
|
||||||
onTap: () {
|
// Aktivitas section
|
||||||
// TODO: Navigate to my reviews
|
_sectionLabel('Aktivitas'),
|
||||||
},
|
const SizedBox(height: 8),
|
||||||
),
|
_menuCard([
|
||||||
const SizedBox(height: 16),
|
_menuTile(
|
||||||
_buildSection('Pengaturan'),
|
icon: Icons.receipt_long_outlined,
|
||||||
_buildMenuItem(
|
title: 'Booking Saya',
|
||||||
icon: Icons.notifications,
|
subtitle: 'Lihat riwayat booking Anda',
|
||||||
title: 'Notifikasi',
|
onTap: () {
|
||||||
subtitle: 'Atur preferensi notifikasi',
|
Navigator.push(
|
||||||
onTap: () {
|
context,
|
||||||
// TODO: Navigate to notifications settings
|
MaterialPageRoute(
|
||||||
},
|
builder: (_) =>
|
||||||
),
|
const BookingHistoryScreen(),
|
||||||
_buildMenuItem(
|
),
|
||||||
icon: Icons.language,
|
);
|
||||||
title: 'Bahasa',
|
},
|
||||||
subtitle: 'Pilih bahasa aplikasi',
|
),
|
||||||
trailing: const Text('Indonesia', style: TextStyle(color: Colors.grey)),
|
]),
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 20),
|
||||||
_buildSection('Bantuan'),
|
|
||||||
_buildMenuItem(
|
// Informasi section
|
||||||
icon: Icons.help,
|
_sectionLabel('Informasi'),
|
||||||
title: 'Pusat Bantuan',
|
const SizedBox(height: 8),
|
||||||
subtitle: 'FAQ dan panduan penggunaan',
|
_menuCard([
|
||||||
onTap: () {
|
_menuTile(
|
||||||
// TODO: Navigate to help center
|
icon: Icons.info_outline,
|
||||||
},
|
title: 'Tentang Aplikasi',
|
||||||
),
|
subtitle: 'SPK Kontrakan v1.0.0',
|
||||||
_buildMenuItem(
|
onTap: _showAboutDialog,
|
||||||
icon: Icons.info,
|
),
|
||||||
title: 'Tentang Aplikasi',
|
]),
|
||||||
subtitle: 'Versi 1.0.0',
|
|
||||||
onTap: () {
|
const SizedBox(height: 28),
|
||||||
_showAboutDialog();
|
|
||||||
},
|
// Logout
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
// Logout Button
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton.icon(
|
height: 52,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
onPressed: _handleLogout,
|
onPressed: _handleLogout,
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout, size: 20),
|
||||||
label: const Text('Logout'),
|
label: const Text(
|
||||||
style: ElevatedButton.styleFrom(
|
'Keluar dari Akun',
|
||||||
backgroundColor: Colors.red,
|
style: TextStyle(
|
||||||
foregroundColor: Colors.white,
|
fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red.shade600,
|
||||||
|
side: BorderSide(color: Colors.red.shade300),
|
||||||
shape: RoundedRectangleBorder(
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
|
padding: const EdgeInsets.only(left: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
text.toUpperCase(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.grey[700],
|
color: Colors.grey.shade500,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMenuItem({
|
Widget _menuCard(List<Widget> children) {
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
VoidCallback? onTap,
|
|
||||||
Widget? trailing,
|
|
||||||
}) {
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: Colors.black.withOpacity(0.04),
|
||||||
blurRadius: 4,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 2),
|
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,
|
onTap: onTap,
|
||||||
leading: Container(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
|
children: [
|
||||||
borderRadius: BorderRadius.circular(8),
|
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() {
|
void _showAboutDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 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'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Container(
|
||||||
'Sistem Pendukung Keputusan',
|
padding: const EdgeInsets.all(16),
|
||||||
style: TextStyle(
|
decoration: BoxDecoration(
|
||||||
fontSize: 16,
|
color: const Color(0xFF1565C0).withOpacity(0.08),
|
||||||
fontWeight: FontWeight.w600,
|
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(
|
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(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14, color: Colors.grey.shade600, height: 1.5),
|
||||||
color: Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildInfoRow('Versi', '1.0.0'),
|
_aboutRow('Developer', 'SPK Team'),
|
||||||
_buildInfoRow('Developer', 'SPK Team'),
|
_aboutRow('Tahun', '2026'),
|
||||||
_buildInfoRow('Tahun', '2026'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -345,27 +442,19 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoRow(String label, String value) {
|
Widget _aboutRow(String label, String value) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(label,
|
||||||
label,
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
|
||||||
style: TextStyle(
|
Text(value,
|
||||||
fontSize: 14,
|
style: const TextStyle(
|
||||||
color: Colors.grey[600],
|
fontSize: 13,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
),
|
color: Colors.black87)),
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -375,32 +464,30 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.logout, color: Colors.red[700]),
|
Icon(Icons.logout, color: Colors.red.shade600, size: 24),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 10),
|
||||||
const Text('Konfirmasi Logout'),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Batal'),
|
child:
|
||||||
|
Text('Batal', style: TextStyle(color: Colors.grey.shade600)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red.shade600,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
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) {
|
if (mounted) {
|
||||||
Navigator.pushAndRemoveUntil(
|
Navigator.pushAndRemoveUntil(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => const LoginScreen()),
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
(route) => false,
|
(route) => false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -173,4 +173,82 @@ class AuthService {
|
||||||
return null;
|
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:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../models/booking.dart';
|
import '../models/booking.dart';
|
||||||
import 'auth_service.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({
|
Future<Map<String, dynamic>> createBooking({
|
||||||
required int kontrakanId,
|
required int kontrakanId,
|
||||||
required DateTime tanggalMulai,
|
required DateTime tanggalMulai,
|
||||||
required int durasiBulan,
|
required int durasiBulan,
|
||||||
String? catatan,
|
String? catatan,
|
||||||
|
File? paymentProof,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await http.post(
|
// Validate payment proof is provided
|
||||||
Uri.parse('${AppConfig.baseUrl}/bookings'),
|
if (paymentProof == null) {
|
||||||
headers: _headers,
|
return {
|
||||||
body: jsonEncode({
|
'success': false,
|
||||||
'kontrakan_id': kontrakanId,
|
'message': 'Bukti pembayaran wajib diunggah',
|
||||||
'tanggal_mulai': tanggalMulai.toIso8601String().split('T')[0],
|
};
|
||||||
'durasi_bulan': durasiBulan,
|
}
|
||||||
'catatan': catatan,
|
|
||||||
}),
|
// 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);
|
final data = jsonDecode(response.body);
|
||||||
|
|
||||||
if (response.statusCode == 201 && data['success'] == true) {
|
if (response.statusCode == 201 && data['success'] == true) {
|
||||||
|
|
@ -124,4 +144,49 @@ class BookingService {
|
||||||
return null;
|
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) {
|
if (permission == LocationPermission.denied) {
|
||||||
final result = await Geolocator.requestPermission();
|
final result = await Geolocator.requestPermission();
|
||||||
return result == LocationPermission.whileInUse ||
|
return result == LocationPermission.whileInUse ||
|
||||||
result == LocationPermission.always;
|
result == LocationPermission.always;
|
||||||
} else if (permission == LocationPermission.deniedForever) {
|
} else if (permission == LocationPermission.deniedForever) {
|
||||||
// Permission permanently denied, open app settings
|
// Permission permanently denied, open app settings
|
||||||
await Geolocator.openLocationSettings();
|
await Geolocator.openLocationSettings();
|
||||||
|
|
@ -55,7 +55,8 @@ class LocationService {
|
||||||
final dLat = _degreesToRadians(lat2 - lat1);
|
final dLat = _degreesToRadians(lat2 - lat1);
|
||||||
final dLon = _degreesToRadians(lon2 - lon1);
|
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(lat1)) *
|
||||||
cos(_degreesToRadians(lat2)) *
|
cos(_degreesToRadians(lat2)) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
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 =
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import file_selector_macos
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -113,6 +121,38 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -142,6 +182,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
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:
|
flutter_spinkit:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -240,6 +288,70 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
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:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -312,6 +424,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
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:
|
native_toolchain_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ dependencies:
|
||||||
|
|
||||||
# Image picker & display
|
# Image picker & display
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
|
image_picker: ^1.1.2
|
||||||
|
|
||||||
# State management
|
# State management
|
||||||
provider: ^6.1.1
|
provider: ^6.1.1
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@
|
||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
GeolocatorWindowsRegisterWithRegistrar(
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
file_selector_windows
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue