Initial commit: SPK Rekomendasi Kontrakan & Laundry dengan Flutter dan Laravel 11

This commit is contained in:
micko samawa 2026-02-10 21:20:48 +07:00
commit fd956653ea
158 changed files with 15185 additions and 0 deletions

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# Flutter
spk_mobile/build/
spk_mobile/.dart_tool/
spk_mobile/.flutter-plugins
spk_mobile/.flutter-plugins-dependencies
spk_mobile/.packages
spk_mobile/*.iml
spk_mobile/.gradle/
spk_mobile/.idea/
spk_mobile/android/.gradle/
spk_mobile/android/local.properties
spk_mobile/ios/Pods/
spk_mobile/ios/Podfile.lock
spk_mobile/macos/Podfile.lock
spk_mobile/windows/flutter/ephemeral/
# Laravel
spk_kontrakan/vendor/
spk_kontrakan/node_modules/
spk_kontrakan/.env
spk_kontrakan/.env.local
spk_kontrakan/.env.*.local
spk_kontrakan/storage/logs/*
spk_kontrakan/storage/backups/*
spk_kontrakan/public/storage/
spk_kontrakan/bootstrap/cache/
spk_kontrakan/.vscode/
spk_kontrakan/dist/
spk_kontrakan/build/
# OS
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# Build artifacts
*.apk
*.aab
*.ipa
*.dmg
*.exe
*.msi
# Dependencies
node_modules/
.npm
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

196
MOBILE_FIX_DOCUMENTATION.md Normal file
View File

@ -0,0 +1,196 @@
# Perbaikan Sistem Mobile SPK Kontrakan
## Masalah yang Ditemukan
1. Data di mobile tidak sesuai dengan data di web
2. Endpoint API tidak benar (menggunakan GET dengan query parameters, harusnya POST dengan body)
3. Model data tidak sesuai dengan response API dari backend
4. Tampilan tidak menunjukkan sistem rekomendasi dengan ranking dan scoring
5. Tidak ada tampilan untuk top recommendations di home screen
## Perbaikan yang Dilakukan
### 1. Perbaikan API Endpoints
**File: `lib/screens/recommendation_screen.dart`**
- ✅ Mengubah endpoint dari `/api/saw/kontrakan` menjadi `/api/saw/calculate/kontrakan`
- ✅ Mengubah method dari GET (dengan query params) menjadi POST (dengan body)
- ✅ Mengirim parameters dalam request body menggunakan JSON
**File: `lib/services/kontrakan_service.dart`**
- ✅ Menambahkan method `getRecommendations()` untuk mendapatkan rekomendasi SAW
- ✅ Menggunakan POST method dengan JSON body sesuai dengan API backend
**File: `lib/services/laundry_service.dart`**
- ✅ Menambahkan method `getRecommendations()` untuk laundry
- ✅ Implementasi yang sama dengan kontrakan service
### 2. Perbaikan Model Data
**File: `lib/models/laundry.dart`**
- ✅ Mengubah nama field dari `jarak` menjadi `jarakKampus` (sesuai API)
- ✅ Mengubah `hargaKiloan` dan `hargaSatuan` menjadi `hargaPerKg` (sesuai API)
- ✅ Mengubah `estimasiSelesai` menjadi `waktuProses` (sesuai API)
- ✅ Menambahkan field `deskripsi`, `fotoUtama`, `galeri` yang hilang
- ✅ Menambahkan `avgRating` dan `totalReviews` untuk rating display
- ✅ Menambahkan class `GaleriLaundry` untuk galeri photos
- ✅ Menambahkan getter `primaryPhoto` untuk mendapatkan foto utama
- ✅ Memperbaiki getter `formattedHarga` untuk format Rupiah
**File: `lib/models/kontrakan.dart`**
- ✅ Sudah sesuai dengan API response
### 3. Pembuatan Widget Komponen
**File: `lib/widgets/kontrakan_card.dart` (BARU)**
- ✅ Widget card untuk menampilkan kontrakan
- ✅ Mendukung tampilan ranking dan skor (untuk sistem rekomendasi)
- ✅ Menampilkan badge ranking dengan warna berbeda (emas, perak, perunggu)
- ✅ Menampilkan persentase skor SAW
- ✅ Tampilan foto, harga, jumlah kamar, jarak kampus, dan rating
- ✅ Clickable untuk navigasi ke detail screen
**File: `lib/widgets/laundry_card.dart` (BARU)**
- ✅ Widget card untuk menampilkan laundry
- ✅ Sama seperti kontrakan card dengan design yang konsisten
- ✅ Menampilkan harga per kg dan waktu proses
### 4. Improved Home Screen
**File: `lib/screens/improved_home_screen.dart` (BARU)**
- ✅ Home screen baru yang fokus pada sistem rekomendasi
- ✅ Menampilkan "Top 5 Kontrakan Terbaik" berdasarkan SAW
- ✅ Setiap kartu menampilkan ranking dan skor
- ✅ Quick action button untuk laundry
- ✅ Pull-to-refresh untuk reload rekomendasi
- ✅ Bottom navigation yang konsisten
- ✅ Design modern dengan gradient header
- ✅ Loading state yang informatif
### 5. Update Entry Points
**File: `lib/main.dart`**
- ✅ Menggunakan `ImprovedHomeScreen` sebagai home default
- ✅ Splash screen tetap berfungsi untuk auth check
**File: `lib/login.dart`**
- ✅ Navigate ke `ImprovedHomeScreen` setelah login sukses
- ✅ Import statement sudah diupdate
## Struktur Sistem Rekomendasi
### Flow Data:
```
1. Mobile App → POST /api/saw/calculate/kontrakan (dengan filter opsional)
2. Backend → Proses SAW → Return ranked results dengan skor
3. Mobile App → Parse results → Tampilkan dengan ranking & skor
```
### Response Format dari API:
```json
{
"success": true,
"data": {
"kriteria": [...],
"hasil": [
{
"id": 1,
"nama": "Kontrakan ABC",
"ranking": 1,
"skor": 0.95,
"nilai": {...},
"normalisasi": {...},
"data": {
"id": 1,
"nama": "Kontrakan ABC",
"harga": 500000,
...
}
}
]
}
}
```
### Tampilan di Mobile:
- **Home Screen**: Top 5 rekomendasi dengan badge ranking (🏆 #1, #2, #3, dll)
- **Recommendation Screen**: Full list dengan filter dan ranking
- **Card**: Menampilkan ranking badge + skor persentase di bagian atas card
## Cara Menggunakan
### 1. Pastikan Backend Running
```bash
cd spk_kontrakan
php artisan serve
```
### 2. Cek Database
Pastikan data kontrakan dan laundry sudah ada di database dengan field yang benar:
- `kontrakan`: harga, jumlah_kamar, jarak_kampus, fasilitas
- `laundry`: harga_per_kg, jarak_kampus, waktu_proses
### 3. Konfigurasi URL
**File: `lib/config/app_config.dart`**
```dart
// Untuk Android Emulator
static const String baseUrl = 'http://10.0.2.2:8000/api';
// Untuk Real Device (ganti dengan IP komputer Anda)
static const String baseUrl = 'http://192.168.1.100:8000/api';
```
### 4. Run Flutter App
```bash
cd spk_mobile
flutter pub get
flutter run
```
## Testing Checklist
- [ ] Login berhasil → Redirect ke Improved Home Screen
- [ ] Home screen menampilkan Top 5 Kontrakan dengan ranking
- [ ] Setiap card menampilkan badge ranking (🏆 #1, #2, #3)
- [ ] Setiap card menampilkan skor dalam persentase
- [ ] Pull-to-refresh berfungsi reload data
- [ ] Click card → Navigate ke detail screen
- [ ] Button "Lihat Semua" → Navigate ke Recommendation Screen
- [ ] Recommendation Screen menampilkan semua hasil dengan filter
- [ ] Filter berfungsi (harga, jarak, kamar, fasilitas)
- [ ] Laundry button navigate ke Laundry List
- [ ] Bottom navigation berfungsi semua tab
## Keunggulan Sistem Baru
1. **Data Konsisten**: Mobile dan web menggunakan API dan logika yang sama
2. **Visual Ranking**: User langsung melihat mana kontrakan/laundry terbaik
3. **Sistem Scoring**: Transparansi dengan menampilkan skor SAW
4. **User Experience**: Design modern, intuitive, dan informatif
5. **Performance**: Caching dengan CachedNetworkImage untuk loading foto lebih cepat
## Troubleshooting
### Data tidak muncul?
1. Cek API response di console: `flutter run --verbose`
2. Pastikan baseUrl benar di `app_config.dart`
3. Test API menggunakan Postman: `POST http://localhost:8000/api/saw/calculate/kontrakan`
### Foto tidak muncul?
1. Pastikan foto ada di folder `public/storage` backend
2. Jalankan: `php artisan storage:link`
3. Cek URL foto di CachedNetworkImage (ada prefix storage/)
### Ranking tidak sesuai?
1. Cek bobot kriteria di database `kriteria` table
2. Pastikan metode SAW berjalan dengan benar di backend
3. Test SAW calculation manual di web
## Next Steps (Opsional)
1. **Favorites**: Implementasi favorite kontrakan/laundry
2. **Reviews**: Tampilkan dan submit review dari mobile
3. **Booking**: Integrasi booking flow yang lengkap
4. **Maps**: Tampilkan lokasi di Google Maps
5. **Notifications**: Push notification untuk booking updates
6. **Offline Mode**: Cache data untuk offline viewing
---
**Dibuat oleh**: GitHub Copilot
**Tanggal**: 31 Januari 2026
**Versi**: 1.0

325
WEB_MOBILE_SEPARATION.md Normal file
View File

@ -0,0 +1,325 @@
# Dokumentasi Pemisahan Web & Mobile SPK Kontrakan
## 📋 Overview
Project SPK Kontrakan telah direstrukturisasi menjadi 2 bagian terpisah:
### 1. **spk_kontrakan** (Web Laravel)
- **Fungsi**: Dashboard Admin/Pemilik Kontrakan
- **Pengguna**: Admin, Pemilik
- **Akses**: `http://localhost/spk_kontrakan`
- **Fitur**:
- Manajemen Kontrakan & Laundry
- Dashboard statistik
- Perhitungan SAW
- Manajemen Booking
- Activity Logs
- User Management
- Export Data (PDF/Excel)
- Backup & Restore
### 2. **spk_mobile** (Flutter)
- **Fungsi**: Aplikasi Mobile untuk User/Pencari Kontrakan
- **Pengguna**: User Umum (Pencari Kontrakan)
- **Fitur**:
- Register & Login User
- Pencarian Kontrakan & Laundry dengan SAW
- Detail Kontrakan dengan Galeri
- Booking Kontrakan
- Review & Rating
- Favorit Kontrakan/Laundry
- History Booking
---
## 🚀 Cara Menjalankan
### Web (spk_kontrakan)
1. **Start Laravel Server**
```bash
cd spk_kontrakan
php artisan serve
```
2. **Akses Admin Portal**
- URL: `http://localhost:8000/admin-portal`
- Login Admin: `http://localhost:8000/admin/login`
### Mobile (spk_mobile)
1. **Install Dependencies**
```bash
cd spk_mobile
flutter pub get
```
2. **Jalankan di Emulator/Device**
```bash
flutter run
```
3. **Konfigurasi API Base URL**
- Edit file config untuk mengarah ke Laravel API
- Base URL: `http://10.0.2.2:8000/api` (untuk Android Emulator)
- Base URL: `http://localhost:8000/api` (untuk iOS Simulator)
---
## 🔗 API Endpoints untuk Mobile
Base URL: `http://localhost:8000/api`
### Authentication (Public)
```
POST /api/register - Register user baru
POST /api/login - Login user
```
### Kontrakan (Public)
```
GET /api/kontrakan - List semua kontrakan
GET /api/kontrakan/{id} - Detail kontrakan
GET /api/kontrakan/{id}/galeri - Galeri foto kontrakan
GET /api/kontrakan/{id}/reviews - Review kontrakan
```
### Laundry (Public)
```
GET /api/laundry - List semua laundry
GET /api/laundry/{id} - Detail laundry
GET /api/laundry/{id}/galeri - Galeri foto laundry
GET /api/laundry/{id}/reviews - Review laundry
```
### SAW Calculation (Public)
```
GET /api/saw/kriteria/kontrakan - Get kriteria kontrakan
POST /api/saw/calculate/kontrakan - Hitung SAW kontrakan
GET /api/saw/kriteria/laundry - Get kriteria laundry
POST /api/saw/calculate/laundry - Hitung SAW laundry
```
### Protected Endpoints (Butuh Token)
**User Profile**
```
GET /api/user - Get user info
POST /api/logout - Logout
PUT /api/profile/update - Update profile
```
**Booking**
```
GET /api/bookings - History booking
GET /api/bookings/{id} - Detail booking
POST /api/bookings - Create booking
POST /api/bookings/{id}/cancel - Cancel booking
POST /api/bookings/{id}/extend - Perpanjang booking
```
**Reviews**
```
POST /api/reviews/kontrakan/{id} - Buat review kontrakan
POST /api/reviews/laundry/{id} - Buat review laundry
PUT /api/reviews/{id} - Update review
DELETE /api/reviews/{id} - Hapus review
```
**Favorites**
```
GET /api/favorites - List favorites
POST /api/favorites/kontrakan/{id} - Toggle favorite kontrakan
POST /api/favorites/laundry/{id} - Toggle favorite laundry
DELETE /api/favorites/{id} - Hapus favorite
```
---
## 🔐 Authentication (Mobile)
Mobile app menggunakan **Laravel Sanctum** untuk authentication.
### Login Flow
1. User login → POST `/api/login`
2. Server return `token`
3. Simpan token di local storage
4. Kirim token di header untuk protected endpoints:
```
Authorization: Bearer {token}
```
### Request Example
```dart
// Login
final response = await http.post(
Uri.parse('http://10.0.2.2:8000/api/login'),
body: {
'email': 'user@example.com',
'password': 'password123',
},
);
// Get token
final token = json.decode(response.body)['data']['token'];
// Use token for protected endpoint
final bookingsResponse = await http.get(
Uri.parse('http://10.0.2.2:8000/api/bookings'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);
```
---
## 📦 Response Format
Semua API endpoint mengembalikan response dalam format JSON:
### Success Response
```json
{
"success": true,
"message": "Operasi berhasil",
"data": {
// data object or array
}
}
```
### Error Response
```json
{
"success": false,
"message": "Error message",
"errors": {
// validation errors (optional)
}
}
```
---
## 🗄️ Database
Kedua aplikasi menggunakan database yang sama:
- **Database**: `spk_kontrakan`
- **Tables**: users, kontrakan, laundry, kriteria, bookings, reviews, favorites, dll.
### User Roles
- `admin` - Akses web dashboard
- `user` - Akses mobile app
---
## ⚙️ Setup Laravel Sanctum
Jika belum diinstall, jalankan:
```bash
cd spk_kontrakan
# Install Sanctum (biasanya sudah terinstall di Laravel 11)
composer require laravel/sanctum
# Publish config
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
# Migrate
php artisan migrate
# Add Sanctum middleware ke api kernel
```
Pastikan di `config/sanctum.php`:
```php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
```
---
## 📱 Mobile App Structure
```
spk_mobile/lib/
├── main.dart # Entry point
├── login.dart # Login screen
├── register.dart # Register screen
├── screens/ # Screens lainnya
│ ├── home_screen.dart
│ ├── search_screen.dart
│ ├── detail_screen.dart
│ ├── booking_screen.dart
│ └── profile_screen.dart
├── services/ # API Services
│ ├── api_service.dart
│ ├── auth_service.dart
│ └── saw_service.dart
└── models/ # Data models
├── kontrakan.dart
├── booking.dart
└── user.dart
```
---
## 🔧 Troubleshooting
### CORS Error
Tambahkan di `config/cors.php`:
```php
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_origins' => ['*'],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
```
### Connection Refused (Android Emulator)
Gunakan `10.0.2.2` instead of `localhost`:
```dart
final baseUrl = 'http://10.0.2.2:8000/api';
```
### 419 CSRF Token Error
Pastikan Sanctum sudah terkonfigurasi dengan benar dan gunakan `Accept: application/json` header.
---
## 📝 Catatan Penting
1. **Web Dashboard** hanya untuk Admin - tidak ada akses user
2. **Mobile App** hanya untuk User - tidak ada akses admin
3. API menggunakan **JSON** untuk semua komunikasi
4. Pastikan **Laravel server running** sebelum menjalankan mobile app
5. Gunakan **Sanctum token** untuk protected endpoints
---
## 🎯 Next Steps
### Untuk Web:
- [ ] Tambah fitur notifikasi booking baru
- [ ] Tambah dashboard analytics lebih detail
- [ ] Implementasi role permissions (owner vs superadmin)
### Untuk Mobile:
- [ ] Implementasi push notifications
- [ ] Tambah filter advanced di search
- [ ] Implementasi Google Maps untuk lokasi
- [ ] Tambah payment gateway integration
- [ ] Dark mode support
---
## 📞 Support
Jika ada pertanyaan atau masalah, hubungi developer team.
**Terakhir diupdate**: 30 Januari 2026

1
spk_kontrakan Submodule

@ -0,0 +1 @@
Subproject commit e804a3b75cf0bd371dbc112df86ddd42895fbd8e

45
spk_mobile/.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
spk_mobile/.metadata Normal file
View File

@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "66dd93f9a27ffe2a9bfc8297506ce066ff51265f"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: android
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: ios
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: linux
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: macos
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: web
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
- platform: windows
create_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
base_revision: 66dd93f9a27ffe2a9bfc8297506ce066ff51265f
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

141
spk_mobile/README.md Normal file
View File

@ -0,0 +1,141 @@
# SPK Mobile - Aplikasi Rekomendasi Tempat Tinggal dan Laundry
Aplikasi Android SPK (Sistem Pendukung Keputusan) untuk rekomendasi tempat tinggal dan laundry di area kampus menggunakan metode SAW.
## Getting Started
### Prerequisites
- Flutter SDK (versi terbaru)
- Android Studio / VS Code dengan Flutter extension
- Android device dengan Android 5.0 (API 21) atau lebih tinggi
- USB cable untuk menghubungkan device ke komputer
### Menjalankan Aplikasi di Device Android via USB
#### 1. Persiapan Device Android
**Aktifkan Developer Options:**
- Buka **Settings** → **About Phone**
- Ketuk **Build Number** sebanyak 7 kali
- Akan muncul notifikasi "You are now a developer"
**Aktifkan USB Debugging:**
- Buka **Settings** → **Developer Options**
- Aktifkan **USB Debugging**
- Aktifkan **Install via USB** (opsional)
#### 2. Sambungkan Device ke Komputer
- Sambungkan Android device ke komputer menggunakan USB cable
- Pilih mode **File Transfer** atau **MTP** saat muncul notifikasi di device
- Jika muncul popup "Allow USB debugging?", pilih **Allow** dan centang **Always allow from this computer**
#### 3. Verifikasi Device Terdeteksi
Buka terminal/command prompt di folder project dan jalankan:
```bash
flutter devices
```
Pastikan device Android Anda muncul dalam daftar, contoh:
```
2 connected devices:
sdk gphone64 arm64 (mobile) • emulator-5554 • android-arm64 • Android 13 (API 33)
SM A525F (mobile) • R58M30XXXXX • android-arm64 • Android 13 (API 33)
```
#### 4. Menjalankan Aplikasi
**Cara 1: Run ke semua device yang terdeteksi**
```bash
flutter run
```
**Cara 2: Run ke device tertentu**
```bash
flutter run -d <device-id>
```
Contoh:
```bash
flutter run -d R58M30XXXXX
```
**Cara 3: Run dengan hot reload (untuk development)**
```bash
flutter run --hot
```
#### 5. Troubleshooting
**Device tidak terdeteksi:**
- Install USB driver untuk device Anda (Samsung USB Driver, Xiaomi USB Driver, dll)
- Coba gunakan kabel USB lain
- Restart ADB:
```bash
adb kill-server
adb start-server
```
**Error "unauthorized":**
- Di device, izinkan USB debugging saat muncul popup
- Centang "Always allow from this computer"
**Error "insufficient permissions" (Linux/Mac):**
- Tambahkan udev rules untuk device Anda
- Atau jalankan dengan sudo (tidak disarankan)
**Device terdeteksi tapi tidak bisa install:**
- Pastikan device memiliki ruang penyimpanan yang cukup
- Pastikan "Install via USB" sudah diaktifkan di Developer Options
- Coba restart device dan komputer
### Menjalankan di Emulator
Jika tidak memiliki device fisik, Anda bisa menggunakan emulator:
1. Buka Android Studio
2. Tools → Device Manager
3. Create Virtual Device
4. Pilih device dan system image
5. Start emulator
6. Jalankan `flutter run`
### Build APK untuk Install Manual
Jika ingin membuat file APK untuk diinstall manual:
```bash
flutter build apk
```
File APK akan berada di: `build/app/outputs/flutter-apk/app-release.apk`
## Struktur Project
```
lib/
├── main.dart # Halaman utama (HomeScreen)
├── login.dart # Halaman login
└── register.dart # Halaman register
```
## Fitur
- ✅ Login & Register (UI only)
- ✅ Pilih Tipe: Kontrakan / Laundry
- ✅ Pilih Bobot: Harga, Luas, Jarak dari Kampus
- ✅ Sistem Rekomendasi menggunakan metode SAW
## Teknologi
- Flutter 3.10.3+
- Material Design 3
- Dart 3.10.3+
## Author
SPK Mobile App

View File

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
spk_mobile/android/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@ -0,0 +1,33 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.spk_mobile"
compileSdk = flutter.compileSdkVersion
defaultConfig {
applicationId = "com.example.spk_mobile"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("debug")
}
}
}

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions for location -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<application
android:label="spk_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.example.spk_mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Xlint:-options
android.useAndroidX=true

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

BIN
spk_mobile/build_output.txt Normal file

Binary file not shown.

34
spk_mobile/ios/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.spkMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.spkMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.spkMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.spkMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.spkMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.spkMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Spk Mobile</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spk_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

View File

@ -0,0 +1,17 @@
class AppConfig {
// Base URL - Ganti sesuai environment
// 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)
static const String baseUrl = 'http://192.168.18.16:8000/api';
// Timeouts
static const Duration connectionTimeout = Duration(seconds: 10);
static const Duration receiveTimeout = Duration(seconds: 10);
// Local Storage Keys
static const String tokenKey = 'auth_token';
static const String userKey = 'user_data';
}

505
spk_mobile/lib/login.dart Normal file
View File

@ -0,0 +1,505 @@
import 'package:flutter/material.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
import 'screens/improved_home_screen.dart';
import 'register.dart';
import 'services/auth_service.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _authService = AuthService();
bool _obscurePassword = true;
bool _isLoading = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: Column(
children: [
// Header
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: const BoxDecoration(
color: Color(0xFF1565C0), // Dark blue
),
child: SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
Navigator.pop(context);
},
),
const Spacer(),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Color(0xFF1565C0)),
),
],
),
),
),
// Main Content
Expanded(
child: Container(
color: Colors.white,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title Section
const Text(
'LOGIN',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(height: 1, color: Colors.grey[300]),
const SizedBox(height: 24),
// Section Heading
RichText(
text: const TextSpan(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: [
TextSpan(text: 'Masuk ke '),
TextSpan(
text: 'Akun',
style: TextStyle(color: Color(0xFF1565C0)),
),
TextSpan(text: ' Anda'),
],
),
),
const SizedBox(height: 32),
// Email Field
_buildTextField(
label: 'Email',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email_outlined,
),
const SizedBox(height: 20),
// Password Field
_buildTextField(
label: 'Password',
controller: _passwordController,
obscureText: _obscurePassword,
prefixIcon: Icons.lock_outline,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: const Color(0xFF1565C0),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
const SizedBox(height: 32),
// Login Button
SizedBox(
width: double.infinity,
child: _buildActionButton(
label: 'LOGIN',
onPressed: _isLoading ? null : _handleLogin,
),
),
const SizedBox(height: 24),
// Info Aplikasi untuk Mahasiswa Polije
_buildInfoCard(),
const SizedBox(height: 16),
// Register Link
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Belum punya akun? ',
style: TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RegisterScreen(),
),
);
},
child: const Text(
'Daftar',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1565C0),
),
),
),
],
),
const SizedBox(height: 20),
],
),
),
),
),
),
],
),
);
}
Future<void> _handleLogin() async {
print('=== LOGIN BUTTON CLICKED ===');
print('FormKey currentState: ${_formKey.currentState}');
if (_formKey.currentState == null) {
print('ERROR: FormKey currentState is null!');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: Form not initialized')),
);
return;
}
if (!_formKey.currentState!.validate()) {
print('Form validation failed');
return;
}
print('Form validated, attempting login...');
print('Email: ${_emailController.text.trim()}');
setState(() => _isLoading = true);
final result = await _authService.login(
email: _emailController.text.trim(),
password: _passwordController.text,
);
print('Login result: $result');
setState(() => _isLoading = false);
if (!mounted) return;
if (result['success']) {
print('Login successful, navigating to home...');
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()),
);
} else {
print('Login failed: ${result['message']}');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(result['message'] ?? 'Login gagal'),
backgroundColor: Colors.red,
),
);
}
}
Widget _buildTextField({
required String label,
required TextEditingController controller,
TextInputType? keyboardType,
bool obscureText = false,
IconData? prefixIcon,
Widget? suffixIcon,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Field ini tidak boleh kosong';
}
if (keyboardType == TextInputType.emailAddress) {
if (!value.contains('@')) {
return 'Email tidak valid';
}
}
return null;
},
decoration: InputDecoration(
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: const Color(0xFF1565C0))
: null,
suffixIcon: suffixIcon,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF1565C0),
width: 1.5,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF1565C0),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF1565C0), width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
],
);
}
Widget _buildInfoCard() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
const Color(0xFF1565C0).withValues(alpha: 0.1),
const Color(0xFF1565C0).withValues(alpha: 0.05),
],
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF1565C0).withValues(alpha: 0.3),
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF1565C0),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.school, color: Colors.white, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Khusus untuk Mahasiswa',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
const Text(
'Politeknik Negeri Jember',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoItem(
Icons.location_city,
'Temukan Kontrakan Terbaik',
'Dapatkan rekomendasi kontrakan terdekat dari kampus Polije dengan harga terjangkau dan fasilitas lengkap',
),
const SizedBox(height: 12),
_buildInfoItem(
Icons.local_laundry_service,
'Cari Laundry Terpercaya',
'Temukan jasa laundry terdekat dengan kualitas terbaik untuk kebutuhan harian Anda',
),
const SizedBox(height: 12),
_buildInfoItem(
Icons.star,
'Rekomendasi Terpercaya',
'Sistem akan memberikan rekomendasi terbaik berdasarkan preferensi dan kebutuhan Anda',
),
],
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: Color(0xFF1565C0),
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Aplikasi ini menggunakan metode SAW untuk memberikan rekomendasi terbaik',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
fontStyle: FontStyle.italic,
),
),
),
],
),
),
],
),
);
}
Widget _buildInfoItem(IconData icon, String title, String description) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: const Color(0xFF1565C0), size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
height: 1.4,
),
),
],
),
),
],
);
}
Widget _buildActionButton({
required String label,
required VoidCallback? onPressed,
}) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
disabledBackgroundColor: Colors.grey,
),
child: _isLoading
? const SpinKitThreeBounce(color: Colors.white, size: 20)
: Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
);
}
}

114
spk_mobile/lib/main.dart Normal file
View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'login.dart';
import 'screens/mobile_home_screen.dart';
import 'services/auth_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SPK Rekomendasi',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
primary: Colors.blue,
secondary: Colors.blue.shade700,
surface: Colors.white,
),
useMaterial3: true,
),
home: const SplashScreen(),
debugShowCheckedModeBanner: false,
);
}
}
// Splash screen untuk check authentication
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
final _authService = AuthService();
@override
void initState() {
super.initState();
_checkAuth();
}
Future<void> _checkAuth() async {
await _authService.loadToken();
// Delay untuk splash effect
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
// Navigate based on auth status
if (_authService.isAuthenticated) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MobileHomeScreen()),
);
} else {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1565C0),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(
Icons.home_work,
size: 80,
color: Color(0xFF1565C0),
),
),
const SizedBox(height: 24),
const Text(
'SPK Kontrakan',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
const Text(
'Rekomendasi Kontrakan Terbaik',
style: TextStyle(fontSize: 14, color: Colors.white70),
),
const SizedBox(height: 32),
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
],
),
),
);
}
}

View File

@ -0,0 +1,79 @@
class Booking {
final int id;
final int userId;
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
Booking({
required this.id,
required this.userId,
required this.kontrakanId,
required this.tanggalMulai,
required this.tanggalSelesai,
required this.durasiBulan,
required this.totalBiaya,
required this.status,
this.catatan,
this.kontrakan,
});
factory Booking.fromJson(Map<String, dynamic> json) {
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,
status: json['status'] ?? 'pending',
catatan: json['catatan'],
kontrakan: json['kontrakan'],
);
}
// Format total biaya
String get formattedTotalBiaya {
return 'Rp ${totalBiaya.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
// Status badge color
String get statusColor {
switch (status.toLowerCase()) {
case 'confirmed':
return 'green';
case 'active':
return 'blue';
case 'completed':
return 'gray';
case 'cancelled':
return 'red';
default:
return 'orange';
}
}
// Status label Indonesia
String get statusLabel {
switch (status.toLowerCase()) {
case 'pending':
return 'Menunggu';
case 'confirmed':
return 'Dikonfirmasi';
case 'active':
return 'Aktif';
case 'completed':
return 'Selesai';
case 'cancelled':
return 'Dibatalkan';
default:
return status;
}
}
}

View File

@ -0,0 +1,107 @@
class Kontrakan {
final int id;
final String nama;
final String alamat;
final double harga;
final int jumlahKamar;
final double jarakKampus;
final String fasilitas;
final String? deskripsi;
final String status;
final String? fotoUtama;
final List<Galeri> galeri;
final double? avgRating;
final int? totalReviews;
Kontrakan({
required this.id,
required this.nama,
required this.alamat,
required this.harga,
required this.jumlahKamar,
required this.jarakKampus,
required this.fasilitas,
this.deskripsi,
required this.status,
this.fotoUtama,
this.galeri = const [],
this.avgRating,
this.totalReviews,
});
factory Kontrakan.fromJson(Map<String, dynamic> json) {
// Try to get jarak from different possible field names
double jarak = 0;
if (json['jarak_kampus'] != null) {
jarak = double.tryParse(json['jarak_kampus'].toString()) ?? 0;
} else if (json['jarak'] != null) {
// 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'] ?? '',
alamat: json['alamat'] ?? '',
harga: double.tryParse(json['harga']?.toString() ?? '0') ?? 0,
jumlahKamar: json['jumlah_kamar'] ?? 0,
jarakKampus: jarak,
fasilitas: json['fasilitas'] ?? '',
deskripsi: json['deskripsi'],
status: json['status'] ?? 'tersedia',
fotoUtama: json['foto_utama'],
galeri: json['galeri'] != null
? (json['galeri'] as List).map((g) => Galeri.fromJson(g)).toList()
: [],
avgRating: json['avg_rating'] != null
? double.tryParse(json['avg_rating'].toString())
: null,
totalReviews: json['total_reviews'],
);
}
// Get primary photo URL
String get primaryPhoto {
if (galeri.isNotEmpty) {
final primary = galeri.firstWhere(
(g) => g.isPrimary,
orElse: () => galeri.first,
);
return primary.foto;
}
return fotoUtama ?? 'https://via.placeholder.com/300';
}
// Format harga dengan Rupiah
String get formattedHarga {
return 'Rp ${harga.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
// Get fasilitas as list
List<String> get fasilitasList {
return fasilitas.split(',').map((f) => f.trim()).toList();
}
}
class Galeri {
final int id;
final String foto;
final bool isPrimary;
final int urutan;
Galeri({
required this.id,
required this.foto,
required this.isPrimary,
required this.urutan,
});
factory Galeri.fromJson(Map<String, dynamic> json) {
return Galeri(
id: json['id'] ?? 0,
foto: json['foto'] ?? '',
isPrimary: json['is_primary'] == 1 || json['is_primary'] == true,
urutan: json['urutan'] ?? 0,
);
}
}

View File

@ -0,0 +1,158 @@
class Laundry {
final int id;
final String nama;
final String alamat;
final String? noWhatsapp;
final double? latitude;
final double? longitude;
final double jarakKampus; // jarak ke kampus dalam km
final String jamBuka;
final String jamTutup;
final double hargaPerKg;
final int waktuProses; // dalam jam
final String? deskripsi;
final String? fotoUtama;
final List<GaleriLaundry> galeri;
final double? avgRating;
final int? totalReviews;
final String status;
Laundry({
required this.id,
required this.nama,
required this.alamat,
this.noWhatsapp,
this.latitude,
this.longitude,
required this.jarakKampus,
required this.jamBuka,
required this.jamTutup,
required this.hargaPerKg,
required this.waktuProses,
this.deskripsi,
this.fotoUtama,
this.galeri = const [],
this.avgRating,
this.totalReviews,
required this.status,
});
factory Laundry.fromJson(Map<String, dynamic> json) {
// Try to get jarak from different possible field names
double jarak = 0;
if (json['jarak_kampus'] != null) {
jarak = double.tryParse(json['jarak_kampus'].toString()) ?? 0;
} else if (json['jarak'] != null) {
// Backend uses 'jarak' column, convert meters to km
jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000;
}
// Get harga from layanan if harga_per_kg is not set directly
double harga = 0;
if (json['harga_per_kg'] != null) {
harga = double.tryParse(json['harga_per_kg'].toString()) ?? 0;
} else if (json['layanan'] != null &&
(json['layanan'] as List).isNotEmpty) {
final layananList = json['layanan'] as List;
harga =
double.tryParse(layananList.first['harga']?.toString() ?? '0') ?? 0;
}
return Laundry(
id: json['id'] ?? 0,
nama: json['nama'] ?? '',
alamat: json['alamat'] ?? '',
noWhatsapp: json['no_whatsapp'],
latitude: json['latitude'] != null
? double.tryParse(json['latitude'].toString())
: null,
longitude: json['longitude'] != null
? double.tryParse(json['longitude'].toString())
: null,
jarakKampus: jarak,
jamBuka: json['jam_buka'] ?? '08:00',
jamTutup: json['jam_tutup'] ?? '17:00',
hargaPerKg: harga,
waktuProses: json['waktu_proses'] ?? 24,
deskripsi: json['deskripsi'],
fotoUtama: json['foto_utama'],
galeri: json['galeri'] != null
? (json['galeri'] as List)
.map((g) => GaleriLaundry.fromJson(g))
.toList()
: [],
avgRating: json['avg_rating'] != null
? double.tryParse(json['avg_rating'].toString())
: null,
totalReviews: json['total_reviews'],
status: json['status'] ?? 'buka',
);
}
// Get primary photo URL
String get primaryPhoto {
if (galeri.isNotEmpty) {
final primary = galeri.firstWhere(
(g) => g.isPrimary,
orElse: () => galeri.first,
);
return primary.foto;
}
return fotoUtama ?? 'https://via.placeholder.com/300';
}
// Format harga dengan Rupiah
String get formattedHarga {
return 'Rp ${hargaPerKg.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}';
}
// Legacy getters for backward compatibility
String get formattedHargaKiloan => formattedHarga;
String get formattedHargaSatuan => formattedHarga;
double get hargaKiloan => hargaPerKg;
double get jarak => jarakKampus; // alias for jarak field
double get rating => avgRating ?? 0.0;
String get estimasiSelesai => '$waktuProses jam';
// Check if currently open
bool get isOpen {
if (status != 'buka') return false;
final now = DateTime.now();
final currentTime =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
return currentTime.compareTo(jamBuka) >= 0 &&
currentTime.compareTo(jamTutup) <= 0;
}
// Get status text
String get statusText {
if (status == 'tutup') return 'Tutup';
if (isOpen) return 'Buka';
return 'Tutup Sementara';
}
}
class GaleriLaundry {
final int id;
final String foto;
final bool isPrimary;
final int urutan;
GaleriLaundry({
required this.id,
required this.foto,
required this.isPrimary,
required this.urutan,
});
factory GaleriLaundry.fromJson(Map<String, dynamic> json) {
return GaleriLaundry(
id: json['id'] ?? 0,
foto: json['foto'] ?? '',
isPrimary: json['is_primary'] == 1 || json['is_primary'] == true,
urutan: json['urutan'] ?? 0,
);
}
}

View File

@ -0,0 +1,41 @@
class User {
final int id;
final String name;
final String email;
final String? phone;
final String role;
final DateTime? createdAt;
User({
required this.id,
required this.name,
required this.email,
this.phone,
required this.role,
this.createdAt,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] ?? 0,
name: json['name'] ?? '',
email: json['email'] ?? '',
phone: json['phone'],
role: json['role'] ?? 'user',
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'phone': phone,
'role': role,
'created_at': createdAt?.toIso8601String(),
};
}
}

View File

@ -0,0 +1,366 @@
import 'package:flutter/material.dart';
import 'login.dart';
import 'screens/mobile_home_screen.dart';
import 'services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _isLoading = false;
final _authService = AuthService();
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: const BoxDecoration(color: Color(0xFF1565C0)),
child: SafeArea(
bottom: false,
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
const Spacer(),
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Color(0xFF1565C0)),
),
],
),
),
),
Expanded(
child: Container(
color: Colors.white,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'REGISTER',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Container(height: 1, color: Colors.grey[300]),
const SizedBox(height: 24),
RichText(
text: const TextSpan(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: [
TextSpan(text: 'Buat '),
TextSpan(
text: 'Akun',
style: TextStyle(color: Color(0xFF1565C0)),
),
TextSpan(text: ' Baru'),
],
),
),
const SizedBox(height: 32),
_buildTextField(
label: 'Nama',
controller: _nameController,
prefixIcon: Icons.person_outline,
),
const SizedBox(height: 20),
_buildTextField(
label: 'Email',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email_outlined,
),
const SizedBox(height: 20),
_buildTextField(
label: 'Password',
controller: _passwordController,
obscureText: _obscurePassword,
prefixIcon: Icons.lock_outline,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: const Color(0xFF1565C0),
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
const SizedBox(height: 20),
_buildTextField(
label: 'Konfirmasi Password',
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
prefixIcon: Icons.lock_outline,
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: const Color(0xFF1565C0),
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 2,
),
)
: const Text(
'DAFTAR',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Sudah punya akun? ',
style: TextStyle(fontSize: 14, color: Colors.black87),
),
GestureDetector(
onTap: () => Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => const LoginScreen(),
),
),
child: const Text(
'Masuk',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1565C0),
),
),
),
],
),
const SizedBox(height: 20),
],
),
),
),
),
],
),
);
}
Widget _buildTextField({
required String label,
required TextEditingController controller,
TextInputType? keyboardType,
bool obscureText = false,
IconData? prefixIcon,
Widget? suffixIcon,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
const SizedBox(height: 8),
TextField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
decoration: InputDecoration(
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: const Color(0xFF1565C0))
: null,
suffixIcon: suffixIcon,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF1565C0),
width: 1.5,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF1565C0),
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF1565C0),
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
],
);
}
Future<void> _handleRegister() async {
final name = _nameController.text.trim();
final email = _emailController.text.trim();
final password = _passwordController.text;
final confirmPassword = _confirmPasswordController.text;
if (name.isEmpty) {
_showError('Nama tidak boleh kosong');
return;
}
if (email.isEmpty) {
_showError('Email tidak boleh kosong');
return;
}
if (password.isEmpty) {
_showError('Password tidak boleh kosong');
return;
}
if (password.length < 6) {
_showError('Password minimal 6 karakter');
return;
}
if (password != confirmPassword) {
_showError('Password tidak cocok');
return;
}
setState(() {
_isLoading = true;
});
try {
final result = await _authService.register(
name: name,
email: email,
password: password,
passwordConfirmation: confirmPassword,
);
if (!mounted) return;
if (result['success'] == true) {
_showSuccess('Registrasi berhasil! Selamat datang!');
await Future.delayed(const Duration(seconds: 1));
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const MobileHomeScreen()),
);
}
} else {
final message = result['message'] ?? 'Registrasi gagal';
_showError(message);
}
} catch (e) {
_showError('Error: $e');
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
void _showSuccess(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
}

View File

@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import '../services/booking_service.dart';
import '../models/booking.dart';
class BookingHistoryScreen extends StatefulWidget {
const BookingHistoryScreen({super.key});
@override
State<BookingHistoryScreen> createState() => _BookingHistoryScreenState();
}
class _BookingHistoryScreenState extends State<BookingHistoryScreen>
with SingleTickerProviderStateMixin {
final _bookingService = BookingService();
late TabController _tabController;
List<Booking> _activeBookings = [];
List<Booking> _pastBookings = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_loadBookings();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadBookings() async {
setState(() => _isLoading = true);
final bookings = await _bookingService.getBookingHistory();
setState(() {
_activeBookings = bookings
.where((b) => b.status == 'confirmed' || b.status == 'pending')
.toList();
_pastBookings = bookings
.where((b) => b.status == 'completed' || b.status == 'cancelled')
.toList();
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF4CAF50), Color(0xFF66BB6A)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.bookmark_border,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
const Text(
'Booking Saya',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
const SizedBox(height: 16),
// Tabs
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
labelColor: const Color(0xFF4CAF50),
unselectedLabelColor: Colors.white,
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
tabs: const [
Tab(text: 'Aktif'),
Tab(text: 'Riwayat'),
],
),
),
],
),
),
// Content
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
_buildBookingList(_activeBookings, true),
_buildBookingList(_pastBookings, false),
],
),
),
],
),
),
);
}
Widget _buildBookingList(List<Booking> bookings, bool isActive) {
if (bookings.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isActive ? Icons.bookmark_border : Icons.history,
size: 80,
color: Colors.grey[300],
),
const SizedBox(height: 16),
Text(
isActive ? 'Belum ada booking aktif' : 'Belum ada riwayat',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
isActive
? 'Booking Anda akan ditampilkan di sini'
: 'Riwayat booking akan ditampilkan di sini',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: bookings.length,
itemBuilder: (context, index) {
return _buildBookingCard(bookings[index]);
},
);
}
Widget _buildBookingCard(Booking booking) {
Color statusColor;
String statusText;
IconData statusIcon;
switch (booking.status) {
case 'pending':
statusColor = Colors.orange;
statusText = 'Menunggu';
statusIcon = Icons.schedule;
break;
case 'confirmed':
statusColor = Colors.green;
statusText = 'Dikonfirmasi';
statusIcon = Icons.check_circle;
break;
case 'completed':
statusColor = Colors.blue;
statusText = 'Selesai';
statusIcon = Icons.done_all;
break;
case 'cancelled':
statusColor = Colors.red;
statusText = 'Dibatalkan';
statusIcon = Icons.cancel;
break;
default:
statusColor = Colors.grey;
statusText = 'Unknown';
statusIcon = Icons.help;
}
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Booking #${booking.id}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 16, color: statusColor),
const SizedBox(width: 4),
Text(
statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.calendar_today, size: 18, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
'Tanggal Mulai: ${_formatDate(booking.tanggalMulai)}',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.event, size: 18, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
'Tanggal Selesai: ${_formatDate(booking.tanggalSelesai)}',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.schedule, size: 18, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
'Durasi: ${booking.durasiBulan} Bulan',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total Harga',
style: TextStyle(fontSize: 14, color: Colors.black54),
),
Text(
'Rp ${_formatPrice(booking.totalBiaya)}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF4CAF50),
),
),
],
),
if (booking.status == 'pending') ...[
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
// Cancel booking
},
icon: const Icon(Icons.cancel),
label: const Text('Batalkan Booking'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
],
),
),
);
}
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year}';
}
String _formatPrice(double price) {
return price
.toStringAsFixed(0)
.replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,699 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/kontrakan.dart';
import '../models/laundry.dart';
import '../services/kontrakan_service.dart';
import '../services/laundry_service.dart';
import '../services/auth_service.dart';
import 'search_screen.dart';
import 'kontrakan_detail_screen.dart';
import 'laundry_detail_screen.dart';
import 'booking_history_screen.dart';
import 'profile_screen.dart';
import 'recommendation_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _kontrakanService = KontrakanService();
final _laundryService = LaundryService();
final _authService = AuthService();
List<Kontrakan> _kontrakanList = [];
List<Laundry> _laundryList = [];
bool _isLoading = true;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
final kontrakan = await _kontrakanService.getKontrakan();
final laundry = await _laundryService.getLaundry();
setState(() {
_kontrakanList = kontrakan.take(4).toList();
_laundryList = laundry.take(4).toList();
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: _buildBody(),
bottomNavigationBar: _buildBottomNav(),
);
}
Widget _buildBody() {
switch (_selectedIndex) {
case 0:
return _buildHomeContent();
case 1:
return const SearchScreen();
case 2:
return const BookingHistoryScreen();
case 3:
return const ProfileScreen();
default:
return _buildHomeContent();
}
}
Widget _buildHomeContent() {
return RefreshIndicator(
onRefresh: _loadData,
child: CustomScrollView(
slivers: [
// Gradient Header
SliverAppBar(
expandedHeight: 200,
floating: false,
pinned: true,
backgroundColor: const Color(0xFF1565C0),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
Color(0xFF1976D2),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_work,
color: Colors.white,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'SPK Kontrakan & Laundry',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
letterSpacing: 0.3,
),
),
const SizedBox(height: 4),
Text(
'Selamat datang, ${_authService.currentUser?.name ?? "User"}',
style: TextStyle(
color: Colors.white.withValues(
alpha: 0.9,
),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
IconButton(
icon: const Icon(
Icons.notifications_outlined,
color: Colors.white,
size: 28,
),
onPressed: () {},
),
],
),
const SizedBox(height: 20),
// Search Bar
GestureDetector(
onTap: () => setState(() => _selectedIndex = 1),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Icon(Icons.search, color: Colors.grey[400]),
const SizedBox(width: 12),
Text(
'Cari kontrakan atau laundry...',
style: TextStyle(
color: Colors.grey[400],
fontSize: 15,
),
),
],
),
),
),
],
),
),
),
),
),
),
// Content
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quick Actions
_buildQuickActions(),
const SizedBox(height: 24),
// Featured Kontrakan Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.home_work,
color: Colors.blue[700],
size: 22,
),
const SizedBox(width: 8),
const Text(
'Kontrakan Tersedia',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
TextButton(
onPressed: () {
setState(() => _selectedIndex = 1);
},
child: const Text('Lihat Semua'),
),
],
),
const SizedBox(height: 12),
// Kontrakan List
_isLoading
? const Center(child: CircularProgressIndicator())
: _kontrakanList.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('Tidak ada kontrakan tersedia'),
),
)
: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _kontrakanList.length,
itemBuilder: (context, index) {
return _buildKontrakanCard(_kontrakanList[index]);
},
),
const SizedBox(height: 32),
// Laundry Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.local_laundry_service,
color: Colors.cyan[700],
size: 22,
),
const SizedBox(width: 8),
const Text(
'Laundry Tersedia',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
TextButton(
onPressed: () {
setState(() => _selectedIndex = 1);
},
child: const Text('Lihat Semua'),
),
],
),
const SizedBox(height: 12),
// Laundry List
_isLoading
? const Center(child: CircularProgressIndicator())
: _laundryList.isEmpty
? const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('Tidak ada laundry tersedia'),
),
)
: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _laundryList.length,
itemBuilder: (context, index) {
return _buildLaundryCard(_laundryList[index]);
},
),
],
),
),
),
],
),
);
}
Widget _buildQuickActions() {
return Column(
children: [
Row(
children: [
Expanded(
child: _buildActionCard(
icon: Icons.star,
title: 'Rekomendasi Kontrakan',
color: Colors.amber,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const RecommendationScreen(category: 'kontrakan'),
),
);
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
icon: Icons.local_laundry_service,
title: 'Rekomendasi Laundry',
color: Colors.purple,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const RecommendationScreen(category: 'laundry'),
),
);
},
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildActionCard(
icon: Icons.bookmark,
title: 'Booking Saya',
color: Colors.green,
onTap: () => setState(() => _selectedIndex = 2),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCard(
icon: Icons.person,
title: 'Profil',
color: Colors.orange,
onTap: () => setState(() => _selectedIndex = 3),
),
),
],
),
],
);
}
Widget _buildActionCard({
required IconData icon,
required String title,
required Color color,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(height: 8),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
),
);
}
Widget _buildKontrakanCard(Kontrakan kontrakan) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => KontrakanDetailScreen(kontrakan: kontrakan),
),
);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: CachedNetworkImage(
imageUrl: kontrakan.primaryPhoto,
height: 120,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[300],
child: const Icon(Icons.image, size: 50),
),
),
),
// Info
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kontrakan.nama,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
kontrakan.formattedHarga + '/bln',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF1565C0),
),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.location_on,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'${kontrakan.jarakKampus} km',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildLaundryCard(Laundry laundry) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LaundryDetailScreen(laundry: laundry),
),
);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
),
),
child: const Icon(
Icons.local_laundry_service,
size: 60,
color: Colors.white,
),
),
),
// Info
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.nama,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Text(
laundry.formattedHargaKiloan,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF00BCD4),
),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.location_on,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'${laundry.jarak.toStringAsFixed(1)} km dari kampus',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBottomNav() {
return BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
type: BottomNavigationBarType.fixed,
selectedItemColor: const Color(0xFF1565C0),
unselectedItemColor: Colors.grey,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Cari'),
BottomNavigationBarItem(icon: Icon(Icons.bookmark), label: 'Booking'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
);
}
// Method untuk logout (digunakan di ProfileScreen)
// Future<void> _handleLogout() async {
// final confirm = await showDialog<bool>(
// context: context,
// builder: (context) => AlertDialog(
// title: const Text('Logout'),
// content: const Text('Apakah Anda yakin ingin keluar?'),
// actions: [
// TextButton(
// onPressed: () => Navigator.pop(context, false),
// child: const Text('Batal'),
// ),
// TextButton(
// onPressed: () => Navigator.pop(context, true),
// child: const Text('Logout'),
// ),
// ],
// ),
// );
// if (confirm == true && mounted) {
// await _authService.logout();
// if (mounted) {
// Navigator.pushReplacement(
// context,
// MaterialPageRoute(builder: (context) => const LoginScreen()),
// );
// }
// }
// }
}

View File

@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../config/app_config.dart';
import '../models/kontrakan.dart';
import '../services/auth_service.dart';
import '../widgets/kontrakan_card.dart';
import 'search_screen.dart';
import 'booking_history_screen.dart';
import 'profile_screen.dart';
import 'recommendation_screen.dart';
import 'laundry_list_screen.dart';
import '../login.dart';
class ImprovedHomeScreen extends StatefulWidget {
const ImprovedHomeScreen({super.key});
@override
State<ImprovedHomeScreen> createState() => _ImprovedHomeScreenState();
}
class _ImprovedHomeScreenState extends State<ImprovedHomeScreen> {
final _authService = AuthService();
List<Map<String, dynamic>> _topKontrakan = [];
List<Map<String, dynamic>> _recommendations = [];
bool _isLoading = true;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
_loadRecommendations();
}
Future<void> _loadRecommendations() async {
setState(() => _isLoading = true);
await Future.wait([_loadTopKontrakan(), _loadTopLaundry()]);
setState(() => _isLoading = false);
}
Future<void> _loadTopKontrakan() async {
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/saw/calculate/kontrakan'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode({}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['success'] == true) {
final List hasil = data['data']['hasil'] ?? [];
setState(() {
_topKontrakan = hasil.take(5).toList().cast<Map<String, dynamic>>();
});
}
}
} catch (e) {
print('Error loading top kontrakan: $e');
}
}
Future<void> _loadTopLaundry() async {
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/saw/calculate/laundry'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: json.encode({}),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['success'] == true) {
final List hasil = data['data']['hasil'] ?? [];
setState(() {
_recommendations = hasil
.take(5)
.toList()
.cast<Map<String, dynamic>>();
});
}
}
} catch (e) {
print('Error loading top laundry: $e');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: _buildBody(),
bottomNavigationBar: _buildBottomNav(),
);
}
Widget _buildBody() {
switch (_selectedIndex) {
case 0:
return _buildHomeContent();
case 1:
return const SearchScreen();
case 2:
return const BookingHistoryScreen();
case 3:
return const ProfileScreen();
default:
return _buildHomeContent();
}
}
Widget _buildHomeContent() {
return RefreshIndicator(
onRefresh: _loadRecommendations,
child: CustomScrollView(
slivers: [
// Gradient Header
SliverAppBar(
expandedHeight: 180,
floating: false,
pinned: true,
backgroundColor: const Color(0xFF1565C0),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
Color(0xFF1976D2),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.home_work,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'SPK Kontrakan',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Hi, ${_authService.currentUser?.name ?? "User"}!',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 15,
),
),
],
),
),
IconButton(
icon: const Icon(
Icons.logout_outlined,
color: Colors.white,
size: 26,
),
onPressed: _handleLogout,
),
],
),
const SizedBox(height: 16),
GestureDetector(
onTap: () => setState(() => _selectedIndex = 1),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
Icon(
Icons.search,
color: Colors.grey[400],
size: 22,
),
const SizedBox(width: 12),
Text(
'Cari kontrakan atau laundry...',
style: TextStyle(
color: Colors.grey[400],
fontSize: 15,
),
),
],
),
),
),
],
),
),
),
),
),
),
// Top Kontrakan Recommendations
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.emoji_events,
color: Colors.amber,
size: 20,
),
),
const SizedBox(width: 12),
const Text(
'Rekomendasi Kontrakan Terbaik',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const RecommendationScreen(category: 'kontrakan'),
),
);
},
child: const Text('Lihat Semua'),
),
],
),
),
),
// Kontrakan List
_isLoading
? const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
),
)
: _topKontrakan.isEmpty
? const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text('Tidak ada data kontrakan'),
),
),
)
: SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = _topKontrakan[index];
final kontrakan = Kontrakan.fromJson(item['data']);
final ranking = item['ranking'] ?? (index + 1);
final skor = (item['skor'] ?? 0.0) as double;
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: KontrakanCard(
kontrakan: kontrakan,
ranking: ranking,
skor: skor,
showRanking: true,
),
);
}, childCount: _topKontrakan.length),
),
),
// Quick Action - Laundry
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 20, 16, 12),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LaundryListScreen(),
),
);
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF00BCD4), Color(0xFF00ACC1)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF00BCD4).withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.local_laundry_service,
color: Colors.white,
size: 28,
),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Layanan Laundry',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 4),
Text(
'Temukan laundry terbaik di sekitar kampus',
style: TextStyle(
fontSize: 13,
color: Colors.white70,
),
),
],
),
),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 18,
),
],
),
),
),
),
),
// Bottom Padding
const SliverToBoxAdapter(child: SizedBox(height: 80)),
],
),
);
}
Widget _buildBottomNav() {
return Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, -5),
),
],
),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: const Color(0xFF1565C0),
unselectedItemColor: Colors.grey,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(Icons.search_outlined),
activeIcon: Icon(Icons.search),
label: 'Cari',
),
BottomNavigationBarItem(
icon: Icon(Icons.bookmark_outline),
activeIcon: Icon(Icons.bookmark),
label: 'Booking',
),
BottomNavigationBarItem(
icon: Icon(Icons.person_outline),
activeIcon: Icon(Icons.person),
label: 'Profil',
),
],
),
);
}
Future<void> _handleLogout() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Konfirmasi Logout'),
content: const Text('Apakah Anda yakin ingin keluar?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Logout'),
),
],
),
);
if (confirm == true) {
await _authService.logout();
if (mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
(route) => false,
);
}
}
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/kontrakan.dart';
class KontrakanDetailScreen extends StatelessWidget {
final Kontrakan kontrakan;
const KontrakanDetailScreen({super.key, required this.kontrakan});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('Detail Kontrakan'),
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image Gallery
SizedBox(
height: 250,
child: PageView.builder(
itemCount: kontrakan.galeri.isEmpty
? 1
: kontrakan.galeri.length,
itemBuilder: (context, index) {
final imageUrl = kontrakan.galeri.isEmpty
? kontrakan.primaryPhoto
: kontrakan.galeri[index].foto;
return CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => Container(
color: Colors.grey[300],
child: const Icon(Icons.image, size: 100),
),
);
},
),
),
// Info Section
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title & Price
Text(
kontrakan.nama,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'${kontrakan.formattedHarga}/bulan',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF1565C0),
),
),
const SizedBox(height: 16),
// Location
_buildInfoRow(Icons.location_on, kontrakan.alamat),
_buildInfoRow(
Icons.directions_walk,
'${kontrakan.jarakKampus} km dari kampus',
),
_buildInfoRow(Icons.bed, '${kontrakan.jumlahKamar} Kamar'),
const SizedBox(height: 20),
// Facilities
const Text(
'Fasilitas',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: kontrakan.fasilitasList.map((f) {
return Chip(
label: Text(f),
backgroundColor: Colors.blue[50],
);
}).toList(),
),
if (kontrakan.deskripsi != null) ...[
const SizedBox(height: 20),
const Text(
'Deskripsi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
kontrakan.deskripsi!,
style: const TextStyle(fontSize: 14, height: 1.5),
),
],
const SizedBox(height: 80),
],
),
),
],
),
),
bottomSheet: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: ElevatedButton(
onPressed: () {
// TODO: Navigate to booking screen
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fitur booking segera hadir')),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Booking Sekarang',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
),
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(icon, size: 20, color: Colors.grey[600]),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14, color: Colors.black87),
),
),
],
),
);
}
}

View File

@ -0,0 +1,415 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/laundry.dart';
class LaundryDetailScreen extends StatelessWidget {
final Laundry laundry;
const LaundryDetailScreen({super.key, required this.laundry});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: CustomScrollView(
slivers: [
// App Bar with gradient
SliverAppBar(
expandedHeight: 200,
pinned: true,
backgroundColor: const Color(0xFF00BCD4),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF00BCD4), Color(0xFF00ACC1)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.local_laundry_service,
size: 40,
color: Color(0xFF00BCD4),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.nama,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.star,
size: 20,
color: Colors.amber[300],
),
const SizedBox(width: 6),
Text(
laundry.rating?.toStringAsFixed(1) ??
'N/A',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(
alpha: 0.95,
),
),
),
],
),
],
),
),
],
),
],
),
),
),
),
),
),
// Content
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Status Card
_buildStatusCard(),
const SizedBox(height: 16),
// Info Section
_buildSection(
icon: Icons.info_outline,
title: 'Informasi',
child: Column(
children: [
_buildInfoRow(
Icons.location_on,
'Alamat',
laundry.alamat,
),
_buildInfoRow(
Icons.access_time,
'Jam Operasional',
'${laundry.jamBuka} - ${laundry.jamTutup}',
),
_buildInfoRow(
Icons.schedule,
'Estimasi Selesai',
'${laundry.estimasiSelesai} jam',
),
],
),
),
const SizedBox(height: 16),
// Pricing Section
_buildSection(
icon: Icons.price_check,
title: 'Harga',
child: Column(
children: [
_buildPriceCard(
'Laundry Kiloan',
laundry.formattedHargaKiloan,
Icons.scale,
const Color(0xFF00BCD4),
),
const SizedBox(height: 12),
_buildPriceCard(
'Laundry Satuan',
laundry.formattedHargaSatuan,
Icons.checkroom,
Colors.purple,
),
],
),
),
const SizedBox(height: 24),
// Action Buttons
if (laundry.noWhatsapp != null) ...[
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton.icon(
onPressed: () => _launchWhatsApp(laundry.noWhatsapp!),
icon: const Icon(Icons.message, size: 24),
label: const Text(
'Hubungi via WhatsApp',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF25D366),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 12),
],
if (laundry.latitude != null && laundry.longitude != null)
SizedBox(
width: double.infinity,
height: 50,
child: OutlinedButton.icon(
onPressed: () =>
_launchMaps(laundry.latitude!, laundry.longitude!),
icon: const Icon(Icons.map, size: 24),
label: const Text(
'Lihat di Maps',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF00BCD4),
side: const BorderSide(
color: Color(0xFF00BCD4),
width: 2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 24),
]),
),
),
],
),
);
}
Widget _buildStatusCard() {
Color statusColor = laundry.status == 'buka' ? Colors.green : Colors.red;
if (laundry.status == 'buka' && !laundry.isOpen) {
statusColor = Colors.orange;
}
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: statusColor, width: 2),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
child: const Icon(Icons.store, color: Colors.white, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status Toko',
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
const SizedBox(height: 4),
Text(
laundry.statusText,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
],
),
),
],
),
);
}
Widget _buildSection({
required IconData icon,
required String title,
required Widget child,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: const Color(0xFF00BCD4)),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
],
),
const SizedBox(height: 16),
child,
],
),
);
}
Widget _buildInfoRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Colors.grey[600]),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 13, color: Colors.grey[600]),
),
const SizedBox(height: 2),
Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
),
],
),
);
}
Widget _buildPriceCard(
String title,
String price,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: Colors.white, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
const SizedBox(height: 4),
Text(
price,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
],
),
);
}
Future<void> _launchWhatsApp(String phone) async {
final url = 'https://wa.me/$phone';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
Future<void> _launchMaps(double lat, double lng) async {
final url = 'https://www.google.com/maps/search/?api=1&query=$lat,$lng';
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
}
}

View File

@ -0,0 +1,531 @@
import 'package:flutter/material.dart';
import '../models/laundry.dart';
import '../services/laundry_service.dart';
import 'laundry_detail_screen.dart';
class LaundryListScreen extends StatefulWidget {
const LaundryListScreen({super.key});
@override
State<LaundryListScreen> createState() => _LaundryListScreenState();
}
class _LaundryListScreenState extends State<LaundryListScreen> {
final _laundryService = LaundryService();
List<Laundry> _laundryList = [];
List<Laundry> _filteredList = [];
bool _isLoading = true;
String _sortBy = 'rating'; // rating, harga, jarak
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_loadLaundry();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadLaundry() async {
setState(() => _isLoading = true);
final list = await _laundryService.getLaundry();
setState(() {
_laundryList = list;
_filteredList = list;
_isLoading = false;
});
}
void _filterList(String query) {
setState(() {
if (query.isEmpty) {
_filteredList = _laundryList;
} else {
_filteredList = _laundryList.where((laundry) {
return laundry.nama.toLowerCase().contains(query.toLowerCase()) ||
laundry.alamat.toLowerCase().contains(query.toLowerCase());
}).toList();
}
});
}
void _sortList(String sortBy) {
setState(() {
_sortBy = sortBy;
if (sortBy == 'rating') {
_filteredList.sort((a, b) => (b.rating ?? 0).compareTo(a.rating ?? 0));
} else if (sortBy == 'harga') {
_filteredList.sort((a, b) => a.hargaKiloan.compareTo(b.hargaKiloan));
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF00BCD4), Color(0xFF00ACC1)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.local_laundry_service,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Laundry Terdekat',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
IconButton(
icon: const Icon(Icons.tune, color: Colors.white),
onPressed: _showSortDialog,
),
],
),
const SizedBox(height: 12),
// Search Bar
TextField(
controller: _searchController,
onChanged: _filterList,
style: const TextStyle(fontSize: 16),
decoration: InputDecoration(
hintText: 'Cari laundry...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_filterList('');
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
],
),
),
// Info Bar
if (!_isLoading)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: Colors.white,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.store, size: 18, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
'${_filteredList.length} Laundry Tersedia',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
],
),
Text(
'Urut: ${_sortBy == "rating" ? "Rating" : "Harga"}',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
// List
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredList.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.local_laundry_service_outlined,
size: 80,
color: Colors.grey[300],
),
const SizedBox(height: 16),
Text(
'Belum ada laundry',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Laundry akan ditampilkan di sini',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadLaundry,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _filteredList.length,
itemBuilder: (context, index) {
return _buildLaundryCard(_filteredList[index]);
},
),
),
),
],
),
),
);
}
Widget _buildLaundryCard(Laundry laundry) {
Color statusColor = laundry.status == 'buka' ? Colors.green : Colors.red;
if (laundry.status == 'buka' && !laundry.isOpen) {
statusColor = Colors.orange;
}
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LaundryDetailScreen(laundry: laundry),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF00BCD4).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.local_laundry_service,
color: Color(0xFF00BCD4),
size: 28,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.nama,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.star, size: 16, color: Colors.amber[700]),
const SizedBox(width: 4),
Text(
laundry.rating?.toStringAsFixed(1) ?? 'N/A',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
laundry.statusText,
style: TextStyle(
color: statusColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 12),
// Info
Row(
children: [
Icon(Icons.location_on, size: 16, color: Colors.grey[600]),
const SizedBox(width: 6),
Expanded(
child: Text(
laundry.alamat,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.access_time, size: 16, color: Colors.grey[600]),
const SizedBox(width: 6),
Text(
'${laundry.jamBuka} - ${laundry.jamTutup}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
],
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 12),
// Pricing
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF00BCD4).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Kiloan',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
laundry.formattedHargaKiloan,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF00BCD4),
),
),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.purple.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Satuan',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Text(
laundry.formattedHargaSatuan,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.purple,
),
),
],
),
),
),
],
),
const SizedBox(height: 12),
// Estimasi
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, size: 16, color: Colors.orange[700]),
const SizedBox(width: 6),
Text(
'Selesai dalam ${laundry.estimasiSelesai} jam',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.orange[700],
),
),
],
),
),
],
),
),
),
);
}
void _showSortDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Urutkan Berdasarkan',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.star, color: Colors.amber),
title: const Text('Rating Tertinggi'),
onTap: () {
Navigator.pop(context);
_sortList('rating');
},
),
ListTile(
leading: const Icon(Icons.attach_money, color: Colors.green),
title: const Text('Harga Termurah'),
onTap: () {
Navigator.pop(context);
_sortList('harga');
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,449 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../login.dart';
import 'search_screen.dart';
import 'booking_history_screen.dart';
import 'profile_screen.dart';
import 'recommendation_screen.dart';
import 'laundry_list_screen.dart';
class MobileHomeScreen extends StatefulWidget {
const MobileHomeScreen({super.key});
@override
State<MobileHomeScreen> createState() => _MobileHomeScreenState();
}
class _MobileHomeScreenState extends State<MobileHomeScreen> {
final _authService = AuthService();
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: _buildBody(),
bottomNavigationBar: _buildBottomNav(),
);
}
Widget _buildBody() {
switch (_selectedIndex) {
case 0:
return _buildMobileHomeContent();
case 1:
return const SearchScreen();
case 2:
return const BookingHistoryScreen();
case 3:
return const ProfileScreen();
default:
return _buildMobileHomeContent();
}
}
Widget _buildMobileHomeContent() {
return CustomScrollView(
slivers: [
// Mobile-friendly Header
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: const Color(0xFF1565C0),
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF1565C0),
Color(0xFF0D47A1),
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.home_work,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'SPK Kontrakan',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'Hi, ${_authService.currentUser?.name ?? "User"}!',
style: TextStyle(
color: Colors.white.withOpacity(0.9),
fontSize: 12,
),
),
],
),
),
IconButton(
icon: const Icon(
Icons.logout_outlined,
color: Colors.white,
size: 20,
),
onPressed: _handleLogout,
),
],
),
),
),
),
),
),
// Welcome Section
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Selamat Datang! 👋',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
const SizedBox(height: 8),
Text(
'Temukan rekomendasi terbaik untuk kebutuhan Anda',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
),
// Category Selection
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Apa yang Anda cari?',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
),
),
// Category Cards
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Kontrakan Card
_buildCategoryCard(
title: 'Kontrakan',
subtitle: 'Temukan kontrakan ideal untuk Anda',
icon: Icons.home,
color: const Color(0xFF1565C0),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RecommendationScreen(
category: 'kontrakan',
),
),
);
},
),
const SizedBox(height: 16),
// Laundry Card
_buildCategoryCard(
title: 'Layanan Laundry',
subtitle: 'Temukan laundry terbaik di sekitar kampus',
icon: Icons.local_laundry_service,
color: const Color(0xFF00ACC1),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LaundryListScreen(),
),
);
},
),
],
),
),
),
// Quick Search Section
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.search,
color: Colors.grey[600],
size: 20,
),
const SizedBox(width: 8),
Text(
'Pencarian Cepat',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
],
),
const SizedBox(height: 16),
GestureDetector(
onTap: () => setState(() => _selectedIndex = 1),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[300]!),
),
child: Row(
children: [
Icon(
Icons.search,
color: Colors.grey[400],
size: 20,
),
const SizedBox(width: 12),
Text(
'Cari kontrakan atau laundry...',
style: TextStyle(
color: Colors.grey[400],
fontSize: 14,
),
),
],
),
),
),
],
),
),
),
],
);
}
Widget _buildCategoryCard({
required String title,
required String subtitle,
required IconData icon,
required Color color,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 3),
),
],
border: Border.all(color: color.withOpacity(0.1)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: color,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: color.withOpacity(0.7),
size: 18,
),
],
),
),
);
}
Widget _buildBottomNav() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) => setState(() => _selectedIndex = index),
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.transparent,
elevation: 0,
selectedItemColor: const Color(0xFF1565C0),
unselectedItemColor: Colors.grey,
selectedFontSize: 12,
unselectedFontSize: 11,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Cari',
),
BottomNavigationBarItem(
icon: Icon(Icons.bookmark),
label: 'Booking',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profil',
),
],
),
),
),
);
}
Future<void> _handleLogout() async {
final shouldLogout = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Konfirmasi Logout'),
content: const Text('Apakah Anda yakin ingin keluar?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Batal'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
),
);
if (shouldLogout == true) {
await _authService.logout();
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
);
}
}
}
}

View File

@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../models/user.dart';
import '../login.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
final _authService = AuthService();
User? _currentUser;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
setState(() => _isLoading = true);
final user = await _authService.getCurrentUser();
setState(() {
_currentUser = user;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SafeArea(
child: CustomScrollView(
slivers: [
// Header with Profile
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1565C0), Color(0xFF1976D2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
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),
),
),
),
),
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
SliverPadding(
padding: const EdgeInsets.all(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
SizedBox(
width: double.infinity,
child: ElevatedButton.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),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 24),
]),
),
),
],
),
),
);
}
Widget _buildSection(String title) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey[700],
letterSpacing: 0.5,
),
),
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
required String subtitle,
VoidCallback? onTap,
Widget? trailing,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: ListTile(
onTap: onTap,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
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,
),
),
);
}
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'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Sistem Pendukung Keputusan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Aplikasi untuk membantu mahasiswa menemukan kontrakan terbaik dengan metode SAW (Simple Additive Weighting)',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
_buildInfoRow('Versi', '1.0.0'),
_buildInfoRow('Developer', 'SPK Team'),
_buildInfoRow('Tahun', '2026'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Tutup'),
),
],
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
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,
),
),
],
),
);
}
Future<void> _handleLogout() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(Icons.logout, color: Colors.red[700]),
const SizedBox(width: 12),
const Text('Konfirmasi Logout'),
],
),
content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Logout'),
),
],
),
);
if (confirm == true) {
await _authService.logout();
if (mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (context) => const LoginScreen()),
(route) => false,
);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,869 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/kontrakan.dart';
import '../models/laundry.dart';
import '../services/kontrakan_service.dart';
import '../services/laundry_service.dart';
import 'kontrakan_detail_screen.dart';
import 'laundry_detail_screen.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _kontrakanService = KontrakanService();
final _laundryService = LaundryService();
final _searchController = TextEditingController();
List<Kontrakan> _allKontrakan = [];
List<Kontrakan> _filteredKontrakan = [];
List<Laundry> _allLaundry = [];
List<Laundry> _filteredLaundry = [];
bool _isLoading = true;
String _selectedCategory = 'Kontrakan';
String _selectedFilter = 'Semua';
RangeValues _priceRange = const RangeValues(0, 20000000);
@override
void initState() {
super.initState();
_loadData();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
final kontrakan = await _kontrakanService.getKontrakan();
final laundry = await _laundryService.getLaundry();
setState(() {
_allKontrakan = kontrakan;
_filteredKontrakan = kontrakan;
_allLaundry = laundry;
_filteredLaundry = laundry;
_isLoading = false;
});
}
void _filterKontrakan(String query) {
if (_selectedCategory == 'Kontrakan') {
setState(() {
if (query.isEmpty) {
_filteredKontrakan = _allKontrakan;
} else {
_filteredKontrakan = _allKontrakan.where((kontrakan) {
return kontrakan.nama.toLowerCase().contains(query.toLowerCase()) ||
kontrakan.alamat.toLowerCase().contains(query.toLowerCase());
}).toList();
}
_applyFilters();
});
} else {
setState(() {
if (query.isEmpty) {
_filteredLaundry = _allLaundry;
} else {
_filteredLaundry = _allLaundry.where((laundry) {
return laundry.nama.toLowerCase().contains(query.toLowerCase()) ||
laundry.alamat.toLowerCase().contains(query.toLowerCase());
}).toList();
}
_applyLaundryFilters();
});
}
}
void _applyFilters() {
var filtered = List<Kontrakan>.from(_allKontrakan);
// Apply search
if (_searchController.text.isNotEmpty) {
filtered = filtered.where((k) {
return k.nama.toLowerCase().contains(
_searchController.text.toLowerCase(),
) ||
k.alamat.toLowerCase().contains(
_searchController.text.toLowerCase(),
);
}).toList();
}
// Filter by status
if (_selectedFilter != 'Semua') {
filtered = filtered.where((k) {
if (_selectedFilter == 'Tersedia') return k.status == 'available';
if (_selectedFilter == 'Penuh') return k.status == 'occupied';
return true;
}).toList();
}
// Filter by price
filtered = filtered.where((k) {
return k.harga >= _priceRange.start && k.harga <= _priceRange.end;
}).toList();
setState(() => _filteredKontrakan = filtered);
}
void _applyLaundryFilters() {
var filtered = List<Laundry>.from(_allLaundry);
// Apply search
if (_searchController.text.isNotEmpty) {
filtered = filtered.where((l) {
return l.nama.toLowerCase().contains(
_searchController.text.toLowerCase(),
) ||
l.alamat.toLowerCase().contains(
_searchController.text.toLowerCase(),
);
}).toList();
}
// Filter by price
filtered = filtered.where((l) {
final harga = l.hargaKiloan * 10; // Approximate for 10kg
return harga >= _priceRange.start && harga <= _priceRange.end;
}).toList();
setState(() => _filteredLaundry = filtered);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
child: Column(
children: [
// Header with Search Bar
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF1565C0), Color(0xFF1976D2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.search, color: Colors.white, size: 28),
const SizedBox(width: 12),
const Text(
'Cari & Jelajahi',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.tune, color: Colors.white),
onPressed: _showFilterDialog,
),
],
),
const SizedBox(height: 16),
// Category Tabs
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () =>
setState(() => _selectedCategory = 'Kontrakan'),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _selectedCategory == 'Kontrakan'
? Colors.white
: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.home_work,
color: _selectedCategory == 'Kontrakan'
? const Color(0xFF1565C0)
: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
'Kontrakan',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _selectedCategory == 'Kontrakan'
? const Color(0xFF1565C0)
: Colors.white,
),
),
],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () =>
setState(() => _selectedCategory = 'Laundry'),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _selectedCategory == 'Laundry'
? Colors.white
: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.local_laundry_service,
color: _selectedCategory == 'Laundry'
? const Color(0xFF00BCD4)
: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
'Laundry',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _selectedCategory == 'Laundry'
? const Color(0xFF00BCD4)
: Colors.white,
),
),
],
),
),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _searchController,
onChanged: _filterKontrakan,
style: const TextStyle(fontSize: 16),
decoration: InputDecoration(
hintText: _selectedCategory == 'Kontrakan'
? 'Cari kontrakan...'
: 'Cari laundry...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_filterKontrakan('');
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
const SizedBox(height: 12),
// Quick Filters (only for Kontrakan)
if (_selectedCategory == 'Kontrakan')
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildFilterChip('Semua'),
const SizedBox(width: 8),
_buildFilterChip('Tersedia'),
const SizedBox(width: 8),
_buildFilterChip('Penuh'),
],
),
),
],
),
),
// Results Count
if (!_isLoading)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
color: Colors.white,
child: Row(
children: [
Icon(Icons.filter_list, size: 18, color: Colors.grey[600]),
const SizedBox(width: 8),
Text(
_selectedCategory == 'Kontrakan'
? 'Ditemukan ${_filteredKontrakan.length} kontrakan'
: 'Ditemukan ${_filteredLaundry.length} laundry',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
],
),
),
// List
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _selectedCategory == 'Kontrakan'
? _buildKontrakanList()
: _buildLaundryList(),
),
],
),
),
);
}
Widget _buildKontrakanList() {
if (_filteredKontrakan.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 80, color: Colors.grey[300]),
const SizedBox(height: 16),
Text(
'Tidak ada hasil',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Coba kata kunci lain',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _filteredKontrakan.length,
itemBuilder: (context, index) {
return _buildKontrakanItem(_filteredKontrakan[index]);
},
);
}
Widget _buildLaundryList() {
if (_filteredLaundry.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 80, color: Colors.grey[300]),
const SizedBox(height: 16),
Text(
'Tidak ada hasil',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Coba kata kunci lain',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _filteredLaundry.length,
itemBuilder: (context, index) {
return _buildLaundryItem(_filteredLaundry[index]);
},
);
}
Widget _buildFilterChip(String label) {
final isSelected = _selectedFilter == label;
return GestureDetector(
onTap: () {
setState(() => _selectedFilter = label);
_applyFilters();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? Colors.white
: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Colors.white
: Colors.white.withValues(alpha: 0.5),
width: 2,
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? const Color(0xFF1565C0) : Colors.white,
fontWeight: isSelected ? FontWeight.bold : FontWeight.w600,
fontSize: 14,
),
),
),
);
}
Widget _buildKontrakanItem(Kontrakan kontrakan) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => KontrakanDetailScreen(kontrakan: kontrakan),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
child:
kontrakan.fotoUtama != null && kontrakan.fotoUtama!.isNotEmpty
? CachedNetworkImage(
imageUrl:
'http://192.168.18.16:8000/storage/${kontrakan.fotoUtama}',
width: 120,
height: 140,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 120,
height: 140,
color: Colors.grey[200],
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
),
errorWidget: (context, url, error) => Container(
width: 120,
height: 140,
color: Colors.grey[200],
child: Icon(
Icons.home_work,
size: 40,
color: Colors.grey[400],
),
),
)
: Container(
width: 120,
height: 140,
color: Colors.grey[200],
child: Icon(
Icons.home_work,
size: 40,
color: Colors.grey[400],
),
),
),
// Info
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
kontrakan.nama,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: kontrakan.status == 'available'
? Colors.green
: Colors.orange,
borderRadius: BorderRadius.circular(12),
),
child: Text(
kontrakan.status == 'available'
? 'Tersedia'
: 'Penuh',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Text(
kontrakan.alamat,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(Icons.bed, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${kontrakan.jumlahKamar} Kamar',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
const SizedBox(width: 12),
Icon(Icons.near_me, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${kontrakan.jarakKampus.toStringAsFixed(1)} km',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 10,
),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${(kontrakan.harga / 1000).toStringAsFixed(0)}K/bln',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
),
],
),
),
),
],
),
),
);
}
Widget _buildLaundryItem(Laundry laundry) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LaundryDetailScreen(laundry: laundry),
),
);
},
child: Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image/Icon
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
),
child: Container(
width: 120,
height: 140,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.cyan[400]!, Colors.cyan[600]!],
),
),
child: const Icon(
Icons.local_laundry_service,
size: 50,
color: Colors.white,
),
),
),
// Info
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.nama,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Expanded(
child: Text(
laundry.alamat,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.access_time,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
'${laundry.estimasiSelesai}jam',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
const SizedBox(width: 12),
Icon(Icons.near_me, size: 14, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
'${laundry.jarak.toStringAsFixed(1)} km',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 10,
),
decoration: BoxDecoration(
color: const Color(0xFF00BCD4).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Rp ${(laundry.hargaKiloan / 1000).toStringAsFixed(0)}K/kg',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF00BCD4),
),
),
),
],
),
),
),
],
),
),
);
}
void _showFilterDialog() {
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Filter Harga',
style: TextStyle(fontWeight: FontWeight.bold),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Rp ${(_priceRange.start / 1000000).toStringAsFixed(1)}jt - Rp ${(_priceRange.end / 1000000).toStringAsFixed(1)}jt',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
const SizedBox(height: 8),
RangeSlider(
values: _priceRange,
min: 0,
max: 20000000,
divisions: 20,
activeColor: const Color(0xFF1565C0),
labels: RangeLabels(
'Rp ${(_priceRange.start / 1000).toStringAsFixed(0)}K',
'Rp ${(_priceRange.end / 1000).toStringAsFixed(0)}K',
),
onChanged: (values) {
setDialogState(() => _priceRange = values);
},
),
],
),
actions: [
TextButton(
onPressed: () {
setState(() {
_priceRange = const RangeValues(0, 20000000);
});
Navigator.pop(context);
_applyFilters();
},
child: const Text('Reset'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1565C0),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () {
Navigator.pop(context);
_applyFilters();
},
child: const Text('Terapkan'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,176 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
import '../models/user.dart';
class AuthService {
// Singleton pattern
static final AuthService _instance = AuthService._internal();
factory AuthService() => _instance;
AuthService._internal();
String? _token;
User? _currentUser;
String? get token => _token;
User? get currentUser => _currentUser;
bool get isAuthenticated => _token != null;
// Load token from local storage
Future<void> loadToken() async {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(AppConfig.tokenKey);
final userJson = prefs.getString(AppConfig.userKey);
if (userJson != null) {
_currentUser = User.fromJson(jsonDecode(userJson));
}
}
// Save token to local storage
Future<void> _saveToken(String token, User user) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConfig.tokenKey, token);
await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson()));
_token = token;
_currentUser = user;
}
// Clear token (logout)
Future<void> clearToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(AppConfig.tokenKey);
await prefs.remove(AppConfig.userKey);
_token = null;
_currentUser = null;
}
// Register
Future<Map<String, dynamic>> register({
required String name,
required String email,
required String password,
required String passwordConfirmation,
String? phone,
}) async {
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/register'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({
'name': name,
'email': email,
'password': password,
'password_confirmation': passwordConfirmation,
'phone': phone,
}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 201 && data['success'] == true) {
final token = data['data']['token'];
final user = User.fromJson(data['data']['user']);
await _saveToken(token, user);
return {'success': true, 'message': data['message']};
} else {
return {
'success': false,
'message': data['message'] ?? 'Registrasi gagal',
'errors': data['errors'],
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
// Login
Future<Map<String, dynamic>> login({
required String email,
required String password,
}) async {
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/login'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({'email': email, 'password': password}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
final token = data['data']['token'];
final user = User.fromJson(data['data']['user']);
await _saveToken(token, user);
return {'success': true, 'message': data['message']};
} else {
return {'success': false, 'message': data['message'] ?? 'Login gagal'};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
// Logout
Future<Map<String, dynamic>> logout() async {
try {
if (_token == null) {
await clearToken();
return {'success': true};
}
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/logout'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $_token',
},
);
await clearToken();
if (response.statusCode == 200) {
return {'success': true, 'message': 'Logout berhasil'};
} else {
return {'success': true}; // Still clear local token
}
} catch (e) {
await clearToken(); // Clear local token anyway
return {'success': true};
}
}
// Get current user
Future<User?> getCurrentUser() async {
if (_token == null) return null;
try {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/user'),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $_token',
},
);
if (response.statusCode == 200) {
final user = User.fromJson(jsonDecode(response.body));
_currentUser = user;
return user;
}
return null;
} catch (e) {
return null;
}
}
}

View File

@ -0,0 +1,127 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../models/booking.dart';
import 'auth_service.dart';
class BookingService {
final AuthService _authService = AuthService();
Map<String, String> get _headers {
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (_authService.token != null) {
headers['Authorization'] = 'Bearer ${_authService.token}';
}
return headers;
}
// Get booking history
Future<List<Booking>> getBookingHistory() async {
try {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/bookings'),
headers: _headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
final List items = data['data']['data'] ?? data['data'];
return items.map((json) => Booking.fromJson(json)).toList();
}
}
return [];
} catch (e) {
print('Error getting booking history: $e');
return [];
}
}
// Create booking
Future<Map<String, dynamic>> createBooking({
required int kontrakanId,
required DateTime tanggalMulai,
required int durasiBulan,
String? catatan,
}) 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,
}),
);
final data = jsonDecode(response.body);
if (response.statusCode == 201 && data['success'] == true) {
return {
'success': true,
'message': data['message'],
'booking': Booking.fromJson(data['data']),
};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal membuat booking',
'errors': data['errors'],
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
// Cancel booking
Future<Map<String, dynamic>> cancelBooking(int bookingId) async {
try {
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/bookings/$bookingId/cancel'),
headers: _headers,
);
final data = jsonDecode(response.body);
if (response.statusCode == 200 && data['success'] == true) {
return {'success': true, 'message': data['message']};
} else {
return {
'success': false,
'message': data['message'] ?? 'Gagal membatalkan booking',
};
}
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
// Get booking detail
Future<Booking?> getBookingById(int id) async {
try {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/bookings/$id'),
headers: _headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
return Booking.fromJson(data['data']);
}
}
return null;
} catch (e) {
print('Error getting booking detail: $e');
return null;
}
}
}

View File

@ -0,0 +1,130 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../models/kontrakan.dart';
import 'auth_service.dart';
class KontrakanService {
final AuthService _authService = AuthService();
// Get headers with token if available
Map<String, String> get _headers {
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (_authService.token != null) {
headers['Authorization'] = 'Bearer ${_authService.token}';
}
return headers;
}
// Get all kontrakan
Future<List<Kontrakan>> getKontrakan({
String? search,
double? hargaMin,
double? hargaMax,
int? jumlahKamar,
String status = 'tersedia',
}) async {
try {
var url = '${AppConfig.baseUrl}/kontrakan?status=$status';
if (search != null && search.isNotEmpty) {
url += '&search=$search';
}
if (hargaMin != null) {
url += '&harga_min=$hargaMin';
}
if (hargaMax != null) {
url += '&harga_max=$hargaMax';
}
if (jumlahKamar != null) {
url += '&jumlah_kamar=$jumlahKamar';
}
final response = await http.get(Uri.parse(url), headers: _headers);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
final List items = data['data']['data'] ?? data['data'];
return items.map((json) => Kontrakan.fromJson(json)).toList();
}
}
return [];
} catch (e) {
print('Error getting kontrakan: $e');
return [];
}
}
// Get kontrakan by ID
Future<Kontrakan?> getKontrakanById(int id) async {
try {
final response = await http.get(
Uri.parse('${AppConfig.baseUrl}/kontrakan/$id'),
headers: _headers,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
return Kontrakan.fromJson(data['data']);
}
}
return null;
} catch (e) {
print('Error getting kontrakan detail: $e');
return null;
}
}
// Calculate SAW for kontrakan (recommendation)
Future<Map<String, dynamic>> getRecommendations({
double? hargaMin,
double? hargaMax,
int? jumlahKamar,
double? jarakMax,
String? fasilitas,
}) async {
try {
final body = <String, dynamic>{};
if (hargaMin != null) body['harga_min'] = hargaMin;
if (hargaMax != null) body['harga_max'] = hargaMax;
if (jumlahKamar != null) body['jumlah_kamar'] = jumlahKamar;
if (jarakMax != null) body['jarak_max'] = jarakMax;
if (fasilitas != null && fasilitas.isNotEmpty) {
body['fasilitas'] = fasilitas;
}
final response = await http.post(
Uri.parse('${AppConfig.baseUrl}/saw/calculate/kontrakan'),
headers: _headers,
body: jsonEncode(body),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
return {
'success': true,
'kriteria': data['data']['kriteria'],
'hasil': data['data']['hasil'],
};
}
}
final data = jsonDecode(response.body);
return {
'success': false,
'message': data['message'] ?? 'Gagal menghitung SAW',
};
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
}

View File

@ -0,0 +1,110 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
import '../models/laundry.dart';
import 'auth_service.dart';
class LaundryService {
final AuthService _authService = AuthService();
Map<String, String> get _headers {
final headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (_authService.token != null) {
headers['Authorization'] = 'Bearer ${_authService.token}';
}
return headers;
}
// Get all laundry
Future<List<Laundry>> getLaundry() async {
try {
final response = await http
.get(Uri.parse('${AppConfig.baseUrl}/laundry'), headers: _headers)
.timeout(AppConfig.connectionTimeout);
print('Laundry API Response: ${response.statusCode}');
print('Laundry API Body: ${response.body}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
final List items = data['data']['data'] ?? data['data'] ?? [];
return items.map((json) => Laundry.fromJson(json)).toList();
}
}
return [];
} catch (e) {
print('Error getting laundry: $e');
return [];
}
}
// Get laundry by ID
Future<Laundry?> getLaundryById(int id) async {
try {
final response = await http
.get(Uri.parse('${AppConfig.baseUrl}/laundry/$id'), headers: _headers)
.timeout(AppConfig.connectionTimeout);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
return Laundry.fromJson(data['data']);
}
}
return null;
} catch (e) {
print('Error getting laundry detail: $e');
return null;
}
}
// Get laundry recommendations using SAW
Future<Map<String, dynamic>> getRecommendations({
double? hargaMin,
double? hargaMax,
double? jarakMax,
double? ratingMin,
}) async {
try {
final body = <String, dynamic>{};
if (hargaMin != null) body['harga_min'] = hargaMin;
if (hargaMax != null) body['harga_max'] = hargaMax;
if (jarakMax != null) body['jarak_max'] = jarakMax;
if (ratingMin != null) body['rating_min'] = ratingMin;
final response = await http
.post(
Uri.parse('${AppConfig.baseUrl}/saw/calculate/laundry'),
headers: _headers,
body: jsonEncode(body),
)
.timeout(AppConfig.connectionTimeout);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['success'] == true) {
return {
'success': true,
'kriteria': data['data']['kriteria'],
'hasil': data['data']['hasil'],
};
}
}
final data = jsonDecode(response.body);
return {
'success': false,
'message': data['message'] ?? 'Gagal memuat rekomendasi',
};
} catch (e) {
return {'success': false, 'message': 'Error: $e'};
}
}
}

View File

@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/kontrakan.dart';
import '../screens/kontrakan_detail_screen.dart';
class KontrakanCard extends StatelessWidget {
final Kontrakan kontrakan;
final int? ranking;
final double? skor;
final bool showRanking;
const KontrakanCard({
super.key,
required this.kontrakan,
this.ranking,
this.skor,
this.showRanking = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => KontrakanDetailScreen(kontrakan: kontrakan),
),
);
},
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ranking Badge (if showRanking is true)
if (showRanking && ranking != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: _getRankingColor(ranking!),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
_getRankingIcon(ranking!),
color: Colors.white,
size: 18,
),
const SizedBox(width: 6),
Text(
'Ranking #$ranking',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
if (skor != null)
Text(
'${(skor! * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
],
// Image
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(showRanking && ranking != null ? 0 : 16),
),
child: CachedNetworkImage(
imageUrl: kontrakan.primaryPhoto,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 180,
color: Colors.grey[300],
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 180,
color: Colors.grey[300],
child: const Icon(Icons.image_not_supported, size: 50),
),
),
),
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: kontrakan.status == 'tersedia'
? Colors.green
: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
kontrakan.status == 'tersedia' ? 'Tersedia' : 'Penuh',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
// Content
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kontrakan.nama,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.location_on, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
kontrakan.alamat,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
kontrakan.formattedHarga,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
const Text(
'/bulan',
style: TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF1565C0).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.bed,
size: 14,
color: Color(0xFF1565C0),
),
const SizedBox(width: 4),
Text(
'${kontrakan.jumlahKamar}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF1565C0),
),
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.directions_walk,
size: 14,
color: Colors.orange,
),
const SizedBox(width: 4),
Text(
'${kontrakan.jarakKampus.toStringAsFixed(1)} km dari kampus',
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
if (kontrakan.avgRating != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.star, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'${kontrakan.avgRating!.toStringAsFixed(1)}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
Text(
' (${kontrakan.totalReviews ?? 0} review)',
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
],
],
),
),
],
),
),
);
}
Color _getRankingColor(int ranking) {
if (ranking == 1) return Colors.amber;
if (ranking == 2) return Colors.grey[400]!;
if (ranking == 3) return Colors.brown[300]!;
return Colors.blue;
}
IconData _getRankingIcon(int ranking) {
if (ranking <= 3) return Icons.emoji_events;
return Icons.star;
}
}

View File

@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/laundry.dart';
import '../screens/laundry_detail_screen.dart';
class LaundryCard extends StatelessWidget {
final Laundry laundry;
final int? ranking;
final double? skor;
final bool showRanking;
const LaundryCard({
super.key,
required this.laundry,
this.ranking,
this.skor,
this.showRanking = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LaundryDetailScreen(laundry: laundry),
),
);
},
child: Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ranking Badge (if showRanking is true)
if (showRanking && ranking != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: _getRankingColor(ranking!),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
_getRankingIcon(ranking!),
color: Colors.white,
size: 18,
),
const SizedBox(width: 6),
Text(
'Ranking #$ranking',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
if (skor != null)
Text(
'${(skor! * 100).toStringAsFixed(0)}%',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
),
],
// Image
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(showRanking && ranking != null ? 0 : 16),
),
child: CachedNetworkImage(
imageUrl: laundry.primaryPhoto,
height: 180,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 180,
color: Colors.grey[300],
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 180,
color: Colors.grey[300],
child: const Icon(Icons.local_laundry_service, size: 50),
),
),
),
Positioned(
top: 10,
right: 10,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: laundry.status == 'buka'
? Colors.green
: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
laundry.status == 'buka' ? 'Buka' : 'Tutup',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
// Content
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.nama,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.location_on, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
laundry.alamat,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
laundry.formattedHarga,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF00BCD4),
),
),
const Text(
'/kg',
style: TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: const Color(0xFF00BCD4).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
Icons.access_time,
size: 14,
color: Color(0xFF00BCD4),
),
const SizedBox(width: 4),
Text(
'${laundry.waktuProses}h',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF00BCD4),
),
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Icon(
Icons.directions_walk,
size: 14,
color: Colors.orange,
),
const SizedBox(width: 4),
Text(
'${laundry.jarakKampus.toStringAsFixed(1)} km dari kampus',
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.schedule,
size: 14,
color: Colors.green,
),
const SizedBox(width: 4),
Text(
'${laundry.jamBuka} - ${laundry.jamTutup}',
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
if (laundry.avgRating != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.star, size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'${laundry.avgRating!.toStringAsFixed(1)}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
Text(
' (${laundry.totalReviews ?? 0} review)',
style: const TextStyle(
fontSize: 11,
color: Colors.grey,
),
),
],
),
],
],
),
),
],
),
),
);
}
Color _getRankingColor(int ranking) {
if (ranking == 1) return Colors.amber;
if (ranking == 2) return Colors.grey[400]!;
if (ranking == 3) return Colors.brown[300]!;
return const Color(0xFF00BCD4);
}
IconData _getRankingIcon(int ranking) {
if (ranking <= 3) return Icons.emoji_events;
return Icons.star;
}
}

1
spk_mobile/linux/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
flutter/ephemeral

View File

@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "spk_mobile")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.spk_mobile")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -0,0 +1,24 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

View File

@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

Some files were not shown because too many files have changed in this diff Show More