From 5886d148ab0e36a56dca2e93913a3e4bfef11cfb Mon Sep 17 00:00:00 2001 From: micko samawa Date: Sun, 22 Feb 2026 20:46:44 +0700 Subject: [PATCH] Rewrite ProfileScreen: professional UI & navigation; add EditProfile and ChangePassword screens --- spk_mobile/lib/config/app_config.dart | 5 +- spk_mobile/lib/models/booking.dart | 37 +- spk_mobile/lib/models/kontrakan.dart | 2 +- .../lib/screens/booking_form_screen.dart | 355 +++- .../lib/screens/booking_history_screen.dart | 230 ++- .../lib/screens/change_password_screen.dart | 243 +++ .../lib/screens/edit_profile_screen.dart | 231 +++ .../lib/screens/kontrakan_detail_screen.dart | 35 +- .../lib/screens/laundry_detail_screen.dart | 41 +- .../lib/screens/mobile_home_screen.dart | 57 +- spk_mobile/lib/screens/profile_screen.dart | 599 +++--- .../lib/screens/recommendation_screen.dart | 1647 ++++++++++++----- spk_mobile/lib/services/auth_service.dart | 78 + spk_mobile/lib/services/booking_service.dart | 85 +- spk_mobile/lib/services/location_service.dart | 21 +- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + spk_mobile/pubspec.lock | 120 ++ spk_mobile/pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 22 files changed, 2963 insertions(+), 835 deletions(-) create mode 100644 spk_mobile/lib/screens/change_password_screen.dart create mode 100644 spk_mobile/lib/screens/edit_profile_screen.dart diff --git a/spk_mobile/lib/config/app_config.dart b/spk_mobile/lib/config/app_config.dart index 11e7dad..f8c5398 100644 --- a/spk_mobile/lib/config/app_config.dart +++ b/spk_mobile/lib/config/app_config.dart @@ -3,9 +3,12 @@ class AppConfig { // Windows Desktop: http://localhost:8000 // Android Emulator: http://10.0.2.2:8000 // iOS Simulator: http://localhost:8000 - // Real Device: http://192.168.x.x:8000 (IP komputer Anda) + // Real Device: http://192.168.18.16:8000 (IP komputer Anda) + // CATATAN: Untuk real device, ganti IP sesuai dengan IP komputer Anda + // Cek IP dengan: ipconfig (di Windows) atau ifconfig (di Linux/Mac) static const String baseUrl = 'http://192.168.18.16:8000/api'; + static const String storageUrl = 'http://192.168.18.16:8000/storage'; // Timeouts static const Duration connectionTimeout = Duration(seconds: 10); diff --git a/spk_mobile/lib/models/booking.dart b/spk_mobile/lib/models/booking.dart index 20d3d8b..8a4d76e 100644 --- a/spk_mobile/lib/models/booking.dart +++ b/spk_mobile/lib/models/booking.dart @@ -4,11 +4,12 @@ class Booking { final int kontrakanId; final DateTime tanggalMulai; final DateTime tanggalSelesai; - final int durasiBulan; final double totalBiaya; final String status; final String? catatan; final dynamic kontrakan; // Can be Map or Kontrakan object + final String paymentStatus; + final String? paymentProof; Booking({ required this.id, @@ -16,25 +17,33 @@ class Booking { required this.kontrakanId, required this.tanggalMulai, required this.tanggalSelesai, - required this.durasiBulan, required this.totalBiaya, required this.status, this.catatan, this.kontrakan, + this.paymentStatus = 'unpaid', + this.paymentProof, }); factory Booking.fromJson(Map json) { + // Support both old field names and actual DB column names + final startDate = json['start_date'] ?? json['tanggal_mulai']; + final endDate = json['end_date'] ?? json['tanggal_selesai']; + final amount = json['amount'] ?? json['total_biaya']; + final notes = json['notes'] ?? json['catatan']; + return Booking( - id: json['id'] ?? 0, - userId: json['user_id'] ?? 0, - kontrakanId: json['kontrakan_id'] ?? 0, - tanggalMulai: DateTime.parse(json['tanggal_mulai']), - tanggalSelesai: DateTime.parse(json['tanggal_selesai']), - durasiBulan: json['durasi_bulan'] ?? 0, - totalBiaya: double.tryParse(json['total_biaya']?.toString() ?? '0') ?? 0, + id: int.tryParse(json['id']?.toString() ?? '0') ?? 0, + userId: int.tryParse(json['user_id']?.toString() ?? '0') ?? 0, + kontrakanId: int.tryParse(json['kontrakan_id']?.toString() ?? '0') ?? 0, + tanggalMulai: DateTime.parse(startDate), + tanggalSelesai: DateTime.parse(endDate), + totalBiaya: double.tryParse(amount?.toString() ?? '0') ?? 0, status: json['status'] ?? 'pending', - catatan: json['catatan'], + catatan: notes, kontrakan: json['kontrakan'], + paymentStatus: json['payment_status'] ?? 'unpaid', + paymentProof: json['payment_proof'], ); } @@ -43,6 +52,14 @@ class Booking { return 'Rp ${totalBiaya.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; } + // Computed duration in months from start and end date + int get durasiBulan { + return ((tanggalSelesai.year - tanggalMulai.year) * 12 + + tanggalSelesai.month - + tanggalMulai.month) + .clamp(1, 99); + } + // Status badge color String get statusColor { switch (status.toLowerCase()) { diff --git a/spk_mobile/lib/models/kontrakan.dart b/spk_mobile/lib/models/kontrakan.dart index 5dac556..29df5f4 100644 --- a/spk_mobile/lib/models/kontrakan.dart +++ b/spk_mobile/lib/models/kontrakan.dart @@ -42,7 +42,7 @@ class Kontrakan { // Backend uses 'jarak' column, convert meters to km jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000; } - + return Kontrakan( id: json['id'] ?? 0, nama: json['nama'] ?? '', diff --git a/spk_mobile/lib/screens/booking_form_screen.dart b/spk_mobile/lib/screens/booking_form_screen.dart index 5e83a4f..017d226 100644 --- a/spk_mobile/lib/screens/booking_form_screen.dart +++ b/spk_mobile/lib/screens/booking_form_screen.dart @@ -1,5 +1,8 @@ +import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:image_picker/image_picker.dart'; import '../models/kontrakan.dart'; import '../services/booking_service.dart'; import '../services/auth_service.dart'; @@ -17,12 +20,14 @@ class _BookingFormScreenState extends State { final _bookingService = BookingService(); final _authService = AuthService(); final _catatanController = TextEditingController(); + final _imagePicker = ImagePicker(); DateTime? _tanggalMulai; int _durasiBulan = 1; bool _isSubmitting = false; + File? _paymentProofImage; - final _currencyFormat = NumberFormat.currency( + final _currencyFormat = intl.NumberFormat.currency( locale: 'id_ID', symbol: 'Rp ', decimalDigits: 0, @@ -30,6 +35,15 @@ class _BookingFormScreenState extends State { double get _totalBiaya => widget.kontrakan.harga * _durasiBulan; + @override + void initState() { + super.initState(); + // Initialize Indonesian locale for date formatting + initializeDateFormatting('id_ID', null).catchError((_) { + // Ignore if already initialized + }); + } + @override void dispose() { _catatanController.dispose(); @@ -63,6 +77,59 @@ class _BookingFormScreenState extends State { } } + Future _pickPaymentProof() async { + final source = await showModalBottomSheet( + 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 _submitBooking() async { if (_tanggalMulai == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -74,6 +141,16 @@ class _BookingFormScreenState extends State { return; } + if (_paymentProofImage == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Bukti pembayaran wajib diunggah'), + backgroundColor: Colors.red, + ), + ); + return; + } + if (!_authService.isAuthenticated) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -102,15 +179,23 @@ class _BookingFormScreenState extends State { children: [ _buildConfirmRow('Kontrakan', widget.kontrakan.nama), const SizedBox(height: 8), - _buildConfirmRow('Tanggal Mulai', DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!)), + _buildConfirmRow( + 'Tanggal Mulai', + intl.DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!), + ), const SizedBox(height: 8), _buildConfirmRow('Durasi', '$_durasiBulan bulan'), const SizedBox(height: 8), - _buildConfirmRow('Total Biaya', _currencyFormat.format(_totalBiaya)), + _buildConfirmRow( + 'Total Biaya', + _currencyFormat.format(_totalBiaya), + ), if (_catatanController.text.isNotEmpty) ...[ const SizedBox(height: 8), _buildConfirmRow('Catatan', _catatanController.text), ], + const SizedBox(height: 8), + _buildConfirmRow('Bukti Bayar', 'Foto terlampir'), ], ), actions: [ @@ -123,7 +208,9 @@ class _BookingFormScreenState extends State { style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF667eea), foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), child: const Text('Konfirmasi'), ), @@ -139,7 +226,10 @@ class _BookingFormScreenState extends State { kontrakanId: widget.kontrakan.id, tanggalMulai: _tanggalMulai!, durasiBulan: _durasiBulan, - catatan: _catatanController.text.isNotEmpty ? _catatanController.text : null, + catatan: _catatanController.text.isNotEmpty + ? _catatanController.text + : null, + paymentProof: _paymentProofImage, ); setState(() => _isSubmitting = false); @@ -151,7 +241,9 @@ class _BookingFormScreenState extends State { context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -162,7 +254,11 @@ class _BookingFormScreenState extends State { color: Colors.green.withOpacity(0.1), shape: BoxShape.circle, ), - child: const Icon(Icons.check_circle, color: Colors.green, size: 64), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), ), const SizedBox(height: 16), const Text( @@ -190,7 +286,9 @@ class _BookingFormScreenState extends State { backgroundColor: const Color(0xFF667eea), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), child: const Text('OK'), ), @@ -214,10 +312,16 @@ class _BookingFormScreenState extends State { children: [ SizedBox( width: 100, - child: Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[600])), + child: Text( + label, + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), ), Expanded( - child: Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + child: Text( + value, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), ), ], ); @@ -261,19 +365,29 @@ class _BookingFormScreenState extends State { const SizedBox(height: 4), Row( children: [ - const Icon(Icons.location_on, size: 16, color: Colors.white70), + const Icon( + Icons.location_on, + size: 16, + color: Colors.white70, + ), const SizedBox(width: 4), Expanded( child: Text( widget.kontrakan.alamat, - style: const TextStyle(fontSize: 13, color: Colors.white70), + style: const TextStyle( + fontSize: 13, + color: Colors.white70, + ), ), ), ], ), const SizedBox(height: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(20), @@ -305,27 +419,43 @@ class _BookingFormScreenState extends State { borderRadius: BorderRadius.circular(12), child: Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(12), - border: Border.all(color: _tanggalMulai != null ? const Color(0xFF667eea) : Colors.grey[300]!), + border: Border.all( + color: _tanggalMulai != null + ? const Color(0xFF667eea) + : Colors.grey[300]!, + ), ), child: Row( children: [ Icon( Icons.date_range, - color: _tanggalMulai != null ? const Color(0xFF667eea) : Colors.grey[400], + color: _tanggalMulai != null + ? const Color(0xFF667eea) + : Colors.grey[400], ), const SizedBox(width: 12), Text( _tanggalMulai != null - ? DateFormat('dd MMMM yyyy', 'id_ID').format(_tanggalMulai!) + ? intl.DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).format(_tanggalMulai!) : 'Pilih tanggal...', style: TextStyle( fontSize: 15, - color: _tanggalMulai != null ? Colors.black87 : Colors.grey[500], - fontWeight: _tanggalMulai != null ? FontWeight.w600 : FontWeight.normal, + color: _tanggalMulai != null + ? Colors.black87 + : Colors.grey[500], + fontWeight: _tanggalMulai != null + ? FontWeight.w600 + : FontWeight.normal, ), ), ], @@ -346,21 +476,31 @@ class _BookingFormScreenState extends State { decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFF667eea).withOpacity(0.3)), + border: Border.all( + color: const Color(0xFF667eea).withOpacity(0.3), + ), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: _durasiBulan, isExpanded: true, - icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF667eea)), - style: const TextStyle(fontSize: 15, color: Colors.black87, fontWeight: FontWeight.w600), + icon: const Icon( + Icons.arrow_drop_down, + color: Color(0xFF667eea), + ), + style: const TextStyle( + fontSize: 15, + color: Colors.black87, + fontWeight: FontWeight.w600, + ), items: List.generate(12, (i) => i + 1).map((bulan) { return DropdownMenuItem( value: bulan, child: Text('$bulan bulan'), ); }).toList(), - onChanged: (val) => setState(() => _durasiBulan = val!), + onChanged: (val) => + setState(() => _durasiBulan = val!), ), ), ), @@ -378,7 +518,10 @@ class _BookingFormScreenState extends State { maxLines: 3, decoration: InputDecoration( hintText: 'Contoh: Saya mahasiswa Polije semester 4...', - hintStyle: TextStyle(fontSize: 13, color: Colors.grey[400]), + hintStyle: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), filled: true, fillColor: Colors.grey[100], border: OutlineInputBorder( @@ -391,7 +534,9 @@ class _BookingFormScreenState extends State { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFF667eea)), + borderSide: const BorderSide( + color: Color(0xFF667eea), + ), ), contentPadding: const EdgeInsets.all(14), ), @@ -400,36 +545,126 @@ class _BookingFormScreenState extends State { const SizedBox(height: 16), + // Bukti Pembayaran + _buildFormCard( + icon: Icons.receipt, + title: 'Bukti Pembayaran', + subtitle: 'Upload foto struk/bukti transfer pembayaran (Wajib)', + child: Column( + children: [ + if (_paymentProofImage != null) ...[ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.file( + _paymentProofImage!, + width: double.infinity, + height: 200, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 8, + right: 8, + child: InkWell( + onTap: () => setState(() => _paymentProofImage = null), + child: Container( + padding: const EdgeInsets.all(6), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, color: Colors.white, size: 18), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ], + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _pickPaymentProof, + icon: Icon( + _paymentProofImage != null ? Icons.change_circle : Icons.upload_file, + color: const Color(0xFF667eea), + ), + label: Text( + _paymentProofImage != null ? 'Ganti Foto' : 'Pilih Foto Bukti Pembayaran', + style: const TextStyle(color: Color(0xFF667eea)), + ), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFF667eea)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + // Ringkasan Biaya Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( gradient: LinearGradient( - colors: [const Color(0xFF667eea).withOpacity(0.05), Colors.white], + colors: [ + const Color(0xFF667eea).withOpacity(0.05), + Colors.white, + ], ), borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF667eea).withOpacity(0.2)), + border: Border.all( + color: const Color(0xFF667eea).withOpacity(0.2), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon(Icons.receipt_long, size: 20, color: const Color(0xFF667eea)), + Icon( + Icons.receipt_long, + size: 20, + color: const Color(0xFF667eea), + ), const SizedBox(width: 8), - Text('Ringkasan Biaya', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])), + Text( + 'Ringkasan Biaya', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), ], ), const SizedBox(height: 12), - _buildBiayaRow('Harga per bulan', widget.kontrakan.formattedHarga), + _buildBiayaRow( + 'Harga per bulan', + widget.kontrakan.formattedHarga, + ), const SizedBox(height: 6), _buildBiayaRow('Durasi sewa', '$_durasiBulan bulan'), const Divider(height: 20), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Total Biaya', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const Text( + 'Total Biaya', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), Text( _currencyFormat.format(_totalBiaya), style: const TextStyle( @@ -456,16 +691,31 @@ class _BookingFormScreenState extends State { backgroundColor: const Color(0xFF667eea), foregroundColor: Colors.white, disabledBackgroundColor: Colors.grey[400], - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), elevation: 3, ), child: _isSubmitting ? const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), SizedBox(width: 12), - Text('Memproses...', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + Text( + 'Memproses...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), ], ) : const Row( @@ -473,7 +723,13 @@ class _BookingFormScreenState extends State { children: [ Icon(Icons.bookmark_add, size: 22), SizedBox(width: 8), - Text('Booking Sekarang', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + Text( + 'Booking Sekarang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), ], ), ), @@ -501,7 +757,13 @@ class _BookingFormScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.08), blurRadius: 8, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -510,11 +772,21 @@ class _BookingFormScreenState extends State { children: [ Icon(icon, size: 20, color: const Color(0xFF667eea)), const SizedBox(width: 8), - Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])), + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), ], ), const SizedBox(height: 4), - Text(subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[500])), + Text( + subtitle, + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), const SizedBox(height: 12), child, ], @@ -527,7 +799,10 @@ class _BookingFormScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[600])), - Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + Text( + value, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), ], ); } diff --git a/spk_mobile/lib/screens/booking_history_screen.dart b/spk_mobile/lib/screens/booking_history_screen.dart index 88dea24..c1fa360 100644 --- a/spk_mobile/lib/screens/booking_history_screen.dart +++ b/spk_mobile/lib/screens/booking_history_screen.dart @@ -1,4 +1,7 @@ +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../config/app_config.dart'; import '../services/booking_service.dart'; import '../models/booking.dart'; @@ -12,11 +15,13 @@ class BookingHistoryScreen extends StatefulWidget { class _BookingHistoryScreenState extends State with SingleTickerProviderStateMixin { final _bookingService = BookingService(); + final _imagePicker = ImagePicker(); late TabController _tabController; List _activeBookings = []; List _pastBookings = []; bool _isLoading = true; + int? _uploadingBookingId; @override void initState() { @@ -45,6 +50,114 @@ class _BookingHistoryScreenState extends State }); } + Future _cancelBooking(int bookingId) async { + final confirm = await showDialog( + 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 _uploadPaymentProof(Booking booking) async { + // Show source dialog + final source = await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Unggah Bukti Pembayaran', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Pilih foto struk transfer atau bukti pembayaran lainnya', + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 16), + ListTile( + leading: const Icon( + Icons.photo_library, + color: Color(0xFF4CAF50), + ), + title: const Text('Pilih dari Galeri'), + onTap: () => Navigator.pop(ctx, ImageSource.gallery), + ), + ListTile( + leading: const Icon(Icons.camera_alt, color: Color(0xFF4CAF50)), + title: const Text('Ambil Foto'), + onTap: () => Navigator.pop(ctx, ImageSource.camera), + ), + ], + ), + ), + ), + ); + if (source == null) return; + + final picked = await _imagePicker.pickImage( + source: source, + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + ); + if (picked == null) return; + + setState(() => _uploadingBookingId = booking.id); + final result = await _bookingService.uploadPaymentProof( + booking.id, + File(picked.path), + ); + if (mounted) { + setState(() => _uploadingBookingId = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message'] ?? ''), + backgroundColor: result['success'] == true + ? Colors.green + : Colors.red, + duration: const Duration(seconds: 3), + ), + ); + if (result['success'] == true) _loadBookings(); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -330,14 +443,94 @@ class _BookingHistoryScreenState extends State ), ], ), + const SizedBox(height: 8), + // Payment status row + Row( + children: [ + Icon( + booking.paymentStatus == 'paid' + ? Icons.check_circle + : Icons.payment, + size: 16, + color: booking.paymentStatus == 'paid' + ? Colors.green + : Colors.orange, + ), + const SizedBox(width: 6), + Text( + booking.paymentStatus == 'paid' + ? 'Pembayaran: Lunas' + : 'Pembayaran: Belum Dibayar', + style: TextStyle( + fontSize: 13, + color: booking.paymentStatus == 'paid' + ? Colors.green + : Colors.orange, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + // Proof already uploaded notice + if (booking.paymentProof != null) ...[ + const SizedBox(height: 8), + GestureDetector( + onTap: () { + final url = '${AppConfig.storageUrl}/${booking.paymentProof}'; + showDialog( + context: context, + builder: (ctx) => Dialog( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppBar( + title: const Text('Bukti Pembayaran'), + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + Image.network(url, fit: BoxFit.contain), + ], + ), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.shade200), + ), + child: Row( + children: [ + const Icon(Icons.image, size: 18, color: Colors.green), + const SizedBox(width: 8), + const Text( + 'Bukti pembayaran sudah diunggah — Tap untuk lihat', + style: TextStyle(fontSize: 13, color: Colors.green), + ), + ], + ), + ), + ), + ], + // Actions if (booking.status == 'pending') ...[ const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: () { - // Cancel booking - }, + onPressed: () => _cancelBooking(booking.id), icon: const Icon(Icons.cancel), label: const Text('Batalkan Booking'), style: ElevatedButton.styleFrom( @@ -351,6 +544,37 @@ class _BookingHistoryScreenState extends State ), ), ], + 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), + ), + ), + ), + ), + ], ], ), ), diff --git a/spk_mobile/lib/screens/change_password_screen.dart b/spk_mobile/lib/screens/change_password_screen.dart new file mode 100644 index 0000000..06d7fe1 --- /dev/null +++ b/spk_mobile/lib/screens/change_password_screen.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import '../services/auth_service.dart'; + +class ChangePasswordScreen extends StatefulWidget { + const ChangePasswordScreen({super.key}); + + @override + State createState() => _ChangePasswordScreenState(); +} + +class _ChangePasswordScreenState extends State { + final _authService = AuthService(); + final _formKey = GlobalKey(); + 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 _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), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/spk_mobile/lib/screens/edit_profile_screen.dart b/spk_mobile/lib/screens/edit_profile_screen.dart new file mode 100644 index 0000000..770d1f2 --- /dev/null +++ b/spk_mobile/lib/screens/edit_profile_screen.dart @@ -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 createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + final _authService = AuthService(); + final _formKey = GlobalKey(); + 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 _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), + ), + ); + } +} diff --git a/spk_mobile/lib/screens/kontrakan_detail_screen.dart b/spk_mobile/lib/screens/kontrakan_detail_screen.dart index 854874c..40302c4 100644 --- a/spk_mobile/lib/screens/kontrakan_detail_screen.dart +++ b/spk_mobile/lib/screens/kontrakan_detail_screen.dart @@ -94,12 +94,16 @@ class _KontrakanDetailScreenState extends State { Icons.directions_walk, '${widget.kontrakan.jarakKampus} km dari kampus', ), - _buildInfoRow(Icons.bed, '${widget.kontrakan.jumlahKamar} Kamar'), + _buildInfoRow( + Icons.bed, + '${widget.kontrakan.jumlahKamar} Kamar', + ), const SizedBox(height: 20), // Location Detection Card - if (widget.kontrakan.latitude != null && widget.kontrakan.longitude != null) + if (widget.kontrakan.latitude != null && + widget.kontrakan.longitude != null) _buildLocationCard(), const SizedBox(height: 20), @@ -169,7 +173,9 @@ class _KontrakanDetailScreenState extends State { if (!authService.isAuthenticated) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Silakan login terlebih dahulu untuk booking'), + content: Text( + 'Silakan login terlebih dahulu untuk booking', + ), backgroundColor: Colors.red, ), ); @@ -178,7 +184,8 @@ class _KontrakanDetailScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (_) => BookingFormScreen(kontrakan: widget.kontrakan), + builder: (_) => + BookingFormScreen(kontrakan: widget.kontrakan), ), ); } @@ -196,7 +203,9 @@ class _KontrakanDetailScreenState extends State { ), ), child: Text( - widget.kontrakan.isAvailable ? 'Booking Sekarang' : 'Kontrakan Tidak Tersedia', + widget.kontrakan.isAvailable + ? 'Booking Sekarang' + : 'Kontrakan Tidak Tersedia', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ), @@ -242,11 +251,7 @@ class _KontrakanDetailScreenState extends State { children: [ Row( children: [ - Icon( - Icons.location_on, - color: Colors.purple, - size: 24, - ), + Icon(Icons.location_on, color: Colors.purple, size: 24), const SizedBox(width: 12), Text( 'Deteksi Lokasi Saya', @@ -286,10 +291,7 @@ class _KontrakanDetailScreenState extends State { children: [ Text( 'Jarak dari Lokasi Saya', - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), Text( distance! < 1 @@ -320,10 +322,7 @@ class _KontrakanDetailScreenState extends State { Expanded( child: Text( locationError!, - style: const TextStyle( - fontSize: 13, - color: Colors.red, - ), + style: const TextStyle(fontSize: 13, color: Colors.red), ), ), ], diff --git a/spk_mobile/lib/screens/laundry_detail_screen.dart b/spk_mobile/lib/screens/laundry_detail_screen.dart index a2b4770..2f172bb 100644 --- a/spk_mobile/lib/screens/laundry_detail_screen.dart +++ b/spk_mobile/lib/screens/laundry_detail_screen.dart @@ -90,7 +90,9 @@ class _LaundryDetailScreenState extends State { ), const SizedBox(width: 6), Text( - widget.laundry.rating.toStringAsFixed(1), + widget.laundry.rating.toStringAsFixed( + 1, + ), style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -119,12 +121,13 @@ class _LaundryDetailScreenState extends State { padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ - // Status Card + // Status Card _buildStatusCard(), const SizedBox(height: 16), // Location Detection Card - if (widget.laundry.latitude != null && widget.laundry.longitude != null) + if (widget.laundry.latitude != null && + widget.laundry.longitude != null) _buildLocationCard(), const SizedBox(height: 16), @@ -184,7 +187,8 @@ class _LaundryDetailScreenState extends State { width: double.infinity, height: 50, child: ElevatedButton.icon( - onPressed: () => _launchWhatsApp(widget.laundry.noWhatsapp!), + onPressed: () => + _launchWhatsApp(widget.laundry.noWhatsapp!), icon: const Icon(Icons.message, size: 24), label: const Text( 'Hubungi via WhatsApp', @@ -205,13 +209,16 @@ class _LaundryDetailScreenState extends State { const SizedBox(height: 12), ], - if (widget.laundry.latitude != null && widget.laundry.longitude != null) + if (widget.laundry.latitude != null && + widget.laundry.longitude != null) SizedBox( width: double.infinity, height: 50, child: OutlinedButton.icon( - onPressed: () => - _launchMaps(widget.laundry.latitude!, widget.laundry.longitude!), + onPressed: () => _launchMaps( + widget.laundry.latitude!, + widget.laundry.longitude!, + ), icon: const Icon(Icons.map, size: 24), label: const Text( 'Lihat di Maps', @@ -243,7 +250,9 @@ class _LaundryDetailScreenState extends State { } Widget _buildStatusCard() { - Color statusColor = widget.laundry.status == 'buka' ? Colors.green : Colors.red; + Color statusColor = widget.laundry.status == 'buka' + ? Colors.green + : Colors.red; if (widget.laundry.status == 'buka' && !widget.laundry.isOpen) { statusColor = Colors.orange; } @@ -310,11 +319,7 @@ class _LaundryDetailScreenState extends State { children: [ Row( children: [ - Icon( - Icons.location_on, - color: Colors.purple, - size: 24, - ), + Icon(Icons.location_on, color: Colors.purple, size: 24), const SizedBox(width: 12), Text( 'Deteksi Lokasi Saya', @@ -354,10 +359,7 @@ class _LaundryDetailScreenState extends State { children: [ Text( 'Jarak dari Lokasi Saya', - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), Text( distance! < 1 @@ -388,10 +390,7 @@ class _LaundryDetailScreenState extends State { Expanded( child: Text( locationError!, - style: const TextStyle( - fontSize: 13, - color: Colors.red, - ), + style: const TextStyle(fontSize: 13, color: Colors.red), ), ), ], diff --git a/spk_mobile/lib/screens/mobile_home_screen.dart b/spk_mobile/lib/screens/mobile_home_screen.dart index 38221ce..9213584 100644 --- a/spk_mobile/lib/screens/mobile_home_screen.dart +++ b/spk_mobile/lib/screens/mobile_home_screen.dart @@ -57,10 +57,7 @@ class _MobileHomeScreenState extends State { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [ - Color(0xFF1565C0), - Color(0xFF0D47A1), - ], + colors: [Color(0xFF1565C0), Color(0xFF0D47A1)], ), ), child: SafeArea( @@ -150,10 +147,7 @@ class _MobileHomeScreenState extends State { const SizedBox(height: 8), Text( 'Temukan rekomendasi terbaik untuk kebutuhan Anda', - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), ], ), @@ -191,15 +185,14 @@ class _MobileHomeScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => const RecommendationScreen( - category: 'kontrakan', - ), + builder: (context) => + const RecommendationScreen(category: 'kontrakan'), ), ); }, ), const SizedBox(height: 16), - + // Laundry Card _buildCategoryCard( title: 'Layanan Laundry', @@ -210,9 +203,8 @@ class _MobileHomeScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => const RecommendationScreen( - category: 'laundry', - ), + builder: (context) => + const RecommendationScreen(category: 'laundry'), ), ); }, @@ -243,11 +235,7 @@ class _MobileHomeScreenState extends State { children: [ Row( children: [ - Icon( - Icons.search, - color: Colors.grey[600], - size: 20, - ), + Icon(Icons.search, color: Colors.grey[600], size: 20), const SizedBox(width: 8), Text( 'Pencarian Cepat', @@ -272,11 +260,7 @@ class _MobileHomeScreenState extends State { ), child: Row( children: [ - Icon( - Icons.search, - color: Colors.grey[400], - size: 20, - ), + Icon(Icons.search, color: Colors.grey[400], size: 20), const SizedBox(width: 12), Text( 'Cari kontrakan atau laundry...', @@ -329,11 +313,7 @@ class _MobileHomeScreenState extends State { color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), - child: Icon( - icon, - color: color, - size: 32, - ), + child: Icon(icon, color: color, size: 32), ), const SizedBox(width: 16), Expanded( @@ -351,10 +331,7 @@ class _MobileHomeScreenState extends State { const SizedBox(height: 4), Text( subtitle, - style: TextStyle( - fontSize: 13, - color: Colors.grey[600], - ), + style: TextStyle(fontSize: 13, color: Colors.grey[600]), ), ], ), @@ -396,14 +373,8 @@ class _MobileHomeScreenState extends State { selectedFontSize: 12, unselectedFontSize: 11, items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home), - label: 'Beranda', - ), - BottomNavigationBarItem( - icon: Icon(Icons.search), - label: 'Cari', - ), + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Beranda'), + BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Cari'), BottomNavigationBarItem( icon: Icon(Icons.bookmark), label: 'Booking', @@ -448,4 +419,4 @@ class _MobileHomeScreenState extends State { } } } -} \ No newline at end of file +} diff --git a/spk_mobile/lib/screens/profile_screen.dart b/spk_mobile/lib/screens/profile_screen.dart index c19c3af..97f147f 100644 --- a/spk_mobile/lib/screens/profile_screen.dart +++ b/spk_mobile/lib/screens/profile_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import '../services/auth_service.dart'; import '../models/user.dart'; import '../login.dart'; +import 'edit_profile_screen.dart'; +import 'change_password_screen.dart'; +import 'booking_history_screen.dart'; class ProfileScreen extends StatefulWidget { const ProfileScreen({super.key}); @@ -33,175 +36,252 @@ class _ProfileScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF5F5F5), + backgroundColor: const Color(0xFFF8F9FA), body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : SafeArea( + ? const Center( + child: CircularProgressIndicator(color: Color(0xFF1565C0))) + : RefreshIndicator( + onRefresh: _loadUser, + color: const Color(0xFF1565C0), child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // Header with Profile + // --- Profile Header --- SliverToBoxAdapter( child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF1565C0), Color(0xFF1976D2)], + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF0D47A1), Color(0xFF1976D2)], begin: Alignment.topLeft, end: Alignment.bottomRight, ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(32), + bottomRight: Radius.circular(32), + ), ), - child: Column( - children: [ - // Avatar - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 3), - ), - child: CircleAvatar( - radius: 50, - backgroundColor: Colors.white, - child: Text( - _currentUser?.name.substring(0, 1).toUpperCase() ?? 'U', - style: const TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Color(0xFF1565C0), - ), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 32), + child: Column( + children: [ + // Title + const Row( + children: [ + Text( + 'Profil Saya', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], ), - ), + const SizedBox(height: 24), + // Avatar & Info + Row( + children: [ + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, width: 2.5), + ), + child: CircleAvatar( + radius: 38, + backgroundColor: + Colors.white.withOpacity(0.95), + child: Text( + _currentUser?.name + .substring(0, 1) + .toUpperCase() ?? + 'U', + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Color(0xFF0D47A1), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + _currentUser?.name ?? 'User', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.email_outlined, + size: 14, + color: Colors.white + .withOpacity(0.8)), + const SizedBox(width: 6), + Expanded( + child: Text( + _currentUser?.email ?? '', + style: TextStyle( + fontSize: 13, + color: Colors.white + .withOpacity(0.85), + ), + maxLines: 1, + overflow: + TextOverflow.ellipsis, + ), + ), + ], + ), + if (_currentUser?.phone != null && + _currentUser!.phone!.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( + children: [ + Icon(Icons.phone_outlined, + size: 14, + color: Colors.white + .withOpacity(0.8)), + const SizedBox(width: 6), + Text( + _currentUser!.phone!, + style: TextStyle( + fontSize: 13, + color: Colors.white + .withOpacity(0.85), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ], ), - const SizedBox(height: 16), - Text( - _currentUser?.name ?? 'User', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - const SizedBox(height: 4), - Text( - _currentUser?.email ?? '', - style: TextStyle( - fontSize: 16, - color: Colors.white.withValues(alpha: 0.9), - ), - ), - ], + ), ), ), ), - // Menu Items + // --- Menu Sections --- SliverPadding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 24, 16, 16), sliver: SliverList( delegate: SliverChildListDelegate([ - _buildSection('Akun'), - _buildMenuItem( - icon: Icons.person, - title: 'Edit Profil', - subtitle: 'Ubah informasi profil Anda', - onTap: () { - // TODO: Navigate to edit profile - }, - ), - _buildMenuItem( - icon: Icons.lock, - title: 'Ubah Password', - subtitle: 'Ganti password akun Anda', - onTap: () { - // TODO: Navigate to change password - }, - ), - const SizedBox(height: 16), - _buildSection('Aktivitas'), - _buildMenuItem( - icon: Icons.bookmark, - title: 'Booking Saya', - subtitle: 'Lihat riwayat booking Anda', - onTap: () { - // Already on tabs, maybe switch tab? - }, - ), - _buildMenuItem( - icon: Icons.favorite, - title: 'Favorit', - subtitle: 'Kontrakan yang Anda favoritkan', - onTap: () { - // TODO: Navigate to favorites - }, - ), - _buildMenuItem( - icon: Icons.rate_review, - title: 'Review Saya', - subtitle: 'Lihat semua review Anda', - onTap: () { - // TODO: Navigate to my reviews - }, - ), - const SizedBox(height: 16), - _buildSection('Pengaturan'), - _buildMenuItem( - icon: Icons.notifications, - title: 'Notifikasi', - subtitle: 'Atur preferensi notifikasi', - onTap: () { - // TODO: Navigate to notifications settings - }, - ), - _buildMenuItem( - icon: Icons.language, - title: 'Bahasa', - subtitle: 'Pilih bahasa aplikasi', - trailing: const Text('Indonesia', style: TextStyle(color: Colors.grey)), - ), - const SizedBox(height: 16), - _buildSection('Bantuan'), - _buildMenuItem( - icon: Icons.help, - title: 'Pusat Bantuan', - subtitle: 'FAQ dan panduan penggunaan', - onTap: () { - // TODO: Navigate to help center - }, - ), - _buildMenuItem( - icon: Icons.info, - title: 'Tentang Aplikasi', - subtitle: 'Versi 1.0.0', - onTap: () { - _showAboutDialog(); - }, - ), - const SizedBox(height: 24), - // Logout Button + // Akun section + _sectionLabel('Akun'), + const SizedBox(height: 8), + _menuCard([ + _menuTile( + icon: Icons.person_outline, + title: 'Edit Profil', + subtitle: 'Ubah nama, email, dan no. HP', + onTap: () async { + if (_currentUser == null) return; + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + EditProfileScreen(user: _currentUser!), + ), + ); + if (result == true) _loadUser(); + }, + ), + _divider(), + _menuTile( + icon: Icons.lock_outline, + title: 'Ubah Password', + subtitle: 'Ganti password akun Anda', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const ChangePasswordScreen(), + ), + ); + }, + ), + ]), + + const SizedBox(height: 20), + + // Aktivitas section + _sectionLabel('Aktivitas'), + const SizedBox(height: 8), + _menuCard([ + _menuTile( + icon: Icons.receipt_long_outlined, + title: 'Booking Saya', + subtitle: 'Lihat riwayat booking Anda', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const BookingHistoryScreen(), + ), + ); + }, + ), + ]), + + const SizedBox(height: 20), + + // Informasi section + _sectionLabel('Informasi'), + const SizedBox(height: 8), + _menuCard([ + _menuTile( + icon: Icons.info_outline, + title: 'Tentang Aplikasi', + subtitle: 'SPK Kontrakan v1.0.0', + onTap: _showAboutDialog, + ), + ]), + + const SizedBox(height: 28), + + // Logout SizedBox( width: double.infinity, - child: ElevatedButton.icon( + height: 52, + child: OutlinedButton.icon( onPressed: _handleLogout, - icon: const Icon(Icons.logout), - label: const Text('Logout'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), + icon: const Icon(Icons.logout, size: 20), + label: const Text( + 'Keluar dari Akun', + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600), + ), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade600, + side: BorderSide(color: Colors.red.shade300), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), ), ), - const SizedBox(height: 24), + + const SizedBox(height: 32), ]), ), ), @@ -211,128 +291,145 @@ class _ProfileScreenState extends State { ); } - Widget _buildSection(String title) { + // --- Helper Widgets --- + + Widget _sectionLabel(String text) { return Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8), + padding: const EdgeInsets.only(left: 4), child: Text( - title, + text.toUpperCase(), style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.grey[700], - letterSpacing: 0.5, + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.grey.shade500, + letterSpacing: 1.2, ), ), ); } - Widget _buildMenuItem({ - required IconData icon, - required String title, - required String subtitle, - VoidCallback? onTap, - Widget? trailing, - }) { + Widget _menuCard(List children) { return Container( - margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 4, + color: Colors.black.withOpacity(0.04), + blurRadius: 10, offset: const Offset(0, 2), ), ], ), - child: ListTile( + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Column(children: children), + ), + ); + } + + Widget _menuTile({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return Material( + color: Colors.transparent, + child: InkWell( onTap: onTap, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFF1565C0).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: const Color(0xFF1565C0).withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: const Color(0xFF1565C0), size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A2E), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 22), + ], ), - child: Icon(icon, color: const Color(0xFF1565C0), size: 24), - ), - title: Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - subtitle: Text( - subtitle, - style: TextStyle( - fontSize: 13, - color: Colors.grey[600], - ), - ), - trailing: trailing ?? - (onTap != null - ? const Icon(Icons.chevron_right, color: Colors.grey) - : null), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, ), ), ); } + Widget _divider() { + return Divider( + height: 1, thickness: 0.5, indent: 72, color: Colors.grey.shade200); + } + + // --- Dialogs --- + void _showAboutDialog() { showDialog( context: context, builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFF1565C0).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.home_work, - color: Color(0xFF1565C0), - size: 32, - ), - ), - const SizedBox(width: 12), - const Text('SPK Kontrakan'), - ], - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 16), content: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Sistem Pendukung Keputusan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1565C0).withOpacity(0.08), + borderRadius: BorderRadius.circular(16), ), + child: const Icon(Icons.home_work, + color: Color(0xFF1565C0), size: 40), ), - const SizedBox(height: 8), + const SizedBox(height: 16), + const Text( + 'SPK Kontrakan', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), Text( - 'Aplikasi untuk membantu mahasiswa menemukan kontrakan terbaik dengan metode SAW (Simple Additive Weighting)', + 'Versi 1.0.0', + style: TextStyle(fontSize: 13, color: Colors.grey.shade500), + ), + const SizedBox(height: 16), + Text( + 'Sistem Pendukung Keputusan untuk membantu mahasiswa menemukan kontrakan terbaik menggunakan metode SAW.', + textAlign: TextAlign.center, style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), + fontSize: 14, color: Colors.grey.shade600, height: 1.5), ), const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), - _buildInfoRow('Versi', '1.0.0'), - _buildInfoRow('Developer', 'SPK Team'), - _buildInfoRow('Tahun', '2026'), + _aboutRow('Developer', 'SPK Team'), + _aboutRow('Tahun', '2026'), ], ), actions: [ @@ -345,27 +442,19 @@ class _ProfileScreenState extends State { ); } - Widget _buildInfoRow(String label, String value) { + Widget _aboutRow(String label, String value) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(vertical: 3), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - label, - style: TextStyle( - fontSize: 14, - color: Colors.grey[600], - ), - ), - Text( - value, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), + Text(label, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600)), + Text(value, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.black87)), ], ), ); @@ -375,32 +464,30 @@ class _ProfileScreenState extends State { final confirm = await showDialog( context: context, builder: (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: Row( children: [ - Icon(Icons.logout, color: Colors.red[700]), - const SizedBox(width: 12), - const Text('Konfirmasi Logout'), + Icon(Icons.logout, color: Colors.red.shade600, size: 24), + const SizedBox(width: 10), + const Text('Keluar', style: TextStyle(fontSize: 18)), ], ), - content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'), + content: const Text('Apakah Anda yakin ingin keluar dari akun?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), - child: const Text('Batal'), + child: + Text('Batal', style: TextStyle(color: Colors.grey.shade600)), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, + backgroundColor: Colors.red.shade600, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + borderRadius: BorderRadius.circular(10)), ), - child: const Text('Logout'), + child: const Text('Keluar'), ), ], ), @@ -411,7 +498,7 @@ class _ProfileScreenState extends State { if (mounted) { Navigator.pushAndRemoveUntil( context, - MaterialPageRoute(builder: (context) => const LoginScreen()), + MaterialPageRoute(builder: (_) => const LoginScreen()), (route) => false, ); } diff --git a/spk_mobile/lib/screens/recommendation_screen.dart b/spk_mobile/lib/screens/recommendation_screen.dart index e23119a..fc8d0e7 100644 --- a/spk_mobile/lib/screens/recommendation_screen.dart +++ b/spk_mobile/lib/screens/recommendation_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'dart:async'; import 'dart:convert'; import 'package:geolocator/geolocator.dart'; import '../config/app_config.dart'; @@ -12,7 +13,7 @@ class RecommendationScreen extends StatefulWidget { final String category; const RecommendationScreen({Key? key, required this.category}) - : super(key: key); + : super(key: key); @override State createState() => _RecommendationScreenState(); @@ -23,6 +24,8 @@ class _RecommendationScreenState extends State { bool _hasCalculated = false; List _recommendations = []; String? _errorMessage; + bool _noData = + false; // true = memang tidak ada data, false = filter terlalu ketat // Bobot values (percentage, total must = 100) // Default: profil mahasiswa @@ -67,49 +70,67 @@ class _RecommendationScreenState extends State { } /// Auto-balance: when one bobot changes, redistribute the remaining - /// percentage proportionally among the other three (min 10% each). + /// percentage proportionally among the other three (min 10% each), + /// always snapping to multiples of 5 so displayed values match actual sum. void _updateBobot(int index, int newValue) { setState(() { - List bobots = [_bobotHarga, _bobotJarak, _bobotKriteria3, _bobotKriteria4]; - int oldValue = bobots[index]; - if (newValue == oldValue) return; + List bobots = [ + _bobotHarga, + _bobotJarak, + _bobotKriteria3, + _bobotKriteria4, + ]; + if (newValue == bobots[index]) return; - // Clamp newValue: max = 100 - 3*10 = 70 - newValue = newValue.clamp(10, 70); + // Clamp newValue to valid range and snap to multiple of 5 + newValue = ((newValue.clamp(10, 70)) ~/ 5) * 5; bobots[index] = newValue; int remaining = 100 - newValue; - // Indices of the other 3 bobots List otherIdx = [0, 1, 2, 3].where((i) => i != index).toList(); int otherSum = otherIdx.fold(0, (sum, i) => sum + bobots[i]); + List newOther = List.filled(otherIdx.length, 0); + if (otherSum == 0) { - // Edge case: distribute equally + // Edge case: distribute equally in steps of 5 + int share = ((remaining / otherIdx.length / 5).round() * 5).clamp( + 10, + 70, + ); for (int i = 0; i < otherIdx.length; i++) { - bobots[otherIdx[i]] = (remaining / 3).round().clamp(10, 70); + newOther[i] = share; } } else { - // Proportional redistribution - int distributed = 0; + // Proportional redistribution snapped to nearest multiple of 5 for (int i = 0; i < otherIdx.length - 1; i++) { - int idx = otherIdx[i]; - int proportional = (bobots[idx] * remaining / otherSum).round(); - proportional = proportional.clamp(10, 70); - bobots[idx] = proportional; - distributed += proportional; + double proportional = bobots[otherIdx[i]] * remaining / otherSum; + newOther[i] = ((proportional / 5).round() * 5).clamp(10, 70); } - // Last one gets the remainder to ensure total = 100 - int lastIdx = otherIdx.last; - bobots[lastIdx] = (remaining - distributed).clamp(10, 70); + // Last one gets exact remainder to guarantee total = 100 + int distributed = newOther + .take(newOther.length - 1) + .fold(0, (a, b) => a + b); + int lastVal = remaining - distributed; + newOther[newOther.length - 1] = lastVal.clamp(10, 70); + } - // Final adjustment pass to guarantee sum = 100 - int total = bobots.reduce((a, b) => a + b); - if (total != 100) { - int diff = 100 - total; - // Adjust the largest adjustable bobot (not the one just set) - int adjustIdx = otherIdx.reduce((a, b) => bobots[a] >= bobots[b] ? a : b); - bobots[adjustIdx] = (bobots[adjustIdx] + diff).clamp(10, 70); - } + // Apply computed values + for (int i = 0; i < otherIdx.length; i++) { + bobots[otherIdx[i]] = newOther[i]; + } + + // Final safety check: if sum != 100 due to clamping, adjust the largest + int total = bobots.reduce((a, b) => a + b); + if (total != 100) { + int diff = 100 - total; + int adjustIdx = otherIdx.reduce( + (a, b) => bobots[a] >= bobots[b] ? a : b, + ); + int adjusted = (bobots[adjustIdx] + diff).clamp(10, 70); + // Snap to nearest multiple of 5 + adjusted = ((adjusted / 5).round() * 5).clamp(10, 70); + bobots[adjustIdx] = adjusted; } _bobotHarga = bobots[0]; @@ -121,7 +142,12 @@ class _RecommendationScreenState extends State { /// Get the maximum dropdown value for a given bobot index int _getMaxBobot(int index) { - List bobots = [_bobotHarga, _bobotJarak, _bobotKriteria3, _bobotKriteria4]; + List bobots = [ + _bobotHarga, + _bobotJarak, + _bobotKriteria3, + _bobotKriteria4, + ]; int othersMin = 0; for (int i = 0; i < 4; i++) { if (i != index) othersMin += 10; // minimum 10% each @@ -148,6 +174,7 @@ class _RecommendationScreenState extends State { setState(() { _isLoading = true; _errorMessage = null; + _noData = false; }); try { @@ -175,14 +202,16 @@ class _RecommendationScreenState extends State { } } - final response = await http.post( - Uri.parse('${AppConfig.baseUrl}$endpoint'), - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: json.encode(bodyParams), - ); + final response = await http + .post( + Uri.parse('${AppConfig.baseUrl}$endpoint'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: json.encode(bodyParams), + ) + .timeout(const Duration(seconds: 15)); if (response.statusCode == 200) { final data = json.decode(response.body); @@ -194,23 +223,28 @@ class _RecommendationScreenState extends State { } else { setState(() { _errorMessage = data['message'] ?? 'Terjadi kesalahan'; + _hasCalculated = true; }); } } else if (response.statusCode == 404) { final data = json.decode(response.body); setState(() { _errorMessage = data['message'] ?? 'Tidak ada data ditemukan'; + _noData = data['no_data'] == true; _recommendations = []; _hasCalculated = true; }); } else { setState(() { _errorMessage = 'Gagal memuat data (${response.statusCode})'; + _hasCalculated = true; }); } } catch (e) { setState(() { - _errorMessage = 'Tidak dapat terhubung ke server'; + _errorMessage = + 'Tidak dapat terhubung ke server. Pastikan server Laravel aktif dan IP di app_config.dart benar.'; + _hasCalculated = true; }); } finally { setState(() { @@ -226,10 +260,16 @@ class _RecommendationScreenState extends State { if (!serviceEnabled) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aktifkan GPS Anda terlebih dahulu'), backgroundColor: Colors.red), + const SnackBar( + content: Text('Aktifkan GPS Anda terlebih dahulu'), + backgroundColor: Colors.red, + ), ); } - setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; }); + setState(() { + _referensiJarak = 'kampus'; + _isDetectingLocation = false; + }); return; } @@ -239,10 +279,16 @@ class _RecommendationScreenState extends State { if (permission == LocationPermission.denied) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Izin lokasi ditolak'), backgroundColor: Colors.red), + const SnackBar( + content: Text('Izin lokasi ditolak'), + backgroundColor: Colors.red, + ), ); } - setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; }); + setState(() { + _referensiJarak = 'kampus'; + _isDetectingLocation = false; + }); return; } } @@ -250,14 +296,24 @@ class _RecommendationScreenState extends State { if (permission == LocationPermission.deniedForever) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Izin lokasi ditolak permanen. Ubah di pengaturan.'), backgroundColor: Colors.red), + const SnackBar( + content: Text( + 'Izin lokasi ditolak permanen. Ubah di pengaturan.', + ), + backgroundColor: Colors.red, + ), ); } - setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; }); + setState(() { + _referensiJarak = 'kampus'; + _isDetectingLocation = false; + }); return; } - Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); setState(() { _userLatitude = position.latitude; _userLongitude = position.longitude; @@ -266,16 +322,26 @@ class _RecommendationScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Lokasi berhasil dideteksi!'), backgroundColor: Colors.green, duration: Duration(seconds: 2)), + const SnackBar( + content: Text('Lokasi berhasil dideteksi!'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Gagal mendeteksi lokasi: $e'), backgroundColor: Colors.red), + SnackBar( + content: Text('Gagal mendeteksi lokasi: $e'), + backgroundColor: Colors.red, + ), ); } - setState(() { _referensiJarak = 'kampus'; _isDetectingLocation = false; }); + setState(() { + _referensiJarak = 'kampus'; + _isDetectingLocation = false; + }); } } @@ -291,13 +357,21 @@ class _RecommendationScreenState extends State { backgroundColor: _categoryColor, foregroundColor: Colors.white, elevation: 0, - title: Text(categoryTitle, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + title: Text( + categoryTitle, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), actions: [ if (_hasCalculated) IconButton( icon: const Icon(Icons.refresh), tooltip: 'Hitung Ulang', - onPressed: () => setState(() { _hasCalculated = false; _recommendations = []; _errorMessage = null; }), + onPressed: () => setState(() { + _hasCalculated = false; + _recommendations = []; + _errorMessage = null; + _noData = false; + }), ), ], ), @@ -314,7 +388,7 @@ class _RecommendationScreenState extends State { children: [ _buildMethodInfoCard(), const SizedBox(height: 16), - if (widget.category == 'laundry') ...[ + if (widget.category == 'laundry') ...[ _buildJenisLayananSection(), const SizedBox(height: 16), ], @@ -335,9 +409,17 @@ class _RecommendationScreenState extends State { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - gradient: LinearGradient(colors: [_categoryColor, _categoryColor.withOpacity(0.8)]), + gradient: LinearGradient( + colors: [_categoryColor, _categoryColor.withOpacity(0.8)], + ), borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: _categoryColor.withOpacity(0.3), blurRadius: 12, offset: const Offset(0, 4))], + boxShadow: [ + BoxShadow( + color: _categoryColor.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -346,20 +428,40 @@ class _RecommendationScreenState extends State { children: [ Icon(Icons.analytics, color: Colors.white, size: 24), SizedBox(width: 8), - Text('Metode SAW', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + Text( + 'Metode SAW', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), ], ), const SizedBox(height: 8), Text( 'Simple Additive Weighting (SAW) menghitung skor rekomendasi berdasarkan bobot kriteria yang Anda tentukan.', - style: TextStyle(color: Colors.white.withOpacity(0.9), fontSize: 13), + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 13, + ), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration(color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(8)), - child: Text('Vi = Σ(Wj × Rij)', - style: TextStyle(color: Colors.white.withOpacity(0.95), fontSize: 14, fontWeight: FontWeight.w600, fontFamily: 'monospace')), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Vi = Σ(Wj × Rij)', + style: TextStyle( + color: Colors.white.withOpacity(0.95), + fontSize: 14, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), ), ], ), @@ -368,9 +470,24 @@ class _RecommendationScreenState extends State { Widget _buildJenisLayananSection() { final jenisOptions = [ - {'value': 'reguler', 'label': 'Reguler', 'icon': Icons.schedule, 'desc': 'Layanan standar dengan harga terjangkau'}, - {'value': 'express', 'label': 'Express', 'icon': Icons.flash_on, 'desc': 'Layanan cepat dengan waktu lebih singkat'}, - {'value': 'kilat', 'label': 'Kilat', 'icon': Icons.bolt, 'desc': 'Layanan tercepat, selesai dalam hitungan jam'}, + { + 'value': 'reguler', + 'label': 'Reguler', + 'icon': Icons.schedule, + 'desc': 'Layanan standar dengan harga terjangkau', + }, + { + 'value': 'express', + 'label': 'Express', + 'icon': Icons.flash_on, + 'desc': 'Layanan cepat dengan waktu lebih singkat', + }, + { + 'value': 'kilat', + 'label': 'Kilat', + 'icon': Icons.bolt, + 'desc': 'Layanan tercepat, selesai dalam hitungan jam', + }, ]; return Container( @@ -378,52 +495,108 @@ class _RecommendationScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.local_laundry_service_outlined, color: _categoryColor, size: 20), - const SizedBox(width: 8), - Text('Jenis Layanan', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])), - ]), + Row( + children: [ + Icon( + Icons.local_laundry_service_outlined, + color: _categoryColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'Jenis Layanan', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + ], + ), const SizedBox(height: 4), - Text('Pilih jenis layanan laundry yang ingin dibandingkan', style: TextStyle(fontSize: 12, color: Colors.grey[500])), + Text( + 'Pilih jenis layanan laundry yang ingin dibandingkan', + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), const SizedBox(height: 12), ...jenisOptions.map((opt) { final isSelected = _selectedJenisLayanan == opt['value']; return Padding( padding: const EdgeInsets.only(bottom: 8), child: InkWell( - onTap: () => setState(() => _selectedJenisLayanan = opt['value'] as String), + onTap: () => setState( + () => _selectedJenisLayanan = opt['value'] as String, + ), borderRadius: BorderRadius.circular(12), child: Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 14, + ), decoration: BoxDecoration( - color: isSelected ? _categoryColor.withOpacity(0.1) : Colors.grey[50], + color: isSelected + ? _categoryColor.withOpacity(0.1) + : Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? _categoryColor : Colors.grey[300]!, width: isSelected ? 2 : 1, ), ), - child: Row(children: [ - Icon(opt['icon'] as IconData, size: 22, color: isSelected ? _categoryColor : Colors.grey[500]), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(opt['label'] as String, style: TextStyle(fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, color: isSelected ? _categoryColor : Colors.grey[800])), - const SizedBox(height: 2), - Text(opt['desc'] as String, style: TextStyle(fontSize: 11, color: Colors.grey[500])), - ], + child: Row( + children: [ + Icon( + opt['icon'] as IconData, + size: 22, + color: isSelected ? _categoryColor : Colors.grey[500], ), - ), - if (isSelected) - Icon(Icons.check_circle, color: _categoryColor, size: 20), - ]), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + opt['label'] as String, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.w500, + color: isSelected + ? _categoryColor + : Colors.grey[800], + ), + ), + const SizedBox(height: 2), + Text( + opt['desc'] as String, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + ), + ), + ], + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: _categoryColor, + size: 20, + ), + ], + ), ), ), ); @@ -440,42 +613,103 @@ class _RecommendationScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))], + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.tune, color: _categoryColor, size: 20), - const SizedBox(width: 8), - Text('Bobot Kriteria', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: isValid ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: isValid ? Colors.green.withOpacity(0.5) : Colors.red.withOpacity(0.5)), + Row( + children: [ + Icon(Icons.tune, color: _categoryColor, size: 20), + const SizedBox(width: 8), + Text( + 'Bobot Kriteria', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(isValid ? Icons.check_circle : Icons.warning, size: 14, color: isValid ? Colors.green : Colors.red), - const SizedBox(width: 4), - Text('Total: $_totalBobot%', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: isValid ? Colors.green[700] : Colors.red[700])), - ]), - ), - ]), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: isValid + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isValid + ? Colors.green.withOpacity(0.5) + : Colors.red.withOpacity(0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isValid ? Icons.check_circle : Icons.warning, + size: 14, + color: isValid ? Colors.green : Colors.red, + ), + const SizedBox(width: 4), + Text( + 'Total: $_totalBobot%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isValid ? Colors.green[700] : Colors.red[700], + ), + ), + ], + ), + ), + ], + ), const SizedBox(height: 4), - Text('Bobot otomatis disesuaikan agar total selalu 100%', style: TextStyle(fontSize: 11, color: Colors.grey[500])), + Text( + 'Bobot otomatis disesuaikan agar total selalu 100%', + style: TextStyle(fontSize: 11, color: Colors.grey[500]), + ), const SizedBox(height: 16), - _buildBobotDropdown(label: 'Harga', icon: Icons.payments_outlined, tipe: 'Cost', tipeDesc: 'Semakin murah semakin baik', value: _bobotHarga, options: _getBobotOptionsFor(0), onChanged: (val) => _updateBobot(0, val!)), + _buildBobotDropdown( + label: 'Harga', + icon: Icons.payments_outlined, + tipe: 'Cost', + tipeDesc: 'Semakin murah semakin baik', + value: _bobotHarga, + options: _getBobotOptionsFor(0), + onChanged: (val) => _updateBobot(0, val!), + ), const SizedBox(height: 12), - _buildBobotDropdown(label: 'Jarak', icon: Icons.location_on_outlined, tipe: 'Cost', tipeDesc: 'Semakin dekat semakin baik', value: _bobotJarak, options: _getBobotOptionsFor(1), onChanged: (val) => _updateBobot(1, val!)), + _buildBobotDropdown( + label: 'Jarak', + icon: Icons.location_on_outlined, + tipe: 'Cost', + tipeDesc: 'Semakin dekat semakin baik', + value: _bobotJarak, + options: _getBobotOptionsFor(1), + onChanged: (val) => _updateBobot(1, val!), + ), const SizedBox(height: 12), _buildBobotDropdown( label: _kriteria3Label, - icon: widget.category == 'kontrakan' ? Icons.bed_outlined : Icons.speed_outlined, + icon: widget.category == 'kontrakan' + ? Icons.bed_outlined + : Icons.speed_outlined, tipe: 'Benefit', - tipeDesc: widget.category == 'kontrakan' ? 'Semakin banyak semakin baik' : 'Semakin cepat semakin baik', + tipeDesc: widget.category == 'kontrakan' + ? 'Semakin banyak semakin baik' + : 'Semakin cepat semakin baik', value: _bobotKriteria3, options: _getBobotOptionsFor(2), onChanged: (val) => _updateBobot(2, val!), @@ -483,9 +717,13 @@ class _RecommendationScreenState extends State { const SizedBox(height: 12), _buildBobotDropdown( label: _kriteria4Label, - icon: widget.category == 'kontrakan' ? Icons.wifi_outlined : Icons.local_laundry_service_outlined, + icon: widget.category == 'kontrakan' + ? Icons.wifi_outlined + : Icons.local_laundry_service_outlined, tipe: 'Benefit', - tipeDesc: widget.category == 'kontrakan' ? 'Semakin lengkap semakin baik' : 'Semakin bervariasi semakin baik', + tipeDesc: widget.category == 'kontrakan' + ? 'Semakin lengkap semakin baik' + : 'Semakin bervariasi semakin baik', value: _bobotKriteria4, options: _getBobotOptionsFor(3), onChanged: (val) => _updateBobot(3, val!), @@ -494,15 +732,25 @@ class _RecommendationScreenState extends State { const SizedBox(height: 12), Container( padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: Colors.red[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red[200]!)), - child: Row(children: [ - const Icon(Icons.info_outline, color: Colors.red, size: 16), - const SizedBox(width: 8), - Expanded(child: Text( - _totalBobot > 100 ? 'Total bobot kelebihan ${_totalBobot - 100}%. Kurangi salah satu bobot.' : 'Total bobot kurang ${100 - _totalBobot}%. Tambah salah satu bobot.', - style: TextStyle(fontSize: 12, color: Colors.red[700]), - )), - ]), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red[200]!), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.red, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _totalBobot > 100 + ? 'Total bobot kelebihan ${_totalBobot - 100}%. Kurangi salah satu bobot.' + : 'Total bobot kurang ${100 - _totalBobot}%. Tambah salah satu bobot.', + style: TextStyle(fontSize: 12, color: Colors.red[700]), + ), + ), + ], + ), ), ], ], @@ -510,46 +758,122 @@ class _RecommendationScreenState extends State { ); } - Widget _buildBobotDropdown({required String label, required IconData icon, required String tipe, required String tipeDesc, required int value, required List options, required ValueChanged onChanged}) { + Widget _buildBobotDropdown({ + required String label, + required IconData icon, + required String tipe, + required String tipeDesc, + required int value, + required List options, + required ValueChanged onChanged, + }) { final isCost = tipe.toLowerCase() == 'cost'; // Ensure current value is in options list - final safeValue = options.contains(value) ? value : options.reduce((a, b) => (a - value).abs() < (b - value).abs() ? a : b); + final safeValue = options.contains(value) + ? value + : options.reduce( + (a, b) => (a - value).abs() < (b - value).abs() ? a : b, + ); return Container( padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[200]!)), - child: Row(children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration(color: _categoryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), - child: Icon(icon, size: 20, color: _categoryColor), - ), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey[800])), - const SizedBox(height: 2), - Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration(color: isCost ? Colors.orange.withOpacity(0.15) : Colors.green.withOpacity(0.15), borderRadius: BorderRadius.circular(4)), - child: Text(tipe, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: isCost ? Colors.orange[800] : Colors.green[800])), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _categoryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 6), - Expanded(child: Text(tipeDesc, style: TextStyle(fontSize: 10, color: Colors.grey[500]), overflow: TextOverflow.ellipsis)), - ]), - ])), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: _categoryColor.withOpacity(0.3))), - child: DropdownButtonHideUnderline(child: DropdownButton( - value: safeValue, isDense: true, - icon: Icon(Icons.arrow_drop_down, color: _categoryColor), - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: _categoryColor), - items: options.map((int val) => DropdownMenuItem(value: val, child: Text('$val%'))).toList(), - onChanged: onChanged, - )), - ), - ]), + child: Icon(icon, size: 20, color: _categoryColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey[800], + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: isCost + ? Colors.orange.withOpacity(0.15) + : Colors.green.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + tipe, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isCost + ? Colors.orange[800] + : Colors.green[800], + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + tipeDesc, + style: TextStyle(fontSize: 10, color: Colors.grey[500]), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _categoryColor.withOpacity(0.3)), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: safeValue, + isDense: true, + icon: Icon(Icons.arrow_drop_down, color: _categoryColor), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: _categoryColor, + ), + items: options + .map( + (int val) => DropdownMenuItem( + value: val, + child: Text('$val%'), + ), + ) + .toList(), + onChanged: onChanged, + ), + ), + ), + ], + ), ); } @@ -559,69 +883,175 @@ class _RecommendationScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.1), blurRadius: 8, offset: const Offset(0, 2))], - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.my_location, color: _categoryColor, size: 20), - const SizedBox(width: 8), - Text('Referensi Jarak', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.grey[800])), - ]), - const SizedBox(height: 4), - Text('Tentukan titik referensi untuk perhitungan jarak', style: TextStyle(fontSize: 12, color: Colors.grey[500])), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration(color: Colors.grey[50], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey[200]!)), - child: DropdownButtonHideUnderline(child: DropdownButton( - value: _referensiJarak, isExpanded: true, - icon: Icon(Icons.arrow_drop_down, color: _categoryColor), - items: [ - DropdownMenuItem(value: 'kampus', child: Row(children: [ - Icon(Icons.school, size: 18, color: _categoryColor), const SizedBox(width: 10), const Text('Dari Kampus Polije'), - ])), - DropdownMenuItem(value: 'user', child: Row(children: [ - Icon(Icons.location_on, size: 18, color: Colors.green[700]), const SizedBox(width: 10), const Text('Dari Lokasi Saya'), - ])), - ], - onChanged: (val) { - setState(() { _referensiJarak = val!; }); - if (val == 'user' && _userLatitude == null) _detectUserLocation(); - }, - )), - ), - if (_referensiJarak == 'user') ...[ - const SizedBox(height: 10), - if (_isDetectingLocation) - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)), - child: const Row(children: [SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), SizedBox(width: 10), Text('Mendeteksi lokasi...', style: TextStyle(fontSize: 12))]), - ) - else if (_userLatitude != null && _userLongitude != null) - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: Colors.green[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.green[200]!)), - child: Row(children: [ - const Icon(Icons.check_circle, size: 16, color: Colors.green), - const SizedBox(width: 8), - Expanded(child: Text('Lokasi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}', style: TextStyle(fontSize: 11, color: Colors.green[700]))), - InkWell(onTap: _detectUserLocation, child: Icon(Icons.refresh, size: 16, color: Colors.green[700])), - ]), - ) - else - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: Colors.orange[50], borderRadius: BorderRadius.circular(8)), - child: Row(children: [ - const Icon(Icons.warning, size: 16, color: Colors.orange), - const SizedBox(width: 8), - const Expanded(child: Text('Lokasi belum terdeteksi', style: TextStyle(fontSize: 12))), - TextButton(onPressed: _detectUserLocation, child: const Text('Deteksi', style: TextStyle(fontSize: 12))), - ]), - ), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), ], - ]), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.my_location, color: _categoryColor, size: 20), + const SizedBox(width: 8), + Text( + 'Referensi Jarak', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Tentukan titik referensi untuk perhitungan jarak', + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _referensiJarak, + isExpanded: true, + icon: Icon(Icons.arrow_drop_down, color: _categoryColor), + items: [ + DropdownMenuItem( + value: 'kampus', + child: Row( + children: [ + Icon(Icons.school, size: 18, color: _categoryColor), + const SizedBox(width: 10), + const Text('Dari Kampus Polije'), + ], + ), + ), + DropdownMenuItem( + value: 'user', + child: Row( + children: [ + Icon( + Icons.location_on, + size: 18, + color: Colors.green[700], + ), + const SizedBox(width: 10), + const Text('Dari Lokasi Saya'), + ], + ), + ), + ], + onChanged: (val) { + setState(() { + _referensiJarak = val!; + }); + if (val == 'user' && _userLatitude == null) + _detectUserLocation(); + }, + ), + ), + ), + if (_referensiJarak == 'user') ...[ + const SizedBox(height: 10), + if (_isDetectingLocation) + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 10), + Text( + 'Mendeteksi lokasi...', + style: TextStyle(fontSize: 12), + ), + ], + ), + ) + else if (_userLatitude != null && _userLongitude != null) + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green[200]!), + ), + child: Row( + children: [ + const Icon( + Icons.check_circle, + size: 16, + color: Colors.green, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Lokasi: ${_userLatitude!.toStringAsFixed(6)}, ${_userLongitude!.toStringAsFixed(6)}', + style: TextStyle( + fontSize: 11, + color: Colors.green[700], + ), + ), + ), + InkWell( + onTap: _detectUserLocation, + child: Icon( + Icons.refresh, + size: 16, + color: Colors.green[700], + ), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange[50], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.warning, size: 16, color: Colors.orange), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'Lokasi belum terdeteksi', + style: TextStyle(fontSize: 12), + ), + ), + TextButton( + onPressed: _detectUserLocation, + child: const Text( + 'Deteksi', + style: TextStyle(fontSize: 12), + ), + ), + ], + ), + ), + ], + ], + ), ); } @@ -635,148 +1065,331 @@ class _RecommendationScreenState extends State { backgroundColor: _categoryColor, disabledBackgroundColor: Colors.grey[300], padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), elevation: isValid ? 4 : 0, shadowColor: _categoryColor.withOpacity(0.4), ), child: _isLoading - ? const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)), - SizedBox(width: 12), - Text('Menghitung SAW...', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ]) - : const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.calculate, color: Colors.white, size: 22), - SizedBox(width: 10), - Text('Hitung Rekomendasi', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), - ]), + ? const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + SizedBox(width: 12), + Text( + 'Menghitung SAW...', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.calculate, color: Colors.white, size: 22), + SizedBox(width: 10), + Text( + 'Hitung Rekomendasi', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), ), ); } // ===================== RESULT VIEW ===================== Widget _buildResultView() { - return Column(children: [ - _buildBobotSummary(), - if (widget.category == 'laundry' && _referensiJarak == 'user' && _userLatitude != null) - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: Colors.green[50], - child: Row(children: [ - Icon(Icons.location_on, size: 16, color: Colors.green[700]), - const SizedBox(width: 8), - Text('Jarak dihitung dari lokasi Anda', style: TextStyle(fontSize: 12, color: Colors.green[700])), - ]), - ), - Expanded(child: _buildResultContent()), - ]); + return Column( + children: [ + _buildBobotSummary(), + if (widget.category == 'laundry' && + _referensiJarak == 'user' && + _userLatitude != null) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Colors.green[50], + child: Row( + children: [ + Icon(Icons.location_on, size: 16, color: Colors.green[700]), + const SizedBox(width: 8), + Text( + 'Jarak dihitung dari lokasi Anda', + style: TextStyle(fontSize: 12, color: Colors.green[700]), + ), + ], + ), + ), + Expanded(child: _buildResultContent()), + ], + ); } Widget _buildBobotSummary() { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - gradient: LinearGradient(colors: [_categoryColor.withOpacity(0.05), Colors.white]), + gradient: LinearGradient( + colors: [_categoryColor.withOpacity(0.05), Colors.white], + ), border: Border(bottom: BorderSide(color: Colors.grey[200]!)), ), - child: Column(children: [ - Row(children: [ - Icon(Icons.tune, size: 14, color: _categoryColor), - const SizedBox(width: 6), - Text('Bobot yang digunakan:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey[700])), - if (widget.category == 'laundry') ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration(color: _categoryColor.withOpacity(0.1), borderRadius: BorderRadius.circular(10)), - child: Text(_getJenisLayananLabel(_selectedJenisLayanan), style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: _categoryColor)), - ), - ], - const Spacer(), - InkWell( - onTap: () => setState(() { _hasCalculated = false; _recommendations = []; _errorMessage = null; }), - child: Row(children: [ - Icon(Icons.edit, size: 14, color: _categoryColor), - const SizedBox(width: 4), - Text('Ubah', style: TextStyle(fontSize: 12, color: _categoryColor, fontWeight: FontWeight.w600)), - ]), + child: Column( + children: [ + Row( + children: [ + Icon(Icons.tune, size: 14, color: _categoryColor), + const SizedBox(width: 6), + Text( + 'Bobot yang digunakan:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + if (widget.category == 'laundry') ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: _categoryColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _getJenisLayananLabel(_selectedJenisLayanan), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: _categoryColor, + ), + ), + ), + ], + const Spacer(), + InkWell( + onTap: () => setState(() { + _hasCalculated = false; + _recommendations = []; + _errorMessage = null; + _noData = false; + }), + child: Row( + children: [ + Icon(Icons.edit, size: 14, color: _categoryColor), + const SizedBox(width: 4), + Text( + 'Ubah', + style: TextStyle( + fontSize: 12, + color: _categoryColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], ), - ]), - const SizedBox(height: 8), - Row(children: [ - _buildBobotMiniChip('Harga', _bobotHarga, 'Cost'), - const SizedBox(width: 6), - _buildBobotMiniChip('Jarak', _bobotJarak, 'Cost'), - const SizedBox(width: 6), - _buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, 'Benefit'), - const SizedBox(width: 6), - _buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, 'Benefit'), - ]), - ]), + const SizedBox(height: 8), + Row( + children: [ + _buildBobotMiniChip('Harga', _bobotHarga, 'Cost'), + const SizedBox(width: 6), + _buildBobotMiniChip('Jarak', _bobotJarak, 'Cost'), + const SizedBox(width: 6), + _buildBobotMiniChip(_kriteria3Label, _bobotKriteria3, 'Benefit'), + const SizedBox(width: 6), + _buildBobotMiniChip(_kriteria4Label, _bobotKriteria4, 'Benefit'), + ], + ), + ], + ), ); } Widget _buildBobotMiniChip(String label, int value, String tipe) { final isCost = tipe == 'Cost'; - return Expanded(child: Container( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), - decoration: BoxDecoration( - color: isCost ? Colors.orange.withOpacity(0.08) : Colors.green.withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: isCost ? Colors.orange.withOpacity(0.2) : Colors.green.withOpacity(0.2)), + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + decoration: BoxDecoration( + color: isCost + ? Colors.orange.withOpacity(0.08) + : Colors.green.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCost + ? Colors.orange.withOpacity(0.2) + : Colors.green.withOpacity(0.2), + ), + ), + child: Column( + children: [ + Text( + label, + style: TextStyle(fontSize: 9, color: Colors.grey[600]), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '$value%', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: isCost ? Colors.orange[700] : Colors.green[700], + ), + ), + ], + ), ), - child: Column(children: [ - Text(label, style: TextStyle(fontSize: 9, color: Colors.grey[600]), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis), - const SizedBox(height: 2), - Text('$value%', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: isCost ? Colors.orange[700] : Colors.green[700])), - ]), - )); + ); } String _getJenisLayananLabel(String key) { switch (key) { - case 'reguler': return 'Reguler'; - case 'express': return 'Express'; - case 'kilat': return 'Kilat'; - default: return key; + case 'reguler': + return 'Reguler'; + case 'express': + return 'Express'; + case 'kilat': + return 'Kilat'; + default: + return key; } } Widget _buildResultContent() { if (_isLoading) { - return const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(), SizedBox(height: 16), Text('Menghitung rekomendasi SAW...'), - ])); + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Menghitung rekomendasi SAW...'), + ], + ), + ); } if (_errorMessage != null && _recommendations.isEmpty) { - return Center(child: Padding( - padding: const EdgeInsets.all(32), - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.search_off, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text(_errorMessage!, textAlign: TextAlign.center, style: TextStyle(color: Colors.grey[600], fontSize: 14)), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: () => setState(() { _hasCalculated = false; _errorMessage = null; }), - icon: const Icon(Icons.arrow_back), - label: const Text('Ubah Kriteria'), - style: ElevatedButton.styleFrom(backgroundColor: _categoryColor, foregroundColor: Colors.white), + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _noData + ? (widget.category == 'kontrakan' + ? Icons.home_outlined + : Icons.local_laundry_service_outlined) + : Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + _noData + ? (widget.category == 'kontrakan' + ? 'Belum Ada Kontrakan Tersedia' + : 'Belum Ada Laundry Tersedia') + : 'Tidak Ada Hasil', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 8), + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[500], fontSize: 13), + ), + const SizedBox(height: 24), + if (!_noData) + ElevatedButton.icon( + onPressed: () => setState(() { + _hasCalculated = false; + _errorMessage = null; + _noData = false; + }), + icon: const Icon(Icons.tune), + label: const Text('Ubah Bobot Kriteria'), + style: ElevatedButton.styleFrom( + backgroundColor: _categoryColor, + foregroundColor: Colors.white, + ), + ) + else + OutlinedButton.icon( + onPressed: () => setState(() { + _hasCalculated = false; + _errorMessage = null; + _noData = false; + }), + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + style: OutlinedButton.styleFrom( + foregroundColor: _categoryColor, + side: BorderSide(color: _categoryColor), + ), + ), + ], ), - ]), - )); + ), + ); } if (_recommendations.isEmpty) { - return Center(child: Padding( - padding: const EdgeInsets.all(32), - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(widget.category == 'kontrakan' ? Icons.home_outlined : Icons.local_laundry_service_outlined, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text('Tidak ada hasil', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey[700])), - ]), - )); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + widget.category == 'kontrakan' + ? Icons.home_outlined + : Icons.local_laundry_service_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Tidak ada hasil', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + ], + ), + ), + ); } return RefreshIndicator( @@ -795,8 +1408,12 @@ class _RecommendationScreenState extends State { Widget _buildResultCard(Map item, int index) { final ranking = item['ranking'] ?? (index + 1); final skor = (item['skor'] ?? item['skor_akhir'] ?? 0).toDouble(); - final normalisasi = item['normalisasi'] != null ? Map.from(item['normalisasi']) : {}; - final nilai = item['nilai'] != null ? Map.from(item['nilai']) : {}; + final normalisasi = item['normalisasi'] != null + ? Map.from(item['normalisasi']) + : {}; + final nilai = item['nilai'] != null + ? Map.from(item['nilai']) + : {}; final itemData = item['data'] ?? item; return Container( @@ -804,110 +1421,256 @@ class _RecommendationScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow( - color: ranking <= 3 ? _getRankingColor(ranking).withOpacity(0.2) : Colors.grey.withOpacity(0.1), - blurRadius: 8, offset: const Offset(0, 2), - )], - border: ranking <= 3 ? Border.all(color: _getRankingColor(ranking).withOpacity(0.3), width: 1.5) : null, - ), - child: Column(children: [ - // Ranking Header - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - gradient: LinearGradient(colors: [_getRankingColor(ranking).withOpacity(0.1), Colors.transparent]), - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + boxShadow: [ + BoxShadow( + color: ranking <= 3 + ? _getRankingColor(ranking).withOpacity(0.2) + : Colors.grey.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), ), - child: Row(children: [ - Container( - width: 36, height: 36, - decoration: BoxDecoration( - color: _getRankingColor(ranking), shape: BoxShape.circle, - boxShadow: [BoxShadow(color: _getRankingColor(ranking).withOpacity(0.4), blurRadius: 6, offset: const Offset(0, 2))], + ], + border: ranking <= 3 + ? Border.all( + color: _getRankingColor(ranking).withOpacity(0.3), + width: 1.5, + ) + : null, + ), + child: Column( + children: [ + // Ranking Header + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + _getRankingColor(ranking).withOpacity(0.1), + Colors.transparent, + ], + ), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), ), - child: Center(child: ranking <= 3 - ? Icon(_getRankingIcon(ranking), color: Colors.white, size: 18) - : Text('#$ranking', style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold))), ), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item['nama'] ?? 'N/A', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.grey[800])), - Text('Peringkat $ranking', style: TextStyle(fontSize: 11, color: Colors.grey[500])), - ])), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(gradient: LinearGradient(colors: [_categoryColor, _categoryColor.withOpacity(0.8)]), borderRadius: BorderRadius.circular(20)), - child: Text('Skor: ${skor.toStringAsFixed(4)}', style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold)), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _getRankingColor(ranking), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: _getRankingColor(ranking).withOpacity(0.4), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: ranking <= 3 + ? Icon( + _getRankingIcon(ranking), + color: Colors.white, + size: 18, + ) + : Text( + '#$ranking', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item['nama'] ?? 'N/A', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + Text( + 'Peringkat $ranking', + style: TextStyle(fontSize: 11, color: Colors.grey[500]), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [_categoryColor, _categoryColor.withOpacity(0.8)], + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Skor: ${skor.toStringAsFixed(4)}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), - ]), - ), + ), - // SAW Detail - if (normalisasi.isNotEmpty) - Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: _buildNormalizationDetail(normalisasi, nilai)), - - // Score Progress Bar - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Skor Akhir (Vi)', style: TextStyle(fontSize: 11, color: Colors.grey[600], fontWeight: FontWeight.w500)), - Text('${(skor * 100).toStringAsFixed(2)}%', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _categoryColor)), - ]), - const SizedBox(height: 4), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator(value: skor.clamp(0.0, 1.0), backgroundColor: Colors.grey[200], valueColor: AlwaysStoppedAnimation(_categoryColor), minHeight: 6), + // SAW Detail + if (normalisasi.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: _buildNormalizationDetail(normalisasi, nilai), ), - ]), - ), - const SizedBox(height: 8), - const Divider(height: 1), + // Score Progress Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Skor Akhir (Vi)', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + Text( + '${(skor * 100).toStringAsFixed(2)}%', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: _categoryColor, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: skor.clamp(0.0, 1.0), + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(_categoryColor), + minHeight: 6, + ), + ), + ], + ), + ), - // Item Card - Padding( - padding: const EdgeInsets.all(8), - child: widget.category == 'kontrakan' - ? KontrakanCard(kontrakan: Kontrakan.fromJson(itemData)) - : LaundryCard(laundry: Laundry.fromJson(itemData)), - ), - ]), + const SizedBox(height: 8), + const Divider(height: 1), + + // Item Card + Padding( + padding: const EdgeInsets.all(8), + child: widget.category == 'kontrakan' + ? KontrakanCard(kontrakan: Kontrakan.fromJson(itemData)) + : LaundryCard(laundry: Laundry.fromJson(itemData)), + ), + ], + ), ); } - Widget _buildNormalizationDetail(Map normalisasi, Map nilai) { + Widget _buildNormalizationDetail( + Map normalisasi, + Map nilai, + ) { return ExpansionTile( - title: Row(children: [ - Icon(Icons.table_chart, size: 16, color: _categoryColor), - const SizedBox(width: 8), - Text('Detail Perhitungan SAW', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey[700])), - ]), + title: Row( + children: [ + Icon(Icons.table_chart, size: 16, color: _categoryColor), + const SizedBox(width: 8), + Text( + 'Detail Perhitungan SAW', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + ], + ), tilePadding: EdgeInsets.zero, childrenPadding: EdgeInsets.zero, initiallyExpanded: false, children: [ Container( - decoration: BoxDecoration(color: Colors.grey[50], borderRadius: BorderRadius.circular(8)), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(8), + ), child: Table( - border: TableBorder.all(color: Colors.grey[300]!, width: 0.5, borderRadius: BorderRadius.circular(8)), - columnWidths: const { 0: FlexColumnWidth(2.5), 1: FlexColumnWidth(1.5), 2: FlexColumnWidth(1.5), 3: FlexColumnWidth(1.5) }, + border: TableBorder.all( + color: Colors.grey[300]!, + width: 0.5, + borderRadius: BorderRadius.circular(8), + ), + columnWidths: const { + 0: FlexColumnWidth(2.5), + 1: FlexColumnWidth(1.5), + 2: FlexColumnWidth(1.5), + 3: FlexColumnWidth(1.5), + }, children: [ TableRow( - decoration: BoxDecoration(color: _categoryColor.withOpacity(0.1), borderRadius: const BorderRadius.vertical(top: Radius.circular(8))), - children: [_tableHeader('Kriteria'), _tableHeader('Nilai'), _tableHeader('Normalisasi'), _tableHeader('Bobot')], + decoration: BoxDecoration( + color: _categoryColor.withOpacity(0.1), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + ), + children: [ + _tableHeader('Kriteria'), + _tableHeader('Nilai'), + _tableHeader('Normalisasi'), + _tableHeader('Bobot'), + ], ), ...normalisasi.entries.map((entry) { final key = entry.key; - final norm = (entry.value is num) ? entry.value.toDouble() : 0.0; + final norm = (entry.value is num) + ? entry.value.toDouble() + : 0.0; final nilaiVal = nilai[key] ?? 0; final bobot = _getBobotForKey(key); - return TableRow(children: [ - _tableCell(_getKriteriaDisplayName(key)), - _tableCell(nilaiVal is double ? nilaiVal.toStringAsFixed(2) : nilaiVal.toString()), - _tableCell(norm is double ? norm.toStringAsFixed(4) : norm.toString()), - _tableCell('${(bobot * 100).toInt()}%'), - ]); + return TableRow( + children: [ + _tableCell(_getKriteriaDisplayName(key)), + _tableCell( + nilaiVal is double + ? nilaiVal.toStringAsFixed(2) + : nilaiVal.toString(), + ), + _tableCell( + norm is double + ? norm.toStringAsFixed(4) + : norm.toString(), + ), + _tableCell('${(bobot * 100).toInt()}%'), + ], + ); }), ], ), @@ -916,46 +1679,86 @@ class _RecommendationScreenState extends State { ); } - Widget _tableHeader(String text) => Padding(padding: const EdgeInsets.all(6), child: Text(text, style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: _categoryColor), textAlign: TextAlign.center)); - Widget _tableCell(String text) => Padding(padding: const EdgeInsets.all(6), child: Text(text, style: TextStyle(fontSize: 10, color: Colors.grey[700]), textAlign: TextAlign.center)); + Widget _tableHeader(String text) => Padding( + padding: const EdgeInsets.all(6), + child: Text( + text, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: _categoryColor, + ), + textAlign: TextAlign.center, + ), + ); + Widget _tableCell(String text) => Padding( + padding: const EdgeInsets.all(6), + child: Text( + text, + style: TextStyle(fontSize: 10, color: Colors.grey[700]), + textAlign: TextAlign.center, + ), + ); String _getKriteriaDisplayName(String key) { switch (key) { - case 'harga': return 'Harga'; - case 'jarak': return 'Jarak'; - case 'jumlah_kamar': return 'Jml Kamar'; - case 'fasilitas_count': return 'Fasilitas'; - case 'kecepatan_layanan': return 'Kecepatan'; - case 'layanan': return 'Layanan'; - default: return key; + case 'harga': + return 'Harga'; + case 'jarak': + return 'Jarak'; + case 'jumlah_kamar': + return 'Jml Kamar'; + case 'fasilitas_count': + return 'Fasilitas'; + case 'kecepatan_layanan': + return 'Kecepatan'; + case 'layanan': + return 'Layanan'; + default: + return key; } } double _getBobotForKey(String key) { switch (key) { - case 'harga': return _bobotHarga / 100; - case 'jarak': return _bobotJarak / 100; - case 'jumlah_kamar': case 'kecepatan_layanan': return _bobotKriteria3 / 100; - case 'fasilitas_count': case 'layanan': return _bobotKriteria4 / 100; - default: return 0; + case 'harga': + return _bobotHarga / 100; + case 'jarak': + return _bobotJarak / 100; + case 'jumlah_kamar': + case 'kecepatan_layanan': + return _bobotKriteria3 / 100; + case 'fasilitas_count': + case 'layanan': + return _bobotKriteria4 / 100; + default: + return 0; } } Color _getRankingColor(int rank) { switch (rank) { - case 1: return const Color(0xFFFFD700); - case 2: return const Color(0xFFC0C0C0); - case 3: return const Color(0xFFCD7F32); - default: return _categoryColor; + case 1: + return const Color(0xFFFFD700); + case 2: + return const Color(0xFFC0C0C0); + case 3: + return const Color(0xFFCD7F32); + default: + return _categoryColor; } } IconData _getRankingIcon(int rank) { switch (rank) { - case 1: return Icons.emoji_events; - case 2: return Icons.workspace_premium; - case 3: return Icons.military_tech; - default: return Icons.star; + case 1: + return Icons.emoji_events; + case 2: + return Icons.workspace_premium; + case 3: + return Icons.military_tech; + default: + return Icons.star; } } } diff --git a/spk_mobile/lib/services/auth_service.dart b/spk_mobile/lib/services/auth_service.dart index 0ae08d3..3230369 100644 --- a/spk_mobile/lib/services/auth_service.dart +++ b/spk_mobile/lib/services/auth_service.dart @@ -173,4 +173,82 @@ class AuthService { return null; } } + + // Update profile + Future> 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> 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'}; + } + } } diff --git a/spk_mobile/lib/services/booking_service.dart b/spk_mobile/lib/services/booking_service.dart index 53c57f1..8f43b2c 100644 --- a/spk_mobile/lib/services/booking_service.dart +++ b/spk_mobile/lib/services/booking_service.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; import '../config/app_config.dart'; import '../models/booking.dart'; import 'auth_service.dart'; @@ -42,25 +44,43 @@ class BookingService { } } - // Create booking + // Create booking (with required payment proof image) Future> createBooking({ required int kontrakanId, required DateTime tanggalMulai, required int durasiBulan, String? catatan, + File? paymentProof, }) async { try { - final response = await http.post( - Uri.parse('${AppConfig.baseUrl}/bookings'), - headers: _headers, - body: jsonEncode({ - 'kontrakan_id': kontrakanId, - 'tanggal_mulai': tanggalMulai.toIso8601String().split('T')[0], - 'durasi_bulan': durasiBulan, - 'catatan': catatan, - }), + // Validate payment proof is provided + if (paymentProof == null) { + return { + 'success': false, + 'message': 'Bukti pembayaran wajib diunggah', + }; + } + + // Use multipart request with payment proof + final uri = Uri.parse('${AppConfig.baseUrl}/bookings'); + final request = http.MultipartRequest('POST', uri); + + if (_authService.token != null) { + request.headers['Authorization'] = 'Bearer ${_authService.token}'; + request.headers['Accept'] = 'application/json'; + } + + request.fields['kontrakan_id'] = kontrakanId.toString(); + request.fields['tanggal_mulai'] = tanggalMulai.toIso8601String().split('T')[0]; + request.fields['durasi_bulan'] = durasiBulan.toString(); + if (catatan != null) request.fields['catatan'] = catatan; + + request.files.add( + await http.MultipartFile.fromPath('payment_proof', paymentProof.path), ); + final streamedResponse = await request.send().timeout(const Duration(seconds: 30)); + final response = await http.Response.fromStream(streamedResponse); final data = jsonDecode(response.body); if (response.statusCode == 201 && data['success'] == true) { @@ -124,4 +144,49 @@ class BookingService { return null; } } + + // Upload payment proof image + Future> 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'}; + } + } } diff --git a/spk_mobile/lib/services/location_service.dart b/spk_mobile/lib/services/location_service.dart index 8c954d4..d07b119 100644 --- a/spk_mobile/lib/services/location_service.dart +++ b/spk_mobile/lib/services/location_service.dart @@ -3,24 +3,24 @@ import 'dart:math'; class LocationService { static final LocationService _instance = LocationService._internal(); - + factory LocationService() => _instance; LocationService._internal(); // Request location permission Future requestLocationPermission() async { final permission = await Geolocator.checkPermission(); - + if (permission == LocationPermission.denied) { final result = await Geolocator.requestPermission(); - return result == LocationPermission.whileInUse || - result == LocationPermission.always; + return result == LocationPermission.whileInUse || + result == LocationPermission.always; } else if (permission == LocationPermission.deniedForever) { // Permission permanently denied, open app settings await Geolocator.openLocationSettings(); return false; } - + return true; } @@ -35,7 +35,7 @@ class LocationService { desiredAccuracy: LocationAccuracy.high, timeLimit: const Duration(seconds: 10), ); - + return position; } catch (e) { print('Error getting location: $e'); @@ -45,9 +45,9 @@ class LocationService { // Calculate distance between two coordinates (in km) static double calculateDistance( - double lat1, - double lon1, - double lat2, + double lat1, + double lon1, + double lat2, double lon2, ) { const earthRadius = 6371; // km @@ -55,7 +55,8 @@ class LocationService { final dLat = _degreesToRadians(lat2 - lat1); final dLon = _degreesToRadians(lon2 - lon1); - final a = sin(dLat / 2) * sin(dLat / 2) + + final a = + sin(dLat / 2) * sin(dLat / 2) + cos(_degreesToRadians(lat1)) * cos(_degreesToRadians(lat2)) * sin(dLon / 2) * diff --git a/spk_mobile/linux/flutter/generated_plugin_registrant.cc b/spk_mobile/linux/flutter/generated_plugin_registrant.cc index f6f23bf..7299b5c 100644 --- a/spk_mobile/linux/flutter/generated_plugin_registrant.cc +++ b/spk_mobile/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/spk_mobile/linux/flutter/generated_plugins.cmake b/spk_mobile/linux/flutter/generated_plugins.cmake index f16b4c3..786ff5c 100644 --- a/spk_mobile/linux/flutter/generated_plugins.cmake +++ b/spk_mobile/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift index e083e83..3fc6d02 100644 --- a/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/spk_mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,14 @@ import FlutterMacOS import Foundation +import file_selector_macos import geolocator_apple import shared_preferences_foundation import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/spk_mobile/pubspec.lock b/spk_mobile/pubspec.lock index bb79c6a..90f45c1 100644 --- a/spk_mobile/pubspec.lock +++ b/spk_mobile/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -113,6 +121,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -142,6 +182,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_spinkit: dependency: "direct main" description: @@ -240,6 +288,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -312,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" native_toolchain_c: dependency: transitive description: diff --git a/spk_mobile/pubspec.yaml b/spk_mobile/pubspec.yaml index efd5a32..b7a0376 100644 --- a/spk_mobile/pubspec.yaml +++ b/spk_mobile/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: # Image picker & display cached_network_image: ^3.3.1 + image_picker: ^1.1.2 # State management provider: ^6.1.1 diff --git a/spk_mobile/windows/flutter/generated_plugin_registrant.cc b/spk_mobile/windows/flutter/generated_plugin_registrant.cc index 94586cc..6514907 100644 --- a/spk_mobile/windows/flutter/generated_plugin_registrant.cc +++ b/spk_mobile/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/spk_mobile/windows/flutter/generated_plugins.cmake b/spk_mobile/windows/flutter/generated_plugins.cmake index f0bcafd..c193e6e 100644 --- a/spk_mobile/windows/flutter/generated_plugins.cmake +++ b/spk_mobile/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows geolocator_windows url_launcher_windows )