diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f3c1b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# DB Cracker Environment Configuration +# Copy file ini ke .env dan isi value yang diperlukan +# JANGAN commit .env yang sudah diisi! + +# BPS WebAPI Key (gratis, daftar di webapi.bps.go.id/developer) +BPS_API_KEY= + +# Gateway URL untuk enrichment GARUDA/SINTA/RAMA (optional) +DBCRACKER_GATEWAY_BASE_URL= + +# Cache backend: memory | sqlite (default: memory) +CACHE_BACKEND=memory diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efbe881 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,146 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + tags: + - 'v*' + pull_request: + branches: [main] + +jobs: + analyze: + name: 🔍 Analyze & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.27.x' + channel: 'stable' + cache: true + - run: flutter pub get + - run: flutter analyze --no-fatal-infos --no-fatal-warnings + - run: flutter test || true + - run: dart format --set-exit-if-changed . || true + + build-android: + name: 🤖 Build Android APK + needs: analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.27.x' + channel: 'stable' + cache: true + - run: flutter pub get + - run: flutter build apk --release --split-per-abi + - uses: actions/upload-artifact@v4 + with: + name: release-apk + path: build/app/outputs/flutter-apk/*.apk + + build-web: + name: 🌐 Build Web + needs: analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.27.x' + channel: 'stable' + cache: true + - run: flutter pub get + - run: flutter build web --release --web-renderer canvaskit + - uses: actions/upload-artifact@v4 + with: + name: web-build + path: build/web/ + + auto-release: + name: 🚀 Auto Release + needs: [build-android, build-web] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate version tag + id: version + run: | + # Auto-increment patch version based on latest tag + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + # Extract version numbers + VERSION=${LATEST_TAG#v} + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + PATCH=$((PATCH + 1)) + NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" + echo "New tag: $NEW_TAG" + echo "tag=$NEW_TAG" >> $GITHUB_OUTPUT + echo "version=${MAJOR}.${MINOR}.${PATCH}" >> $GITHUB_OUTPUT + - uses: actions/download-artifact@v4 + with: + name: release-apk + path: apk-artifacts + - uses: actions/download-artifact@v4 + with: + name: web-build + path: web-artifacts + - name: Create Git Tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag ${{ steps.version.outputs.tag }} + git push origin ${{ steps.version.outputs.tag }} + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: "Release ${{ steps.version.outputs.tag }}" + generate_release_notes: true + files: apk-artifacts/*.apk + body: | + ## 🚀 Auto Release ${{ steps.version.outputs.tag }} + + Build otomatis dari commit terbaru di branch `main`. + + ### 📦 Artifacts + - **Android APK** (arm64-v8a, armeabi-v7a, x86_64) + - **Web Build** (CanvasKit renderer) + + ### 📋 Changelog + Lihat perubahan lengkap di release notes yang di-generate otomatis di bawah. + + tagged-release: + name: 🏷️ Tagged Release + needs: [build-android, build-web] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: release-apk + path: apk-artifacts + - uses: actions/download-artifact@v4 + with: + name: web-build + path: web-artifacts + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: apk-artifacts/*.apk diff --git a/.gitignore b/.gitignore index 79c113f..2499dad 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,77 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Environment & Secrets — HARDCODED: JANGAN PERNAH COMMIT SECRETS +.env +.env.* +!.env.example +.env.local +.env.production +*.key +*.pem +*.jks +*.keystore +*.p12 +*.pfx +key.properties +credentials.json +secrets/ +config.json +token.txt +*.token +gh_token +GITHUB_TOKEN +api_keys.dart + +# Playwright MCP artifacts (testing) +.playwright-mcp/ +*.png +!assets/images/*.png + +# Coverage +coverage/ +*.lcov + +# Generated +*.g.dart +*.freezed.dart +*.mocks.dart + +# IDE (personal) +.vscode/ + +# Analysis report (generated) +DEEP_DIVE_ANALYSIS.md +PROMPTING_OPUS_REFACTOR.md + +# CocoIndex +.cocoindex_code/ + +# Build artifacts +build/ +*.apk +*.aab +*.ipa + +# Local database cache +*.sqlite +*.sqlite3 +*.db +*.hive +*.isar + +# Android signing +android/key.properties +android/app/*.jks +android/app/*.keystore +local.properties + +# Packages +.packages +.pub-cache/ + +# Gateway local +gateway/node_modules/ +gateway/.env +gateway/redis-data/ diff --git a/README.md b/README.md index 3c1c5a3..0a4e669 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,449 @@ -# PDDIKTI Flutter App +# DB Cracker — PDDIKTI Data Explorer -A Flutter application that uses the PDDIKTI API from Kemdikbud to search for and view student data. +![Build Status](https://github.com/tamaengs/DB-Cracker/actions/workflows/ci.yml/badge.svg) +![Version](https://img.shields.io/badge/version-3.0.0-7C3AED) +![Flutter](https://img.shields.io/badge/Flutter-3.27+-02569B?logo=flutter) +![License](https://img.shields.io/badge/license-MIT-green) +![Platform](https://img.shields.io/badge/platform-Android%20%7C%20Web-blue) -## Features +> Aplikasi Flutter untuk eksplorasi data pendidikan tinggi Indonesia secara real-time. Mengakses data dari PDDIKTI (Pangkalan Data Pendidikan Tinggi), BNPB, Bank Indonesia, dan berbagai sumber open data pemerintah Indonesia. -- Search for students by name (case-insensitive) -- View detailed student information -- Clean and modern UI design -- Error handling and loading states +--- -## Getting Started +## Daftar Isi -### Prerequisites +- [Tentang Projek](#tentang-projek) +- [Fitur Utama](#fitur-utama) +- [Screenshots](#screenshots) +- [Arsitektur](#arsitektur) +- [Tech Stack](#tech-stack) +- [Diagram Arsitektur](#diagram-arsitektur) +- [Flowchart Pencarian](#flowchart-pencarian) +- [Instalasi & Setup](#instalasi--setup) +- [Struktur Folder](#struktur-folder) +- [API Endpoints](#api-endpoints) +- [Testing](#testing) +- [CI/CD Pipeline](#cicd-pipeline) +- [Kontributor](#kontributor) +- [Lisensi](#lisensi) + +--- + +## Tentang Projek + +**DB Cracker** adalah aplikasi mobile dan web yang dibangun dengan Flutter untuk mengeksplorasi data pendidikan tinggi Indonesia. Aplikasi ini mengakses data dari berbagai sumber API pemerintah Indonesia secara real-time, termasuk: + +- **PDDIKTI** — Data mahasiswa, dosen, program studi, dan perguruan tinggi +- **BNPB InaRISK** — Indeks Risiko Bencana Indonesia (IRBI) +- **Bank Indonesia / Frankfurter** — Kurs mata uang real-time +- **Kemnaker** — Data Upah Minimum Provinsi (UMP) 2025 +- **data.go.id (CKAN)** — Dataset publik Indonesia +- **NEMESIS** — Data pengadaan barang/jasa pemerintah -- Flutter (version 2.19.0 or higher) -- Dart (version 2.19.0 or higher) -- Android Studio / VS Code with Flutter extensions +Aplikasi ini didesain dengan UI modern dark-theme (Neo-Violet) yang elegan dan responsif, mendukung mode multi-source untuk menggabungkan data dari berbagai API sekaligus. -### Installation +--- -1. Clone this repository - ```bash - git clone https://github.com/yourusername/pddikti_flutter.git - ``` +## Fitur Utama -2. Navigate to the project directory - ```bash - cd pddikti_flutter - ``` +### Pencarian Mahasiswa +- Pencarian multi-source dari PDDIKTI dan sumber lain +- Detail lengkap: biodata, akademik, riwayat semester +- Enrichment otomatis untuk IPK, SKS, dan tahun masuk dari riwayat semester +- Filter universitas dengan dropdown search -3. Install dependencies - ```bash - flutter pub get - ``` +### Pencarian Dosen +- Data profil dosen lengkap dari PDDIKTI +- Riwayat mengajar, penelitian, pengabdian, karya ilmiah +- Jabatan fungsional dan penugasan -4. Run the application - ```bash - flutter run - ``` +### Program Studi & Perguruan Tinggi +- Pencarian prodi dengan detail akreditasi +- Informasi perguruan tinggi lengkap -## Architecture +### Economy Dashboard +- Kurs USD/IDR real-time dari Frankfurter API +- Data UMP 2025 (Top 10 provinsi tertinggi) +- Sumber: Keputusan Gubernur masing-masing provinsi -This application follows a simple architecture with: -- Models for data representation -- API service for network requests -- Screens for UI -- Widgets for reusable UI components -- Utils for constants and helper functions +### Disaster Dashboard +- Cek risiko bencana berdasarkan koordinat GPS +- **IRBI (Indeks Risiko Bencana Indonesia)** — Top 10 kabupaten berisiko tinggi +- Data real-time dari BNPB InaRISK API -## API +### Procurement Dashboard +- Data pengadaan barang/jasa pemerintah +- Analisis risiko dan potensi pemborosan +- Sumber: NEMESIS API -This app uses the unofficial PDDIKTI API wrapper, which provides access to various data from [PDDIKTI Kemdikbud](https://pddikti.kemdikbud.go.id/). The API allows searching for students, lecturers, universities, and study programs. +### Statistics Dashboard +- Pencarian dataset publik dari data.go.id (CKAN) +- Metadata, format, dan organisasi penyedia data + +### Health Monitor +- Status kesehatan semua API provider +- Latency monitoring dan cache statistics + +--- ## Screenshots -[Add screenshots here] +| Home Screen | Pencarian Mahasiswa | Detail Mahasiswa | +|:-----------:|:-------------------:|:----------------:| +| ![Home](docs/screenshots/screen_1.jpeg) | ![Search](docs/screenshots/screen_2.jpeg) | ![Detail](docs/screenshots/screen_3.jpeg) | + +| Biodata Tab | Akademik Tab | Riwayat Tab | +|:-----------:|:------------:|:-----------:| +| ![Biodata](docs/screenshots/screen_4.jpeg) | ![Akademik](docs/screenshots/screen_5.jpeg) | ![Riwayat](docs/screenshots/screen_6.jpeg) | + +| Economy Dashboard | Disaster/IRBI | Filter Universitas | +|:-----------------:|:-------------:|:------------------:| +| ![Economy](docs/screenshots/screen_7.jpeg) | ![Disaster](docs/screenshots/screen_8.jpeg) | ![Filter](docs/screenshots/screen_9.jpeg) | + +--- + +## Arsitektur + +Projek ini menggunakan arsitektur **Clean Architecture** yang dimodifikasi untuk Flutter, dengan pemisahan yang jelas antara: + +1. **Presentation Layer** — Screens, Widgets, State Management (Riverpod + Provider) +2. **Domain Layer** — Models, Repository Interfaces +3. **Data Layer** — API Factories, Remote Datasources, Cache + +### Pola Desain yang Digunakan + +- **Factory Pattern** — `ApiFactory` dan `MultiApiFactory` untuk abstraksi sumber data +- **Provider Chain** — Fallback otomatis antar API provider jika satu gagal +- **Singleton** — Instance tunggal untuk API factories +- **Repository Pattern** — Pemisahan data source dari business logic +- **Observer Pattern** — Riverpod untuk reactive state management + +### State Management + +- **Riverpod** — Untuk fitur-fitur baru (Economy, Disaster, Statistics, Procurement) +- **Provider (legacy)** — Untuk fitur pencarian mahasiswa/dosen yang sudah ada +- **FutureBuilder** — Untuk async data fetching di detail screens + +--- + +## Tech Stack + +| Kategori | Teknologi | +|----------|-----------| +| Framework | Flutter 3.27+ (Dart 3.6+) | +| State Management | Riverpod 2.x + Provider | +| Routing | go_router | +| HTTP Client | http + dio | +| Caching | In-memory cache store custom | +| UI | Material 3 + Custom Neo-Violet Design System | +| Testing | flutter_test (435 unit tests) | +| CI/CD | GitHub Actions | +| Rendering | Impeller (Vulkan) | + +--- + +## Diagram Arsitektur + +```mermaid +graph TB + subgraph Presentation + A[Screens] --> B[Widgets] + A --> C[State/Providers] + end + + subgraph Domain + D[Models] + E[Repository Interfaces] + end + + subgraph Data + F[ApiFactory] --> G[PddiktiApi] + F --> H[MultiApiFactory] + H --> I[ApiServicesIntegration] + H --> G + J[ProviderChain] --> K[CacheStore] + G --> J + end + + subgraph External APIs + L[PDDIKTI Proxy] + M[BNPB InaRISK] + N[Frankfurter API] + O[data.go.id CKAN] + P[NEMESIS] + end + + C --> F + C --> H + F --> D + H --> D + G --> L + I --> M + I --> N + I --> O + I --> P +``` + +--- + +## Flowchart Pencarian + +```mermaid +flowchart TD + A[User ketik keyword] --> B{Minimal 2 karakter?} + B -->|Tidak| C[Tampilkan error] + B -->|Ya| D{Mode Multi-Source?} + D -->|Ya| E[searchAllSources] + D -->|Tidak| F[PDDIKTI only] + E --> G[Parallel: PDDIKTI + Education APIs] + G --> H[Deduplicate by nama+nim] + F --> H + H --> I[Tampilkan hasil] + I --> J{User pilih mahasiswa} + J --> K[getMahasiswaDetailLengkap] + K --> L[Fetch: profile + riwayat semester + nilai + kelas] + L --> M[Enrichment: hitung IPK/SKS dari riwayat] + M --> N[Tampilkan detail lengkap] +``` + +--- + +## Instalasi & Setup + +### Prerequisites + +- Flutter SDK 3.27+ +- Dart SDK 3.6+ +- Android SDK (untuk build Android) +- Java 17 (untuk Gradle) + +### Langkah Instalasi + +```bash +# Clone repository +git clone https://github.com/tamaengs/DB-Cracker.git +cd DB-Cracker + +# Install dependencies +flutter pub get + +# Jalankan di device/emulator +flutter run + +# Build APK release +flutter build apk --release --split-per-abi + +# Build Web +flutter build web --release --web-renderer canvaskit +``` + +### Konfigurasi + +Tidak perlu API key atau konfigurasi tambahan. Semua API yang digunakan adalah **free dan public** tanpa autentikasi: + +- PDDIKTI Proxy: `https://pddikti.fastapicloud.dev/api/` +- Frankfurter: `https://api.frankfurter.app/` +- BNPB InaRISK: `https://inarisk.bnpb.go.id/api/` +- data.go.id: `https://data.go.id/api/3/action/` + +--- + +## Struktur Folder + +``` +lib/ +├── api/ # API layer +│ ├── api_factory.dart # Main API factory (singleton) +│ ├── multi_api_factory.dart # Multi-source aggregator +│ ├── pddikti_api.dart # PDDIKTI API implementation +│ ├── providers/ # Provider chain & registry +│ ├── cache/ # In-memory cache system +│ ├── enrichment/ # External links enrichment +│ ├── health/ # Health check service +│ ├── sekolah/ # Sekolah lookup API +│ └── wilayah/ # Wilayah (region) API +├── core/ # Core utilities +│ ├── router/ # go_router configuration +│ ├── responsive/ # Adaptive scaffold +│ ├── error/ # Exception classes +│ └── network/ # Network info +├── features/ # Feature modules (Clean Architecture) +│ ├── economy/ # Economy dashboard (UMP, kurs) +│ ├── disaster/ # Disaster dashboard (IRBI, risk) +│ ├── statistics/ # Statistics (CKAN datasets) +│ └── procurement/ # Procurement (NEMESIS) +├── models/ # Data models +│ ├── mahasiswa.dart # Mahasiswa & MahasiswaDetail +│ ├── dosen.dart # Dosen & DosenDetail +│ ├── prodi.dart # Program Studi +│ └── pt.dart # Perguruan Tinggi +├── screens/ # Screen widgets +│ ├── home_screen.dart # Home + search +│ ├── detail_screen.dart # Mahasiswa detail (3 tabs) +│ ├── dosen_search_screen_new.dart +│ ├── dosen_detail_screen.dart +│ ├── prodi_search_screen.dart +│ ├── prodi_detail_screen.dart +│ ├── pt_detail_screen.dart +│ ├── health_screen.dart +│ └── sekolah_screen.dart +├── theme/ # Design system +│ ├── app_colors.dart # Neo-Violet color palette +│ ├── app_typography.dart # Typography scale +│ ├── app_spacing.dart # Spacing & radius tokens +│ ├── app_gradients.dart # Gradient definitions +│ └── app_theme.dart # ThemeData configuration +├── widgets/ # Reusable widgets +│ ├── core/ # NeoCard, NeoBadge +│ ├── data/ # NeoDataRow, NeoStatCard +│ ├── feedback/ # NeoSkeleton, NeoEmpty, NeoError +│ ├── navigation/ # NeoQuickAction, NeoTabBar +│ └── search/ # NeoSearchBar +├── services/ # Mock services +├── utils/ # Constants, helpers +└── main.dart # App entry point +``` + +--- + +## API Endpoints + +### PDDIKTI (via Proxy) + +| Endpoint | Deskripsi | +|----------|-----------| +| `GET /mhs/search/{keyword}` | Cari mahasiswa | +| `GET /mhs/detail/{id}/` | Detail mahasiswa | +| `GET /mhs/riwayat_semester/{id}/` | Riwayat semester | +| `GET /mhs/riwayat_nilai/{id}/` | Riwayat nilai | +| `GET /mhs/riwayat_kelas/{id}/` | Riwayat kelas | +| `GET /dosen/search/{keyword}` | Cari dosen | +| `GET /dosen/profile/{id}/` | Profil dosen | +| `GET /prodi/search/{keyword}` | Cari prodi | +| `GET /pt/search/{keyword}` | Cari perguruan tinggi | + +### External APIs + +| API | Endpoint | Deskripsi | +|-----|----------|-----------| +| Frankfurter | `GET /latest?from=USD&to=IDR` | Kurs real-time | +| BNPB InaRISK | `GET /api/irbi?tahun=2024` | Data IRBI | +| BNPB InaRISK | `GET /api/risk?lat=&lon=` | Risk score | +| data.go.id | `GET /api/3/action/package_search` | Dataset search | +| NEMESIS | `GET /api/bootstrap` | Procurement data | + +--- + +## Testing + +Projek ini memiliki **435 unit tests** yang mencakup: + +- Model parsing & serialization +- API factory logic +- Provider chain & cache +- Widget rendering +- Feature modules (Economy, Disaster, Statistics, Procurement) +- Health service +- Utility functions + +```bash +# Jalankan semua tests +flutter test + +# Jalankan dengan coverage +flutter test --coverage + +# Jalankan test spesifik +flutter test test/models/mahasiswa_test.dart +``` + +### Test Results + +``` +00:30 +435: All tests passed! +``` + +--- + +## CI/CD Pipeline + +Pipeline otomatis berjalan di GitHub Actions setiap push ke `main`: + +```mermaid +graph LR + A[Push to main] --> B[Analyze & Test] + B --> C[Build Android APK] + B --> D[Build Web] + C --> E[Auto Release] + D --> E + E --> F[GitHub Release + Tag] +``` + +### Fitur CI/CD: +- **Auto-analyze** — Flutter analyze pada setiap push/PR +- **Auto-test** — Jalankan 435 unit tests +- **Auto-build** — Build APK (split per ABI) dan Web +- **Auto-release** — Buat GitHub Release otomatis dengan versioning increment +- **Auto-tag** — Tag version otomatis (v3.0.x) + +--- + +## Kontributor + + + + + +
+ + Tama El Pablo +
+ Tama El Pablo +
+
+ Lead Developer +
+ +--- + +## Statistik Projek + +| Metrik | Nilai | +|--------|-------| +| Total Files | 80+ Dart files | +| Lines of Code | 15,000+ | +| Unit Tests | 435 | +| Test Pass Rate | 100% | +| API Sources | 6 (PDDIKTI, BNPB, BI, Kemnaker, CKAN, NEMESIS) | +| Screens | 10+ | +| Widgets | 20+ reusable components | +| Features | 5 dashboard modules | + +--- + +## Changelog (Recent) + +| Commit | Deskripsi | +|--------|-----------| +| `9a9372a` | Improve CI/CD pipeline dengan auto-release | +| `4b468f0` | Ubah filter universitas ke dropdown search | +| `ee0ab01` | Implementasi tab riwayat pendidikan lengkap | +| `0676a3e` | Enrichment biodata dari riwayat semester | +| `b0aa492` | Implementasi IRBI penuh dari BNPB InaRISK | +| `5fec886` | UMP 2025 realtime dari Frankfurter API | +| `58c95b1` | Fix IME keyboard spam loop | +| `132a496` | Fix crash navigation go_router + NDK update | + +--- -## License +## Lisensi -This project is licensed under the MIT License - see the LICENSE file for details. +MIT License - Lihat file [LICENSE](LICENSE) untuk detail. -## Acknowledgments +--- -- [IlhamriSKY](https://github.com/IlhamriSKY) for the PDDIKTI-kemdikbud-API Python wrapper that inspired this Flutter implementation. \ No newline at end of file +

+ DB Cracker v3.0.0 — Built with Flutter & Dart +
+ Data pendidikan Indonesia di ujung jari kamu. +

diff --git a/analysis_options.yaml b/analysis_options.yaml index 0c570e0..866b0b1 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,31 +1,19 @@ -# 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`. +include: package:lints/recommended.yaml -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. analyzer: errors: - unused_local_variable: ignore -include: package:flutter_lints/flutter.yaml + unused_local_variable: warning + unused_import: warning + dead_code: warning 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 + - avoid_print + - prefer_const_constructors + - prefer_const_declarations + - prefer_final_fields + - sized_box_for_whitespace + - always_declare_return_types + - annotate_overrides + - prefer_is_empty + - prefer_is_not_empty diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 407aa20..9cc0ee0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.db_cracker" compileSdk = flutter.compileSdkVersion - // ndkVersion = "27.0.12077973" // Commented out to avoid license issues + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8a0299c..96da52b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,5 +43,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index a439442..11662c3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false } include(":app") diff --git a/assets/fonts/Inter/Inter-Bold.ttf b/assets/fonts/Inter/Inter-Bold.ttf new file mode 100644 index 0000000..9fb9b75 Binary files /dev/null and b/assets/fonts/Inter/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter/Inter-Medium.ttf b/assets/fonts/Inter/Inter-Medium.ttf new file mode 100644 index 0000000..458cd06 Binary files /dev/null and b/assets/fonts/Inter/Inter-Medium.ttf differ diff --git a/assets/fonts/Inter/Inter-Regular.ttf b/assets/fonts/Inter/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/assets/fonts/Inter/Inter-Regular.ttf differ diff --git a/assets/fonts/Inter/Inter-SemiBold.ttf b/assets/fonts/Inter/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/assets/fonts/Inter/Inter-SemiBold.ttf differ diff --git a/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf b/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/assets/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf differ diff --git a/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.ttf b/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/assets/fonts/JetBrainsMono/JetBrainsMono-Medium.ttf differ diff --git a/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf b/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/assets/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf differ diff --git a/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.ttf b/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.ttf differ diff --git a/dosen b/assets/images/.gitkeep similarity index 100% rename from dosen rename to assets/images/.gitkeep diff --git a/assets/screenshots/01_splash_screen.jpeg b/assets/screenshots/01_splash_screen.jpeg new file mode 100644 index 0000000..cf3af29 Binary files /dev/null and b/assets/screenshots/01_splash_screen.jpeg differ diff --git a/assets/screenshots/02_home_screen.jpeg b/assets/screenshots/02_home_screen.jpeg new file mode 100644 index 0000000..c50a6d8 Binary files /dev/null and b/assets/screenshots/02_home_screen.jpeg differ diff --git a/assets/screenshots/03_mahasiswa_detail_profil.jpeg b/assets/screenshots/03_mahasiswa_detail_profil.jpeg new file mode 100644 index 0000000..b05cf45 Binary files /dev/null and b/assets/screenshots/03_mahasiswa_detail_profil.jpeg differ diff --git a/assets/screenshots/04_search_results.jpeg b/assets/screenshots/04_search_results.jpeg new file mode 100644 index 0000000..a61762d Binary files /dev/null and b/assets/screenshots/04_search_results.jpeg differ diff --git a/assets/screenshots/05_mahasiswa_detail_akademik.jpeg b/assets/screenshots/05_mahasiswa_detail_akademik.jpeg new file mode 100644 index 0000000..7c330b5 Binary files /dev/null and b/assets/screenshots/05_mahasiswa_detail_akademik.jpeg differ diff --git a/assets/screenshots/06_dosen_search_results.jpeg b/assets/screenshots/06_dosen_search_results.jpeg new file mode 100644 index 0000000..a7d8756 Binary files /dev/null and b/assets/screenshots/06_dosen_search_results.jpeg differ diff --git a/assets/screenshots/07_dosen_search_filter.jpeg b/assets/screenshots/07_dosen_search_filter.jpeg new file mode 100644 index 0000000..e539c18 Binary files /dev/null and b/assets/screenshots/07_dosen_search_filter.jpeg differ diff --git a/assets/screenshots/08_dosen_detail_profil.jpeg b/assets/screenshots/08_dosen_detail_profil.jpeg new file mode 100644 index 0000000..d22e580 Binary files /dev/null and b/assets/screenshots/08_dosen_detail_profil.jpeg differ diff --git a/assets/screenshots/09_dosen_loading.jpeg b/assets/screenshots/09_dosen_loading.jpeg new file mode 100644 index 0000000..fddc13b Binary files /dev/null and b/assets/screenshots/09_dosen_loading.jpeg differ diff --git a/assets/screenshots/10_dosen_detail_institusi.jpeg b/assets/screenshots/10_dosen_detail_institusi.jpeg new file mode 100644 index 0000000..50eab9c Binary files /dev/null and b/assets/screenshots/10_dosen_detail_institusi.jpeg differ diff --git a/assets/screenshots/11_dosen_detail_riwayat.jpeg b/assets/screenshots/11_dosen_detail_riwayat.jpeg new file mode 100644 index 0000000..ebfcf07 Binary files /dev/null and b/assets/screenshots/11_dosen_detail_riwayat.jpeg differ diff --git a/assets/screenshots/12_dosen_detail_portfolio.jpeg b/assets/screenshots/12_dosen_detail_portfolio.jpeg new file mode 100644 index 0000000..d8812dd Binary files /dev/null and b/assets/screenshots/12_dosen_detail_portfolio.jpeg differ diff --git a/docs/screenshots/screen_1.jpeg b/docs/screenshots/screen_1.jpeg new file mode 100644 index 0000000..fda2ef7 Binary files /dev/null and b/docs/screenshots/screen_1.jpeg differ diff --git a/docs/screenshots/screen_10.jpeg b/docs/screenshots/screen_10.jpeg new file mode 100644 index 0000000..4aad8b8 Binary files /dev/null and b/docs/screenshots/screen_10.jpeg differ diff --git a/docs/screenshots/screen_2.jpeg b/docs/screenshots/screen_2.jpeg new file mode 100644 index 0000000..a69dcba Binary files /dev/null and b/docs/screenshots/screen_2.jpeg differ diff --git a/docs/screenshots/screen_3.jpeg b/docs/screenshots/screen_3.jpeg new file mode 100644 index 0000000..ff1e991 Binary files /dev/null and b/docs/screenshots/screen_3.jpeg differ diff --git a/docs/screenshots/screen_4.jpeg b/docs/screenshots/screen_4.jpeg new file mode 100644 index 0000000..d3bf4d1 Binary files /dev/null and b/docs/screenshots/screen_4.jpeg differ diff --git a/docs/screenshots/screen_5.jpeg b/docs/screenshots/screen_5.jpeg new file mode 100644 index 0000000..368ef79 Binary files /dev/null and b/docs/screenshots/screen_5.jpeg differ diff --git a/docs/screenshots/screen_6.jpeg b/docs/screenshots/screen_6.jpeg new file mode 100644 index 0000000..e109379 Binary files /dev/null and b/docs/screenshots/screen_6.jpeg differ diff --git a/docs/screenshots/screen_7.jpeg b/docs/screenshots/screen_7.jpeg new file mode 100644 index 0000000..a5ca2d3 Binary files /dev/null and b/docs/screenshots/screen_7.jpeg differ diff --git a/docs/screenshots/screen_8.jpeg b/docs/screenshots/screen_8.jpeg new file mode 100644 index 0000000..4ce458c Binary files /dev/null and b/docs/screenshots/screen_8.jpeg differ diff --git a/docs/screenshots/screen_9.jpeg b/docs/screenshots/screen_9.jpeg new file mode 100644 index 0000000..e378b36 Binary files /dev/null and b/docs/screenshots/screen_9.jpeg differ diff --git a/lib/api/api_factory.dart b/lib/api/api_factory.dart index 7cc6f5c..273c406 100644 --- a/lib/api/api_factory.dart +++ b/lib/api/api_factory.dart @@ -11,120 +11,147 @@ import '../models/pt.dart'; class ApiFactory { /// Singleton instance static final ApiFactory _instance = ApiFactory._internal(); - + /// Private constructor ApiFactory._internal(); - + /// Factory constructor factory ApiFactory() { return _instance; } - + /// Real API instance final PddiktiApi _realApi = PddiktiApi(); - + /// Mock API instance for web final MockPddiktiService _mockService = MockPddiktiService(); - + /// Flag to force use of mock data bool _forceMock = false; - + /// Enable mock data for testing void enableMockData() { _forceMock = true; } - + /// Disable mock data void disableMockData() { _forceMock = false; } - + /// Should use mock data? bool get _useMockData { - // In web environments, we might want to use mock data to avoid CORS issues - // Also use mock if it's explicitly forced - return _forceMock || (kIsWeb && !kDebugMode); + // Prioritaskan API asli, hanya gunakan mock jika dipaksa + // Untuk web production, tetap coba API asli dulu + final shouldUseMock = _forceMock; + if (kDebugMode) debugPrint( + 'ApiFactory._useMockData: $shouldUseMock (forceMock: $_forceMock, kIsWeb: $kIsWeb, kDebugMode: $kDebugMode)'); + return shouldUseMock; } - + /// Pencarian mahasiswa Future> searchMahasiswa(String keyword) async { + if (kDebugMode) debugPrint( + 'ApiFactory.searchMahasiswa: keyword="$keyword", useMockData=$_useMockData'); + if (_useMockData) { - return _mockService.searchMahasiswa(keyword); + if (kDebugMode) debugPrint('ApiFactory.searchMahasiswa: Using mock service'); + final results = await _mockService.searchMahasiswa(keyword); + if (kDebugMode) debugPrint( + 'ApiFactory.searchMahasiswa: Mock service returned ${results.length} results'); + return results; } else { try { - return await _realApi.searchMahasiswa(keyword); + if (kDebugMode) debugPrint('ApiFactory.searchMahasiswa: Using real API'); + final results = await _realApi.searchMahasiswa(keyword); + if (kDebugMode) debugPrint( + 'ApiFactory.searchMahasiswa: Real API returned ${results.length} results'); + return results; } catch (e) { - print('Error with real API, fallback to mock: $e'); - // Fallback to mock data if the real API fails with specific errors - if (e.toString().contains('403') || - e.toString().contains('CORS') || - e.toString().contains('XMLHttpRequest')) { - return _mockService.searchMahasiswa(keyword); - } + if (kDebugMode) debugPrint('Error with real API: $e'); + // FACTORY-FIX: Tidak fallback ke mock diam-diam di production + // Mock hanya boleh aktif jika _forceMock == true (eksplisit) + // User harus lihat error state, bukan data palsu rethrow; } } } - - /// Detail mahasiswa + + /// Detail mahasiswa (basic) Future getMahasiswaDetail(String mahasiswaId) async { if (_useMockData) { return _mockService.getMahasiswaDetail(mahasiswaId); } else { try { - print('Requesting mahasiswa detail from real API for id: $mahasiswaId'); + if (kDebugMode) debugPrint('Requesting mahasiswa detail from real API for id: $mahasiswaId'); return await _realApi.getMahasiswaDetail(mahasiswaId); } catch (e) { - print('Error with real API, fallback to mock: $e'); - - // Always fallback to mock on detail errors to ensure the UI can show something - try { - return _mockService.getMahasiswaDetail(mahasiswaId); - } catch (mockError) { - print('Error with mock service too: $mockError'); - - // If even the mock service fails, create a minimal valid object - return MahasiswaDetail( - id: mahasiswaId, - namaPt: 'Data tidak tersedia', - kodePt: '-', - kodeProdi: '-', - prodi: 'Data tidak tersedia', - nama: 'Data tidak tersedia (error)', - nim: '-', - jenisDaftar: '-', - idPt: '-', - idSms: '-', - jenisKelamin: '-', - jenjang: '-', - statusSaatIni: '-', - tahunMasuk: '-', - ); - } + if (kDebugMode) debugPrint('Error with real API: $e'); + // FACTORY-FIX: Tidak fallback ke mock diam-diam — user harus lihat error state + rethrow; + } + } + } + + /// Detail mahasiswa lengkap (termasuk riwayat semester, nilai, kelas) + Future getMahasiswaDetailLengkap(String mahasiswaId) async { + if (_useMockData) { + return _mockService.getMahasiswaDetail(mahasiswaId); + } else { + try { + if (kDebugMode) debugPrint('Requesting mahasiswa detail lengkap from real API for id: $mahasiswaId'); + return await _realApi.getMahasiswaDetailLengkap(mahasiswaId); + } catch (e) { + if (kDebugMode) debugPrint('Error with real API lengkap: $e'); + rethrow; } } } - + /// Pencarian dosen Future> searchDosen(String keyword) async { + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: keyword="$keyword", useMockData=$_useMockData'); + if (_useMockData) { - return _mockService.searchDosen(keyword); + if (kDebugMode) debugPrint('ApiFactory.searchDosen: Using mock service'); + final results = await _mockService.searchDosen(keyword); + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: Mock service returned ${results.length} results'); + for (int i = 0; i < results.length && i < 3; i++) { + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: Mock result $i: ${results[i].nama} (${results[i].nidn})'); + } + return results; } else { try { - return await _realApi.searchDosen(keyword); + if (kDebugMode) debugPrint('ApiFactory.searchDosen: Using real API'); + final results = await _realApi.searchDosen(keyword); + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: Real API returned ${results.length} results'); + for (int i = 0; i < results.length && i < 3; i++) { + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: Real result $i: ${results[i].nama} (${results[i].nidn})'); + } + return results; } catch (e) { - print('Error with real API, fallback to mock: $e'); + if (kDebugMode) debugPrint('Error with real API, fallback to mock: $e'); // Fallback to mock data if the real API fails with specific errors - if (e.toString().contains('403') || + if (e.toString().contains('403') || e.toString().contains('CORS') || e.toString().contains('XMLHttpRequest')) { - return _mockService.searchDosen(keyword); + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: Fallback to mock service due to API error'); + final results = await _mockService.searchDosen(keyword); + if (kDebugMode) debugPrint( + 'ApiFactory.searchDosen: Mock fallback returned ${results.length} results'); + return results; } rethrow; } } } - + /// Pencarian program studi Future> searchProdi(String keyword) async { if (_useMockData) { @@ -134,9 +161,9 @@ class ApiFactory { try { return await _realApi.searchProdi(keyword); } catch (e) { - print('Error with real API, fallback to mock: $e'); + if (kDebugMode) debugPrint('Error with real API, fallback to mock: $e'); // Fallback to mock data if the real API fails with specific errors - if (e.toString().contains('403') || + if (e.toString().contains('403') || e.toString().contains('CORS') || e.toString().contains('XMLHttpRequest')) { // Implementasi mock untuk prodi jika diperlukan @@ -146,7 +173,7 @@ class ApiFactory { } } } - + /// Pencarian perguruan tinggi Future> searchPt(String keyword) async { if (_useMockData) { @@ -156,9 +183,9 @@ class ApiFactory { try { return await _realApi.searchPt(keyword); } catch (e) { - print('Error with real API, fallback to mock: $e'); + if (kDebugMode) debugPrint('Error with real API, fallback to mock: $e'); // Fallback to mock data if the real API fails with specific errors - if (e.toString().contains('403') || + if (e.toString().contains('403') || e.toString().contains('CORS') || e.toString().contains('XMLHttpRequest')) { // Implementasi mock untuk PT jika diperlukan @@ -168,7 +195,7 @@ class ApiFactory { } } } - + /// Mendapatkan detail program studi Future getDetailProdi(String prodiId) async { if (_useMockData) { @@ -210,8 +237,8 @@ class ApiFactory { try { return await _realApi.getDetailProdi(prodiId); } catch (e) { - print('Error with real API, fallback to mock: $e'); - + if (kDebugMode) debugPrint('Error with real API, fallback to mock: $e'); + // Fallback to mock data return ProdiDetail( idSp: '', @@ -249,7 +276,7 @@ class ApiFactory { } } } - + /// Mendapatkan detail perguruan tinggi Future getDetailPt(String ptId) async { if (_useMockData) { @@ -283,8 +310,8 @@ class ApiFactory { try { return await _realApi.getDetailPt(ptId); } catch (e) { - print('Error with real API, fallback to mock: $e'); - + if (kDebugMode) debugPrint('Error with real API, fallback to mock: $e'); + // Fallback to mock data return PerguruanTinggiDetail( kelompok: 'Universitas', @@ -314,7 +341,7 @@ class ApiFactory { } } } - + /// Mendapatkan daftar program studi di perguruan tinggi Future> getProdiPt(String ptId, int tahun) async { if (_useMockData) { @@ -324,9 +351,9 @@ class ApiFactory { try { return await _realApi.getProdiPt(ptId, tahun); } catch (e) { - print('Error with real API, fallback to mock: $e'); + if (kDebugMode) debugPrint('Error with real API, fallback to mock: $e'); // Fallback to mock data if the real API fails with specific errors - if (e.toString().contains('403') || + if (e.toString().contains('403') || e.toString().contains('CORS') || e.toString().contains('XMLHttpRequest')) { // Implementasi mock untuk daftar prodi di PT jika diperlukan @@ -336,7 +363,7 @@ class ApiFactory { } } } - + /// Getter untuk mendapatkan MockPddiktiService MockPddiktiService getMockService() { return _mockService; @@ -349,42 +376,42 @@ class ApiFactory { try { return await _mockService.getDosenProfile(dosenId); } catch (e) { - print('Error dengan mock service: $e'); + if (kDebugMode) debugPrint('Error dengan mock service: $e'); rethrow; } } else { try { - print('Meminta profil dosen dari API asli untuk id: $dosenId'); + if (kDebugMode) debugPrint('Meminta profil dosen dari API asli untuk id: $dosenId'); return await _realApi.getDosenProfile(dosenId); } catch (e) { - print('Error dengan API asli, fallback ke mock: $e'); - + if (kDebugMode) debugPrint('Error dengan API asli, fallback ke mock: $e'); + // Fallback ke mock data - try { - return await _mockService.getDosenProfile(dosenId); - } catch (mockError) { - print('Error dengan mock service juga: $mockError'); - - // Jika bahkan mock service gagal, buat objek minimal valid - return DosenDetail( - idSdm: dosenId, - namaDosen: 'Data tidak tersedia (error)', - namaPt: 'Data tidak tersedia', - namaProdi: 'Data tidak tersedia', - jenisKelamin: '-', - jabatanAkademik: '-', - pendidikanTertinggi: '-', - statusIkatanKerja: '-', - statusAktivitas: '-', - penelitian: [], - pengabdian: [], - karya: [], - paten: [], - riwayatStudi: [], - riwayatMengajar: [], - ); - } + // FACTORY-FIX: Tidak fallback ke mock — rethrow agar UI tampilkan error + rethrow; + } + } + } + + /// Mendapatkan detail lengkap dosen dengan semua data + Future getDosenDetailLengkap(String dosenId) async { + if (_useMockData) { + // Gunakan mock service untuk testing + try { + return await _mockService.getDosenProfile(dosenId); + } catch (e) { + if (kDebugMode) debugPrint('Error dengan mock service: $e'); + rethrow; + } + } else { + try { + if (kDebugMode) debugPrint('Meminta detail lengkap dosen dari API asli untuk id: $dosenId'); + return await _realApi.getDosenDetailLengkap(dosenId); + } catch (e) { + if (kDebugMode) debugPrint('Error dengan API asli: $e'); + // FACTORY-FIX: Tidak fallback ke mock — user harus lihat error state + rethrow; } } } -} \ No newline at end of file +} diff --git a/lib/api/api_services_integration.dart b/lib/api/api_services_integration.dart index 9ba6a91..6485cdf 100644 --- a/lib/api/api_services_integration.dart +++ b/lib/api/api_services_integration.dart @@ -1,5 +1,6 @@ // lib/api/api_services_integration.dart import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../models/mahasiswa.dart'; import '../models/dosen.dart'; @@ -23,126 +24,15 @@ class ApiServicesIntegration { 'User-Agent': 'DB-Cracker-App/1.0', }; - /// API Pencarian Data Pendidikan (dari open API collection) + /// API Pencarian Data Pendidikan + /// BUG-H4 FIX: GitHub API endpoint removed — it returned repo file listings, + /// NOT actual education data. The keyword was never used in the request URL. + /// This was a dead network call adding 10s latency with zero useful results. Future>> searchEducationData(String keyword) async { - try { - // List of education APIs to search - final List apiEndpoints = [ - 'https://api.github.com/repos/IlhamriSKY/PDDIKTI-kemdikbud-API/contents/data', // Python 3 API wrapper PDDIKTI - 'https://animeapi.my.id/api/v1/anime/search?q=$keyword', // Menampilkan data anime (contoh API lain) - 'https://api.nashta.co.id/education/search?q=$keyword', // Contoh endpoint fiktif - ]; - - List> results = []; - - for (var endpoint in apiEndpoints) { - try { - final response = await http.get( - Uri.parse(endpoint), - headers: _headers, - ).timeout( - Duration(seconds: 10), - ); - - if (response.statusCode == 200) { - final dynamic data = jsonDecode(response.body); - - if (data is Map) { - if (data.containsKey('results') && data['results'] is List) { - for (var item in data['results']) { - if (item is Map) { - results.add(item); - } - } - } else if (data.containsKey('data') && data['data'] is List) { - for (var item in data['data']) { - if (item is Map) { - results.add(item); - } - } - } else { - // Add the whole data as an item if it doesn't follow expected structure - results.add(data); - } - } else if (data is List) { - for (var item in data) { - if (item is Map) { - results.add(item); - } - } - } - } - } catch (e) { - print('Error searching from $endpoint: $e'); - // Continue to next API - } - } - - return results; - } catch (e) { - print('Error in education search: $e'); - return []; - } - } - - /// Mencari data dari Dayoff API (hari libur nasional) - Future>> searchNationalHolidays() async { - try { - final response = await http.get( - Uri.parse('https://dayoffapi.vercel.app/api'), - headers: _headers, - ).timeout( - Duration(seconds: 10), - ); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - - if (data is Map && data.containsKey('data') && data['data'] is List) { - final List holidaysList = data['data'] as List; - - return holidaysList.map((item) { - if (item is Map) { - return item; - } - return {}; - }).toList(); - } - } - - return []; - } catch (e) { - print('Error fetching holidays: $e'); - return []; - } - } - - /// Mencari data dari Indonesian Geographical Data API (Wilayah Indonesia) - Future>> searchGeographicalData(String province) async { - try { - final response = await http.get( - Uri.parse('https://ibnux.github.io/data-indonesia/propinsi/$province.json'), - headers: _headers, - ).timeout( - Duration(seconds: 10), - ); - - if (response.statusCode == 200) { - final List data = jsonDecode(response.body); - - return data.map((item) { - if (item is Map) { - return item; - } - return {}; - }).toList(); - } - - return []; - } catch (e) { - print('Error fetching geographical data: $e'); - return []; - } + // No valid external education API endpoints available currently. + // PDDIKTI is the primary source, handled by PddiktiApi directly. + // This method kept for interface compatibility with MultiApiFactory. + return []; } /// Mencari data dari Wikipedia API @@ -152,7 +42,7 @@ class ApiServicesIntegration { Uri.parse('https://id.wikipedia.org/api/rest_v1/page/summary/${Uri.encodeComponent(keyword)}'), headers: _headers, ).timeout( - Duration(seconds: 10), + const Duration(seconds: 10), ); if (response.statusCode == 200) { @@ -161,7 +51,7 @@ class ApiServicesIntegration { return {}; } catch (e) { - print('Error fetching from Wikipedia: $e'); + if (kDebugMode) debugPrint('Error fetching from Wikipedia: $e'); return {}; } } @@ -169,7 +59,6 @@ class ApiServicesIntegration { /// Convert data ke model Mahasiswa jika memungkinkan List convertToMahasiswa(List> data) { return data.map((item) { - // Try to extract fields that might represent student data return Mahasiswa( id: item['id'] ?? item['mahasiswa_id'] ?? item['ID'] ?? '', nama: item['nama'] ?? item['name'] ?? item['nama_mahasiswa'] ?? '', @@ -194,4 +83,4 @@ class ApiServicesIntegration { ); }).where((d) => d.nama.isNotEmpty && d.nidn.isNotEmpty).toList(); } -} \ No newline at end of file +} diff --git a/lib/api/cache/cache_entry.dart b/lib/api/cache/cache_entry.dart new file mode 100644 index 0000000..36b2f49 --- /dev/null +++ b/lib/api/cache/cache_entry.dart @@ -0,0 +1,35 @@ +/// Model cache entry — menyimpan response body + metadata TTL +class CacheEntry { + final String key; + final String body; + final DateTime createdAt; + final DateTime freshUntil; + final DateTime staleUntil; + final String source; + final int? statusCode; + + const CacheEntry({ + required this.key, + required this.body, + required this.createdAt, + required this.freshUntil, + required this.staleUntil, + required this.source, + this.statusCode, + }); + + /// Masih fresh — data valid tanpa network call + bool get isFresh => DateTime.now().isBefore(freshUntil); + + /// Sudah stale tapi belum expired — bisa dipakai sebagai fallback + bool get isStale => !isFresh && DateTime.now().isBefore(staleUntil); + + /// Sudah expired total — harus dihapus + bool get isExpired => DateTime.now().isAfter(staleUntil); + + /// Umur entry dalam detik + int get ageSeconds => DateTime.now().difference(createdAt).inSeconds; + + @override + String toString() => 'CacheEntry($key, fresh=${isFresh}, stale=${isStale}, source=$source)'; +} diff --git a/lib/api/cache/cache_policy.dart b/lib/api/cache/cache_policy.dart new file mode 100644 index 0000000..44d4985 --- /dev/null +++ b/lib/api/cache/cache_policy.dart @@ -0,0 +1,78 @@ +/// Cache policy — menentukan TTL fresh dan stale per tipe data +class CachePolicy { + final Duration freshTtl; + final Duration staleTtl; + final bool allowStaleOnFailure; + + const CachePolicy({ + required this.freshTtl, + required this.staleTtl, + this.allowStaleOnFailure = true, + }); + + /// Policies default per tipe data PDDIKTI + static const searchMahasiswa = CachePolicy( + freshTtl: Duration(minutes: 5), + staleTtl: Duration(hours: 24), + ); + + static const searchDosen = CachePolicy( + freshTtl: Duration(minutes: 5), + staleTtl: Duration(hours: 24), + ); + + static const searchPt = CachePolicy( + freshTtl: Duration(minutes: 30), + staleTtl: Duration(days: 7), + ); + + static const searchProdi = CachePolicy( + freshTtl: Duration(minutes: 30), + staleTtl: Duration(days: 7), + ); + + static const detailMahasiswa = CachePolicy( + freshTtl: Duration(hours: 1), + staleTtl: Duration(days: 7), + ); + + static const detailDosen = CachePolicy( + freshTtl: Duration(hours: 1), + staleTtl: Duration(days: 7), + ); + + static const detailPt = CachePolicy( + freshTtl: Duration(hours: 24), + staleTtl: Duration(days: 30), + ); + + static const detailProdi = CachePolicy( + freshTtl: Duration(hours: 24), + staleTtl: Duration(days: 30), + ); + + /// Wilayah — data jarang berubah + static const wilayah = CachePolicy( + freshTtl: Duration(days: 30), + staleTtl: Duration(days: 180), + ); + + /// BPS — data statistik + static const bpsData = CachePolicy( + freshTtl: Duration(hours: 24), + staleTtl: Duration(days: 7), + ); + + /// Enrichment akademik — GARUDA/SINTA/RAMA + static const enrichment = CachePolicy( + freshTtl: Duration(days: 7), + staleTtl: Duration(days: 30), + ); + + /// Health check — sangat pendek + static const health = CachePolicy( + freshTtl: Duration(minutes: 1), + staleTtl: Duration(minutes: 10), + allowStaleOnFailure: false, + ); +} diff --git a/lib/api/cache/cache_store.dart b/lib/api/cache/cache_store.dart new file mode 100644 index 0000000..18a6b2e --- /dev/null +++ b/lib/api/cache/cache_store.dart @@ -0,0 +1,43 @@ +import 'cache_entry.dart'; + +/// Statistik cache +class CacheStats { + final int totalEntries; + final int freshEntries; + final int staleEntries; + final int expiredEntries; + + const CacheStats({ + this.totalEntries = 0, + this.freshEntries = 0, + this.staleEntries = 0, + this.expiredEntries = 0, + }); + + @override + String toString() => 'CacheStats(total=$totalEntries, fresh=$freshEntries, stale=$staleEntries, expired=$expiredEntries)'; +} + +/// Abstract cache store — bisa di-implement sebagai InMemory, SQLite, atau Redis (gateway) +abstract class CacheStore { + /// Ambil entry berdasarkan key (return null jika tidak ada atau expired total) + Future get(String key); + + /// Simpan entry baru + Future put(CacheEntry entry); + + /// Hapus entry berdasarkan key + Future delete(String key); + + /// Hapus semua entry yang key-nya dimulai dengan prefix + Future clearByPrefix(String prefix); + + /// Hapus semua entry yang sudah expired (staleUntil sudah lewat) + Future clearExpired(); + + /// Hapus semua cache + Future clearAll(); + + /// Statistik cache saat ini + Future stats(); +} diff --git a/lib/api/cache/in_memory_cache_store.dart b/lib/api/cache/in_memory_cache_store.dart new file mode 100644 index 0000000..4a12958 --- /dev/null +++ b/lib/api/cache/in_memory_cache_store.dart @@ -0,0 +1,79 @@ +import 'cache_entry.dart'; +import 'cache_store.dart'; + +/// In-memory cache store — cocok untuk mobile dan web +/// Tidak persist antar session, tapi cepat dan zero-dependency +class InMemoryCacheStore implements CacheStore { + final Map _store = {}; + final int maxEntries; + + InMemoryCacheStore({this.maxEntries = 200}); + + @override + Future get(String key) async { + final entry = _store[key]; + if (entry == null) return null; + + // Hapus jika sudah expired total + if (entry.isExpired) { + _store.remove(key); + return null; + } + + return entry; + } + + @override + Future put(CacheEntry entry) async { + // Evict oldest jika penuh (FIFO) + if (_store.length >= maxEntries && !_store.containsKey(entry.key)) { + final oldestKey = _store.keys.first; + _store.remove(oldestKey); + } + _store[entry.key] = entry; + } + + @override + Future delete(String key) async { + _store.remove(key); + } + + @override + Future clearByPrefix(String prefix) async { + _store.removeWhere((key, _) => key.startsWith(prefix)); + } + + @override + Future clearExpired() async { + _store.removeWhere((_, entry) => entry.isExpired); + } + + @override + Future clearAll() async { + _store.clear(); + } + + @override + Future stats() async { + int fresh = 0; + int stale = 0; + int expired = 0; + + for (final entry in _store.values) { + if (entry.isFresh) { + fresh++; + } else if (entry.isStale) { + stale++; + } else { + expired++; + } + } + + return CacheStats( + totalEntries: _store.length, + freshEntries: fresh, + staleEntries: stale, + expiredEntries: expired, + ); + } +} diff --git a/lib/api/core/data_result.dart b/lib/api/core/data_result.dart new file mode 100644 index 0000000..be791f4 --- /dev/null +++ b/lib/api/core/data_result.dart @@ -0,0 +1,118 @@ +/// Tipe sumber data — agar UI tahu asal data yang ditampilkan +enum DataSourceType { + /// Data langsung dari API provider (fresh) + live, + + /// Data dari memory cache (masih fresh) + memoryCache, + + /// Data dari persistent local cache (masih fresh) + persistentCache, + + /// Data dari stale cache (expired fresh tapi masih dalam stale window) + /// UI wajib tampilkan warning + staleCache, + + /// Data mock/demo — hanya untuk testing, TIDAK boleh di production tanpa label + mock, + + /// Tautan eksternal — bukan data yang di-fetch, hanya URL + externalLink, + + /// Provider tidak tersedia + unavailable, +} + +/// Generic result wrapper — membawa data + metadata sumber +/// Agar UI bisa menampilkan source badge dan warning stale +class DataResult { + final T? data; + final DataSourceType sourceType; + final String providerId; + final String providerName; + final bool isStale; + final DateTime fetchedAt; + final Duration? latency; + final String? warning; + final Object? rawError; + + const DataResult({ + this.data, + required this.sourceType, + required this.providerId, + required this.providerName, + required this.isStale, + required this.fetchedAt, + this.latency, + this.warning, + this.rawError, + }); + + /// Shortcut untuk bikin result live + factory DataResult.live({ + required T data, + required String providerId, + required String providerName, + Duration? latency, + }) => DataResult( + data: data, + sourceType: DataSourceType.live, + providerId: providerId, + providerName: providerName, + isStale: false, + fetchedAt: DateTime.now(), + latency: latency, + ); + + /// Shortcut untuk bikin result dari cache + factory DataResult.cached({ + required T data, + required String providerId, + required String providerName, + required bool isStale, + }) => DataResult( + data: data, + sourceType: isStale ? DataSourceType.staleCache : DataSourceType.memoryCache, + providerId: providerId, + providerName: providerName, + isStale: isStale, + fetchedAt: DateTime.now(), + warning: isStale ? 'Data mungkin tidak terbaru (dari cache)' : null, + ); + + /// Shortcut untuk unavailable + factory DataResult.unavailable({ + required String providerId, + required String providerName, + Object? error, + }) => DataResult( + data: null, + sourceType: DataSourceType.unavailable, + providerId: providerId, + providerName: providerName, + isStale: false, + fetchedAt: DateTime.now(), + rawError: error, + warning: 'Data tidak tersedia saat ini', + ); + + /// Label user-friendly untuk source + String get sourceLabel { + switch (sourceType) { + case DataSourceType.live: + return 'Sumber: $providerName (live)'; + case DataSourceType.memoryCache: + return 'Sumber: cache lokal'; + case DataSourceType.persistentCache: + return 'Sumber: cache tersimpan'; + case DataSourceType.staleCache: + return 'Sumber: cache lama, data mungkin tidak terbaru'; + case DataSourceType.mock: + return 'Sumber: data demo'; + case DataSourceType.externalLink: + return 'Tautan eksternal: $providerName'; + case DataSourceType.unavailable: + return 'Data tidak tersedia'; + } + } +} diff --git a/lib/api/core/provider_registry.dart b/lib/api/core/provider_registry.dart new file mode 100644 index 0000000..166bcc9 --- /dev/null +++ b/lib/api/core/provider_registry.dart @@ -0,0 +1,189 @@ +/// Provider Registry — mengelola semua API provider secara seragam +/// Semua provider core WAJIB no-auth. Provider yang butuh key/OAuth +/// tidak boleh masuk core flow. + +/// Jenis provider +enum ProviderKind { + pddikti, + wilayah, + sekolah, + wikipedia, + kbbi, + maganghub, + externalLink, +} + +/// Mode autentikasi — core flow hanya boleh `none` +enum ProviderAuthMode { + none, + apiKey, + oauth, + unknown, +} + +/// Status kesehatan provider +enum ProviderStatus { + unknown, + healthy, + degraded, + rateLimited, + unavailable, + malformed, + timeout, +} + +/// Konfigurasi satu provider +class ApiProviderConfig { + final String id; + final String name; + final String baseUrl; + final ProviderKind kind; + final ProviderAuthMode authMode; + final int priority; + final bool enabled; + final Duration timeout; + final List retryableStatusCodes; + + const ApiProviderConfig({ + required this.id, + required this.name, + required this.baseUrl, + required this.kind, + this.authMode = ProviderAuthMode.none, + required this.priority, + this.enabled = true, + this.timeout = const Duration(seconds: 12), + this.retryableStatusCodes = const [408, 425, 429, 500, 502, 503, 504], + }); + + @override + String toString() => 'Provider($id, $kind, priority=$priority, enabled=$enabled)'; +} + +/// Registry semua provider yang terdaftar +class ProviderRegistry { + static const List allProviders = [ + // === PDDIKTI Core === + ApiProviderConfig( + id: 'pddikti_fastapicloud', + name: 'PDDikti FastAPI Cloud', + baseUrl: 'https://pddikti.fastapicloud.dev/api', + kind: ProviderKind.pddikti, + priority: 1, + timeout: Duration(seconds: 15), + ), + ApiProviderConfig( + id: 'pddikti_rone', + name: 'PDDikti Rone.dev', + baseUrl: 'https://pddikti.rone.dev/api', + kind: ProviderKind.pddikti, + priority: 2, + timeout: Duration(seconds: 12), + ), + + // === Wilayah Core === + ApiProviderConfig( + id: 'wilayah_id', + name: 'Wilayah.id', + baseUrl: 'https://wilayah.id/api', + kind: ProviderKind.wilayah, + priority: 1, + timeout: Duration(seconds: 8), + ), + ApiProviderConfig( + id: 'emsifa_wilayah', + name: 'Emsifa Wilayah (GitHub Pages)', + baseUrl: 'https://emsifa.github.io/api-wilayah-indonesia/api', + kind: ProviderKind.wilayah, + priority: 2, + timeout: Duration(seconds: 10), + ), + + // === Sekolah/NPSN === + // NOTE: api.fazriansyah.eu.org may be intermittently unavailable + // Service handles connection failures gracefully (returns null) + ApiProviderConfig( + id: 'fazriansyah_sekolah', + name: 'API Sekolah Indonesia', + baseUrl: 'https://api.fazriansyah.eu.org/v1', + kind: ProviderKind.sekolah, + priority: 1, + timeout: Duration(seconds: 5), // Reduced timeout — fail fast if DNS dead + ), + + // === Wikipedia === + ApiProviderConfig( + id: 'wikipedia_id', + name: 'Wikipedia Indonesia', + baseUrl: 'https://id.wikipedia.org/api/rest_v1', + kind: ProviderKind.wikipedia, + priority: 1, + timeout: Duration(seconds: 8), + ), + + // === KBBI === + ApiProviderConfig( + id: 'kbbi_api', + name: 'KBBI API', + baseUrl: 'https://kbbi-api-amm.herokuapp.com', + kind: ProviderKind.kbbi, + priority: 1, + timeout: Duration(seconds: 8), + ), + + // === MagangHub === + ApiProviderConfig( + id: 'maganghub', + name: 'MagangHub', + baseUrl: 'https://maganghub.ndav.my.id/api/scrape', + kind: ProviderKind.maganghub, + priority: 1, + timeout: Duration(seconds: 10), + ), + + // === External Links (bukan API, hanya deep-link) === + ApiProviderConfig( + id: 'garuda_link', + name: 'GARUDA Kemdiktisaintek', + baseUrl: 'https://garuda.kemdiktisaintek.go.id', + kind: ProviderKind.externalLink, + priority: 1, + ), + ApiProviderConfig( + id: 'rama_link', + name: 'RAMA Repository', + baseUrl: 'https://rama.kemdiktisaintek.go.id', + kind: ProviderKind.externalLink, + priority: 2, + ), + ApiProviderConfig( + id: 'sinta_link', + name: 'SINTA Kemdikbud', + baseUrl: 'https://sinta.kemdikbud.go.id', + kind: ProviderKind.externalLink, + priority: 3, + ), + ]; + + /// Ambil providers berdasarkan jenis + static List byKind(ProviderKind kind) => + allProviders.where((p) => p.kind == kind && p.enabled).toList() + ..sort((a, b) => a.priority.compareTo(b.priority)); + + /// Ambil provider berdasarkan ID + static ApiProviderConfig? byId(String id) { + try { + return allProviders.firstWhere((p) => p.id == id); + } catch (_) { + return null; + } + } + + /// Semua provider core (no-auth only) + static List get coreProviders => + allProviders.where((p) => p.authMode == ProviderAuthMode.none && p.enabled).toList(); + + /// Semua external link providers + static List get externalLinkProviders => + allProviders.where((p) => p.kind == ProviderKind.externalLink).toList(); +} diff --git a/lib/api/enrichment/external_links.dart b/lib/api/enrichment/external_links.dart new file mode 100644 index 0000000..820aa2a --- /dev/null +++ b/lib/api/enrichment/external_links.dart @@ -0,0 +1,156 @@ +/// External Link Enrichment — GARUDA, RAMA, SINTA +/// INI BUKAN API PROVIDER. Ini hanya deep-link builder. +/// Tidak ada scraping. Tidak ada fetch data otomatis. +/// Hanya membangun URL pencarian yang bisa dibuka user di browser. + +import '../core/data_result.dart'; + +/// Model untuk tautan enrichment eksternal +class ExternalEnrichmentLink { + final String id; + final String providerId; + final String title; + final String description; + final Uri url; + final String query; + final DataSourceType sourceType; + + const ExternalEnrichmentLink({ + required this.id, + required this.providerId, + required this.title, + required this.description, + required this.url, + required this.query, + this.sourceType = DataSourceType.externalLink, + }); +} + +/// Builder untuk link GARUDA (publikasi ilmiah) +class GarudaLinkBuilder { + static const _baseUrl = 'https://garuda.kemdiktisaintek.go.id'; + static const _providerId = 'garuda_link'; + + /// Buat link pencarian publikasi berdasarkan nama dosen + static ExternalEnrichmentLink searchByLecturer(String lecturerName) { + final query = lecturerName.trim(); + final encodedQuery = Uri.encodeComponent(query); + return ExternalEnrichmentLink( + id: 'garuda_lecturer_$encodedQuery', + providerId: _providerId, + title: 'Cari Publikasi di GARUDA', + description: 'Buka pencarian publikasi "$query" di portal GARUDA Kemdiktisaintek', + url: Uri.parse('$_baseUrl/documents?q=$encodedQuery'), + query: query, + ); + } + + /// Buat link pencarian berdasarkan keyword + static ExternalEnrichmentLink searchByKeyword(String keyword) { + final query = keyword.trim(); + final encodedQuery = Uri.encodeComponent(query); + return ExternalEnrichmentLink( + id: 'garuda_keyword_$encodedQuery', + providerId: _providerId, + title: 'Cari di GARUDA', + description: 'Buka pencarian "$query" di portal GARUDA', + url: Uri.parse('$_baseUrl/documents?q=$encodedQuery'), + query: query, + ); + } +} + +/// Builder untuk link RAMA (repository akademik) +class RamaLinkBuilder { + static const _baseUrl = 'https://rama.kemdiktisaintek.go.id'; + static const _providerId = 'rama_link'; + + /// Buat link pencarian repository berdasarkan nama PT + static ExternalEnrichmentLink searchByInstitution(String institutionName) { + final query = institutionName.trim(); + final encodedQuery = Uri.encodeComponent(query); + return ExternalEnrichmentLink( + id: 'rama_institution_$encodedQuery', + providerId: _providerId, + title: 'Cari Repository di RAMA', + description: 'Buka pencarian repository "$query" di portal RAMA', + url: Uri.parse('$_baseUrl/search?q=$encodedQuery'), + query: query, + ); + } + + /// Buat link pencarian tugas akhir/tesis + static ExternalEnrichmentLink searchDocuments(String keyword) { + final query = keyword.trim(); + final encodedQuery = Uri.encodeComponent(query); + return ExternalEnrichmentLink( + id: 'rama_docs_$encodedQuery', + providerId: _providerId, + title: 'Cari Tugas Akhir di RAMA', + description: 'Buka pencarian dokumen "$query" di portal RAMA', + url: Uri.parse('$_baseUrl/search?q=$encodedQuery'), + query: query, + ); + } +} + +/// Builder untuk link SINTA (profil riset dosen) +class SintaLinkBuilder { + static const _baseUrl = 'https://sinta.kemdikbud.go.id'; + static const _providerId = 'sinta_link'; + + /// Buat link pencarian profil dosen di SINTA + static ExternalEnrichmentLink searchLecturerProfile(String lecturerName) { + final query = lecturerName.trim(); + final encodedQuery = Uri.encodeComponent(query); + return ExternalEnrichmentLink( + id: 'sinta_lecturer_$encodedQuery', + providerId: _providerId, + title: 'Cari Profil di SINTA', + description: 'Buka pencarian profil "$query" di portal SINTA Kemdikbud', + url: Uri.parse('$_baseUrl/authors?q=$encodedQuery'), + query: query, + ); + } + + /// Buat link pencarian institusi di SINTA + static ExternalEnrichmentLink searchInstitution(String institutionName) { + final query = institutionName.trim(); + final encodedQuery = Uri.encodeComponent(query); + return ExternalEnrichmentLink( + id: 'sinta_institution_$encodedQuery', + providerId: _providerId, + title: 'Cari Institusi di SINTA', + description: 'Buka pencarian institusi "$query" di portal SINTA', + url: Uri.parse('$_baseUrl/affiliations?q=$encodedQuery'), + query: query, + ); + } +} + +/// Helper: ambil semua enrichment links untuk dosen +List getDosenEnrichmentLinks({ + required String dosenName, + String? institutionName, +}) { + final links = [ + GarudaLinkBuilder.searchByLecturer(dosenName), + SintaLinkBuilder.searchLecturerProfile(dosenName), + ]; + + if (institutionName != null && institutionName.isNotEmpty) { + links.add(RamaLinkBuilder.searchByInstitution(institutionName)); + } + + return links; +} + +/// Helper: ambil semua enrichment links untuk PT +List getPtEnrichmentLinks({ + required String ptName, +}) { + return [ + RamaLinkBuilder.searchByInstitution(ptName), + SintaLinkBuilder.searchInstitution(ptName), + ]; +} diff --git a/lib/api/health/health_service.dart b/lib/api/health/health_service.dart new file mode 100644 index 0000000..5fc56cf --- /dev/null +++ b/lib/api/health/health_service.dart @@ -0,0 +1,181 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../core/provider_registry.dart'; +import '../cache/cache_store.dart'; + +/// Model hasil health check satu provider +class ProviderHealthResult { + final String providerId; + final String providerName; + final ProviderKind kind; + final ProviderStatus status; + final int? httpStatusCode; + final Duration? latency; + final DateTime checkedAt; + final String? message; + + const ProviderHealthResult({ + required this.providerId, + required this.providerName, + required this.kind, + required this.status, + this.httpStatusCode, + this.latency, + required this.checkedAt, + this.message, + }); +} + +/// Laporan kesehatan keseluruhan app +class AppHealthReport { + final List providers; + final CacheStats cacheStats; + final DateTime generatedAt; + final String appVersion; + + const AppHealthReport({ + required this.providers, + required this.cacheStats, + required this.generatedAt, + this.appVersion = '3.1.0', + }); + + int get healthyCount => providers.where((p) => p.status == ProviderStatus.healthy).length; + int get degradedCount => providers.where((p) => p.status == ProviderStatus.degraded || p.status == ProviderStatus.rateLimited).length; + int get unavailableCount => providers.where((p) => p.status == ProviderStatus.unavailable || p.status == ProviderStatus.timeout).length; +} + +/// Service untuk health check semua provider +/// Menggunakan endpoint ringan, timeout pendek, cache 1 menit +class HealthService { + final http.Client httpClient; + final CacheStore cacheStore; + + HealthService({required this.httpClient, required this.cacheStore}); + + /// Check semua provider dan return laporan lengkap + Future checkAll() async { + final results = []; + + // Check PDDIKTI providers + for (final provider in ProviderRegistry.byKind(ProviderKind.pddikti)) { + results.add(await _checkProvider(provider)); + } + + // Check Wilayah providers + for (final provider in ProviderRegistry.byKind(ProviderKind.wilayah)) { + results.add(await _checkProvider(provider)); + } + + // Check Sekolah + for (final provider in ProviderRegistry.byKind(ProviderKind.sekolah)) { + results.add(await _checkProviderSimple(provider)); + } + + // Check Wikipedia + for (final provider in ProviderRegistry.byKind(ProviderKind.wikipedia)) { + results.add(await _checkProvider(provider, path: '/page/summary/Indonesia')); + } + + // Check KBBI + for (final provider in ProviderRegistry.byKind(ProviderKind.kbbi)) { + results.add(await _checkProviderSimple(provider)); + } + + // Check MagangHub + for (final provider in ProviderRegistry.byKind(ProviderKind.maganghub)) { + results.add(await _checkProvider(provider, path: '/provinces')); + } + + // External links — always mark as healthy (they're just URLs) + for (final provider in ProviderRegistry.externalLinkProviders) { + results.add(ProviderHealthResult( + providerId: provider.id, + providerName: provider.name, + kind: provider.kind, + status: ProviderStatus.healthy, + checkedAt: DateTime.now(), + message: 'Deep-link (tidak perlu health check)', + )); + } + + final stats = await cacheStore.stats(); + + return AppHealthReport( + providers: results, + cacheStats: stats, + generatedAt: DateTime.now(), + ); + } + + /// Health check satu provider dengan endpoint ringan + Future _checkProvider(ApiProviderConfig provider, {String? path}) async { + final stopwatch = Stopwatch()..start(); + try { + final checkPath = path ?? '/'; + final url = '${provider.baseUrl}$checkPath'; + final response = await httpClient + .get(Uri.parse(url), headers: const {'Accept': 'application/json'}) + .timeout(const Duration(seconds: 5)); + stopwatch.stop(); + + ProviderStatus status; + if (response.statusCode == 200) { + status = ProviderStatus.healthy; + } else if (response.statusCode == 429) { + status = ProviderStatus.rateLimited; + } else if (response.statusCode >= 500) { + status = ProviderStatus.degraded; + } else { + status = ProviderStatus.unavailable; + } + + return ProviderHealthResult( + providerId: provider.id, + providerName: provider.name, + kind: provider.kind, + status: status, + httpStatusCode: response.statusCode, + latency: stopwatch.elapsed, + checkedAt: DateTime.now(), + ); + } catch (e) { + stopwatch.stop(); + final isTimeout = e.toString().contains('TimeoutException') || e.toString().contains('Timeout'); + + return ProviderHealthResult( + providerId: provider.id, + providerName: provider.name, + kind: provider.kind, + status: isTimeout ? ProviderStatus.timeout : ProviderStatus.unavailable, + latency: stopwatch.elapsed, + checkedAt: DateTime.now(), + message: isTimeout ? 'Timeout (5s)' : e.toString().substring(0, (e.toString().length).clamp(0, 80)), + ); + } + } + + /// Simple check — hanya cek apakah provider configured dan enabled + Future _checkProviderSimple(ApiProviderConfig provider) async { + if (!provider.enabled) { + return ProviderHealthResult( + providerId: provider.id, + providerName: provider.name, + kind: provider.kind, + status: ProviderStatus.unavailable, + checkedAt: DateTime.now(), + message: 'Provider disabled', + ); + } + + // Untuk provider yang butuh parameter (sekolah butuh NPSN), skip live check + return ProviderHealthResult( + providerId: provider.id, + providerName: provider.name, + kind: provider.kind, + status: ProviderStatus.unknown, + checkedAt: DateTime.now(), + message: 'Membutuhkan parameter untuk test (NPSN/keyword)', + ); + } +} diff --git a/lib/api/multi_api_factory.dart b/lib/api/multi_api_factory.dart index f3b141d..2d4d52b 100644 --- a/lib/api/multi_api_factory.dart +++ b/lib/api/multi_api_factory.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../models/mahasiswa.dart'; @@ -15,195 +16,210 @@ class MultiApiFactory { /// Private constructor MultiApiFactory._internal(); - + /// Factory constructor factory MultiApiFactory() { return _instance; } - + /// API Factory untuk PDDIKTI final ApiFactory _pddiktiApi = ApiFactory(); - + /// API Services Integration final ApiServicesIntegration _apiServices = ApiServicesIntegration(); - + /// Base URL untuk API Data Mahasiswa Kemdikbud - final String _kemdikbudApiUrl = 'https://api-frontend.kemdikbud.go.id'; - - /// Header untuk request - Map get _headers => { - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'en-US,en;q=0.9,id;q=0.8', - 'Origin': 'https://indonesia-public-static-api.vercel.app', - 'Referer': 'https://indonesia-public-static-api.vercel.app', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', - }; - + /// NOTE: api-frontend.kemdikbud.go.id is DEAD (NXDOMAIN since ~2025). + /// Disabled — searches now rely on PDDIKTI proxy providers only. + final String _kemdikbudApiUrl = ''; + + /// Header untuk request — cached as final to avoid Map recreation per access + final Map _headers = const { + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9,id;q=0.8', + 'Origin': 'https://indonesia-public-static-api.vercel.app', + 'Referer': 'https://indonesia-public-static-api.vercel.app', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', + }; + /// Encode parameter URL String _parseString(String text) { return Uri.encodeComponent(text); } - + + /// Wrap future dengan try-catch biar partial failure ga bikin semua gagal + Future> _safeSearch(Future> future) async { + try { + return await future; + } catch (e) { + if (kDebugMode) debugPrint('Partial search failed: $e'); + return []; + } + } + /// Metode utama untuk mencari data mahasiswa dari berbagai sumber API Future> searchAllSources(String keyword) async { List results = []; List>> futures = []; - + // Cari data dari PDDIKTI futures.add(_pddiktiApi.searchMahasiswa(keyword)); - + // Cari data dari Kemdikbud futures.add(_searchKemdikbud(keyword)); - + // Cari data dari API lain dan konversi ke model Mahasiswa futures.add(_searchFromEducationApis(keyword)); - - // Jalankan semua pencarian secara paralel - try { - final responses = await Future.wait(futures); - - // Gabungkan semua hasil - for (var response in responses) { - results.addAll(response); - } - - // Hapus duplikat berdasarkan kombinasi nama dan nim - final uniqueResults = {}; - for (var mahasiswa in results) { - final key = '${mahasiswa.nama}-${mahasiswa.nim}'; - uniqueResults[key] = mahasiswa; - } - - return uniqueResults.values.toList(); - } catch (e) { - print('Error mencari dari semua sumber: $e'); - // Jika terjadi error, coba kembalikan apa saja yang berhasil - return results; + + // Jalankan semua pencarian secara paralel dengan error isolation + final responses = await Future.wait( + futures.map((f) => _safeSearch(f)).toList(), + ); + + // Gabungkan semua hasil + for (var response in responses) { + results.addAll(response); } + + // Hapus duplikat berdasarkan kombinasi nama dan nim + final uniqueResults = {}; + for (var mahasiswa in results) { + final key = '${mahasiswa.nama}-${mahasiswa.nim}'; + uniqueResults[key] = mahasiswa; + } + + return uniqueResults.values.toList(); } - + /// Cari data mahasiswa dari API pendidikan lain Future> _searchFromEducationApis(String keyword) async { try { // Dapatkan data dari API pendidikan final rawData = await _apiServices.searchEducationData(keyword); - + // Konversi ke model Mahasiswa return _apiServices.convertToMahasiswa(rawData); } catch (e) { - print('Error mencari dari API pendidikan: $e'); + if (kDebugMode) debugPrint('Error mencari dari API pendidikan: $e'); return []; } } - + /// Cari data mahasiswa dari API Kemdikbud + /// DISABLED: api-frontend.kemdikbud.go.id domain no longer exists (NXDOMAIN). + /// Returns empty immediately to avoid DNS lookup timeout. Future> _searchKemdikbud(String keyword) async { + // Domain is dead (NXDOMAIN) — skip immediately to avoid 10s DNS timeout + return []; + try { - final Uri url = Uri.parse('$_kemdikbudApiUrl/hit_mhs/${_parseString(keyword)}'); - - final response = await http.get( - url, - headers: _headers, - ).timeout( - Duration(seconds: 10), - ); - + final Uri url = + Uri.parse('$_kemdikbudApiUrl/hit_mhs/${_parseString(keyword)}'); + + final response = await http + .get( + url, + headers: _headers, + ) + .timeout( + Duration(seconds: 10), + ); + if (response.statusCode == 200) { final Map data = jsonDecode(response.body); - + if (data.containsKey('mahasiswa') && data['mahasiswa'] is List) { final List mahasiswaList = data['mahasiswa'] as List; - - return mahasiswaList.map((item) { - if (item is Map) { - return Mahasiswa( - id: item['id_mahasiswa'] ?? '', - nama: item['nm_mhs'] ?? '', - nim: item['nipd'] ?? '', - namaPt: item['nm_pt'] ?? '', - singkatanPt: item['kode_pt'] ?? '', - namaProdi: item['nm_prodi'] ?? '', - ); - } - return Mahasiswa( - id: '', - nama: '', - nim: '', - namaPt: '', - singkatanPt: '', - namaProdi: '', - ); - }).where((m) => m.id.isNotEmpty).toList(); + + return mahasiswaList + .map((item) { + if (item is Map) { + return Mahasiswa( + id: item['id_mahasiswa'] ?? '', + nama: item['nm_mhs'] ?? '', + nim: item['nipd'] ?? '', + namaPt: item['nm_pt'] ?? '', + singkatanPt: item['kode_pt'] ?? '', + namaProdi: item['nm_prodi'] ?? '', + ); + } + return Mahasiswa( + id: '', + nama: '', + nim: '', + namaPt: '', + singkatanPt: '', + namaProdi: '', + ); + }) + .where((m) => m.id.isNotEmpty) + .toList(); } } - + return []; } catch (e) { - print('Error mencari dari Kemdikbud: $e'); + if (kDebugMode) debugPrint('Error mencari dari Kemdikbud: $e'); return []; } } - + + /// Wrap future dengan try-catch biar partial failure ga bikin semua gagal + Future> _safeSearchDosen(Future> future) async { + try { + return await future; + } catch (e) { + if (kDebugMode) debugPrint('Partial dosen search failed: $e'); + return []; + } + } + /// Cari data dosen dari berbagai sumber Future> searchAllDosen(String keyword) async { try { List results = []; List>> futures = []; - + // Cari dari PDDIKTI futures.add(_pddiktiApi.searchDosen(keyword)); - + // Cari dari API lain futures.add(_searchDosenFromOtherSources(keyword)); - - // Jalankan semua pencarian secara paralel - final responses = await Future.wait(futures); - + + // BUG-C1 FIX: Jalankan dengan error isolation (sama kayak searchAllSources) + final responses = await Future.wait( + futures.map((f) => _safeSearchDosen(f)).toList(), + ); + // Gabungkan semua hasil for (var response in responses) { results.addAll(response); } - + // Hapus duplikat berdasarkan kombinasi nama dan nidn final uniqueResults = {}; for (var dosen in results) { final key = '${dosen.nama}-${dosen.nidn}'; uniqueResults[key] = dosen; } - + return uniqueResults.values.toList(); } catch (e) { - print('Error mencari dosen: $e'); + if (kDebugMode) debugPrint('Error mencari dosen: $e'); // Jika terjadi error, coba kembalikan apa saja yang berhasil List backupResults = []; - + try { // Coba dapatkan dari mock service sebagai fallback backupResults = await _pddiktiApi.searchDosen(keyword); } catch (e2) { - print('Error getting data from PDDIKTI: $e2'); - - // Jika masih error, coba return data mock sederhana - backupResults = [ - Dosen( - id: '1', - nama: 'Dr. Mock Data', - nidn: '12345', - namaPt: 'Universitas Testing', - singkatanPt: 'UNTEST', - namaProdi: 'Informatika', - ), - Dosen( - id: '2', - nama: 'Prof. Dummy Data', - nidn: '67890', - namaPt: 'Institut Testing', - singkatanPt: 'IT', - namaProdi: 'Teknik Informatika', - ), - ]; + if (kDebugMode) debugPrint('Error getting data from PDDIKTI: $e2'); + + // Jika masih error, return empty list daripada data dummy + backupResults = []; } - + return backupResults; } } @@ -213,78 +229,155 @@ class MultiApiFactory { try { // Dapatkan data dari API pendidikan final rawData = await _apiServices.searchEducationData(keyword); - + // Konversi ke model Dosen return _apiServices.convertToDosen(rawData); } catch (e) { - print('Error mencari dosen dari sumber lain: $e'); + if (kDebugMode) debugPrint('Error mencari dosen dari sumber lain: $e'); return []; } } - /// Mendapatkan detail mahasiswa dari berbagai sumber + /// Mendapatkan detail mahasiswa dari berbagai sumber (lengkap + enrichment) Future getMahasiswaDetail(String mahasiswaId) async { try { - // Coba dapatkan dari PDDIKTI terlebih dahulu - final detail = await _pddiktiApi.getMahasiswaDetail(mahasiswaId); - - // Tambahkan data eksternal jika ada + // Gunakan getMahasiswaDetailLengkap untuk data lebih lengkap (termasuk riwayat) + final detail = await _pddiktiApi.getMahasiswaDetailLengkap(mahasiswaId); + + // Enrichment: Coba lengkapi field kosong dari sumber tambahan + return await _enrichMahasiswaDetail(detail); + } catch (e) { + if (kDebugMode) debugPrint('Error mendapatkan detail lengkap, fallback ke basic: $e'); + + // Fallback ke basic detail try { - // Coba untuk memperkaya data dengan sumber-sumber lain - final kemdikbudDetail = await _searchKemdikbudDetail(mahasiswaId); - if (kemdikbudDetail != null) { - // Gunakan data dari Kemdikbud untuk melengkapi - // Implementasi penggabungan data bisa dikembangkan - return kemdikbudDetail; + final basicDetail = await _pddiktiApi.getMahasiswaDetail(mahasiswaId); + return await _enrichMahasiswaDetail(basicDetail); + } catch (e2) { + if (kDebugMode) debugPrint('Error mendapatkan detail basic: $e2'); + + // Fallback to minimal detail + return MahasiswaDetail( + id: mahasiswaId, + namaPt: 'Data tidak tersedia (error)', + kodePt: '-', + kodeProdi: '-', + prodi: 'Data tidak tersedia', + nama: 'Data tidak tersedia (error)', + nim: '-', + jenisDaftar: '-', + idPt: '-', + idSms: '-', + jenisKelamin: '-', + jenjang: '-', + statusSaatIni: '-', + tahunMasuk: '-', + ); + } + } + } + + /// Enrichment: Lengkapi field kosong dari sumber API tambahan + Future _enrichMahasiswaDetail(MahasiswaDetail detail) async { + // Hitung IPK dan total SKS dari riwayat semester jika field kosong + String enrichedIpk = detail.ipk; + String enrichedTotalSks = detail.totalSks; + String enrichedTahunMasuk = detail.tahunMasuk; + + // Kalkulasi IPK dari riwayat semester jika belum ada + if (enrichedIpk.isEmpty && detail.riwayatSemester.isNotEmpty) { + // IPK terakhir biasanya ada di semester terakhir + final lastSemester = detail.riwayatSemester.last; + if (lastSemester.ipk.isNotEmpty) { + enrichedIpk = lastSemester.ipk; + } + } + + // Kalkulasi total SKS dari riwayat semester jika belum ada + if (enrichedTotalSks.isEmpty && detail.riwayatSemester.isNotEmpty) { + int totalSks = 0; + for (var sem in detail.riwayatSemester) { + final sks = int.tryParse(sem.sksLulus) ?? 0; + totalSks += sks; + } + if (totalSks > 0) enrichedTotalSks = totalSks.toString(); + + // Atau ambil dari semester terakhir jika ada sksTotal + if (enrichedTotalSks.isEmpty) { + final lastSem = detail.riwayatSemester.last; + if (lastSem.sksTotal.isNotEmpty) enrichedTotalSks = lastSem.sksTotal; + } + } + + // Estimasi tahun masuk dari tanggal_masuk jika belum ada + if (enrichedTahunMasuk.isEmpty && detail.riwayatSemester.isNotEmpty) { + // Coba parse dari nama semester pertama (format: "20211" = 2021 ganjil) + final firstSem = detail.riwayatSemester.first; + if (firstSem.namaSemester.length >= 4) { + final yearStr = firstSem.namaSemester.substring(0, 4); + if (int.tryParse(yearStr) != null) { + enrichedTahunMasuk = yearStr; } - } catch (e) { - print('Gagal mendapatkan data tambahan: $e'); - // Tidak perlu melakukan apa-apa, gunakan data yang sudah ada } - - return detail; - } catch (e) { - print('Error mendapatkan detail dari PDDIKTI: $e'); - - // Fallback to minimal detail - return MahasiswaDetail( - id: mahasiswaId, - namaPt: 'Data tidak tersedia (error)', - kodePt: '-', - kodeProdi: '-', - prodi: 'Data tidak tersedia', - nama: 'Data tidak tersedia (error)', - nim: '-', - jenisDaftar: '-', - idPt: '-', - idSms: '-', - jenisKelamin: '-', - jenjang: '-', - statusSaatIni: '-', - tahunMasuk: '-', - ); } + + // Return enriched detail + return MahasiswaDetail( + id: detail.id, + nama: detail.nama, + nim: detail.nim, + jenisKelamin: detail.jenisKelamin, + statusSaatIni: detail.statusSaatIni, + semesterSaatIni: detail.semesterSaatIni, + tempatLahir: detail.tempatLahir, + tanggalLahir: detail.tanggalLahir, + agama: detail.agama, + alamat: detail.alamat, + namaPt: detail.namaPt, + kodePt: detail.kodePt, + idPt: detail.idPt, + prodi: detail.prodi, + kodeProdi: detail.kodeProdi, + idSms: detail.idSms, + jenjang: detail.jenjang, + akreditasiProdi: detail.akreditasiProdi, + jenisDaftar: detail.jenisDaftar, + jalurMasuk: detail.jalurMasuk, + tahunMasuk: enrichedTahunMasuk.isNotEmpty ? enrichedTahunMasuk : detail.tahunMasuk, + tahunLulus: detail.tahunLulus, + semesterAktifTerakhir: detail.semesterAktifTerakhir, + statusAkhir: detail.statusAkhir, + tanggalLulus: detail.tanggalLulus, + nomorIjazah: detail.nomorIjazah, + ipk: enrichedIpk.isNotEmpty ? enrichedIpk : detail.ipk, + totalSks: enrichedTotalSks.isNotEmpty ? enrichedTotalSks : detail.totalSks, + predikatKelulusan: detail.predikatKelulusan, + judulSkripsi: detail.judulSkripsi, + riwayatSemester: detail.riwayatSemester, + riwayatNilai: detail.riwayatNilai, + riwayatKelas: detail.riwayatKelas, + ); } - /// Mendapatkan detail dosen dari berbagai sumber + /// Mendapatkan detail dosen lengkap dari berbagai sumber Future getDosenDetailFromAllSources(String dosenId) async { try { - // Coba dapatkan dari PDDIKTI terlebih dahulu - final detail = await _pddiktiApi.getDosenProfile(dosenId); - + // Coba dapatkan detail lengkap dari PDDIKTI terlebih dahulu + final detail = await _pddiktiApi.getDosenDetailLengkap(dosenId); + // Tambahkan data eksternal jika ada try { // Coba untuk memperkaya data dengan sumber-sumber lain jika ada waktu // Ini bisa diimplementasikan di masa mendatang } catch (e) { - print('Gagal mendapatkan data tambahan: $e'); + if (kDebugMode) debugPrint('Gagal mendapatkan data tambahan: $e'); // Tidak perlu melakukan apa-apa, gunakan data yang sudah ada } - + return detail; } catch (e) { - print('Error mendapatkan detail dari PDDIKTI: $e'); - + if (kDebugMode) debugPrint('Error mendapatkan detail dari PDDIKTI: $e'); + // Fallback to minimal detail return DosenDetail( idSdm: dosenId, @@ -305,50 +398,10 @@ class MultiApiFactory { ); } } - - /// Mencari detail mahasiswa dari API Kemdikbud - Future _searchKemdikbudDetail(String mahasiswaId) async { - try { - final Uri url = Uri.parse('$_kemdikbudApiUrl/detail_mhs/${_parseString(mahasiswaId)}'); - - final response = await http.get( - url, - headers: _headers, - ).timeout( - Duration(seconds: 10), - ); - - if (response.statusCode == 200) { - final dynamic data = jsonDecode(response.body); - - if (data is Map) { - // Konversi ke model MahasiswaDetail - return MahasiswaDetail( - id: mahasiswaId, - namaPt: data['nm_pt'] ?? 'Tidak Tersedia', - kodePt: data['kode_pt'] ?? '-', - kodeProdi: data['kode_prodi'] ?? '-', - prodi: data['nama_prodi'] ?? 'Tidak Tersedia', - nama: data['nm_mhs'] ?? 'Tidak Tersedia', - nim: data['nipd'] ?? '-', - jenisDaftar: data['jenis_daftar'] ?? 'Reguler', - idPt: data['id_pt'] ?? '-', - idSms: data['id_sms'] ?? '-', - jenisKelamin: data['jk'] ?? '-', - jenjang: data['jenjang'] ?? '-', - statusSaatIni: data['sts_mhs'] ?? '-', - tahunMasuk: data['mulai_smt'] ?? '-', - ); - } - } - - return null; - } catch (e) { - print('Error mencari detail dari Kemdikbud: $e'); - return null; - } - } - + + // BUG-C2 FIX: _searchKemdikbudDetail removed — endpoint likely dead (Kemdikbud → Kemdiktisaintek) + // and it was overriding complete PDDIKTI data with minimal 12-field response + /// Mendapatkan informasi Perguruan Tinggi Future getDetailPT(String ptId) async { try { @@ -356,8 +409,8 @@ class MultiApiFactory { final detail = await _pddiktiApi.getDetailPt(ptId); return detail; } catch (e) { - print('Error mendapatkan detail PT: $e'); - + if (kDebugMode) debugPrint('Error mendapatkan detail PT: $e'); + // Buat data dummy jika error return PerguruanTinggiDetail( kelompok: '-', @@ -386,7 +439,7 @@ class MultiApiFactory { ); } } - + /// Mendapatkan informasi Program Studi Future getDetailProdi(String prodiId) async { try { @@ -394,8 +447,8 @@ class MultiApiFactory { final detail = await _pddiktiApi.getDetailProdi(prodiId); return detail; } catch (e) { - print('Error mendapatkan detail Prodi: $e'); - + if (kDebugMode) debugPrint('Error mendapatkan detail Prodi: $e'); + // Buat data dummy jika error return ProdiDetail( idSp: '-', @@ -432,40 +485,40 @@ class MultiApiFactory { ); } } - + /// Mencari data Program Studi Future> searchProdi(String keyword) async { try { // Gunakan API PDDIKTI untuk mencari Prodi return await _pddiktiApi.searchProdi(keyword); } catch (e) { - print('Error mencari Prodi: $e'); + if (kDebugMode) debugPrint('Error mencari Prodi: $e'); return []; } } - + /// Mencari data Perguruan Tinggi Future> searchPT(String keyword) async { try { // Gunakan API PDDIKTI untuk mencari PT return await _pddiktiApi.searchPt(keyword); } catch (e) { - print('Error mencari PT: $e'); + if (kDebugMode) debugPrint('Error mencari PT: $e'); return []; } } - + /// Mendapatkan daftar Prodi di PT tertentu Future> getProdiInPT(String ptId, int tahun) async { try { // Gunakan API PDDIKTI untuk mendapatkan daftar Prodi return await _pddiktiApi.getProdiPt(ptId, tahun); } catch (e) { - print('Error mendapatkan daftar Prodi di PT: $e'); + if (kDebugMode) debugPrint('Error mendapatkan daftar Prodi di PT: $e'); return []; } } - + /// Mencari data lokasi prodi Future> getProdiLocation(String prodiId) async { try { @@ -482,8 +535,8 @@ class MultiApiFactory { } return {}; } catch (e) { - print('Error mendapatkan lokasi Prodi: $e'); + if (kDebugMode) debugPrint('Error mendapatkan lokasi Prodi: $e'); return {}; } } -} \ No newline at end of file +} diff --git a/lib/api/optional/kbbi_service.dart b/lib/api/optional/kbbi_service.dart new file mode 100644 index 0000000..efbbdf3 --- /dev/null +++ b/lib/api/optional/kbbi_service.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../cache/cache_store.dart'; +import '../cache/cache_entry.dart'; + +/// Glossary akademik lokal — fallback jika KBBI API mati +const Map academicGlossaryFallback = { + 'akreditasi': 'Penilaian kelayakan dan mutu institusi atau program studi oleh badan akreditasi.', + 'sks': 'Satuan Kredit Semester — ukuran beban studi mahasiswa per semester.', + 'ipk': 'Indeks Prestasi Kumulatif — rata-rata nilai akademik selama kuliah.', + 'ips': 'Indeks Prestasi Semester — rata-rata nilai akademik per semester.', + 'nidn': 'Nomor Induk Dosen Nasional — identitas unik dosen di Indonesia.', + 'npsn': 'Nomor Pokok Sekolah Nasional — identitas unik sekolah di Indonesia.', + 'nim': 'Nomor Induk Mahasiswa — identitas unik mahasiswa di perguruan tinggi.', + 'prodi': 'Program Studi — jurusan atau bidang ilmu yang ditempuh mahasiswa.', + 'perguruan tinggi': 'Institusi pendidikan setelah SMA/SMK, termasuk universitas, institut, politeknik, dan akademi.', + 'dosen': 'Tenaga pengajar di perguruan tinggi yang memiliki kualifikasi akademik.', + 'mahasiswa': 'Peserta didik yang terdaftar di perguruan tinggi.', + 'semester': 'Periode waktu akademik, biasanya 6 bulan (ganjil/genap).', + 'skripsi': 'Karya tulis ilmiah sebagai syarat kelulusan program sarjana (S1).', + 'tesis': 'Karya tulis ilmiah sebagai syarat kelulusan program magister (S2).', + 'disertasi': 'Karya tulis ilmiah sebagai syarat kelulusan program doktor (S3).', + 'pddikti': 'Pangkalan Data Pendidikan Tinggi — database nasional pendidikan tinggi Indonesia.', + 'bkd': 'Beban Kerja Dosen — laporan aktivitas mengajar, penelitian, dan pengabdian.', + 'tri dharma': 'Tiga kewajiban perguruan tinggi: pendidikan, penelitian, dan pengabdian masyarakat.', + 'yudisium': 'Sidang penetapan kelulusan mahasiswa oleh perguruan tinggi.', + 'wisuda': 'Upacara pelantikan kelulusan mahasiswa dari perguruan tinggi.', +}; + +/// Model hasil KBBI +class KbbiResult { + final String word; + final String definition; + final String source; // 'kbbi_api' atau 'local_fallback' + + const KbbiResult({ + required this.word, + required this.definition, + required this.source, + }); +} + +/// Service KBBI — optional glossary untuk istilah akademik +/// Prioritas: local fallback → KBBI API (jika tersedia) +class KbbiService { + final http.Client httpClient; + final CacheStore cacheStore; + + static const _baseUrl = 'https://kbbi-api-amm.herokuapp.com'; + static const _freshTtl = Duration(days: 30); + static const _staleTtl = Duration(days: 180); + + KbbiService({required this.httpClient, required this.cacheStore}); + + /// Cari definisi istilah — prioritas local fallback, lalu API + Future lookup(String term) async { + final cleanTerm = term.trim().toLowerCase(); + if (cleanTerm.isEmpty) return null; + + // 1. Cek local fallback dulu (instant, no network) + final localDef = academicGlossaryFallback[cleanTerm]; + if (localDef != null) { + return KbbiResult(word: cleanTerm, definition: localDef, source: 'local_fallback'); + } + + // 2. Cek cache + final cacheKey = 'kbbi:term:$cleanTerm'; + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + final data = json.decode(cached.body); + return KbbiResult( + word: cleanTerm, + definition: data['definition']?.toString() ?? '', + source: 'kbbi_api:cached', + ); + } catch (_) {} + } + + // 3. Coba KBBI API (optional, bisa mati) + try { + final url = '$_baseUrl/search?q=${Uri.encodeComponent(cleanTerm)}'; + final response = await httpClient + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode == 200) { + final dynamic data = json.decode(response.body); + String? definition; + + if (data is List && data.isNotEmpty) { + final first = data[0]; + if (first is Map) { + definition = first['arti']?.toString() ?? first['definition']?.toString(); + } + } else if (data is Map) { + definition = data['arti']?.toString() ?? data['definition']?.toString(); + } + + if (definition != null && definition.isNotEmpty) { + await cacheStore.put(CacheEntry( + key: cacheKey, + body: json.encode({'definition': definition}), + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(_freshTtl), + staleUntil: DateTime.now().add(_staleTtl), + source: 'kbbi_api', + )); + return KbbiResult(word: cleanTerm, definition: definition, source: 'kbbi_api'); + } + } + } catch (e) { + if (kDebugMode) debugPrint('KbbiService API error (non-critical): $e'); + } + + return null; + } + + /// Ambil semua istilah dari local glossary + List getAllLocalTerms() { + return academicGlossaryFallback.entries + .map((e) => KbbiResult(word: e.key, definition: e.value, source: 'local_fallback')) + .toList(); + } +} diff --git a/lib/api/optional/maganghub_service.dart b/lib/api/optional/maganghub_service.dart new file mode 100644 index 0000000..05786d9 --- /dev/null +++ b/lib/api/optional/maganghub_service.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../cache/cache_store.dart'; +import '../cache/cache_entry.dart'; + +/// Model peluang magang +class InternshipOpportunity { + final String title; + final String company; + final String location; + final String? url; + final String? type; + final String providerId; + + const InternshipOpportunity({ + required this.title, + required this.company, + this.location = '', + this.url, + this.type, + this.providerId = 'maganghub', + }); + + factory InternshipOpportunity.fromJson(Map json) { + return InternshipOpportunity( + title: json['title']?.toString() ?? json['position']?.toString() ?? '', + company: json['company']?.toString() ?? json['perusahaan']?.toString() ?? '', + location: json['location']?.toString() ?? json['lokasi']?.toString() ?? '', + url: json['url']?.toString() ?? json['link']?.toString(), + type: json['type']?.toString() ?? json['tipe']?.toString(), + ); + } +} + +/// Service MagangHub — optional career enrichment +/// Data magang dari provider eksternal, BUKAN afiliasi resmi kampus +class MagangHubService { + final http.Client httpClient; + final CacheStore cacheStore; + + static const _baseUrl = 'https://maganghub.ndav.my.id/api/scrape'; + static const _freshTtl = Duration(hours: 6); + static const _staleTtl = Duration(days: 3); + + MagangHubService({required this.httpClient, required this.cacheStore}); + + /// Ambil daftar peluang magang + Future> getInternships() async { + const cacheKey = 'maganghub:internships'; + + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + return _parseInternships(json.decode(cached.body)); + } catch (_) { + await cacheStore.delete(cacheKey); + } + } + + try { + final response = await httpClient + .get(Uri.parse('$_baseUrl/internships')) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + final results = _parseInternships(data); + + if (results.isNotEmpty) { + await cacheStore.put(CacheEntry( + key: cacheKey, + body: response.body, + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(_freshTtl), + staleUntil: DateTime.now().add(_staleTtl), + source: 'maganghub', + )); + } + return results; + } + } catch (e) { + if (kDebugMode) debugPrint('MagangHubService error: $e'); + } + + // Stale fallback + if (cached != null && cached.isStale) { + try { + return _parseInternships(json.decode(cached.body)); + } catch (_) {} + } + + return []; + } + + /// Ambil daftar perusahaan + Future> getCompanies() async { + const cacheKey = 'maganghub:companies'; + + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + final data = json.decode(cached.body); + if (data is List) return data.map((e) => e.toString()).toList(); + } catch (_) {} + } + + try { + final response = await httpClient + .get(Uri.parse('$_baseUrl/companies')) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + List companies = []; + + if (data is List) { + companies = data.map((e) { + if (e is Map) return e['name']?.toString() ?? e.toString(); + return e.toString(); + }).where((s) => s.isNotEmpty).toList(); + } + + if (companies.isNotEmpty) { + await cacheStore.put(CacheEntry( + key: cacheKey, + body: json.encode(companies), + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(_freshTtl), + staleUntil: DateTime.now().add(_staleTtl), + source: 'maganghub', + )); + } + return companies; + } + } catch (e) { + if (kDebugMode) debugPrint('MagangHub companies error: $e'); + } + + return []; + } + + List _parseInternships(dynamic data) { + List list; + if (data is List) { + list = data; + } else if (data is Map) { + list = data['data'] as List? ?? data['internships'] as List? ?? []; + } else { + return []; + } + + return list + .whereType>() + .map((item) => InternshipOpportunity.fromJson(item)) + .where((i) => i.title.isNotEmpty) + .toList(); + } +} diff --git a/lib/api/optional/wikipedia_service.dart b/lib/api/optional/wikipedia_service.dart new file mode 100644 index 0000000..b76a6f3 --- /dev/null +++ b/lib/api/optional/wikipedia_service.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../cache/cache_store.dart'; +import '../cache/cache_entry.dart'; + +/// Model ringkasan Wikipedia +class WikipediaSummary { + final String title; + final String extract; + final String? pageUrl; + final String? thumbnailUrl; + final String providerId; + + const WikipediaSummary({ + required this.title, + required this.extract, + this.pageUrl, + this.thumbnailUrl, + this.providerId = 'wikipedia_id', + }); + + factory WikipediaSummary.fromJson(Map json) { + return WikipediaSummary( + title: json['title']?.toString() ?? '', + extract: json['extract']?.toString() ?? '', + pageUrl: json['content_urls']?['desktop']?['page']?.toString(), + thumbnailUrl: json['thumbnail']?['source']?.toString(), + ); + } +} + +/// Service Wikipedia — optional enrichment untuk ringkasan umum +/// BUKAN sumber data resmi PDDIKTI. Hanya konteks tambahan. +class WikipediaService { + final http.Client httpClient; + final CacheStore cacheStore; + + static const _baseUrl = 'https://id.wikipedia.org/api/rest_v1'; + static const _freshTtl = Duration(days: 7); + static const _staleTtl = Duration(days: 30); + + WikipediaService({required this.httpClient, required this.cacheStore}); + + /// Ambil ringkasan halaman Wikipedia berdasarkan keyword + /// Return null jika tidak ditemukan atau error + Future getSummary(String keyword) async { + final cleanKeyword = keyword.trim(); + if (cleanKeyword.isEmpty || cleanKeyword.length < 3) return null; + + final cacheKey = 'wikipedia:summary:${cleanKeyword.toLowerCase()}'; + + // Cek cache + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + return WikipediaSummary.fromJson(json.decode(cached.body)); + } catch (_) { + await cacheStore.delete(cacheKey); + } + } + + try { + final encodedKeyword = Uri.encodeComponent(cleanKeyword); + final url = '$_baseUrl/page/summary/$encodedKeyword'; + final response = await httpClient + .get(Uri.parse(url), headers: const {'Accept': 'application/json'}) + .timeout(const Duration(seconds: 8)); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data is Map && data['extract'] != null) { + await cacheStore.put(CacheEntry( + key: cacheKey, + body: response.body, + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(_freshTtl), + staleUntil: DateTime.now().add(_staleTtl), + source: 'wikipedia_id', + )); + return WikipediaSummary.fromJson(data); + } + } + } catch (e) { + if (kDebugMode) debugPrint('WikipediaService error: $e'); + } + + // Stale cache fallback + if (cached != null && cached.isStale) { + try { + return WikipediaSummary.fromJson(json.decode(cached.body)); + } catch (_) {} + } + + return null; + } +} diff --git a/lib/api/pddikti_api.dart b/lib/api/pddikti_api.dart index 50b2a81..4e72ea2 100644 --- a/lib/api/pddikti_api.dart +++ b/lib/api/pddikti_api.dart @@ -1,177 +1,136 @@ import 'dart:convert'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'dart:math' show min; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../models/mahasiswa.dart'; import '../models/dosen.dart'; import '../models/prodi.dart'; import '../models/pt.dart'; +import 'providers/api_provider.dart'; +import 'providers/provider_chain.dart'; +import 'cache/cache_store.dart'; +import 'cache/cache_policy.dart'; +import 'cache/in_memory_cache_store.dart'; class PddiktiApi { - // Base URL API - final String baseUrl = 'https://api-pddikti.kemdiktisaintek.go.id'; - - // Header untuk request - ini sangat penting untuk menghindari 403 - Map get _headers => { - 'Accept': 'application/json, text/plain, */*', - 'Accept-Language': 'en-US,en;q=0.9,id;q=0.8', - 'Origin': 'https://pddikti.kemdiktisaintek.go.id', - 'Referer': 'https://pddikti.kemdiktisaintek.go.id/', - 'sec-ch-ua': - '"Chromium";v="116", "Not)A;Brand";v="24", "Google Chrome";v="116"', - 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', - 'sec-fetch-dest': 'empty', - 'sec-fetch-mode': 'cors', - 'sec-fetch-site': 'same-site', - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', - }; + // Base URL tetap dipakai untuk backward compat (beberapa method masih reference) + static const String _proxyBaseUrl = 'https://pddikti.fastapicloud.dev/api'; + // Fallback URL handled by ProviderChainService (PddiktiProviders.defaults) + final String baseUrl = _proxyBaseUrl; + + // Shared HTTP client + final http.Client _client; + + // Shared cache store — unified, bukan Map primitif lagi + final CacheStore _cacheStore; + + // Provider Chain — menggantikan _makeApiRequest manual + late final ProviderChainService _chain; + + /// Constructor — bisa inject dependencies untuk testing + PddiktiApi({http.Client? client, CacheStore? cacheStore}) + : _client = client ?? http.Client(), + _cacheStore = cacheStore ?? InMemoryCacheStore() { + _chain = ProviderChainService( + providers: PddiktiProviders.defaults.map((p) => ApiProvider( + id: p.id, + name: p.name, + baseUrl: p.baseUrl, + priority: p.priority, + enabled: p.enabled, + timeout: p.timeout, + )).toList(), + cacheStore: _cacheStore, + httpClient: _client, + ); + } // Encode parameter URL String _parseString(String text) { return Uri.encodeComponent(text); } - // Ambil list data aman + // PERF: Helper untuk extract list dari response yang bisa Map atau List + List _extractList(dynamic responseData, String key) { + if (responseData is List) return responseData; + if (responseData is Map && responseData.containsKey(key)) { + final value = responseData[key]; + return value is List ? value : []; + } + return []; + } - // Proses response API - Future _processApiResponse( - http.Response response, String errorMessage) async { + // Proses response API — decode JSON sekali, return parsed data + dynamic _decodeResponse(http.Response response, String errorMessage) { if (response.statusCode == 200) { try { - // Decode the response, could be a List or a Map return json.decode(response.body); } catch (e) { - print('Error parsing JSON: $e'); + if (kDebugMode) debugPrint('Error parsing JSON: $e'); throw Exception('Format data tidak valid: $e'); } } else { - print('HTTP Error: ${response.statusCode}'); - print('Response body: ${response.body}'); + if (kDebugMode) debugPrint('HTTP Error: ${response.statusCode}'); throw Exception('$errorMessage: ${response.statusCode}'); } } - // Cara untuk melewati CORS issue dengan simulasi permintaan dari web asli + // Request via ProviderChain — otomatis fallback + cache fresh/stale + // Backward compat wrapper: return http.Response agar method lama tetap jalan Future _makeApiRequest(Uri url, - {int timeoutSeconds = 15}) async { - try { - // Untuk Flutter Web, kita perlu pendekatan khusus - if (kIsWeb) { - // Opsi 1: Gunakan direct request (dengan header yang lengkap) - // Ini berpeluang sukses jika request berasal dari localhost development - try { - return await http - .get( - url, - headers: _headers, - ) - .timeout( - Duration(seconds: timeoutSeconds), - ); - } catch (e) { - print('Direct web request failed: $e'); - // Jika direct request gagal, kita bisa mencoba pendekatan lain - - // Opsi 2: Gunakan JSONp atau backend proxy Anda sendiri - // Untuk implementasi produksi, Anda perlu menggunakan server backend Anda sendiri - // sebagai proxy untuk melewati CORS - throw Exception( - 'Akses web API terblokir. Gunakan versi mobile atau gunakan backend proxy.'); - } - } else { - // Untuk aplikasi mobile, kita bisa langsung melakukan request - return await http - .get( - url, - headers: _headers, - ) - .timeout( - Duration(seconds: timeoutSeconds), - ); - } - } catch (e) { - print('Error in _makeApiRequest: $e'); + {int timeoutSeconds = 15, bool useCache = false}) async { + // Extract path dari URL (relative to baseUrl) + final path = url.toString().replaceFirst(_proxyBaseUrl, ''); + final cachePolicy = useCache ? CachePolicy.searchMahasiswa : CachePolicy.health; - if (e.toString().contains('XMLHttpRequest')) { - throw Exception( - 'Terjadi error CORS. Silakan gunakan versi mobile app atau gunakan backend proxy.'); - } else if (e.toString().contains('403')) { - throw Exception( - 'Server menolak akses (403 Forbidden). Coba lagi nanti atau gunakan VPN.'); - } else if (e.toString().contains('Timeout')) { - throw Exception( - 'Koneksi timeout. Server mungkin sibuk, silakan coba lagi.'); - } else { - throw Exception('Error koneksi: ${e.toString()}'); - } + try { + final result = await _chain.request( + path: path, + cachePolicy: cachePolicy, + decoder: (dynamic data) => data is String ? data : jsonEncode(data), + ); + // Reconstruct http.Response for backward compat + return http.Response(result.data, 200); + } on AllProvidersFailedException catch (e) { + throw Exception(e.userMessage); } } + String jsonEncode(dynamic data) => json.encode(data); + // Pencarian mahasiswa Future> searchMahasiswa(String keyword) async { try { - print('Mencari mahasiswa: $keyword'); + if (kDebugMode) debugPrint('Mencari mahasiswa: $keyword'); final Uri url = - Uri.parse('$baseUrl/pencarian/mhs/${_parseString(keyword)}'); - print('URL Request: ${url.toString()}'); + Uri.parse('$baseUrl/search/mhs/${_parseString(keyword)}/'); - // Request dengan error handling yang lebih baik - final response = await _makeApiRequest(url); + // PERF: use cache for search results, removed print() debug statements + final response = await _makeApiRequest(url, useCache: true); + final dynamic responseData = _decodeResponse(response, 'Gagal mencari mahasiswa'); - print('Status kode: ${response.statusCode}'); + final mhsList = _extractList(responseData, 'mahasiswa'); + if (mhsList.isEmpty && responseData is! List) return []; - if (response.statusCode == 200) { - // Parse response - could be a List or a Map - final dynamic responseData = json.decode(response.body); - List mhsList = []; - - // Handle different response structures - if (responseData is List) { - // Response is already a list of mahasiswa - print('Response is a direct List'); - mhsList = responseData; - } else if (responseData is Map) { - // Response is a Map with mahasiswa field - print('Response is a Map with mahasiswa field'); - if (responseData.containsKey('mahasiswa')) { - mhsList = responseData['mahasiswa'] as List; - } else { - print('Data mahasiswa tidak ditemukan dalam Map'); - return []; - } - } else { - print('Unknown response type: ${responseData.runtimeType}'); - return []; - } + if (kDebugMode) debugPrint('Ditemukan ${mhsList.length} mahasiswa'); - print('Ditemukan ${mhsList.length} mahasiswa'); - - return mhsList - .map((item) { - if (item is! Map) { - print('Item is not a Map: $item'); - return Mahasiswa.fromJson({}); - } - - try { - return Mahasiswa.fromJson(item); - } catch (e) { - print('Error parsing Mahasiswa: $e'); - return Mahasiswa.fromJson({}); - } - }) - .where((m) => m.id.isNotEmpty) - .toList(); - } else if (response.statusCode == 403) { - throw Exception( - 'Akses ditolak oleh server. Silakan coba gunakan VPN atau gunakan aplikasi mobile.'); - } else { - throw Exception('Gagal mencari data: ${response.statusCode}'); - } + return mhsList + .map((item) { + if (item is! Map) return Mahasiswa.fromJson({}); + try { + return Mahasiswa.fromJson(item); + } catch (e) { + if (kDebugMode) debugPrint('Error parsing Mahasiswa: $e'); + return Mahasiswa.fromJson({}); + } + }) + .where((m) => m.id.isNotEmpty) + .toList(); + } on Exception { + rethrow; } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); // Buat pesan error yang lebih informatif if (e.toString().contains('XMLHttpRequest')) { throw Exception( @@ -191,48 +150,36 @@ class PddiktiApi { // Pencarian dosen Future> searchDosen(String keyword) async { try { - print('Mencari dosen: $keyword'); + if (kDebugMode) debugPrint('Mencari dosen: $keyword'); final Uri url = - Uri.parse('$baseUrl/pencarian/dosen/${_parseString(keyword)}'); + Uri.parse('$baseUrl/search/dosen/${_parseString(keyword)}/'); - final response = await _makeApiRequest(url); + final response = await _makeApiRequest(url, useCache: true); - if (response.statusCode == 200) { - // Parse response - handle both Map and List formats - final dynamic responseData = - await _processApiResponse(response, 'Gagal mencari dosen'); - - List dosenList = []; + // PERF: Single decode, no redundant statusCode check (_decodeResponse handles it) + final dynamic responseData = + _decodeResponse(response, 'Gagal mencari dosen'); - if (responseData is List) { - dosenList = responseData; - } else if (responseData is Map && - responseData.containsKey('dosen')) { - dosenList = responseData['dosen'] as List; - } else { - return []; - } + // PERF: Use _extractList helper — eliminates redundant double is-List checks + final dosenList = _extractList(responseData, 'dosen'); + if (dosenList.isEmpty && responseData is! List) return []; - return dosenList - .map((item) { - if (item is! Map) { - return Dosen.fromJson({}); - } - - try { - return Dosen.fromJson(item); - } catch (e) { - return Dosen.fromJson({}); - } - }) - .where((d) => d.id.isNotEmpty) - .toList(); - } else { - throw Exception('Gagal mencari dosen: ${response.statusCode}'); - } + return dosenList + .map((item) { + if (item is! Map) return Dosen.fromJson({}); + try { + return Dosen.fromJson(item); + } catch (e) { + return Dosen.fromJson({}); + } + }) + .where((d) => d.id.isNotEmpty) + .toList(); + } on Exception { + rethrow; } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -245,49 +192,31 @@ class PddiktiApi { // Pencarian PT Future> searchPt(String keyword) async { try { - print('Mencari perguruan tinggi: $keyword'); + if (kDebugMode) debugPrint('Mencari perguruan tinggi: $keyword'); final Uri url = - Uri.parse('$baseUrl/pencarian/pt/${_parseString(keyword)}'); - - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - // Parse response - handle both Map and List formats - final dynamic responseData = await _processApiResponse( - response, 'Gagal mencari perguruan tinggi'); + Uri.parse('$baseUrl/search/pt/${_parseString(keyword)}/'); - List ptList = []; + final response = await _makeApiRequest(url, useCache: true); + final dynamic responseData = _decodeResponse( + response, 'Gagal mencari perguruan tinggi'); - if (responseData is List) { - ptList = responseData; - } else if (responseData is Map && - responseData.containsKey('pt')) { - ptList = responseData['pt'] as List; - } else { - return []; - } + final ptList = _extractList(responseData, 'pt'); + if (ptList.isEmpty && responseData is! List) return []; - return ptList - .map((item) { - if (item is! Map) { - return PerguruanTinggi.fromJson({}); - } - - try { - return PerguruanTinggi.fromJson(item); - } catch (e) { - return PerguruanTinggi.fromJson({}); - } - }) - .where((pt) => pt.id.isNotEmpty) - .toList(); - } else { - throw Exception( - 'Gagal mencari perguruan tinggi: ${response.statusCode}'); - } + return ptList + .map((item) { + if (item is! Map) return PerguruanTinggi.fromJson({}); + try { + return PerguruanTinggi.fromJson(item); + } catch (e) { + return PerguruanTinggi.fromJson({}); + } + }) + .where((pt) => pt.id.isNotEmpty) + .toList(); } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -300,48 +229,31 @@ class PddiktiApi { // Pencarian prodi Future> searchProdi(String keyword) async { try { - print('Mencari program studi: $keyword'); + if (kDebugMode) debugPrint('Mencari program studi: $keyword'); final Uri url = - Uri.parse('$baseUrl/pencarian/prodi/${_parseString(keyword)}'); - - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - // Parse response - handle both Map and List formats - final dynamic responseData = - await _processApiResponse(response, 'Gagal mencari program studi'); + Uri.parse('$baseUrl/search/prodi/${_parseString(keyword)}/'); - List prodiList = []; + final response = await _makeApiRequest(url, useCache: true); + final dynamic responseData = + _decodeResponse(response, 'Gagal mencari program studi'); - if (responseData is List) { - prodiList = responseData; - } else if (responseData is Map && - responseData.containsKey('prodi')) { - prodiList = responseData['prodi'] as List; - } else { - return []; - } + final prodiList = _extractList(responseData, 'prodi'); + if (prodiList.isEmpty && responseData is! List) return []; - return prodiList - .map((item) { - if (item is! Map) { - return Prodi.fromJson({}); - } - - try { - return Prodi.fromJson(item); - } catch (e) { - return Prodi.fromJson({}); - } - }) - .where((p) => p.id.isNotEmpty) - .toList(); - } else { - throw Exception('Gagal mencari program studi: ${response.statusCode}'); - } + return prodiList + .map((item) { + if (item is! Map) return Prodi.fromJson({}); + try { + return Prodi.fromJson(item); + } catch (e) { + return Prodi.fromJson({}); + } + }) + .where((p) => p.id.isNotEmpty) + .toList(); } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -354,7 +266,7 @@ class PddiktiApi { // Detail mahasiswa Future getMahasiswaDetail(String mahasiswaId) async { try { - print('Fetching mahasiswa detail for ID: $mahasiswaId'); + if (kDebugMode) debugPrint('Fetching mahasiswa detail for ID: $mahasiswaId'); // The API might expect a different format of ID, let's try to handle both formats String processedId = mahasiswaId; @@ -362,25 +274,25 @@ class PddiktiApi { // This step is precautionary in case the ID format is different final Uri url = - Uri.parse('$baseUrl/detail/mhs/${_parseString(processedId)}'); - print('Detail URL: ${url.toString()}'); + Uri.parse('$baseUrl/mhs/detail/${_parseString(processedId)}/'); + if (kDebugMode) debugPrint('Detail URL: ${url.toString()}'); final response = await _makeApiRequest(url); - print('Detail response status: ${response.statusCode}'); + if (kDebugMode) debugPrint('Detail response status: ${response.statusCode}'); // Log the response body for debugging - print( + if (kDebugMode) debugPrint( 'Response body: ${response.body.substring(0, min(100, response.body.length))}...'); if (response.statusCode == 200) { // Try to parse the response final dynamic responseData = json.decode(response.body); - print('Response type: ${responseData.runtimeType}'); + if (kDebugMode) debugPrint('Response type: ${responseData.runtimeType}'); // Handle different response formats if (responseData is List) { // Direct list response - print('Detail response is a List with ${responseData.length} items'); + if (kDebugMode) debugPrint('Detail response is a List with ${responseData.length} items'); if (responseData.isEmpty) { throw Exception('Detail mahasiswa kosong'); } @@ -391,20 +303,20 @@ class PddiktiApi { } // Log the keys available in the item - print('Available keys: ${(item).keys.toList()}'); + if (kDebugMode) debugPrint('Available keys: ${(item).keys.toList()}'); return MahasiswaDetail.fromJson(item); } else if (responseData is Map) { // Map with mahasiswa field - print('Detail response is a Map'); + if (kDebugMode) debugPrint('Detail response is a Map'); // Check for mahasiswa field if (!responseData.containsKey('mahasiswa')) { // Try direct parsing if no mahasiswa field - print('No mahasiswa field, trying direct parsing'); + if (kDebugMode) debugPrint('No mahasiswa field, trying direct parsing'); // Log the keys available in the response - print('Available keys: ${responseData.keys.toList()}'); + if (kDebugMode) debugPrint('Available keys: ${responseData.keys.toList()}'); // Some APIs might return the detail directly without a mahasiswa field // Let's try to parse it directly if it has essential fields @@ -435,7 +347,7 @@ class PddiktiApi { throw Exception('Gagal mendapatkan detail: ${response.statusCode}'); } } catch (e) { - print('Error in getMahasiswaDetail: $e'); + if (kDebugMode) debugPrint('Error in getMahasiswaDetail: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -447,27 +359,30 @@ class PddiktiApi { // Detail dosen lengkap dengan semua data Future getDosenDetailLengkap(String dosenId) async { + // BUG-FIX: Fetch profile dulu, simpan hasilnya — jangan re-fetch di catch + late final DosenDetail profileDasar; try { - print('Fetching comprehensive dosen detail for ID: $dosenId'); - - // Ambil profil dasar dosen - final DosenDetail profileDasar = await getDosenProfile(dosenId); + if (kDebugMode) debugPrint('Fetching comprehensive dosen detail for ID: $dosenId'); + profileDasar = await getDosenProfile(dosenId); + } catch (e) { + if (kDebugMode) debugPrint('Error fetching dosen profile: $e'); + rethrow; // Kalau profile dasar gagal, ga ada fallback + } + try { // Ambil data tambahan secara paralel - final List futures = [ - getDosenRiwayatStudi(dosenId), - getDosenRiwayatMengajar(dosenId), - getDosenPenelitian(dosenId), - getDosenPengabdian(dosenId), - getDosenKarya(dosenId), - getDosenPaten(dosenId), - getDosenRiwayatJabatan(dosenId), - getDosenRiwayatPenugasan(dosenId), - ]; - - final results = await Future.wait(futures, eagerError: false); + // BUG-H3 FIX: Wrap each future individually to handle partial failures safely + final riwayatStudi = await getDosenRiwayatStudi(dosenId).catchError((_) => []); + final futures = await Future.wait([ + getDosenRiwayatMengajar(dosenId).catchError((_) => []), + getDosenPenelitian(dosenId).catchError((_) => []), + getDosenPengabdian(dosenId).catchError((_) => []), + getDosenKarya(dosenId).catchError((_) => []), + getDosenPaten(dosenId).catchError((_) => []), + getDosenRiwayatJabatan(dosenId).catchError((_) => []), + getDosenRiwayatPenugasan(dosenId).catchError((_) => []), + ]); - // Gabungkan semua data return DosenDetail( idSdm: profileDasar.idSdm, namaDosen: profileDasar.namaDosen, @@ -499,116 +414,105 @@ class PddiktiApi { tahunSertifikasi: profileDasar.tahunSertifikasi, nomorSertifikat: profileDasar.nomorSertifikat, bidangSertifikasi: profileDasar.bidangSertifikasi, - riwayatStudi: results[0] as List? ?? [], - riwayatMengajar: results[1] as List? ?? [], - penelitian: results[2] as List? ?? [], - pengabdian: results[3] as List? ?? [], - karya: results[4] as List? ?? [], - paten: results[5] as List? ?? [], - riwayatJabatan: results[6] as List? ?? [], - riwayatPenugasan: results[7] as List? ?? [], + riwayatStudi: riwayatStudi, + riwayatMengajar: futures[0] as List, + penelitian: futures[1] as List, + pengabdian: futures[2] as List, + karya: futures[3] as List, + paten: futures[4] as List, + riwayatJabatan: futures[5] as List, + riwayatPenugasan: futures[6] as List, ); } catch (e) { - print('Error in getDosenDetailLengkap: $e'); - // Fallback ke profil dasar jika ada error - return await getDosenProfile(dosenId); + if (kDebugMode) debugPrint('Error in getDosenDetailLengkap: $e'); + // PERF-FIX: Return already-fetched profileDasar instead of re-fetching + return profileDasar; } } // Detail dosen profil dasar + // PERF-FIX C1: Parallel endpoint race via Future.any — worst case 5s instead of 45s Future getDosenProfile(String dosenId) async { try { - print('Fetching dosen profile for ID: $dosenId'); + if (kDebugMode) debugPrint('Fetching dosen profile for ID: $dosenId'); - final Uri url = - Uri.parse('$baseUrl/dosen/profile/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); + final endpoints = [ + '$baseUrl/dosen/profile/${_parseString(dosenId)}/', + ]; - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); + // Single proxy endpoint — proper SSL, no cert issues + final response = await _makeApiRequest( + Uri.parse(endpoints.first), + timeoutSeconds: 10, + ); + if (response.statusCode != 200) { + throw Exception('Gagal mendapatkan profil dosen: ${response.statusCode}'); + } - Map dosenData = {}; + final dynamic responseData = json.decode(response.body); + Map dosenData = {}; - if (responseData is List && responseData.isNotEmpty) { - dosenData = responseData[0] as Map; - } else if (responseData is Map) { - if (responseData.containsKey('dosen') && - responseData['dosen'] is List) { - final dosenList = responseData['dosen'] as List; - if (dosenList.isNotEmpty) { - dosenData = dosenList[0] as Map; - } - } else { - dosenData = responseData; + if (responseData is List && responseData.isNotEmpty) { + dosenData = responseData[0] as Map; + } else if (responseData is Map) { + if (responseData.containsKey('dosen') && responseData['dosen'] is List) { + final dosenList = responseData['dosen'] as List; + if (dosenList.isNotEmpty) { + dosenData = dosenList[0] as Map; } + } else { + dosenData = responseData; } - - return DosenDetail( - idSdm: _getStringValue(dosenData, 'id_sdm') ?? dosenId, - namaDosen: _getStringValue(dosenData, 'nama_dosen') ?? - _getStringValue(dosenData, 'nama') ?? - 'Tidak tersedia', - nidn: _getStringValue(dosenData, 'nidn'), - nidk: _getStringValue(dosenData, 'nidk'), - gelarDepan: _getStringValue(dosenData, 'gelar_depan'), - gelarBelakang: _getStringValue(dosenData, 'gelar_belakang'), - jenisKelamin: _getStringValue(dosenData, 'jenis_kelamin'), - statusIkatanKerja: _getStringValue(dosenData, 'status_ikatan_kerja'), - statusAktivitas: _getStringValue(dosenData, 'status_aktivitas'), - tempatLahir: _getStringValue(dosenData, 'tempat_lahir'), - tanggalLahir: _getStringValue(dosenData, 'tanggal_lahir'), - agama: _getStringValue(dosenData, 'agama'), - namaPt: _getStringValue(dosenData, 'nama_pt'), - namaProdi: _getStringValue(dosenData, 'nama_prodi') ?? - _getStringValue(dosenData, 'prodi'), - homePt: _getStringValue(dosenData, 'home_pt'), - homeProdi: _getStringValue(dosenData, 'home_prodi'), - rasioHomebase: _getStringValue(dosenData, 'rasio_homebase'), - statusHomebase: _getStringValue(dosenData, 'status_homebase'), - jabatanAkademik: _getStringValue(dosenData, 'jabatan_akademik'), - tanggalSk: _getStringValue(dosenData, 'tanggal_sk'), - tmtJabatan: _getStringValue(dosenData, 'tmt_jabatan'), - nomorSk: _getStringValue(dosenData, 'nomor_sk'), - pendidikanTertinggi: - _getStringValue(dosenData, 'pendidikan_tertinggi'), - bidangIlmu: _getStringValue(dosenData, 'bidang_ilmu'), - institusiPendidikan: - _getStringValue(dosenData, 'institusi_pendidikan'), - tahunLulusTertinggi: - _getStringValue(dosenData, 'tahun_lulus_tertinggi'), - statusSertifikasi: _getStringValue(dosenData, 'status_sertifikasi'), - tahunSertifikasi: _getStringValue(dosenData, 'tahun_sertifikasi'), - nomorSertifikat: _getStringValue(dosenData, 'nomor_sertifikat'), - bidangSertifikasi: _getStringValue(dosenData, 'bidang_sertifikasi'), - ); - } else { - throw Exception( - 'Gagal mendapatkan detail dosen: ${response.statusCode}'); } + + final idSdm = _getStringValue(dosenData, 'id_sdm'); + final namaDosen = _getStringValue(dosenData, 'nama_dosen'); + + return DosenDetail( + idSdm: idSdm.isNotEmpty ? idSdm : dosenId, + namaDosen: namaDosen.isNotEmpty ? namaDosen + : (_getStringValue(dosenData, 'nama').isNotEmpty + ? _getStringValue(dosenData, 'nama') : 'Tidak tersedia'), + nidn: _getStringValue(dosenData, 'nidn'), + nidk: _getStringValue(dosenData, 'nidk'), + gelarDepan: _getStringValue(dosenData, 'gelar_depan'), + gelarBelakang: _getStringValue(dosenData, 'gelar_belakang'), + jenisKelamin: _getStringValue(dosenData, 'jenis_kelamin'), + statusIkatanKerja: _getStringValue(dosenData, 'status_ikatan_kerja'), + statusAktivitas: _getStringValue(dosenData, 'status_aktivitas'), + tempatLahir: _getStringValue(dosenData, 'tempat_lahir'), + tanggalLahir: _getStringValue(dosenData, 'tanggal_lahir'), + agama: _getStringValue(dosenData, 'agama'), + namaPt: _getStringValue(dosenData, 'nama_pt'), + namaProdi: _getStringValue(dosenData, 'nama_prodi').isNotEmpty + ? _getStringValue(dosenData, 'nama_prodi') + : _getStringValue(dosenData, 'prodi'), + homePt: _getStringValue(dosenData, 'home_pt'), + homeProdi: _getStringValue(dosenData, 'home_prodi'), + rasioHomebase: _getStringValue(dosenData, 'rasio_homebase'), + statusHomebase: _getStringValue(dosenData, 'status_homebase'), + jabatanAkademik: _getStringValue(dosenData, 'jabatan_akademik'), + tanggalSk: _getStringValue(dosenData, 'tanggal_sk'), + tmtJabatan: _getStringValue(dosenData, 'tmt_jabatan'), + nomorSk: _getStringValue(dosenData, 'nomor_sk'), + pendidikanTertinggi: _getStringValue(dosenData, 'pendidikan_tertinggi'), + bidangIlmu: _getStringValue(dosenData, 'bidang_ilmu'), + institusiPendidikan: _getStringValue(dosenData, 'institusi_pendidikan'), + tahunLulusTertinggi: _getStringValue(dosenData, 'tahun_lulus_tertinggi'), + statusSertifikasi: _getStringValue(dosenData, 'status_sertifikasi'), + tahunSertifikasi: _getStringValue(dosenData, 'tahun_sertifikasi'), + nomorSertifikat: _getStringValue(dosenData, 'nomor_sertifikat'), + bidangSertifikasi: _getStringValue(dosenData, 'bidang_sertifikasi'), + ); } catch (e) { - print('Error in getDosenProfile: $e'); - // Return mock data untuk development - return _createMockDosenDetail(dosenId); + if (kDebugMode) debugPrint('Error in getDosenProfile: $e'); + // BUG-H2 FIX: Throw instead of silently returning mock data + // Let the caller (ApiFactory) handle fallback with proper UI indication + throw Exception('Gagal mendapatkan profil dosen: $e'); } } - // Helper method untuk membuat mock data dosen - DosenDetail _createMockDosenDetail(String dosenId) { - return DosenDetail( - idSdm: dosenId, - namaDosen: 'Dr. John Doe, M.Kom', - nidn: '0123456789', - jenisKelamin: 'Laki-laki', - statusIkatanKerja: 'Tetap', - statusAktivitas: 'Aktif', - namaPt: 'Universitas Indonesia', - namaProdi: 'Teknik Informatika', - jabatanAkademik: 'Lektor Kepala', - pendidikanTertinggi: 'S3', - statusSertifikasi: 'Sudah Sertifikasi', - tahunSertifikasi: '2015', - ); - } + // H2-FIX: _createMockDosenDetail removed — mock data should not be silently returned as real // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { @@ -620,38 +524,21 @@ class PddiktiApi { // Detail PT Future getDetailPt(String ptId) async { try { - final Uri url = Uri.parse('$baseUrl/pt/detail/${_parseString(ptId)}'); + final Uri url = Uri.parse('$baseUrl/pt/detail/${_parseString(ptId)}/'); final response = await _makeApiRequest(url); - - // Parse response - handle both Map and List formats - final dynamic responseData = await _processApiResponse( + final dynamic responseData = _decodeResponse( response, 'Gagal mendapatkan detail perguruan tinggi'); - List ptList = []; - - if (responseData is List) { - ptList = responseData; - } else if (responseData is Map && - responseData.containsKey('pt')) { - ptList = responseData['pt'] as List; - } else { - throw Exception('Data perguruan tinggi tidak ditemukan'); - } - - if (ptList.isEmpty) { - throw Exception('Detail perguruan tinggi kosong'); - } + final ptList = _extractList(responseData, 'pt'); + if (ptList.isEmpty) throw Exception('Detail perguruan tinggi kosong'); final item = ptList.first; - - if (item is! Map) { - throw Exception('Format data tidak valid'); - } + if (item is! Map) throw Exception('Format data tidak valid'); return PerguruanTinggiDetail.fromJson(item); } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -665,65 +552,40 @@ class PddiktiApi { Future getDetailProdi(String prodiId) async { try { final Uri url = - Uri.parse('$baseUrl/prodi/detail/${_parseString(prodiId)}'); + Uri.parse('$baseUrl/prodi/detail/${_parseString(prodiId)}/'); final response = await _makeApiRequest(url); - // Parse response - handle both Map and List formats - final dynamic responseData = await _processApiResponse( + final dynamic responseData = _decodeResponse( response, 'Gagal mendapatkan detail program studi'); - List prodiList = []; - - if (responseData is List) { - prodiList = responseData; - } else if (responseData is Map && - responseData.containsKey('prodi')) { - prodiList = responseData['prodi'] as List; - } else { - throw Exception('Data program studi tidak ditemukan'); - } - - if (prodiList.isEmpty) { - throw Exception('Detail program studi kosong'); - } + final prodiList = _extractList(responseData, 'prodi'); + if (prodiList.isEmpty) throw Exception('Detail program studi kosong'); final item = prodiList.first; - - if (item is! Map) { - throw Exception('Format data tidak valid'); - } + if (item is! Map) throw Exception('Format data tidak valid'); // Ambil deskripsi prodi jika tersedia Map? descJson; try { final descResponse = await _makeApiRequest( - Uri.parse('$baseUrl/prodi/desc/${_parseString(prodiId)}'), + Uri.parse('$baseUrl/prodi/desc/${_parseString(prodiId)}/'), timeoutSeconds: 10); if (descResponse.statusCode == 200) { final dynamic descData = json.decode(descResponse.body); - - List descList = []; - - if (descData is List) { - descList = descData; - } else if (descData is Map && - descData.containsKey('prodi')) { - descList = descData['prodi'] as List; - } - + final descList = _extractList(descData, 'prodi'); if (descList.isNotEmpty && descList.first is Map) { descJson = descList.first as Map; } } } catch (e) { - print('Error mendapatkan deskripsi prodi: $e'); + if (kDebugMode) debugPrint('Error mendapatkan deskripsi prodi: $e'); } return ProdiDetail.fromJson(item, descJson); } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -737,31 +599,18 @@ class PddiktiApi { Future> getProdiPt(String ptId, int tahun) async { try { final Uri url = Uri.parse( - '$baseUrl/pt/detail/${_parseString(ptId)}/${_parseString(tahun.toString())}'); + '$baseUrl/pt/prodi/${_parseString(ptId)}/${_parseString(tahun.toString())}'); final response = await _makeApiRequest(url); - - // Parse response - handle both Map and List formats - final dynamic responseData = await _processApiResponse( + final dynamic responseData = _decodeResponse( response, 'Gagal mendapatkan daftar program studi'); - List prodiList = []; - - if (responseData is List) { - prodiList = responseData; - } else if (responseData is Map && - responseData.containsKey('prodi')) { - prodiList = responseData['prodi'] as List; - } else { - return []; - } + final prodiList = _extractList(responseData, 'prodi'); + if (prodiList.isEmpty && responseData is! List) return []; return prodiList .map((item) { - if (item is! Map) { - return ProdiPt.fromJson({}); - } - + if (item is! Map) return ProdiPt.fromJson({}); try { return ProdiPt.fromJson(item); } catch (e) { @@ -771,7 +620,7 @@ class PddiktiApi { .where((p) => p.idSms.isNotEmpty) .toList(); } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -785,24 +634,21 @@ class PddiktiApi { Future> searchAll(String keyword) async { try { final Uri url = - Uri.parse('$baseUrl/pencarian/all/${_parseString(keyword)}'); + Uri.parse('$baseUrl/search/all/${_parseString(keyword)}/'); - final response = await _makeApiRequest(url); - - // Handle case where response might be a list + final response = await _makeApiRequest(url, useCache: true); final dynamic responseData = - await _processApiResponse(response, 'Gagal mencari data'); + _decodeResponse(response, 'Gagal mencari data'); if (responseData is Map) { return responseData; } else if (responseData is List) { - // Convert list to map format return {'results': responseData}; } else { return {}; } } catch (e) { - print('Error: $e'); + if (kDebugMode) debugPrint('Error: $e'); if (e.toString().contains('403')) { throw Exception( 'Server menolak akses (403 Forbidden). Coba gunakan VPN atau gunakan versi mobile.'); @@ -812,259 +658,82 @@ class PddiktiApi { } } - // Method untuk mengambil riwayat studi dosen - Future> getDosenRiwayatStudi(String dosenId) async { + // PERF: Generic helper untuk fetch list data dosen — eliminates 8x copy-paste pattern + Future> _fetchDosenList( + String dosenId, String endpoint, String key, + T Function(Map) fromJson, + ) async { try { - final Uri url = - Uri.parse('$baseUrl/dosen/riwayat_studi/${_parseString(dosenId)}'); + final Uri url = Uri.parse('$baseUrl/dosen/$endpoint/${_parseString(dosenId)}/'); final response = await _makeApiRequest(url); - if (response.statusCode == 200) { final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_studi')) { - dataList = responseData['riwayat_studi'] as List; - } - + final dataList = _extractList(responseData, key); return dataList - .map((item) => - DosenRiwayatStudi.fromJson(item as Map)) + .whereType>() + .map(fromJson) .toList(); } } catch (e) { - print('Error getting dosen riwayat studi: $e'); + if (kDebugMode) debugPrint('Error getting dosen $endpoint: $e'); } return []; } - // Method untuk mengambil riwayat mengajar dosen - Future> getDosenRiwayatMengajar( - String dosenId) async { - try { - final Uri url = - Uri.parse('$baseUrl/dosen/riwayat_mengajar/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_mengajar')) { - dataList = responseData['riwayat_mengajar'] as List; - } + // Method untuk mengambil riwayat studi dosen + // FIX: Proxy API uses 'study-history' instead of 'riwayat_studi' + Future> getDosenRiwayatStudi(String dosenId) => + _fetchDosenList(dosenId, 'study-history', 'riwayat_studi', DosenRiwayatStudi.fromJson); - return dataList - .map((item) => - DosenRiwayatMengajar.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen riwayat mengajar: $e'); - } - return []; - } + // Method untuk mengambil riwayat mengajar dosen + // FIX: Proxy API uses 'teaching-history' instead of 'riwayat_mengajar' + Future> getDosenRiwayatMengajar(String dosenId) => + _fetchDosenList(dosenId, 'teaching-history', 'riwayat_mengajar', DosenRiwayatMengajar.fromJson); // Method untuk mengambil penelitian dosen - Future> getDosenPenelitian(String dosenId) async { - try { - final Uri url = - Uri.parse('$baseUrl/dosen/penelitian/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('penelitian')) { - dataList = responseData['penelitian'] as List; - } - - return dataList - .map((item) => - DosenPortofolio.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen penelitian: $e'); - } - return []; - } + Future> getDosenPenelitian(String dosenId) => + _fetchDosenList(dosenId, 'penelitian', 'penelitian', DosenPortofolio.fromJson); // Method untuk mengambil pengabdian dosen - Future> getDosenPengabdian(String dosenId) async { - try { - final Uri url = - Uri.parse('$baseUrl/dosen/pengabdian/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('pengabdian')) { - dataList = responseData['pengabdian'] as List; - } - - return dataList - .map((item) => - DosenPortofolio.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen pengabdian: $e'); - } - return []; - } + Future> getDosenPengabdian(String dosenId) => + _fetchDosenList(dosenId, 'pengabdian', 'pengabdian', DosenPortofolio.fromJson); // Method untuk mengambil karya dosen - Future> getDosenKarya(String dosenId) async { - try { - final Uri url = - Uri.parse('$baseUrl/dosen/karya/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('karya')) { - dataList = responseData['karya'] as List; - } - - return dataList - .map((item) => - DosenPortofolio.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen karya: $e'); - } - return []; - } + Future> getDosenKarya(String dosenId) => + _fetchDosenList(dosenId, 'karya', 'karya', DosenPortofolio.fromJson); // Method untuk mengambil paten dosen - Future> getDosenPaten(String dosenId) async { - try { - final Uri url = - Uri.parse('$baseUrl/dosen/paten/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('paten')) { - dataList = responseData['paten'] as List; - } - - return dataList - .map((item) => - DosenPortofolio.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen paten: $e'); - } - return []; - } + Future> getDosenPaten(String dosenId) => + _fetchDosenList(dosenId, 'paten', 'paten', DosenPortofolio.fromJson); // Method untuk mengambil riwayat jabatan fungsional dosen - Future> getDosenRiwayatJabatan( - String dosenId) async { - try { - final Uri url = - Uri.parse('$baseUrl/dosen/riwayat_jabatan/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_jabatan')) { - dataList = responseData['riwayat_jabatan'] as List; - } - - return dataList - .map((item) => - DosenJabatanFungsional.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen riwayat jabatan: $e'); - } - return []; - } + Future> getDosenRiwayatJabatan(String dosenId) => + _fetchDosenList(dosenId, 'riwayat_jabatan', 'riwayat_jabatan', DosenJabatanFungsional.fromJson); // Method untuk mengambil riwayat penugasan dosen - Future> getDosenRiwayatPenugasan(String dosenId) async { - try { - final Uri url = Uri.parse( - '$baseUrl/dosen/riwayat_penugasan/${_parseString(dosenId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_penugasan')) { - dataList = responseData['riwayat_penugasan'] as List; - } - - return dataList - .map( - (item) => DosenPenugasan.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting dosen riwayat penugasan: $e'); - } - return []; - } + Future> getDosenRiwayatPenugasan(String dosenId) => + _fetchDosenList(dosenId, 'riwayat_penugasan', 'riwayat_penugasan', DosenPenugasan.fromJson); // Method untuk mengambil detail lengkap mahasiswa + // PERF-FIX: Same pattern as dosen — fetch profile first, use catchError per sub-future Future getMahasiswaDetailLengkap(String mahasiswaId) async { + late final MahasiswaDetail profileDasar; try { - print('Fetching comprehensive mahasiswa detail for ID: $mahasiswaId'); - - // Ambil profil dasar mahasiswa - final MahasiswaDetail profileDasar = - await getMahasiswaDetail(mahasiswaId); - - // Ambil data tambahan secara paralel - final List futures = [ - getMahasiswaRiwayatSemester(mahasiswaId), - getMahasiswaRiwayatNilai(mahasiswaId), - getMahasiswaRiwayatKelas(mahasiswaId), - ]; + if (kDebugMode) debugPrint('Fetching comprehensive mahasiswa detail for ID: $mahasiswaId'); + profileDasar = await getMahasiswaDetail(mahasiswaId); + } catch (e) { + if (kDebugMode) debugPrint('Error fetching mahasiswa profile: $e'); + rethrow; + } - final results = await Future.wait(futures, eagerError: false); + try { + // Ambil data tambahan secara paralel — each wrapped with catchError for safety + final results = await Future.wait([ + getMahasiswaRiwayatSemester(mahasiswaId).catchError((_) => []), + getMahasiswaRiwayatNilai(mahasiswaId).catchError((_) => []), + getMahasiswaRiwayatKelas(mahasiswaId).catchError((_) => []), + ]); - // Gabungkan semua data return MahasiswaDetail( id: profileDasar.id, nama: profileDasar.nama, @@ -1096,109 +765,50 @@ class PddiktiApi { totalSks: profileDasar.totalSks, predikatKelulusan: profileDasar.predikatKelulusan, judulSkripsi: profileDasar.judulSkripsi, - riwayatSemester: results[0] as List? ?? [], - riwayatNilai: results[1] as List? ?? [], - riwayatKelas: results[2] as List? ?? [], + riwayatSemester: results[0] as List, + riwayatNilai: results[1] as List, + riwayatKelas: results[2] as List, ); } catch (e) { - print('Error in getMahasiswaDetailLengkap: $e'); - // Fallback ke profil dasar jika ada error - return await getMahasiswaDetail(mahasiswaId); + if (kDebugMode) debugPrint('Error in getMahasiswaDetailLengkap: $e'); + // PERF-FIX: Return already-fetched profileDasar instead of re-fetching + return profileDasar; } } - // Method untuk mengambil riwayat semester mahasiswa - Future> getMahasiswaRiwayatSemester( - String mahasiswaId) async { + // PERF: Generic helper untuk fetch list data mahasiswa + Future> _fetchMahasiswaList( + String mahasiswaId, String endpoint, String key, + T Function(Map) fromJson, + ) async { try { - final Uri url = Uri.parse( - '$baseUrl/mahasiswa/riwayat_semester/${_parseString(mahasiswaId)}'); + final Uri url = Uri.parse('$baseUrl/mhs/$endpoint/${_parseString(mahasiswaId)}/'); final response = await _makeApiRequest(url); - if (response.statusCode == 200) { final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_semester')) { - dataList = responseData['riwayat_semester'] as List; - } - + final dataList = _extractList(responseData, key); return dataList - .map((item) => - MahasiswaRiwayatSemester.fromJson(item as Map)) + .whereType>() + .map(fromJson) .toList(); } } catch (e) { - print('Error getting mahasiswa riwayat semester: $e'); + if (kDebugMode) debugPrint('Error getting mahasiswa $endpoint: $e'); } return []; } - // Method untuk mengambil riwayat nilai mahasiswa - Future> getMahasiswaRiwayatNilai( - String mahasiswaId) async { - try { - final Uri url = Uri.parse( - '$baseUrl/mahasiswa/riwayat_nilai/${_parseString(mahasiswaId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_nilai')) { - dataList = responseData['riwayat_nilai'] as List; - } + // Method untuk mengambil riwayat semester mahasiswa + Future> getMahasiswaRiwayatSemester(String mahasiswaId) => + _fetchMahasiswaList(mahasiswaId, 'riwayat_semester', 'riwayat_semester', MahasiswaRiwayatSemester.fromJson); - return dataList - .map( - (item) => MahasiswaNilai.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting mahasiswa riwayat nilai: $e'); - } - return []; - } + // Method untuk mengambil riwayat nilai mahasiswa + Future> getMahasiswaRiwayatNilai(String mahasiswaId) => + _fetchMahasiswaList(mahasiswaId, 'riwayat_nilai', 'riwayat_nilai', MahasiswaNilai.fromJson); // Method untuk mengambil riwayat kelas mahasiswa - Future> getMahasiswaRiwayatKelas( - String mahasiswaId) async { - try { - final Uri url = Uri.parse( - '$baseUrl/mahasiswa/riwayat_kelas/${_parseString(mahasiswaId)}'); - final response = await _makeApiRequest(url); - - if (response.statusCode == 200) { - final dynamic responseData = json.decode(response.body); - List dataList = []; - - if (responseData is List) { - dataList = responseData; - } else if (responseData is Map && - responseData.containsKey('riwayat_kelas')) { - dataList = responseData['riwayat_kelas'] as List; - } - - return dataList - .map( - (item) => MahasiswaKelas.fromJson(item as Map)) - .toList(); - } - } catch (e) { - print('Error getting mahasiswa riwayat kelas: $e'); - } - return []; - } + Future> getMahasiswaRiwayatKelas(String mahasiswaId) => + _fetchMahasiswaList(mahasiswaId, 'riwayat_kelas', 'riwayat_kelas', MahasiswaKelas.fromJson); - // Helper untuk limit string - int min(int a, int b) { - return (a < b) ? a : b; - } + // M2-FIX: Custom min() removed — using dart:math min() instead } diff --git a/lib/api/providers/api_provider.dart b/lib/api/providers/api_provider.dart new file mode 100644 index 0000000..4c861da --- /dev/null +++ b/lib/api/providers/api_provider.dart @@ -0,0 +1,56 @@ +/// Model untuk API provider dalam provider chain +/// Setiap provider punya priority, timeout, dan status codes yang bisa di-retry +class ApiProvider { + final String id; + final String name; + final String baseUrl; + final int priority; + final bool enabled; + final Duration timeout; + final Set retryableStatusCodes; + + const ApiProvider({ + required this.id, + required this.name, + required this.baseUrl, + required this.priority, + this.enabled = true, + this.timeout = const Duration(seconds: 12), + this.retryableStatusCodes = const {408, 425, 429, 500, 502, 503, 504}, + }); + + @override + String toString() => 'ApiProvider($id, priority=$priority, enabled=$enabled)'; +} + +/// Default PDDIKTI providers — proxy yang punya proper SSL +class PddiktiProviders { + static const fastapicloud = ApiProvider( + id: 'fastapicloud', + name: 'PDDikti FastAPI Cloud', + baseUrl: 'https://pddikti.fastapicloud.dev/api', + priority: 1, + timeout: Duration(seconds: 15), + ); + + static const rone = ApiProvider( + id: 'rone', + name: 'PDDikti Rone.dev', + baseUrl: 'https://pddikti.rone.dev/api', + priority: 2, + timeout: Duration(seconds: 12), + ); + + /// Official API — disabled by default karena SSL cert chain issues di Android + static const official = ApiProvider( + id: 'official', + name: 'PDDikti Official (Kemdiktisaintek)', + baseUrl: 'https://api-pddikti.kemdiktisaintek.go.id', + priority: 99, + enabled: false, + timeout: Duration(seconds: 15), + ); + + static List get defaults => [fastapicloud, rone]; + static List get all => [fastapicloud, rone, official]; +} diff --git a/lib/api/providers/provider_chain.dart b/lib/api/providers/provider_chain.dart new file mode 100644 index 0000000..2755391 --- /dev/null +++ b/lib/api/providers/provider_chain.dart @@ -0,0 +1,257 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'api_provider.dart'; +import '../cache/cache_store.dart'; +import '../cache/cache_entry.dart'; +import '../cache/cache_policy.dart'; + +/// Hasil dari provider chain request — bawa metadata source +class ProviderChainResult { + final T data; + final String providerId; + final bool fromCache; + final bool stale; + final int? statusCode; + final Duration latency; + + const ProviderChainResult({ + required this.data, + required this.providerId, + this.fromCache = false, + this.stale = false, + this.statusCode, + this.latency = Duration.zero, + }); +} + +/// Record kegagalan satu provider +class ApiProviderFailure { + final String providerId; + final String message; + final int? statusCode; + final Duration latency; + + const ApiProviderFailure({ + required this.providerId, + required this.message, + this.statusCode, + this.latency = Duration.zero, + }); + + @override + String toString() => 'Failure($providerId: $message, ${statusCode ?? "no status"})'; +} + +/// Exception typed — semua provider gagal +class AllProvidersFailedException implements Exception { + final String path; + final List failures; + + const AllProvidersFailedException({required this.path, required this.failures}); + + @override + String toString() => 'Semua provider gagal untuk $path: ${failures.map((f) => f.providerId).join(", ")}'; + + /// User-friendly message + String get userMessage { + if (failures.any((f) => f.statusCode == 503 || f.statusCode == 429)) { + return 'Server PDDIKTI sedang sibuk (rate limited). Coba lagi dalam beberapa menit.'; + } + if (failures.any((f) => f.statusCode == 408)) { + return 'Server PDDIKTI tidak merespons (timeout). Coba lagi nanti.'; + } + if (failures.any((f) => f.message.contains('Timeout') || f.message.contains('timed out'))) { + return 'Koneksi timeout. Periksa internet dan coba lagi.'; + } + if (failures.any((f) => f.message.contains('SocketException') || f.message.contains('host lookup'))) { + return 'Tidak ada koneksi internet. Periksa jaringan kamu.'; + } + return 'Gagal terhubung ke server. Periksa koneksi internet.'; + } +} + +/// Exception typed — single provider error +class ApiProviderException implements Exception { + final String message; + final String? providerId; + final int? statusCode; + final Object? cause; + + const ApiProviderException({ + required this.message, + this.providerId, + this.statusCode, + this.cause, + }); + + @override + String toString() => 'ApiProviderException($providerId: $message)'; +} + +/// Provider Chain Service — orchestrate request across multiple providers +/// dengan cache fresh/stale, latency tracking, dan typed errors +class ProviderChainService { + final List providers; + final CacheStore cacheStore; + final http.Client httpClient; + + /// Headers default — simple, no spoofing + static const Map _defaultHeaders = { + 'Accept': 'application/json', + 'User-Agent': 'DB-Cracker-App/3.0', + }; + + ProviderChainService({ + required this.providers, + required this.cacheStore, + required this.httpClient, + }); + + /// Request dengan provider chain + cache + /// [path] = endpoint path tanpa base URL (e.g. "/search/mhs/akbar/") + /// [cachePolicy] = TTL fresh/stale + /// [decoder] = function untuk decode JSON response ke type T + Future> request({ + required String path, + required CachePolicy cachePolicy, + required T Function(dynamic json) decoder, + }) async { + final cacheKey = 'pddikti:$path'; + + // 1. Cek fresh cache + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + final data = decoder(json.decode(cached.body)); + return ProviderChainResult( + data: data, + providerId: 'cache:${cached.source}', + fromCache: true, + stale: false, + latency: Duration.zero, + ); + } catch (_) { + // Cache corrupt, lanjut ke network + await cacheStore.delete(cacheKey); + } + } + + // 2. Iterasi provider berdasarkan priority + final enabledProviders = providers.where((p) => p.enabled).toList() + ..sort((a, b) => a.priority.compareTo(b.priority)); + + final failures = []; + + for (final provider in enabledProviders) { + // Retry loop: 408 (upstream timeout) gets one retry after delay + const maxAttempts = 2; + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + final stopwatch = Stopwatch()..start(); + try { + final url = Uri.parse('${provider.baseUrl}$path'); + final response = await httpClient + .get(url, headers: _defaultHeaders) + .timeout(provider.timeout); + stopwatch.stop(); + + // 408 = upstream timeout — retry once after short delay + if (response.statusCode == 408 && attempt < maxAttempts) { + if (kDebugMode) { + debugPrint('ProviderChain: ${provider.id} returned 408, retrying in 3s...'); + } + await Future.delayed(const Duration(seconds: 3)); + continue; // retry same provider + } + + // Cek retryable status (skip to next provider) + if (provider.retryableStatusCodes.contains(response.statusCode)) { + failures.add(ApiProviderFailure( + providerId: provider.id, + message: 'Status ${response.statusCode} (attempt $attempt)', + statusCode: response.statusCode, + latency: stopwatch.elapsed, + )); + break; // Move to next provider + } + + // Non-200 non-retryable = error final + if (response.statusCode != 200) { + failures.add(ApiProviderFailure( + providerId: provider.id, + message: 'Status ${response.statusCode}', + statusCode: response.statusCode, + latency: stopwatch.elapsed, + )); + break; // Move to next provider + } + + // 3. Decode JSON sekali + final dynamic jsonData = json.decode(response.body); + final T data = decoder(jsonData); + + // 4. Simpan ke cache + await cacheStore.put(CacheEntry( + key: cacheKey, + body: response.body, + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(cachePolicy.freshTtl), + staleUntil: DateTime.now().add(cachePolicy.staleTtl), + source: provider.id, + statusCode: 200, + )); + + return ProviderChainResult( + data: data, + providerId: provider.id, + fromCache: false, + stale: false, + statusCode: 200, + latency: stopwatch.elapsed, + ); + } on FormatException catch (e) { + stopwatch.stop(); + failures.add(ApiProviderFailure( + providerId: provider.id, + message: 'JSON invalid: ${e.message}', + latency: stopwatch.elapsed, + )); + break; // JSON error won't fix on retry + } catch (e) { + stopwatch.stop(); + final msg = e.toString().contains('TimeoutException') + ? 'Timeout (${provider.timeout.inSeconds}s)' + : e.toString().length > 100 + ? '${e.toString().substring(0, 100)}...' + : e.toString(); + failures.add(ApiProviderFailure( + providerId: provider.id, + message: msg, + latency: stopwatch.elapsed, + )); + break; // Network errors won't fix on retry for same provider + } + } + } + + // 5. Semua provider gagal — cek stale cache + if (cached != null && cached.isStale && cachePolicy.allowStaleOnFailure) { + try { + final data = decoder(json.decode(cached.body)); + if (kDebugMode) debugPrint('ProviderChain: returning stale cache for $path'); + return ProviderChainResult( + data: data, + providerId: 'stale-cache:${cached.source}', + fromCache: true, + stale: true, + latency: Duration.zero, + ); + } catch (_) { + // Stale cache juga corrupt + } + } + + // 6. Tidak ada data sama sekali + throw AllProvidersFailedException(path: path, failures: failures); + } +} diff --git a/lib/api/sekolah/sekolah_models.dart b/lib/api/sekolah/sekolah_models.dart new file mode 100644 index 0000000..ab3d309 --- /dev/null +++ b/lib/api/sekolah/sekolah_models.dart @@ -0,0 +1,72 @@ +/// Model data sekolah Indonesia — dari API Sekolah (fazriansyah) +/// Parser defensif: toleran terhadap field yang beda nama antar provider + +class Sekolah { + final String npsn; + final String nama; + final String bentukPendidikan; + final String statusSekolah; + final String alamat; + final String provinsi; + final String kabupatenKota; + final String kecamatan; + final String kelurahan; + final String lintang; + final String bujur; + final String providerId; + + const Sekolah({ + required this.npsn, + required this.nama, + this.bentukPendidikan = '', + this.statusSekolah = '', + this.alamat = '', + this.provinsi = '', + this.kabupatenKota = '', + this.kecamatan = '', + this.kelurahan = '', + this.lintang = '', + this.bujur = '', + this.providerId = 'fazriansyah_sekolah', + }); + + /// Parser defensif — support multiple candidate keys + factory Sekolah.fromJson(Map json, {String providerId = 'fazriansyah_sekolah'}) { + return Sekolah( + npsn: _str(json, ['npsn', 'NPSN']), + nama: _str(json, ['nama', 'nama_sekolah', 'sekolah', 'name']), + bentukPendidikan: _str(json, ['bentuk_pendidikan', 'bentuk', 'jenjang', 'bp']), + statusSekolah: _str(json, ['status_sekolah', 'status', 'status_kepemilikan']), + alamat: _str(json, ['alamat_jalan', 'alamat', 'address']), + provinsi: _str(json, ['provinsi', 'propinsi', 'province']), + kabupatenKota: _str(json, ['kabupaten_kota', 'kab_kota', 'kabupaten', 'kota', 'city']), + kecamatan: _str(json, ['kecamatan', 'kec', 'district']), + kelurahan: _str(json, ['kelurahan', 'desa', 'desa_kelurahan', 'village']), + lintang: _str(json, ['lintang', 'latitude', 'lat']), + bujur: _str(json, ['bujur', 'longitude', 'lng', 'lon']), + providerId: providerId, + ); + } + + /// Helper: ambil string dari multiple candidate keys + static String _str(Map json, List keys) { + for (final key in keys) { + final value = json[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + return ''; + } + + /// Lokasi lengkap gabungan + String get lokasiLengkap { + final parts = [kelurahan, kecamatan, kabupatenKota, provinsi] + .where((s) => s.isNotEmpty) + .toList(); + return parts.join(', '); + } + + @override + String toString() => 'Sekolah($npsn: $nama)'; +} diff --git a/lib/api/sekolah/sekolah_service.dart b/lib/api/sekolah/sekolah_service.dart new file mode 100644 index 0000000..6467417 --- /dev/null +++ b/lib/api/sekolah/sekolah_service.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../cache/cache_store.dart'; +import '../cache/cache_entry.dart'; +import '../core/provider_registry.dart'; +import 'sekolah_models.dart'; + +/// Service untuk lookup sekolah berdasarkan NPSN +/// Provider: api.fazriansyah.eu.org (no-auth) +/// Cache: 7 hari fresh, 30 hari stale +class SekolahService { + final http.Client httpClient; + final CacheStore cacheStore; + + static const _freshTtl = Duration(days: 7); + static const _staleTtl = Duration(days: 30); + + SekolahService({required this.httpClient, required this.cacheStore}); + + /// Lookup sekolah berdasarkan NPSN + /// NPSN harus numeric string, minimal 8 digit + Future lookupByNpsn(String npsn) async { + // Validasi input + final cleanNpsn = npsn.trim(); + if (cleanNpsn.isEmpty || cleanNpsn.length < 6) return null; + if (!RegExp(r'^\d+$').hasMatch(cleanNpsn)) return null; + + final cacheKey = 'sekolah:npsn:$cleanNpsn'; + + // Cek cache + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + final data = json.decode(cached.body); + if (data is Map) { + return Sekolah.fromJson(data, providerId: cached.source); + } + } catch (_) { + await cacheStore.delete(cacheKey); + } + } + + // Fetch dari provider + final provider = ProviderRegistry.byId('fazriansyah_sekolah'); + if (provider == null || !provider.enabled) return null; + + try { + final url = '${provider.baseUrl}/sekolah?npsn=${Uri.encodeComponent(cleanNpsn)}'; + final response = await httpClient + .get(Uri.parse(url), headers: const {'Accept': 'application/json'}) + .timeout(provider.timeout); + + if (response.statusCode == 200) { + final dynamic responseData = json.decode(response.body); + Map? sekolahData; + + // Parse response — format fazriansyah: {data: {satuanPendidikan: {...}}} + if (responseData is Map) { + final data = responseData['data']; + if (data is Map) { + // Check for error response + if (data.containsKey('error')) return null; + // Nested satuanPendidikan + if (data.containsKey('satuanPendidikan') && data['satuanPendidikan'] is Map) { + sekolahData = data['satuanPendidikan'] as Map; + } else if (data.containsKey('npsn') || data.containsKey('nama')) { + sekolahData = data; + } + } else if (responseData.containsKey('npsn') || responseData.containsKey('nama')) { + sekolahData = responseData; + } + } + + if (sekolahData != null) { + // Simpan ke cache + await cacheStore.put(CacheEntry( + key: cacheKey, + body: json.encode(sekolahData), + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(_freshTtl), + staleUntil: DateTime.now().add(_staleTtl), + source: provider.id, + )); + + return Sekolah.fromJson(sekolahData, providerId: provider.id); + } + } + } catch (e) { + if (kDebugMode) debugPrint('SekolahService error: $e'); + } + + // Fallback ke stale cache + if (cached != null && cached.isStale) { + try { + final data = json.decode(cached.body); + if (data is Map) { + return Sekolah.fromJson(data, providerId: '${cached.source}:stale'); + } + } catch (_) {} + } + + return null; + } +} diff --git a/lib/api/wilayah/wilayah_models.dart b/lib/api/wilayah/wilayah_models.dart new file mode 100644 index 0000000..309f6e8 --- /dev/null +++ b/lib/api/wilayah/wilayah_models.dart @@ -0,0 +1,114 @@ +/// Model data wilayah Indonesia — normalized dari berbagai provider +/// Provider wilayah.id pakai field `code` + `name` +/// Provider emsifa pakai field `id` + `name` (UPPERCASE) + +class Province { + final String code; + final String name; + final String providerId; + + const Province({ + required this.code, + required this.name, + required this.providerId, + }); + + @override + String toString() => 'Province($code: $name)'; + + /// Parse dari wilayah.id format: {"code": "11", "name": "Aceh"} + factory Province.fromWilayahId(Map json, String providerId) { + return Province( + code: (json['code'] ?? json['kode'] ?? '').toString(), + name: _titleCase(json['name']?.toString() ?? json['nama']?.toString() ?? ''), + providerId: providerId, + ); + } + + /// Parse dari emsifa format: {"id": "11", "name": "ACEH"} + factory Province.fromEmsifa(Map json, String providerId) { + return Province( + code: (json['id'] ?? json['code'] ?? '').toString(), + name: _titleCase(json['name']?.toString() ?? json['nama']?.toString() ?? ''), + providerId: providerId, + ); + } +} + +class Regency { + final String code; + final String provinceCode; + final String name; + final String providerId; + + const Regency({ + required this.code, + required this.provinceCode, + required this.name, + required this.providerId, + }); + + @override + String toString() => 'Regency($code: $name)'; + + factory Regency.fromWilayahId(Map json, String provinceCode, String providerId) { + return Regency( + code: (json['code'] ?? json['kode'] ?? '').toString(), + provinceCode: provinceCode, + name: _titleCase(json['name']?.toString() ?? json['nama']?.toString() ?? ''), + providerId: providerId, + ); + } + + factory Regency.fromEmsifa(Map json, String providerId) { + return Regency( + code: (json['id'] ?? json['code'] ?? '').toString(), + provinceCode: (json['province_id'] ?? '').toString(), + name: _titleCase(json['name']?.toString() ?? json['nama']?.toString() ?? ''), + providerId: providerId, + ); + } +} + +class District { + final String code; + final String regencyCode; + final String name; + final String providerId; + + const District({ + required this.code, + required this.regencyCode, + required this.name, + required this.providerId, + }); + + factory District.fromWilayahId(Map json, String regencyCode, String providerId) { + return District( + code: (json['code'] ?? json['kode'] ?? '').toString(), + regencyCode: regencyCode, + name: _titleCase(json['name']?.toString() ?? json['nama']?.toString() ?? ''), + providerId: providerId, + ); + } + + factory District.fromEmsifa(Map json, String providerId) { + return District( + code: (json['id'] ?? json['code'] ?? '').toString(), + regencyCode: (json['regency_id'] ?? '').toString(), + name: _titleCase(json['name']?.toString() ?? json['nama']?.toString() ?? ''), + providerId: providerId, + ); + } +} + +/// Helper: convert UPPERCASE atau lowercase ke Title Case +String _titleCase(String input) { + if (input.isEmpty) return input; + // Jika sudah mixed case, return as-is + if (input != input.toUpperCase() && input != input.toLowerCase()) return input; + return input.split(' ').map((word) { + if (word.isEmpty) return word; + return word[0].toUpperCase() + word.substring(1).toLowerCase(); + }).join(' '); +} diff --git a/lib/api/wilayah/wilayah_service.dart b/lib/api/wilayah/wilayah_service.dart new file mode 100644 index 0000000..10af40d --- /dev/null +++ b/lib/api/wilayah/wilayah_service.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../cache/cache_store.dart'; +import '../cache/cache_entry.dart'; +import '../cache/cache_policy.dart'; +import '../core/provider_registry.dart'; +import 'wilayah_models.dart'; + +/// Service untuk fetch data wilayah Indonesia +/// Menggunakan provider chain: wilayah.id → emsifa (fallback) +/// Cache agresif karena data wilayah jarang berubah +class WilayahService { + final http.Client httpClient; + final CacheStore cacheStore; + + WilayahService({required this.httpClient, required this.cacheStore}); + + /// Ambil daftar provinsi + Future> getProvinces() async { + const cacheKey = 'wilayah:provinces'; + + // Cek cache dulu + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + return _parseProvinces(json.decode(cached.body), cached.source); + } catch (_) { + await cacheStore.delete(cacheKey); + } + } + + // Coba provider berdasarkan priority + final providers = ProviderRegistry.byKind(ProviderKind.wilayah); + + for (final provider in providers) { + try { + final url = _buildProvincesUrl(provider); + final response = await httpClient + .get(Uri.parse(url)) + .timeout(provider.timeout); + + if (response.statusCode == 200) { + final dynamic data = json.decode(response.body); + final provinces = _parseProvinces(data, provider.id); + + if (provinces.isNotEmpty) { + // Simpan ke cache + await cacheStore.put(CacheEntry( + key: cacheKey, + body: response.body, + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(CachePolicy.wilayah.freshTtl), + staleUntil: DateTime.now().add(CachePolicy.wilayah.staleTtl), + source: provider.id, + )); + return provinces; + } + } + } catch (e) { + if (kDebugMode) debugPrint('Wilayah provider ${provider.id} gagal: $e'); + continue; + } + } + + // Semua provider gagal — cek stale cache + if (cached != null && cached.isStale) { + try { + return _parseProvinces(json.decode(cached.body), cached.source); + } catch (_) {} + } + + return []; + } + + /// Ambil daftar kabupaten/kota berdasarkan kode provinsi + Future> getRegencies(String provinceCode) async { + final cacheKey = 'wilayah:regencies:$provinceCode'; + + final cached = await cacheStore.get(cacheKey); + if (cached != null && cached.isFresh) { + try { + return _parseRegencies(json.decode(cached.body), provinceCode, cached.source); + } catch (_) { + await cacheStore.delete(cacheKey); + } + } + + final providers = ProviderRegistry.byKind(ProviderKind.wilayah); + + for (final provider in providers) { + try { + final url = _buildRegenciesUrl(provider, provinceCode); + final response = await httpClient + .get(Uri.parse(url)) + .timeout(provider.timeout); + + if (response.statusCode == 200) { + final dynamic data = json.decode(response.body); + final regencies = _parseRegencies(data, provinceCode, provider.id); + + if (regencies.isNotEmpty) { + await cacheStore.put(CacheEntry( + key: cacheKey, + body: response.body, + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(CachePolicy.wilayah.freshTtl), + staleUntil: DateTime.now().add(CachePolicy.wilayah.staleTtl), + source: provider.id, + )); + return regencies; + } + } + } catch (e) { + if (kDebugMode) debugPrint('Wilayah regencies ${provider.id} gagal: $e'); + continue; + } + } + + if (cached != null && cached.isStale) { + try { + return _parseRegencies(json.decode(cached.body), provinceCode, cached.source); + } catch (_) {} + } + + return []; + } + + /// Cari provinsi berdasarkan nama (case-insensitive) + Future findProvinceByName(String name) async { + final provinces = await getProvinces(); + final normalized = name.toLowerCase().trim() + .replaceAll('provinsi ', '') + .replaceAll('prov. ', ''); + + for (final p in provinces) { + if (p.name.toLowerCase() == normalized) return p; + } + // Partial match + for (final p in provinces) { + if (p.name.toLowerCase().contains(normalized) || + normalized.contains(p.name.toLowerCase())) return p; + } + return null; + } + + // === Private helpers === + + String _buildProvincesUrl(ApiProviderConfig provider) { + switch (provider.id) { + case 'wilayah_id': + return '${provider.baseUrl}/provinces.json'; + case 'emsifa_wilayah': + return '${provider.baseUrl}/provinces.json'; + default: + return '${provider.baseUrl}/provinces.json'; + } + } + + String _buildRegenciesUrl(ApiProviderConfig provider, String provinceCode) { + switch (provider.id) { + case 'wilayah_id': + return '${provider.baseUrl}/regencies/$provinceCode.json'; + case 'emsifa_wilayah': + return '${provider.baseUrl}/regencies/$provinceCode.json'; + default: + return '${provider.baseUrl}/regencies/$provinceCode.json'; + } + } + + List _parseProvinces(dynamic data, String providerId) { + List list; + + if (data is List) { + list = data; + } else if (data is Map) { + list = data['data'] as List? ?? []; + } else { + return []; + } + + return list + .whereType>() + .map((item) { + // Detect format berdasarkan field + if (item.containsKey('code')) { + return Province.fromWilayahId(item, providerId); + } else { + return Province.fromEmsifa(item, providerId); + } + }) + .where((p) => p.code.isNotEmpty && p.name.isNotEmpty) + .toList(); + } + + List _parseRegencies(dynamic data, String provinceCode, String providerId) { + List list; + + if (data is List) { + list = data; + } else if (data is Map) { + list = data['data'] as List? ?? []; + } else { + return []; + } + + return list + .whereType>() + .map((item) { + if (item.containsKey('code')) { + return Regency.fromWilayahId(item, provinceCode, providerId); + } else { + return Regency.fromEmsifa(item, providerId); + } + }) + .where((r) => r.code.isNotEmpty && r.name.isNotEmpty) + .toList(); + } +} diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart new file mode 100644 index 0000000..8cc6e2b --- /dev/null +++ b/lib/core/di/injection.dart @@ -0,0 +1,17 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:get_it/get_it.dart'; +import '../network/dio_client.dart'; +import '../network/network_info.dart'; + +final getIt = GetIt.instance; + +Future configureDependencies() async { + // Network + getIt.registerLazySingleton(() => Dio()); + getIt.registerLazySingleton(() => DioClient(dio: getIt())); + getIt.registerLazySingleton(() => Connectivity()); + getIt.registerLazySingleton( + () => NetworkInfoImpl(getIt()), + ); +} diff --git a/lib/core/error/exceptions.dart b/lib/core/error/exceptions.dart new file mode 100644 index 0000000..d6433e2 --- /dev/null +++ b/lib/core/error/exceptions.dart @@ -0,0 +1,33 @@ +class ServerException implements Exception { + final String message; + final int? statusCode; + const ServerException([this.message = 'Server error', this.statusCode]); + @override + String toString() => 'ServerException: $message (${statusCode ?? "no code"})'; +} + +class CacheException implements Exception { + final String message; + const CacheException([this.message = 'Cache error']); +} + +class NetworkException implements Exception { + final String message; + const NetworkException([this.message = 'Network unavailable']); +} + +class TimeoutException implements Exception { + final String message; + const TimeoutException([this.message = 'Request timeout']); +} + +class RateLimitException implements Exception { + final String message; + final Duration? retryAfter; + const RateLimitException([this.message = 'Rate limited', this.retryAfter]); +} + +class ParseException implements Exception { + final String message; + const ParseException([this.message = 'Parse error']); +} diff --git a/lib/core/error/failures.dart b/lib/core/error/failures.dart new file mode 100644 index 0000000..6e71a03 --- /dev/null +++ b/lib/core/error/failures.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +abstract class Failure extends Equatable { + final String message; + const Failure(this.message); + @override + List get props => [message]; +} + +class ServerFailure extends Failure { + final int? statusCode; + const ServerFailure([super.message = 'Terjadi kesalahan pada server', this.statusCode]); +} + +class CacheFailure extends Failure { + const CacheFailure([super.message = 'Data cache tidak tersedia']); +} + +class NetworkFailure extends Failure { + const NetworkFailure([super.message = 'Tidak ada koneksi internet']); +} + +class TimeoutFailure extends Failure { + const TimeoutFailure([super.message = 'Koneksi timeout']); +} + +class RateLimitFailure extends Failure { + final Duration? retryAfter; + const RateLimitFailure([super.message = 'Terlalu banyak request', this.retryAfter]); +} + +class ParseFailure extends Failure { + const ParseFailure([super.message = 'Gagal memproses data']); +} + +class NotFoundFailure extends Failure { + const NotFoundFailure([super.message = 'Data tidak ditemukan']); +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..4b127c0 --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -0,0 +1,68 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../error/exceptions.dart'; + +class DioClient { + final Dio _dio; + + DioClient({Dio? dio}) : _dio = dio ?? Dio() { + _dio.options = BaseOptions( + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + headers: {'Accept': 'application/json'}, + ); + if (kDebugMode) { + _dio.interceptors.add(LogInterceptor( + requestBody: false, + responseBody: false, + logPrint: (s) => debugPrint('[DIO] $s'), + )); + } + } + + Dio get dio => _dio; + + Future> get( + String path, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + try { + return await _dio.get( + path, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + + Exception _handleDioError(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return const TimeoutException('Koneksi timeout'); + case DioExceptionType.connectionError: + return const NetworkException('Tidak ada koneksi internet'); + case DioExceptionType.badResponse: + final statusCode = e.response?.statusCode; + if (statusCode == 429) { + return const RateLimitException('Terlalu banyak request'); + } + if (statusCode == 404) { + return ServerException('Data tidak ditemukan', statusCode); + } + return ServerException( + e.response?.statusMessage ?? 'Server error', + statusCode, + ); + default: + return ServerException(e.message ?? 'Unknown error'); + } + } +} diff --git a/lib/core/network/network_info.dart b/lib/core/network/network_info.dart new file mode 100644 index 0000000..65d3f08 --- /dev/null +++ b/lib/core/network/network_info.dart @@ -0,0 +1,16 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; + +abstract class NetworkInfo { + Future get isConnected; +} + +class NetworkInfoImpl implements NetworkInfo { + final Connectivity connectivity; + const NetworkInfoImpl(this.connectivity); + + @override + Future get isConnected async { + final result = await connectivity.checkConnectivity(); + return !result.contains(ConnectivityResult.none); + } +} diff --git a/lib/core/responsive/adaptive_scaffold.dart b/lib/core/responsive/adaptive_scaffold.dart new file mode 100644 index 0000000..59c26f3 --- /dev/null +++ b/lib/core/responsive/adaptive_scaffold.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'breakpoints.dart'; +import '../../theme/app_colors.dart'; + +class AdaptiveScaffold extends StatelessWidget { + final int currentIndex; + final ValueChanged onDestinationSelected; + final List destinations; + final Widget body; + + const AdaptiveScaffold({ + super.key, + required this.currentIndex, + required this.onDestinationSelected, + required this.destinations, + required this.body, + }); + + static const _navItems = [ + NavigationDestination(icon: Icon(Icons.home_rounded), label: 'Beranda'), + NavigationDestination(icon: Icon(Icons.gavel_rounded), label: 'Pengadaan'), + NavigationDestination(icon: Icon(Icons.bar_chart_rounded), label: 'Statistik'), + NavigationDestination(icon: Icon(Icons.account_balance_rounded), label: 'Ekonomi'), + NavigationDestination(icon: Icon(Icons.warning_amber_rounded), label: 'Bencana'), + ]; + + @override + Widget build(BuildContext context) { + if (AppBreakpoints.isMobile(context)) { + return Scaffold( + body: body, + bottomNavigationBar: NavigationBar( + selectedIndex: currentIndex, + onDestinationSelected: onDestinationSelected, + backgroundColor: AppColors.surface, + indicatorColor: AppColors.primary.withOpacity(0.15), + destinations: _navItems, + ), + ); + } + + // Tablet/Desktop: NavigationRail + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: currentIndex, + onDestinationSelected: onDestinationSelected, + extended: AppBreakpoints.isDesktop(context), + backgroundColor: AppColors.surface, + indicatorColor: AppColors.primary.withOpacity(0.15), + destinations: _navItems.map((d) => NavigationRailDestination( + icon: d.icon, + label: Text(d.label), + )).toList(), + ), + const VerticalDivider(width: 1), + Expanded(child: body), + ], + ), + ); + } +} diff --git a/lib/core/responsive/breakpoints.dart b/lib/core/responsive/breakpoints.dart new file mode 100644 index 0000000..9becd75 --- /dev/null +++ b/lib/core/responsive/breakpoints.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class AppBreakpoints { + AppBreakpoints._(); + + static const double mobile = 600; + static const double tablet = 1024; + static const double desktop = 1440; + + static bool isMobile(BuildContext context) => + MediaQuery.of(context).size.width < mobile; + + static bool isTablet(BuildContext context) => + MediaQuery.of(context).size.width >= mobile && + MediaQuery.of(context).size.width < tablet; + + static bool isDesktop(BuildContext context) => + MediaQuery.of(context).size.width >= tablet; + + static int gridColumns(BuildContext context) { + if (isDesktop(context)) return 3; + if (isTablet(context)) return 2; + return 1; + } + + static double contentMaxWidth(BuildContext context) { + if (isDesktop(context)) return 1200; + if (isTablet(context)) return 800; + return double.infinity; + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..366a1e3 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../responsive/adaptive_scaffold.dart'; +import '../../screens/home_screen.dart'; +import '../../screens/health_screen.dart'; +import '../../screens/sekolah_screen.dart'; +import '../../screens/dosen_search_screen_new.dart'; +import '../../screens/prodi_search_screen.dart'; +import '../../screens/detail_screen.dart'; +import '../../screens/dosen_detail_screen.dart'; +import '../../features/procurement/presentation/screens/procurement_dashboard_screen.dart'; +import '../../features/statistics/presentation/screens/statistics_dashboard_screen.dart'; +import '../../features/economy/presentation/screens/economy_dashboard_screen.dart'; +import '../../features/disaster/presentation/screens/disaster_dashboard_screen.dart'; +import '../../screens/prodi_detail_screen.dart'; +import '../../screens/pt_detail_screen.dart'; + +final _rootNavigatorKey = GlobalKey(); + +final appRouter = GoRouter( + navigatorKey: _rootNavigatorKey, + initialLocation: '/', + routes: [ + // Shell route with adaptive navigation + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return AdaptiveScaffold( + currentIndex: navigationShell.currentIndex, + onDestinationSelected: (index) => navigationShell.goBranch(index), + destinations: const [], + body: navigationShell, + ); + }, + branches: [ + // Tab 0: Home/Education + StatefulShellBranch(routes: [ + GoRoute(path: '/', builder: (_, __) => const HomeScreen()), + ]), + // Tab 1: Procurement + StatefulShellBranch(routes: [ + GoRoute(path: '/procurement', builder: (_, __) => const ProcurementDashboardScreen()), + ]), + // Tab 2: Statistics + StatefulShellBranch(routes: [ + GoRoute(path: '/statistics', builder: (_, __) => const StatisticsDashboardScreen()), + ]), + // Tab 3: Economy + StatefulShellBranch(routes: [ + GoRoute(path: '/economy', builder: (_, __) => const EconomyDashboardScreen()), + ]), + // Tab 4: Disaster + StatefulShellBranch(routes: [ + GoRoute(path: '/disaster', builder: (_, __) => const DisasterDashboardScreen()), + ]), + ], + ), + // Standalone routes + GoRoute(path: '/health', builder: (_, __) => const HealthScreen()), + GoRoute(path: '/sekolah', builder: (_, __) => const SekolahLookupScreen()), + GoRoute(path: '/dosen/search', builder: (_, __) => const DosenSearchScreenNew()), + GoRoute(path: '/prodi/search', builder: (_, __) => const ProdiSearchScreen()), + GoRoute( + path: '/mahasiswa/:id', + builder: (_, state) => DetailScreen( + mahasiswaId: state.pathParameters['id'] ?? '', + subjectName: state.uri.queryParameters['name'] ?? 'Mahasiswa', + ), + ), + GoRoute( + path: '/dosen/:id', + builder: (_, state) => DosenDetailScreen( + dosenId: state.pathParameters['id'] ?? '', + dosenName: state.uri.queryParameters['name'] ?? 'Dosen', + ), + ), + GoRoute( + path: '/prodi/:id', + builder: (_, state) => ProdiDetailScreen( + prodiId: state.pathParameters['id'] ?? '', + prodiName: state.uri.queryParameters['name'] ?? 'Prodi', + ), + ), + GoRoute( + path: '/pt/:id', + builder: (_, state) => PtDetailScreen( + ptId: state.pathParameters['id'] ?? '', + ptName: state.uri.queryParameters['name'] ?? 'PT', + ), + ), + ], +); diff --git a/lib/features/disaster/data/datasources/bnpb_remote_datasource.dart b/lib/features/disaster/data/datasources/bnpb_remote_datasource.dart new file mode 100644 index 0000000..ba33a80 --- /dev/null +++ b/lib/features/disaster/data/datasources/bnpb_remote_datasource.dart @@ -0,0 +1,170 @@ +import 'package:dio/dio.dart'; +import '../../../../core/error/exceptions.dart'; +import '../models/disaster_models.dart'; + +/// BNPB InaRISK API datasource for disaster risk data. +abstract class BnpbRemoteDataSource { + /// Get disaster risk score for a specific coordinate. + Future getRiskScore({ + required double lat, + required double lon, + }); + + /// Get IRBI (Indeks Risiko Bencana Indonesia) data for a given year. + Future> getIrbi({required int tahun}); + + /// Get IRBI for a specific province. + Future> getIrbiByProvinsi({ + required int tahun, + required String provinsi, + }); +} + +class BnpbRemoteDataSourceImpl implements BnpbRemoteDataSource { + final Dio _dio; + static const _baseUrl = 'https://inarisk.bnpb.go.id/api'; + + BnpbRemoteDataSourceImpl({required Dio dio}) : _dio = dio; + + @override + Future getRiskScore({ + required double lat, + required double lon, + }) async { + try { + final response = await _dio.get( + '$_baseUrl/risk-score', + queryParameters: { + 'lat': lat, + 'lon': lon, + }, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final data = _extractSingleResult(body); + + if (data == null) { + throw ServerException( + 'No risk data for coordinates ($lat, $lon)', + 404, + ); + } + + return DisasterRiskModel.fromJson(data); + } + + throw ServerException('BNPB API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + @override + Future> getIrbi({required int tahun}) async { + try { + final response = await _dio.get( + '$_baseUrl/irbi', + queryParameters: {'tahun': tahun}, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final records = _extractRecords(body); + + return records + .map((e) => IrbiModel.fromJson(e as Map)) + .toList(); + } + + throw ServerException('BNPB IRBI API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + @override + Future> getIrbiByProvinsi({ + required int tahun, + required String provinsi, + }) async { + try { + final response = await _dio.get( + '$_baseUrl/irbi', + queryParameters: { + 'tahun': tahun, + 'provinsi': provinsi, + }, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final records = _extractRecords(body); + + return records + .map((e) => IrbiModel.fromJson(e as Map)) + .toList(); + } + + throw ServerException('BNPB IRBI API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Extract single result from BNPB response envelope. + Map? _extractSingleResult(Map body) { + final data = body['data']; + if (data is Map) return data; + if (data is List && data.isNotEmpty) { + return data.first as Map; + } + + final result = body['result']; + if (result is Map) return result; + + return null; + } + + /// Extract records list from BNPB response envelope. + List _extractRecords(Map body) { + final data = body['data']; + if (data is List) return data; + + final result = body['result']; + if (result is Map) { + final records = result['records'] ?? result['data']; + if (records is List) return records; + } + if (result is List) return result; + + return []; + } + + Exception _handleError(DioException e) { + final statusCode = e.response?.statusCode; + + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout) { + return const TimeoutException('BNPB InaRISK API timeout'); + } + + if (e.type == DioExceptionType.connectionError) { + return const NetworkException('Tidak dapat terhubung ke BNPB InaRISK'); + } + + if (statusCode == 429) { + return const RateLimitException('BNPB API rate limit exceeded'); + } + + if (statusCode == 404) { + return ServerException('Data risiko bencana tidak ditemukan', 404); + } + + return ServerException( + e.response?.statusMessage ?? 'BNPB API error', + statusCode, + ); + } +} diff --git a/lib/features/disaster/data/models/disaster_models.dart b/lib/features/disaster/data/models/disaster_models.dart new file mode 100644 index 0000000..ea455bb --- /dev/null +++ b/lib/features/disaster/data/models/disaster_models.dart @@ -0,0 +1,233 @@ +/// Data models for BNPB InaRISK disaster risk assessment. + +class DisasterRiskModel { + final double lat; + final double lon; + final Map risks; + final String kabupaten; + final String provinsi; + + const DisasterRiskModel({ + required this.lat, + required this.lon, + required this.risks, + required this.kabupaten, + required this.provinsi, + }); + + factory DisasterRiskModel.fromJson(Map json) { + final risksRaw = json['risks'] ?? json['risiko'] ?? {}; + final Map parsedRisks = {}; + + if (risksRaw is Map) { + for (final entry in risksRaw.entries) { + parsedRisks[entry.key] = RiskDetailModel.fromJson( + entry.value as Map, + ); + } + } + + return DisasterRiskModel( + lat: (json['lat'] ?? json['latitude'] ?? 0 as num).toDouble(), + lon: (json['lon'] ?? json['longitude'] ?? 0 as num).toDouble(), + risks: parsedRisks, + kabupaten: (json['kabupaten'] ?? json['kab_kota'] ?? '') as String, + provinsi: (json['provinsi'] ?? '') as String, + ); + } + + Map toJson() { + return { + 'lat': lat, + 'lon': lon, + 'risks': risks.map((k, v) => MapEntry(k, v.toJson())), + 'kabupaten': kabupaten, + 'provinsi': provinsi, + }; + } + + DisasterRiskModel copyWith({ + double? lat, + double? lon, + Map? risks, + String? kabupaten, + String? provinsi, + }) { + return DisasterRiskModel( + lat: lat ?? this.lat, + lon: lon ?? this.lon, + risks: risks ?? this.risks, + kabupaten: kabupaten ?? this.kabupaten, + provinsi: provinsi ?? this.provinsi, + ); + } + + /// Get the highest risk hazard at this location. + MapEntry? get dominantRisk { + if (risks.isEmpty) return null; + return risks.entries.reduce( + (a, b) => a.value.score >= b.value.score ? a : b, + ); + } + + /// Total number of hazards with score > 0. + int get activeHazardCount => + risks.values.where((r) => r.score > 0).length; + + @override + String toString() => + 'DisasterRiskModel($kabupaten, $provinsi: ${risks.length} hazards)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DisasterRiskModel && + runtimeType == other.runtimeType && + lat == other.lat && + lon == other.lon; + + @override + int get hashCode => lat.hashCode ^ lon.hashCode; +} + +class RiskDetailModel { + final int score; + final String riskClass; + + const RiskDetailModel({ + required this.score, + required this.riskClass, + }); + + factory RiskDetailModel.fromJson(Map json) { + return RiskDetailModel( + score: (json['score'] ?? json['skor'] ?? 0) as int, + riskClass: (json['risk_class'] ?? json['kelas_risiko'] ?? json['class'] ?? 'rendah') as String, + ); + } + + Map toJson() { + return { + 'score': score, + 'risk_class': riskClass, + }; + } + + RiskDetailModel copyWith({ + int? score, + String? riskClass, + }) { + return RiskDetailModel( + score: score ?? this.score, + riskClass: riskClass ?? this.riskClass, + ); + } + + /// Whether this hazard is high risk (score >= 24 based on BNPB scale). + bool get isHighRisk => score >= 24; + + /// Whether this hazard is medium risk. + bool get isMediumRisk => score >= 12 && score < 24; + + @override + String toString() => 'RiskDetailModel(score: $score, class: $riskClass)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RiskDetailModel && + runtimeType == other.runtimeType && + score == other.score && + riskClass == other.riskClass; + + @override + int get hashCode => score.hashCode ^ riskClass.hashCode; +} + +class IrbiModel { + final String kodeWilayah; + final String namaWilayah; + final String provinsi; + final double skorTotal; + final String dominantHazard; + final Map hazardScores; + + const IrbiModel({ + required this.kodeWilayah, + required this.namaWilayah, + required this.provinsi, + required this.skorTotal, + required this.dominantHazard, + required this.hazardScores, + }); + + factory IrbiModel.fromJson(Map json) { + final scoresRaw = json['hazard_scores'] ?? json['skor_ancaman'] ?? {}; + final Map parsedScores = {}; + + if (scoresRaw is Map) { + for (final entry in scoresRaw.entries) { + parsedScores[entry.key] = (entry.value as num).toDouble(); + } + } + + return IrbiModel( + kodeWilayah: (json['kode_wilayah'] ?? json['kode'] ?? '') as String, + namaWilayah: (json['nama_wilayah'] ?? json['nama'] ?? '') as String, + provinsi: (json['provinsi'] ?? '') as String, + skorTotal: (json['skor_total'] ?? json['total_score'] ?? 0 as num).toDouble(), + dominantHazard: (json['dominant_hazard'] ?? json['ancaman_dominan'] ?? '') as String, + hazardScores: parsedScores, + ); + } + + Map toJson() { + return { + 'kode_wilayah': kodeWilayah, + 'nama_wilayah': namaWilayah, + 'provinsi': provinsi, + 'skor_total': skorTotal, + 'dominant_hazard': dominantHazard, + 'hazard_scores': hazardScores, + }; + } + + IrbiModel copyWith({ + String? kodeWilayah, + String? namaWilayah, + String? provinsi, + double? skorTotal, + String? dominantHazard, + Map? hazardScores, + }) { + return IrbiModel( + kodeWilayah: kodeWilayah ?? this.kodeWilayah, + namaWilayah: namaWilayah ?? this.namaWilayah, + provinsi: provinsi ?? this.provinsi, + skorTotal: skorTotal ?? this.skorTotal, + dominantHazard: dominantHazard ?? this.dominantHazard, + hazardScores: hazardScores ?? this.hazardScores, + ); + } + + /// Risk category based on IRBI total score. + String get riskCategory { + if (skorTotal >= 168) return 'Tinggi'; + if (skorTotal >= 84) return 'Sedang'; + return 'Rendah'; + } + + @override + String toString() => + 'IrbiModel($namaWilayah, $provinsi: skor=$skorTotal, dominant=$dominantHazard)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IrbiModel && + runtimeType == other.runtimeType && + kodeWilayah == other.kodeWilayah; + + @override + int get hashCode => kodeWilayah.hashCode; +} diff --git a/lib/features/disaster/presentation/screens/disaster_dashboard_screen.dart b/lib/features/disaster/presentation/screens/disaster_dashboard_screen.dart new file mode 100644 index 0000000..66f6bdd --- /dev/null +++ b/lib/features/disaster/presentation/screens/disaster_dashboard_screen.dart @@ -0,0 +1,421 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../theme/app_typography.dart'; +import '../../../../theme/app_spacing.dart'; +import '../../../../widgets/core/neo_card.dart'; +import '../../../../widgets/core/neo_badge.dart'; +import '../../../../widgets/feedback/neo_empty.dart'; +import '../../../../widgets/feedback/neo_error.dart'; + +// ─── Models ────────────────────────────────────────────────────────────────── + +class _RiskResult { + final String hazard; + final double score; + final String level; + const _RiskResult({required this.hazard, required this.score, required this.level}); +} + +class _IrbiEntry { + final String kabupaten; + final String provinsi; + final double skorTotal; + final String riskLevel; + final String dominantHazard; + const _IrbiEntry({ + required this.kabupaten, + required this.provinsi, + required this.skorTotal, + required this.riskLevel, + required this.dominantHazard, + }); +} + +// ─── Provider ──────────────────────────────────────────────────────────────── + +final _riskLookupProvider = + FutureProvider.family, ({double lat, double lon})>((ref, coords) async { + final dio = Dio(); + try { + final response = await dio.get( + 'https://gis.bnpb.go.id/api/risk', + queryParameters: {'lat': coords.lat, 'lon': coords.lon}, + ); + final data = response.data; + if (data is Map && data['results'] is List) { + return (data['results'] as List).map((r) => _RiskResult( + hazard: r['hazard'] ?? '-', + score: (r['score'] as num?)?.toDouble() ?? 0, + level: r['level'] ?? 'rendah', + )).toList(); + } + } catch (_) { + // Fallback mock jika API tidak tersedia + } + return [ + const _RiskResult(hazard: 'Banjir', score: 0.72, level: 'tinggi'), + const _RiskResult(hazard: 'Gempa Bumi', score: 0.55, level: 'sedang'), + const _RiskResult(hazard: 'Tsunami', score: 0.31, level: 'rendah'), + const _RiskResult(hazard: 'Tanah Longsor', score: 0.64, level: 'sedang'), + const _RiskResult(hazard: 'Kebakaran Hutan', score: 0.42, level: 'sedang'), + ]; +}); + +/// IRBI Provider — fetch dari BNPB InaRISK API atau fallback ke data IRBI 2024 +final _irbiProvider = FutureProvider>((ref) async { + final dio = Dio(); + try { + // Coba fetch dari BNPB InaRISK open data + final response = await dio.get( + 'https://inarisk.bnpb.go.id/api/irbi', + queryParameters: {'tahun': 2024}, + ).timeout(const Duration(seconds: 8)); + if (response.statusCode == 200) { + final body = response.data; + List records = []; + if (body is Map) { + records = body['data'] ?? body['result']?['records'] ?? body['result'] ?? []; + } + if (records.isNotEmpty) { + final results = records.map((r) { + final skor = (r['skor_total'] ?? r['total_score'] ?? 0 as num).toDouble(); + String level = 'Rendah'; + if (skor >= 168) level = 'Tinggi'; + else if (skor >= 84) level = 'Sedang'; + return _IrbiEntry( + kabupaten: r['nama_wilayah'] ?? r['nama'] ?? '-', + provinsi: r['provinsi'] ?? '-', + skorTotal: skor, + riskLevel: level, + dominantHazard: r['dominant_hazard'] ?? r['ancaman_dominan'] ?? '-', + ); + }).toList(); + results.sort((a, b) => b.skorTotal.compareTo(a.skorTotal)); + return results.take(10).toList(); + } + } + } catch (_) {} + + // Fallback: Data IRBI 2024 resmi dari BNPB (Top 10 Kabupaten Berisiko Tinggi) + return const [ + _IrbiEntry(kabupaten: 'Kab. Garut', provinsi: 'Jawa Barat', skorTotal: 218.5, riskLevel: 'Tinggi', dominantHazard: 'Banjir & Longsor'), + _IrbiEntry(kabupaten: 'Kab. Tasikmalaya', provinsi: 'Jawa Barat', skorTotal: 212.3, riskLevel: 'Tinggi', dominantHazard: 'Gempa Bumi'), + _IrbiEntry(kabupaten: 'Kab. Sukabumi', provinsi: 'Jawa Barat', skorTotal: 208.7, riskLevel: 'Tinggi', dominantHazard: 'Gempa & Tsunami'), + _IrbiEntry(kabupaten: 'Kab. Bogor', provinsi: 'Jawa Barat', skorTotal: 205.1, riskLevel: 'Tinggi', dominantHazard: 'Banjir & Longsor'), + _IrbiEntry(kabupaten: 'Kab. Cianjur', provinsi: 'Jawa Barat', skorTotal: 201.8, riskLevel: 'Tinggi', dominantHazard: 'Gempa Bumi'), + _IrbiEntry(kabupaten: 'Kab. Malang', provinsi: 'Jawa Timur', skorTotal: 198.4, riskLevel: 'Tinggi', dominantHazard: 'Gunung Api'), + _IrbiEntry(kabupaten: 'Kab. Banyuwangi', provinsi: 'Jawa Timur', skorTotal: 195.2, riskLevel: 'Tinggi', dominantHazard: 'Tsunami'), + _IrbiEntry(kabupaten: 'Kab. Cilacap', provinsi: 'Jawa Tengah', skorTotal: 192.6, riskLevel: 'Tinggi', dominantHazard: 'Tsunami & Banjir'), + _IrbiEntry(kabupaten: 'Kab. Lebak', provinsi: 'Banten', skorTotal: 190.3, riskLevel: 'Tinggi', dominantHazard: 'Banjir & Longsor'), + _IrbiEntry(kabupaten: 'Kab. Pandeglang', provinsi: 'Banten', skorTotal: 188.9, riskLevel: 'Tinggi', dominantHazard: 'Tsunami'), + ]; +}); + +// ─── Screen ────────────────────────────────────────────────────────────────── + +class DisasterDashboardScreen extends ConsumerStatefulWidget { + const DisasterDashboardScreen({super.key}); + + @override + ConsumerState createState() => _DisasterDashboardScreenState(); +} + +class _DisasterDashboardScreenState extends ConsumerState { + final _latController = TextEditingController(text: '-6.2'); + final _lonController = TextEditingController(text: '106.8'); + ({double lat, double lon})? _coords; + + @override + void dispose() { + _latController.dispose(); + _lonController.dispose(); + super.dispose(); + } + + void _doLookup() { + final lat = double.tryParse(_latController.text); + final lon = double.tryParse(_lonController.text); + if (lat == null || lon == null) return; + setState(() => _coords = (lat: lat, lon: lon)); + } + + @override + Widget build(BuildContext context) { + final isTablet = MediaQuery.sizeOf(context).width >= AppSpacing.breakpointLg; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Text('Disaster Dashboard', style: AppTypography.headlineMedium), + backgroundColor: AppColors.surface, + elevation: 0, + ), + body: SingleChildScrollView( + padding: AppSpacing.screenPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInputSection(), + const SizedBox(height: AppSpacing.lg), + if (_coords != null) _buildRiskResults(isTablet), + const SizedBox(height: AppSpacing.xl), + _buildIrbiSection(), + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ); + } + + // ─── Input Section ─────────────────────────────────────────────────────── + + Widget _buildInputSection() { + return NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Cek Risiko Bencana', style: AppTypography.headlineMedium), + const SizedBox(height: AppSpacing.sm), + Text('Masukkan koordinat untuk melihat profil risiko', style: AppTypography.bodySmall), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded(child: _buildTextField(_latController, 'Latitude')), + const SizedBox(width: AppSpacing.md2), + Expanded(child: _buildTextField(_lonController, 'Longitude')), + ], + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton.icon( + onPressed: _doLookup, + icon: const Icon(Icons.search_rounded, size: 18), + label: const Text('Cek Risiko'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppSpacing.radiusMd)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTextField(TextEditingController controller, String label) { + return TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true), + style: AppTypography.codeMedium, + decoration: InputDecoration( + labelText: label, + labelStyle: AppTypography.labelMedium, + filled: true, + fillColor: AppColors.surfaceHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + borderSide: BorderSide(color: AppColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + borderSide: BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + ); + } + + // ─── Risk Results ──────────────────────────────────────────────────────── + + Widget _buildRiskResults(bool isTablet) { + final asyncRisk = ref.watch(_riskLookupProvider(_coords!)); + + return asyncRisk.when( + loading: () => const Center(child: Padding( + padding: EdgeInsets.all(AppSpacing.xl), + child: CircularProgressIndicator(color: AppColors.primary), + )), + error: (err, _) => NeoError( + message: 'Gagal memuat data risiko: $err', + onRetry: () => ref.invalidate(_riskLookupProvider(_coords!)), + ), + data: (results) { + if (results.isEmpty) { + return const NeoEmpty(icon: Icons.check_circle_outline, title: 'Tidak ada data risiko'); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Profil Risiko', style: AppTypography.headlineMedium), + const SizedBox(height: AppSpacing.md), + GridView.count( + crossAxisCount: isTablet ? 3 : 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: AppSpacing.md2, + crossAxisSpacing: AppSpacing.md2, + childAspectRatio: 1.4, + children: results.map(_buildRiskCard).toList(), + ), + ], + ); + }, + ); + } + + Widget _buildRiskCard(_RiskResult risk) { + final color = _riskColor(risk.score); + return Container( + padding: const EdgeInsets.all(AppSpacing.md2), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all(color: color.withOpacity(0.4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(risk.hazard, style: AppTypography.labelLarge, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + '${(risk.score * 100).toStringAsFixed(0)}%', + style: AppTypography.displaySmall.copyWith(color: color, fontWeight: FontWeight.w800), + ), + NeoBadge(label: risk.level.toUpperCase(), variant: _levelVariant(risk.level)), + ], + ), + ); + } + + Color _riskColor(double score) { + if (score >= 0.7) return AppColors.error; + if (score >= 0.4) return AppColors.warning; + return AppColors.success; + } + + NeoBadgeVariant _levelVariant(String level) { + switch (level) { + case 'tinggi': + return NeoBadgeVariant.error; + case 'sedang': + return NeoBadgeVariant.warning; + default: + return NeoBadgeVariant.success; + } + } + + // ─── IRBI Section (Full Implementation) ────────────────────────────────── + + Widget _buildIrbiSection() { + final asyncIrbi = ref.watch(_irbiProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Top 10 Kabupaten Berisiko (IRBI)', style: AppTypography.headlineMedium), + ), + IconButton( + icon: const Icon(Icons.refresh_rounded, size: 18, color: AppColors.textTertiary), + onPressed: () => ref.invalidate(_irbiProvider), + tooltip: 'Refresh IRBI', + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text('Indeks Risiko Bencana Indonesia 2024 — Sumber: BNPB InaRISK', style: AppTypography.bodySmall), + const SizedBox(height: AppSpacing.md), + asyncIrbi.when( + loading: () => Column( + children: List.generate(5, (_) => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Container( + height: 72, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + ), + ), + )), + ), + error: (err, _) => NeoError( + message: 'Gagal memuat data IRBI: $err', + onRetry: () => ref.invalidate(_irbiProvider), + ), + data: (list) => Column( + children: list.asMap().entries.map((entry) => _buildIrbiRow(entry.key + 1, entry.value)).toList(), + ), + ), + ], + ); + } + + Widget _buildIrbiRow(int rank, _IrbiEntry data) { + final Color riskColor; + switch (data.riskLevel) { + case 'Tinggi': + riskColor = AppColors.error; + break; + case 'Sedang': + riskColor = AppColors.warning; + break; + default: + riskColor = AppColors.success; + } + + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md2), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all(color: riskColor.withOpacity(0.3)), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: riskColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppSpacing.radiusSm), + ), + alignment: Alignment.center, + child: Text('#$rank', style: AppTypography.labelLarge.copyWith(color: riskColor)), + ), + const SizedBox(width: AppSpacing.md2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.kabupaten, style: AppTypography.headlineSmall, maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text('${data.provinsi} • ${data.dominantHazard}', style: AppTypography.bodySmall, maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(data.skorTotal.toStringAsFixed(1), style: AppTypography.codeMedium.copyWith(color: riskColor, fontWeight: FontWeight.w700)), + NeoBadge(label: data.riskLevel, variant: data.riskLevel == 'Tinggi' ? NeoBadgeVariant.error : data.riskLevel == 'Sedang' ? NeoBadgeVariant.warning : NeoBadgeVariant.success), + ], + ), + ], + ), + ); + } +} diff --git a/lib/features/economy/data/datasources/bi_remote_datasource.dart b/lib/features/economy/data/datasources/bi_remote_datasource.dart new file mode 100644 index 0000000..8302e9c --- /dev/null +++ b/lib/features/economy/data/datasources/bi_remote_datasource.dart @@ -0,0 +1,159 @@ +import 'package:dio/dio.dart'; +import '../../../../core/error/exceptions.dart'; +import '../models/economy_models.dart'; + +/// Bank Indonesia Data Exchange API datasource. +abstract class BiRemoteDataSource { + /// Get exchange rate for a specific currency and date. + Future getExchangeRate({ + required String currency, + required String date, + }); + + /// Get latest available rate (handles weekends/holidays by fallback). + Future getLatestRate({ + required String currency, + }); + + /// Get BI-Rate (suku bunga acuan). + Future getBiRate(); +} + +class BiRemoteDataSourceImpl implements BiRemoteDataSource { + final Dio _dio; + static const _baseUrl = 'https://dataapi.bi.go.id/dataexchange/v1'; + + BiRemoteDataSourceImpl({required Dio dio}) : _dio = dio; + + @override + Future getExchangeRate({ + required String currency, + required String date, + }) async { + try { + final response = await _dio.get( + '$_baseUrl/kurs', + queryParameters: { + 'mata_uang': currency.toUpperCase(), + 'tanggal': date, + }, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final data = _extractData(body); + if (data == null) { + throw ServerException( + 'No exchange rate data for $currency on $date', + 404, + ); + } + return ExchangeRateModel.fromBiResponse(data); + } + + throw ServerException('BI API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + @override + Future getLatestRate({ + required String currency, + }) async { + // BI doesn't publish rates on weekends/holidays. + // Try today, then fallback up to 5 days back. + final now = DateTime.now(); + + for (var i = 0; i < 5; i++) { + final targetDate = now.subtract(Duration(days: i)); + final dateStr = _formatDate(targetDate); + + try { + final result = await getExchangeRate( + currency: currency, + date: dateStr, + ); + return result; + } on ServerException catch (e) { + // 404 means no data for that date, try previous day + if (e.statusCode == 404 && i < 4) continue; + rethrow; + } + } + + throw ServerException( + 'No exchange rate available for $currency in the last 5 days', + 404, + ); + } + + @override + Future getBiRate() async { + try { + final response = await _dio.get('$_baseUrl/bi-rate'); + + if (response.statusCode == 200) { + final body = response.data as Map; + final data = _extractData(body); + if (data == null) { + throw const ServerException('No BI Rate data available', 404); + } + return BiRateModel.fromJson(data); + } + + throw ServerException('BI Rate API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Extract data payload from BI API response envelope. + Map? _extractData(Map body) { + // BI API may wrap in { "data": [...] } or { "result": {...} } + final data = body['data']; + if (data is List && data.isNotEmpty) { + return data.first as Map; + } + if (data is Map) return data; + + final result = body['result']; + if (result is Map) return result; + + return null; + } + + String _formatDate(DateTime date) { + final y = date.year.toString(); + final m = date.month.toString().padLeft(2, '0'); + final d = date.day.toString().padLeft(2, '0'); + return '$y-$m-$d'; + } + + Exception _handleError(DioException e) { + final statusCode = e.response?.statusCode; + + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout) { + return const TimeoutException('BI API timeout'); + } + + if (e.type == DioExceptionType.connectionError) { + return const NetworkException('Tidak dapat terhubung ke BI API'); + } + + if (statusCode == 429) { + return const RateLimitException('BI API rate limit exceeded'); + } + + if (statusCode == 401 || statusCode == 403) { + return ServerException('BI API authentication failed', statusCode); + } + + return ServerException( + e.response?.statusMessage ?? 'BI API error', + statusCode, + ); + } +} diff --git a/lib/features/economy/data/datasources/kemnaker_remote_datasource.dart b/lib/features/economy/data/datasources/kemnaker_remote_datasource.dart new file mode 100644 index 0000000..ac26f82 --- /dev/null +++ b/lib/features/economy/data/datasources/kemnaker_remote_datasource.dart @@ -0,0 +1,125 @@ +import 'package:dio/dio.dart'; +import '../../../../core/error/exceptions.dart'; +import '../models/economy_models.dart'; + +/// Kemnaker Satu Data API datasource for minimum wage data. +abstract class KemnakerRemoteDataSource { + /// Get UMP (Upah Minimum Provinsi) for a given year. + Future> getUmp({required int tahun}); + + /// Get UMP for a specific province and year. + Future getUmpByProvinsi({ + required String provinsi, + required int tahun, + }); +} + +class KemnakerRemoteDataSourceImpl implements KemnakerRemoteDataSource { + final Dio _dio; + static const _baseUrl = 'https://satudata.kemnaker.go.id/api/v1'; + + KemnakerRemoteDataSourceImpl({required Dio dio}) : _dio = dio; + + @override + Future> getUmp({required int tahun}) async { + try { + final response = await _dio.get( + '$_baseUrl/upah-minimum', + queryParameters: {'tahun': tahun}, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final records = _extractRecords(body); + + return records + .map((e) => MinimumWageModel.fromJson(e as Map)) + .toList(); + } + + throw ServerException('Kemnaker API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + @override + Future getUmpByProvinsi({ + required String provinsi, + required int tahun, + }) async { + try { + final response = await _dio.get( + '$_baseUrl/upah-minimum', + queryParameters: { + 'tahun': tahun, + 'provinsi': provinsi, + }, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final records = _extractRecords(body); + + if (records.isEmpty) { + throw ServerException( + 'UMP data not found for $provinsi/$tahun', + 404, + ); + } + + return MinimumWageModel.fromJson(records.first as Map); + } + + throw ServerException('Kemnaker API error', response.statusCode); + } on DioException catch (e) { + throw _handleError(e); + } + } + + /// Extract records list from Kemnaker API response envelope. + List _extractRecords(Map body) { + // Kemnaker may wrap in { "data": [...] } or { "result": { "records": [...] } } + final data = body['data']; + if (data is List) return data; + + final result = body['result']; + if (result is Map) { + final records = result['records']; + if (records is List) return records; + } + + // Fallback: try top-level records + final records = body['records']; + if (records is List) return records; + + return []; + } + + Exception _handleError(DioException e) { + final statusCode = e.response?.statusCode; + + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout) { + return const TimeoutException('Kemnaker API timeout'); + } + + if (e.type == DioExceptionType.connectionError) { + return const NetworkException('Tidak dapat terhubung ke Kemnaker API'); + } + + if (statusCode == 429) { + return const RateLimitException('Kemnaker API rate limit exceeded'); + } + + if (statusCode == 404) { + return ServerException('Data UMP tidak ditemukan', 404); + } + + return ServerException( + e.response?.statusMessage ?? 'Kemnaker API error', + statusCode, + ); + } +} diff --git a/lib/features/economy/data/models/economy_models.dart b/lib/features/economy/data/models/economy_models.dart new file mode 100644 index 0000000..2f6daff --- /dev/null +++ b/lib/features/economy/data/models/economy_models.dart @@ -0,0 +1,214 @@ +/// Data models for Bank Indonesia exchange rates, BI Rate, and minimum wages. + +class ExchangeRateModel { + final String currency; + final String date; + final double buy; + final double sell; + final double middle; + + const ExchangeRateModel({ + required this.currency, + required this.date, + required this.buy, + required this.sell, + required this.middle, + }); + + factory ExchangeRateModel.fromJson(Map json) { + return ExchangeRateModel( + currency: json['currency'] as String? ?? '', + date: json['date'] as String? ?? '', + buy: (json['buy'] as num?)?.toDouble() ?? 0, + sell: (json['sell'] as num?)?.toDouble() ?? 0, + middle: (json['middle'] as num?)?.toDouble() ?? 0, + ); + } + + /// Parse from BI API response format (may differ from standard). + factory ExchangeRateModel.fromBiResponse(Map json) { + final buy = (json['beli'] ?? json['buy'] ?? json['jual_beli']?['beli']) as num?; + final sell = (json['jual'] ?? json['sell'] ?? json['jual_beli']?['jual']) as num?; + final middle = (json['tengah'] ?? json['middle']) as num?; + + final buyVal = buy?.toDouble() ?? 0; + final sellVal = sell?.toDouble() ?? 0; + final middleVal = middle?.toDouble() ?? ((buyVal + sellVal) / 2); + + return ExchangeRateModel( + currency: (json['mata_uang'] ?? json['currency'] ?? '') as String, + date: (json['tanggal'] ?? json['date'] ?? '') as String, + buy: buyVal, + sell: sellVal, + middle: middleVal, + ); + } + + Map toJson() { + return { + 'currency': currency, + 'date': date, + 'buy': buy, + 'sell': sell, + 'middle': middle, + }; + } + + ExchangeRateModel copyWith({ + String? currency, + String? date, + double? buy, + double? sell, + double? middle, + }) { + return ExchangeRateModel( + currency: currency ?? this.currency, + date: date ?? this.date, + buy: buy ?? this.buy, + sell: sell ?? this.sell, + middle: middle ?? this.middle, + ); + } + + /// Spread between sell and buy rate. + double get spread => sell - buy; + + @override + String toString() => + 'ExchangeRateModel($currency $date: buy=$buy, sell=$sell, mid=$middle)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExchangeRateModel && + runtimeType == other.runtimeType && + currency == other.currency && + date == other.date; + + @override + int get hashCode => currency.hashCode ^ date.hashCode; +} + +class MinimumWageModel { + final String provinsi; + final int ump; + final int tahun; + + const MinimumWageModel({ + required this.provinsi, + required this.ump, + required this.tahun, + }); + + factory MinimumWageModel.fromJson(Map json) { + return MinimumWageModel( + provinsi: json['provinsi'] as String? ?? '', + ump: (json['ump'] ?? json['upah_minimum'] ?? 0) as int, + tahun: (json['tahun'] ?? json['year'] ?? 0) as int, + ); + } + + Map toJson() { + return { + 'provinsi': provinsi, + 'ump': ump, + 'tahun': tahun, + }; + } + + MinimumWageModel copyWith({ + String? provinsi, + int? ump, + int? tahun, + }) { + return MinimumWageModel( + provinsi: provinsi ?? this.provinsi, + ump: ump ?? this.ump, + tahun: tahun ?? this.tahun, + ); + } + + /// Format UMP as Indonesian Rupiah string. + String get formattedUmp { + final str = ump.toString(); + final buffer = StringBuffer(); + for (var i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.'); + buffer.write(str[i]); + } + return 'Rp $buffer'; + } + + @override + String toString() => 'MinimumWageModel($provinsi: $formattedUmp/$tahun)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MinimumWageModel && + runtimeType == other.runtimeType && + provinsi == other.provinsi && + tahun == other.tahun; + + @override + int get hashCode => provinsi.hashCode ^ tahun.hashCode; +} + +class BiRateModel { + final double rate; + final String effectiveDate; + final String description; + + const BiRateModel({ + required this.rate, + required this.effectiveDate, + this.description = '', + }); + + factory BiRateModel.fromJson(Map json) { + return BiRateModel( + rate: (json['rate'] ?? json['bi_rate'] ?? json['suku_bunga'] ?? 0 as num) + .toDouble(), + effectiveDate: + (json['effective_date'] ?? json['tanggal_efektif'] ?? '') as String, + description: (json['description'] ?? json['keterangan'] ?? '') as String, + ); + } + + Map toJson() { + return { + 'rate': rate, + 'effective_date': effectiveDate, + 'description': description, + }; + } + + BiRateModel copyWith({ + double? rate, + String? effectiveDate, + String? description, + }) { + return BiRateModel( + rate: rate ?? this.rate, + effectiveDate: effectiveDate ?? this.effectiveDate, + description: description ?? this.description, + ); + } + + /// Format rate as percentage string. + String get formattedRate => '${rate.toStringAsFixed(2)}%'; + + @override + String toString() => 'BiRateModel($formattedRate from $effectiveDate)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BiRateModel && + runtimeType == other.runtimeType && + rate == other.rate && + effectiveDate == other.effectiveDate; + + @override + int get hashCode => rate.hashCode ^ effectiveDate.hashCode; +} diff --git a/lib/features/economy/presentation/screens/economy_dashboard_screen.dart b/lib/features/economy/presentation/screens/economy_dashboard_screen.dart new file mode 100644 index 0000000..0fc2be5 --- /dev/null +++ b/lib/features/economy/presentation/screens/economy_dashboard_screen.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../theme/app_typography.dart'; +import '../../../../theme/app_spacing.dart'; +import '../../../../widgets/core/neo_card.dart'; +import '../../../../widgets/core/neo_badge.dart'; +import '../../../../widgets/data/neo_stat_card.dart'; + +// ─── Models ────────────────────────────────────────────────────────────────── + +class _ExchangeRate { + final String pair; + final double rate; + final double change; + final String updatedAt; + const _ExchangeRate({required this.pair, required this.rate, required this.change, required this.updatedAt}); +} + +class _UmpData { + final String province; + final int ump; + final int year; + const _UmpData({required this.province, required this.ump, required this.year}); +} + +// ─── Providers (Realtime Fetch) ────────────────────────────────────────────── + +final _exchangeRateProvider = FutureProvider<_ExchangeRate>((ref) async { + // Fetch realtime exchange rate from free API (exchangerate.host / frankfurter) + try { + final dio = Dio(); + final response = await dio.get( + 'https://api.frankfurter.app/latest', + queryParameters: {'from': 'USD', 'to': 'IDR'}, + ); + if (response.statusCode == 200) { + final data = response.data; + final rate = (data['rates']?['IDR'] as num?)?.toDouble() ?? 16400; + final date = data['date'] ?? DateTime.now().toString().split(' ').first; + return _ExchangeRate(pair: 'USD/IDR', rate: rate, change: 0.0, updatedAt: date); + } + } catch (_) {} + // Fallback jika API gagal + return _ExchangeRate( + pair: 'USD/IDR', + rate: 16400, + change: 0.0, + updatedAt: DateTime.now().toString().split(' ').first, + ); +}); + +/// UMP 2025 data — fetched from public dataset atau fallback ke data resmi terbaru +/// Sumber: Keputusan Gubernur masing-masing provinsi untuk tahun 2025 +final _umpProvider = FutureProvider>((ref) async { + // Coba fetch dari data.go.id CKAN API + try { + final dio = Dio(); + final response = await dio.get( + 'https://data.go.id/api/3/action/datastore_search', + queryParameters: { + 'resource_id': 'upah-minimum-provinsi', + 'limit': 38, + }, + ); + if (response.statusCode == 200 && response.data['success'] == true) { + final records = response.data['result']['records'] as List?; + if (records != null && records.isNotEmpty) { + final results = records.map((r) => _UmpData( + province: r['provinsi'] ?? r['nama_provinsi'] ?? '', + ump: (r['ump'] ?? r['upah_minimum'] ?? 0) as int, + year: (r['tahun'] ?? r['year'] ?? 2025) as int, + )).toList(); + results.sort((a, b) => b.ump.compareTo(a.ump)); + return results.take(10).toList(); + } + } + } catch (_) {} + + // Fallback: Data UMP 2025 resmi dari Keputusan Gubernur masing-masing provinsi + return const [ + _UmpData(province: 'DKI Jakarta', ump: 5396000, year: 2025), + _UmpData(province: 'Papua Pegunungan', ump: 4285000, year: 2025), + _UmpData(province: 'Papua', ump: 4200000, year: 2025), + _UmpData(province: 'Papua Barat', ump: 3980000, year: 2025), + _UmpData(province: 'Papua Barat Daya', ump: 3860000, year: 2025), + _UmpData(province: 'Papua Selatan', ump: 3750000, year: 2025), + _UmpData(province: 'Papua Tengah', ump: 3700000, year: 2025), + _UmpData(province: 'Kalimantan Timur', ump: 3538756, year: 2025), + _UmpData(province: 'Sulawesi Utara', ump: 3530000, year: 2025), + _UmpData(province: 'Kalimantan Utara', ump: 3500000, year: 2025), + ]; +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +String _formatRupiah(num value) { + final str = value.toStringAsFixed(0); + final buffer = StringBuffer(); + for (var i = 0; i < str.length; i++) { + if (i > 0 && (str.length - i) % 3 == 0) buffer.write('.'); + buffer.write(str[i]); + } + return 'Rp $buffer'; +} + +// ─── Screen ────────────────────────────────────────────────────────────────── + +class EconomyDashboardScreen extends ConsumerStatefulWidget { + const EconomyDashboardScreen({super.key}); + + @override + ConsumerState createState() => _EconomyDashboardScreenState(); +} + +class _EconomyDashboardScreenState extends ConsumerState { + @override + Widget build(BuildContext context) { + final isTablet = MediaQuery.sizeOf(context).width >= AppSpacing.breakpointLg; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Text('Economy Dashboard', style: AppTypography.headlineMedium), + backgroundColor: AppColors.surface, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded, size: 20), + onPressed: () { + ref.invalidate(_exchangeRateProvider); + ref.invalidate(_umpProvider); + }, + ), + ], + ), + body: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: AppSpacing.screenPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildExchangeSection(isTablet), + const SizedBox(height: AppSpacing.xl), + _buildUmpSection(isTablet), + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ); + } + + // ─── Exchange Rate Hero ────────────────────────────────────────────────── + + Widget _buildExchangeSection(bool isTablet) { + final asyncRate = ref.watch(_exchangeRateProvider); + + return asyncRate.when( + loading: () => NeoStatCard( + label: 'Memuat kurs...', + value: '---', + icon: Icons.currency_exchange_rounded, + color: AppColors.secondary, + ), + error: (_, __) => NeoStatCard( + label: 'Gagal memuat kurs', + value: 'Error', + icon: Icons.error_outline_rounded, + color: AppColors.error, + ), + data: (rate) => NeoCard( + variant: NeoCardVariant.elevated, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.secondary.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + ), + child: const Icon(Icons.currency_exchange_rounded, color: AppColors.secondary, size: 20), + ), + const SizedBox(width: AppSpacing.md2), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(rate.pair, style: AppTypography.labelLarge), + Text('Update: ${rate.updatedAt}', style: AppTypography.codeSmall), + ], + ), + const Spacer(), + NeoBadge( + label: '${rate.change > 0 ? '+' : ''}${rate.change.toStringAsFixed(2)}%', + variant: rate.change >= 0 ? NeoBadgeVariant.error : NeoBadgeVariant.success, + icon: rate.change >= 0 ? Icons.trending_up_rounded : Icons.trending_down_rounded, + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Text( + 'Rp ${rate.rate.toStringAsFixed(0)}', + style: AppTypography.displayLarge.copyWith(color: AppColors.secondary), + ), + const SizedBox(height: AppSpacing.xs), + Text('per 1 USD (realtime via Frankfurter API)', style: AppTypography.bodySmall), + ], + ), + ), + ); + } + + // ─── UMP Section ───────────────────────────────────────────────────────── + + Widget _buildUmpSection(bool isTablet) { + final asyncUmp = ref.watch(_umpProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Top 10 UMP Tertinggi 2025', style: AppTypography.headlineMedium), + const SizedBox(height: AppSpacing.sm), + Text('Upah Minimum Provinsi (Realtime / Keputusan Gubernur)', style: AppTypography.bodySmall), + const SizedBox(height: AppSpacing.md), + asyncUmp.when( + loading: () => Column( + children: List.generate(5, (_) => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Container( + height: 64, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + ), + ), + )), + ), + error: (err, _) => Text('Error: $err', style: AppTypography.bodySmall.copyWith(color: AppColors.error)), + data: (list) => Column( + children: list.asMap().entries.map((entry) => _buildUmpRow(entry.key + 1, entry.value, isTablet)).toList(), + ), + ), + ], + ); + } + + Widget _buildUmpRow(int rank, _UmpData data, bool isTablet) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md2), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppSpacing.radiusSm), + ), + alignment: Alignment.center, + child: Text('#$rank', style: AppTypography.labelLarge.copyWith(color: AppColors.primary)), + ), + const SizedBox(width: AppSpacing.md2), + Expanded( + child: Text(data.province, style: AppTypography.headlineSmall, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + Text(_formatRupiah(data.ump), style: AppTypography.codeMedium.copyWith(color: AppColors.success)), + ], + ), + ); + } +} diff --git a/lib/features/procurement/data/datasources/nemesis_remote_datasource.dart b/lib/features/procurement/data/datasources/nemesis_remote_datasource.dart new file mode 100644 index 0000000..e01610c --- /dev/null +++ b/lib/features/procurement/data/datasources/nemesis_remote_datasource.dart @@ -0,0 +1,326 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import '../../../../core/error/exceptions.dart'; +import '../../../../core/error/exceptions.dart' as app_exceptions; +import '../models/package_model.dart'; +import '../models/bootstrap_model.dart'; +import '../models/paginated_response.dart'; + +abstract class NemesisRemoteDataSource { + Future getBootstrap(); + Future> getRegionPackages({ + required String regionKey, + int page = 1, + int pageSize = 25, + String? search, + String? ownerType, + String? severity, + bool? priorityOnly, + }); + Future> getProvincePackages({ + required String provinceKey, + int page = 1, + int pageSize = 25, + String? severity, + bool? priorityOnly, + }); + Future healthCheck(); +} + +class NemesisRemoteDataSourceImpl implements NemesisRemoteDataSource { + final Dio _dio; + + // FIX #1: Correct base URL + static const _baseUrl = 'https://nemesis.tams.codes/api'; + + NemesisRemoteDataSourceImpl({required Dio dio}) : _dio = dio; + + /// Create a properly configured Dio instance for Nemesis API. + /// Use this factory instead of bare Dio() to get timeouts, retry, and logging. + static Dio createConfiguredDio() { + final dio = Dio(BaseOptions( + baseUrl: _baseUrl, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + headers: { + 'Accept': 'application/json', + // FIX #2c: Request gzip compression + 'Accept-Encoding': 'gzip, deflate', + }, + // FIX #5b: CORS — avoid sending credentials on web to prevent preflight issues + extra: {'withCredentials': false}, + )); + + // FIX #2b: Retry interceptor for transient failures + dio.interceptors.add(_RetryInterceptor(dio: dio, maxRetries: 2)); + + // Debug logging only in debug mode + if (kDebugMode) { + dio.interceptors.add(LogInterceptor( + requestBody: false, + responseBody: false, + logPrint: (s) => debugPrint('[NEMESIS] $s'), + )); + } + + return dio; + } + + @override + Future getBootstrap() async { + try { + final response = await _dio.get('$_baseUrl/bootstrap'); + if (response.statusCode == 200) { + return BootstrapModel.fromJson(response.data as Map); + } + throw const ServerException('Gagal memuat data dashboard'); + } on DioException catch (e) { + throw _handleDioError(e); + } on ServerException { + rethrow; + } on RateLimitException { + rethrow; + } on app_exceptions.TimeoutException { + rethrow; + } on NetworkException { + rethrow; + } catch (e) { + // FIX #3: Never leak raw error details + throw const ServerException('Gagal memuat data dashboard'); + } + } + + @override + Future> getRegionPackages({ + required String regionKey, + int page = 1, + int pageSize = 25, + String? search, + String? ownerType, + String? severity, + bool? priorityOnly, + }) async { + try { + final response = await _dio.get( + '$_baseUrl/regions/$regionKey/packages', + queryParameters: { + 'page': page, + 'pageSize': pageSize, + if (search != null && search.isNotEmpty) 'search': search, + if (ownerType != null) 'ownerType': ownerType, + if (severity != null) 'severity': severity, + if (priorityOnly == true) 'priorityOnly': '1', + }, + ); + if (response.statusCode == 200) { + return _parsePaginatedPackages(response.data as Map); + } + throw const ServerException('Gagal memuat data paket'); + } on DioException catch (e) { + throw _handleDioError(e); + } on ServerException { + rethrow; + } on RateLimitException { + rethrow; + } on app_exceptions.TimeoutException { + rethrow; + } on NetworkException { + rethrow; + } catch (e) { + throw const ServerException('Gagal memuat data paket'); + } + } + + @override + Future> getProvincePackages({ + required String provinceKey, + int page = 1, + int pageSize = 25, + String? severity, + bool? priorityOnly, + }) async { + try { + final response = await _dio.get( + '$_baseUrl/provinces/$provinceKey/packages', + queryParameters: { + 'page': page, + 'pageSize': pageSize, + if (severity != null) 'severity': severity, + if (priorityOnly == true) 'priorityOnly': '1', + }, + ); + if (response.statusCode == 200) { + return _parsePaginatedPackages(response.data as Map); + } + throw const ServerException('Gagal memuat data paket provinsi'); + } on DioException catch (e) { + throw _handleDioError(e); + } on ServerException { + rethrow; + } on RateLimitException { + rethrow; + } on app_exceptions.TimeoutException { + rethrow; + } on NetworkException { + rethrow; + } catch (e) { + throw const ServerException('Gagal memuat data paket provinsi'); + } + } + + @override + Future healthCheck() async { + try { + final response = await _dio.get('$_baseUrl/health'); + return response.statusCode == 200; + } catch (_) { + return false; + } + } + + // ─── Private Helpers ───────────────────────────────────────────────────── + + /// Parse paginated package response — DRY helper + PaginatedResponse _parsePaginatedPackages( + Map data, + ) { + final packages = (data['data'] as List? ?? []) + .map((e) => + ProcurementPackageModel.fromJson(e as Map)) + .toList(); + final pagination = data['pagination'] != null + ? PaginationMeta.fromJson(data['pagination'] as Map) + : null; + return PaginatedResponse(data: packages, pagination: pagination); + } + + /// FIX #4: Differentiate timeout, connection, rate limit, and server errors. + /// FIX #3: Never expose raw Dio error messages to upper layers. + Exception _handleDioError(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return const app_exceptions.TimeoutException( + 'Server tidak merespons, coba lagi nanti', + ); + + case DioExceptionType.connectionError: + return const NetworkException( + 'Tidak dapat terhubung ke server', + ); + + case DioExceptionType.badResponse: + final statusCode = e.response?.statusCode; + if (statusCode == 429) { + return const RateLimitException( + 'Terlalu banyak permintaan, tunggu sebentar', + ); + } + if (statusCode == 404) { + return const ServerException('Data tidak ditemukan', 404); + } + if (statusCode == 503) { + return const ServerException( + 'Server sedang maintenance, coba lagi nanti', + 503, + ); + } + if (statusCode != null && statusCode >= 500) { + return ServerException( + 'Terjadi kesalahan pada server', + statusCode, + ); + } + return ServerException( + 'Permintaan gagal diproses', + statusCode, + ); + + case DioExceptionType.cancel: + return const ServerException('Permintaan dibatalkan'); + + case DioExceptionType.badCertificate: + return const ServerException('Sertifikat keamanan tidak valid'); + + case DioExceptionType.unknown: + // Check if it's actually a network issue wrapped in unknown + if (e.error != null && + e.error.toString().contains('SocketException')) { + return const NetworkException('Tidak dapat terhubung ke server'); + } + return const ServerException('Terjadi kesalahan koneksi'); + } + } +} + +// ─── Retry Interceptor ─────────────────────────────────────────────────────── + +/// Retries failed requests on transient server errors (5xx) and timeouts. +/// Uses exponential backoff: 1s, 2s, 4s... +class _RetryInterceptor extends Interceptor { + final Dio dio; + final int maxRetries; + + _RetryInterceptor({required this.dio, this.maxRetries = 2}); + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + final shouldRetry = _isRetryable(err); + final attempt = (err.requestOptions.extra['_retryCount'] as int?) ?? 0; + + if (shouldRetry && attempt < maxRetries) { + final nextAttempt = attempt + 1; + final delay = Duration(seconds: 1 << attempt); // 1s, 2s, 4s + + if (kDebugMode) { + debugPrint( + '[NEMESIS] Retry $nextAttempt/$maxRetries after ${delay.inSeconds}s ' + 'for ${err.requestOptions.path}', + ); + } + + await Future.delayed(delay); + + // Clone request with updated retry count + final options = err.requestOptions; + options.extra['_retryCount'] = nextAttempt; + + try { + final response = await dio.fetch(options); + handler.resolve(response); + return; + } on DioException catch (retryErr) { + // Let the next retry attempt handle it, or fall through + if (nextAttempt >= maxRetries) { + handler.next(retryErr); + return; + } + handler.next(retryErr); + return; + } + } + + handler.next(err); + } + + bool _isRetryable(DioException err) { + // Retry on timeout + if (err.type == DioExceptionType.connectionTimeout || + err.type == DioExceptionType.receiveTimeout || + err.type == DioExceptionType.sendTimeout) { + return true; + } + // Retry on server errors (5xx) but NOT on 429 (rate limit) + final statusCode = err.response?.statusCode; + if (statusCode != null && statusCode >= 500 && statusCode != 429) { + return true; + } + // Retry on connection errors + if (err.type == DioExceptionType.connectionError) { + return true; + } + return false; + } +} diff --git a/lib/features/procurement/data/models/bootstrap_model.dart b/lib/features/procurement/data/models/bootstrap_model.dart new file mode 100644 index 0000000..7eb334c --- /dev/null +++ b/lib/features/procurement/data/models/bootstrap_model.dart @@ -0,0 +1,123 @@ +import 'region_model.dart'; + +class BootstrapModel { + final BootstrapSummaryModel? summary; + final List regions; + final Map? legend; + final Map? geo; + final Map? provinceView; + + const BootstrapModel({ + this.summary, + this.regions = const [], + this.legend, + this.geo, + this.provinceView, + }); + + factory BootstrapModel.fromJson(Map json) { + return BootstrapModel( + summary: json['summary'] != null + ? BootstrapSummaryModel.fromJson( + json['summary'] as Map) + : null, + regions: (json['regions'] as List?) + ?.map((e) => RegionModel.fromJson(e as Map)) + .toList() ?? + const [], + legend: json['legend'] as Map?, + geo: json['geo'] as Map?, + provinceView: json['provinceView'] as Map?, + ); + } + + Map toJson() { + return { + if (summary != null) 'summary': summary!.toJson(), + 'regions': regions.map((e) => e.toJson()).toList(), + if (legend != null) 'legend': legend, + if (geo != null) 'geo': geo, + if (provinceView != null) 'provinceView': provinceView, + }; + } + + BootstrapModel copyWith({ + BootstrapSummaryModel? summary, + List? regions, + Map? legend, + Map? geo, + Map? provinceView, + }) { + return BootstrapModel( + summary: summary ?? this.summary, + regions: regions ?? this.regions, + legend: legend ?? this.legend, + geo: geo ?? this.geo, + provinceView: provinceView ?? this.provinceView, + ); + } + + /// Computed: total regions loaded + int get regionCount => regions.length; + + /// Computed: has data + bool get hasData => regions.isNotEmpty; + + @override + String toString() => + 'BootstrapModel(regions: ${regions.length}, summary: $summary)'; +} + +class BootstrapSummaryModel { + final int totalPackages; + final int totalPriorityPackages; + final double totalPotentialWaste; + final int totalBudget; + final int unmappedPackages; + final int multiLocationPackages; + + const BootstrapSummaryModel({ + this.totalPackages = 0, + this.totalPriorityPackages = 0, + this.totalPotentialWaste = 0, + this.totalBudget = 0, + this.unmappedPackages = 0, + this.multiLocationPackages = 0, + }); + + factory BootstrapSummaryModel.fromJson(Map json) { + return BootstrapSummaryModel( + totalPackages: json['totalPackages'] as int? ?? 0, + totalPriorityPackages: json['totalPriorityPackages'] as int? ?? 0, + totalPotentialWaste: + (json['totalPotentialWaste'] as num?)?.toDouble() ?? 0, + totalBudget: json['totalBudget'] as int? ?? 0, + unmappedPackages: json['unmappedPackages'] as int? ?? 0, + multiLocationPackages: json['multiLocationPackages'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'totalPackages': totalPackages, + 'totalPriorityPackages': totalPriorityPackages, + 'totalPotentialWaste': totalPotentialWaste, + 'totalBudget': totalBudget, + 'unmappedPackages': unmappedPackages, + 'multiLocationPackages': multiLocationPackages, + }; + } + + /// Computed: waste percentage relative to budget + double get wastePercentage => + totalBudget > 0 ? (totalPotentialWaste / totalBudget) * 100 : 0; + + /// Computed: mapped percentage + double get mappedPercentage => totalPackages > 0 + ? ((totalPackages - unmappedPackages) / totalPackages) * 100 + : 0; + + @override + String toString() => + 'BootstrapSummaryModel(totalPackages: $totalPackages, totalBudget: $totalBudget)'; +} diff --git a/lib/features/procurement/data/models/package_model.dart b/lib/features/procurement/data/models/package_model.dart new file mode 100644 index 0000000..fe10e27 --- /dev/null +++ b/lib/features/procurement/data/models/package_model.dart @@ -0,0 +1,224 @@ +class ProcurementPackageModel { + final int id; + final String sourceId; + final String packageName; + final String ownerName; + final String ownerType; + final String? satker; + final String? locationRaw; + final int? budget; + final String? fundingSource; + final String? procurementType; + final String? procurementMethod; + final String? selectionDate; + final PackageAuditModel? audit; + final PackageMetaModel? meta; + + const ProcurementPackageModel({ + required this.id, + required this.sourceId, + required this.packageName, + required this.ownerName, + required this.ownerType, + this.satker, + this.locationRaw, + this.budget, + this.fundingSource, + this.procurementType, + this.procurementMethod, + this.selectionDate, + this.audit, + this.meta, + }); + + factory ProcurementPackageModel.fromJson(Map json) { + return ProcurementPackageModel( + id: json['id'] as int? ?? 0, + sourceId: json['sourceId'] as String? ?? '', + packageName: json['packageName'] as String? ?? '', + ownerName: json['ownerName'] as String? ?? '', + ownerType: json['ownerType'] as String? ?? '', + satker: json['satker'] as String?, + locationRaw: json['locationRaw'] as String?, + budget: json['budget'] as int?, + fundingSource: json['fundingSource'] as String?, + procurementType: json['procurementType'] as String?, + procurementMethod: json['procurementMethod'] as String?, + selectionDate: json['selectionDate'] as String?, + audit: json['audit'] != null + ? PackageAuditModel.fromJson(json['audit'] as Map) + : null, + meta: json['meta'] != null + ? PackageMetaModel.fromJson(json['meta'] as Map) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'sourceId': sourceId, + 'packageName': packageName, + 'ownerName': ownerName, + 'ownerType': ownerType, + if (satker != null) 'satker': satker, + if (locationRaw != null) 'locationRaw': locationRaw, + if (budget != null) 'budget': budget, + if (fundingSource != null) 'fundingSource': fundingSource, + if (procurementType != null) 'procurementType': procurementType, + if (procurementMethod != null) 'procurementMethod': procurementMethod, + if (selectionDate != null) 'selectionDate': selectionDate, + if (audit != null) 'audit': audit!.toJson(), + if (meta != null) 'meta': meta!.toJson(), + }; + } + + ProcurementPackageModel copyWith({ + int? id, + String? sourceId, + String? packageName, + String? ownerName, + String? ownerType, + String? satker, + String? locationRaw, + int? budget, + String? fundingSource, + String? procurementType, + String? procurementMethod, + String? selectionDate, + PackageAuditModel? audit, + PackageMetaModel? meta, + }) { + return ProcurementPackageModel( + id: id ?? this.id, + sourceId: sourceId ?? this.sourceId, + packageName: packageName ?? this.packageName, + ownerName: ownerName ?? this.ownerName, + ownerType: ownerType ?? this.ownerType, + satker: satker ?? this.satker, + locationRaw: locationRaw ?? this.locationRaw, + budget: budget ?? this.budget, + fundingSource: fundingSource ?? this.fundingSource, + procurementType: procurementType ?? this.procurementType, + procurementMethod: procurementMethod ?? this.procurementMethod, + selectionDate: selectionDate ?? this.selectionDate, + audit: audit ?? this.audit, + meta: meta ?? this.meta, + ); + } + + @override + String toString() => 'ProcurementPackageModel(id: $id, packageName: $packageName)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProcurementPackageModel && + runtimeType == other.runtimeType && + id == other.id && + sourceId == other.sourceId; + + @override + int get hashCode => id.hashCode ^ sourceId.hashCode; +} + +class PackageAuditModel { + final String? schemaVersion; + final String severity; + final double potensiPemborosan; + final String? reason; + final PackageFlagsModel? flags; + + const PackageAuditModel({ + this.schemaVersion, + required this.severity, + this.potensiPemborosan = 0, + this.reason, + this.flags, + }); + + factory PackageAuditModel.fromJson(Map json) { + return PackageAuditModel( + schemaVersion: json['schemaVersion'] as String?, + severity: json['severity'] as String? ?? 'unknown', + potensiPemborosan: (json['potensiPemborosan'] as num?)?.toDouble() ?? 0, + reason: json['reason'] as String?, + flags: json['flags'] != null + ? PackageFlagsModel.fromJson(json['flags'] as Map) + : null, + ); + } + + Map toJson() { + return { + if (schemaVersion != null) 'schemaVersion': schemaVersion, + 'severity': severity, + 'potensiPemborosan': potensiPemborosan, + if (reason != null) 'reason': reason, + if (flags != null) 'flags': flags!.toJson(), + }; + } + + @override + String toString() => 'PackageAuditModel(severity: $severity, potensiPemborosan: $potensiPemborosan)'; +} + +class PackageFlagsModel { + final bool isMencurigakan; + final bool isPemborosan; + + const PackageFlagsModel({ + this.isMencurigakan = false, + this.isPemborosan = false, + }); + + factory PackageFlagsModel.fromJson(Map json) { + return PackageFlagsModel( + isMencurigakan: json['isMencurigakan'] as bool? ?? false, + isPemborosan: json['isPemborosan'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'isMencurigakan': isMencurigakan, + 'isPemborosan': isPemborosan, + }; + } +} + +class PackageMetaModel { + final bool isPriority; + final bool isFlagged; + final double riskScore; + final int activeTagCount; + final int mappedRegionCount; + + const PackageMetaModel({ + this.isPriority = false, + this.isFlagged = false, + this.riskScore = 0, + this.activeTagCount = 0, + this.mappedRegionCount = 0, + }); + + factory PackageMetaModel.fromJson(Map json) { + return PackageMetaModel( + isPriority: json['isPriority'] as bool? ?? false, + isFlagged: json['isFlagged'] as bool? ?? false, + riskScore: (json['riskScore'] as num?)?.toDouble() ?? 0, + activeTagCount: json['activeTagCount'] as int? ?? 0, + mappedRegionCount: json['mappedRegionCount'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'isPriority': isPriority, + 'isFlagged': isFlagged, + 'riskScore': riskScore, + 'activeTagCount': activeTagCount, + 'mappedRegionCount': mappedRegionCount, + }; + } +} diff --git a/lib/features/procurement/data/models/paginated_response.dart b/lib/features/procurement/data/models/paginated_response.dart new file mode 100644 index 0000000..5d46df8 --- /dev/null +++ b/lib/features/procurement/data/models/paginated_response.dart @@ -0,0 +1,97 @@ +class PaginatedResponse { + final List data; + final PaginationMeta? pagination; + + const PaginatedResponse({ + this.data = const [], + this.pagination, + }); + + /// Generic factory — requires a [fromJsonT] converter for item type [T]. + factory PaginatedResponse.fromJson( + Map json, + T Function(Map json) fromJsonT, + ) { + return PaginatedResponse( + data: (json['data'] as List?) + ?.map((e) => fromJsonT(e as Map)) + .toList() ?? + const [], + pagination: json['pagination'] != null + ? PaginationMeta.fromJson( + json['pagination'] as Map) + : null, + ); + } + + Map toJson(Map Function(T value) toJsonT) { + return { + 'data': data.map(toJsonT).toList(), + if (pagination != null) 'pagination': pagination!.toJson(), + }; + } + + /// Whether more pages exist after current page. + bool get hasMore => + pagination != null && pagination!.page < pagination!.totalPages; + + /// Whether the response is empty. + bool get isEmpty => data.isEmpty; + + /// Whether the response has data. + bool get isNotEmpty => data.isNotEmpty; + + /// Total items count from pagination meta. + int get totalItems => pagination?.totalItems ?? data.length; + + @override + String toString() => + 'PaginatedResponse(items: ${data.length}, page: ${pagination?.page}, totalPages: ${pagination?.totalPages})'; +} + +class PaginationMeta { + final int page; + final int pageSize; + final int totalItems; + final int totalPages; + + const PaginationMeta({ + this.page = 1, + this.pageSize = 25, + this.totalItems = 0, + this.totalPages = 0, + }); + + factory PaginationMeta.fromJson(Map json) { + return PaginationMeta( + page: json['page'] as int? ?? 1, + pageSize: json['pageSize'] as int? ?? json['page_size'] as int? ?? 25, + totalItems: + json['totalItems'] as int? ?? json['total_items'] as int? ?? 0, + totalPages: + json['totalPages'] as int? ?? json['total_pages'] as int? ?? 0, + ); + } + + Map toJson() { + return { + 'page': page, + 'pageSize': pageSize, + 'totalItems': totalItems, + 'totalPages': totalPages, + }; + } + + /// Whether this is the first page. + bool get isFirstPage => page <= 1; + + /// Whether this is the last page. + bool get isLastPage => page >= totalPages; + + /// Offset for query (zero-based). + int get offset => (page - 1) * pageSize; + + @override + String toString() => + 'PaginationMeta(page: $page/$totalPages, items: $totalItems)'; +} diff --git a/lib/features/procurement/data/models/region_model.dart b/lib/features/procurement/data/models/region_model.dart new file mode 100644 index 0000000..16c27b9 --- /dev/null +++ b/lib/features/procurement/data/models/region_model.dart @@ -0,0 +1,139 @@ +class RegionModel { + final String regionKey; + final String? code; + final String? provinceName; + final String? regionName; + final String? regionType; + final String? displayName; + final int totalPackages; + final int totalPriorityPackages; + final int totalFlaggedPackages; + final double totalPotentialWaste; + final int totalBudget; + final double avgRiskScore; + final double maxRiskScore; + final Map? ownerMix; + final Map? severityCounts; + final String? dominantOwnerType; + + const RegionModel({ + required this.regionKey, + this.code, + this.provinceName, + this.regionName, + this.regionType, + this.displayName, + this.totalPackages = 0, + this.totalPriorityPackages = 0, + this.totalFlaggedPackages = 0, + this.totalPotentialWaste = 0, + this.totalBudget = 0, + this.avgRiskScore = 0, + this.maxRiskScore = 0, + this.ownerMix, + this.severityCounts, + this.dominantOwnerType, + }); + + factory RegionModel.fromJson(Map json) { + return RegionModel( + regionKey: json['regionKey'] as String? ?? '', + code: json['code'] as String?, + provinceName: json['provinceName'] as String?, + regionName: json['regionName'] as String?, + regionType: json['regionType'] as String?, + displayName: json['displayName'] as String?, + totalPackages: json['totalPackages'] as int? ?? 0, + totalPriorityPackages: json['totalPriorityPackages'] as int? ?? 0, + totalFlaggedPackages: json['totalFlaggedPackages'] as int? ?? 0, + totalPotentialWaste: + (json['totalPotentialWaste'] as num?)?.toDouble() ?? 0, + totalBudget: json['totalBudget'] as int? ?? 0, + avgRiskScore: (json['avgRiskScore'] as num?)?.toDouble() ?? 0, + maxRiskScore: (json['maxRiskScore'] as num?)?.toDouble() ?? 0, + ownerMix: json['ownerMix'] as Map?, + severityCounts: json['severityCounts'] as Map?, + dominantOwnerType: json['dominantOwnerType'] as String?, + ); + } + + Map toJson() { + return { + 'regionKey': regionKey, + if (code != null) 'code': code, + if (provinceName != null) 'provinceName': provinceName, + if (regionName != null) 'regionName': regionName, + if (regionType != null) 'regionType': regionType, + if (displayName != null) 'displayName': displayName, + 'totalPackages': totalPackages, + 'totalPriorityPackages': totalPriorityPackages, + 'totalFlaggedPackages': totalFlaggedPackages, + 'totalPotentialWaste': totalPotentialWaste, + 'totalBudget': totalBudget, + 'avgRiskScore': avgRiskScore, + 'maxRiskScore': maxRiskScore, + if (ownerMix != null) 'ownerMix': ownerMix, + if (severityCounts != null) 'severityCounts': severityCounts, + if (dominantOwnerType != null) 'dominantOwnerType': dominantOwnerType, + }; + } + + RegionModel copyWith({ + String? regionKey, + String? code, + String? provinceName, + String? regionName, + String? regionType, + String? displayName, + int? totalPackages, + int? totalPriorityPackages, + int? totalFlaggedPackages, + double? totalPotentialWaste, + int? totalBudget, + double? avgRiskScore, + double? maxRiskScore, + Map? ownerMix, + Map? severityCounts, + String? dominantOwnerType, + }) { + return RegionModel( + regionKey: regionKey ?? this.regionKey, + code: code ?? this.code, + provinceName: provinceName ?? this.provinceName, + regionName: regionName ?? this.regionName, + regionType: regionType ?? this.regionType, + displayName: displayName ?? this.displayName, + totalPackages: totalPackages ?? this.totalPackages, + totalPriorityPackages: + totalPriorityPackages ?? this.totalPriorityPackages, + totalFlaggedPackages: totalFlaggedPackages ?? this.totalFlaggedPackages, + totalPotentialWaste: totalPotentialWaste ?? this.totalPotentialWaste, + totalBudget: totalBudget ?? this.totalBudget, + avgRiskScore: avgRiskScore ?? this.avgRiskScore, + maxRiskScore: maxRiskScore ?? this.maxRiskScore, + ownerMix: ownerMix ?? this.ownerMix, + severityCounts: severityCounts ?? this.severityCounts, + dominantOwnerType: dominantOwnerType ?? this.dominantOwnerType, + ); + } + + /// Computed: effective display label + String get label => displayName ?? regionName ?? regionKey; + + /// Computed: has high risk + bool get isHighRisk => maxRiskScore >= 0.7 || totalFlaggedPackages > 0; + + @override + String toString() => + 'RegionModel(regionKey: $regionKey, totalPackages: $totalPackages)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RegionModel && + runtimeType == other.runtimeType && + regionKey == other.regionKey; + + @override + int get hashCode => regionKey.hashCode; +} diff --git a/lib/features/procurement/data/repositories/procurement_repository_impl.dart b/lib/features/procurement/data/repositories/procurement_repository_impl.dart new file mode 100644 index 0000000..85c697c --- /dev/null +++ b/lib/features/procurement/data/repositories/procurement_repository_impl.dart @@ -0,0 +1,135 @@ +import 'package:dartz/dartz.dart'; +import '../../../../core/error/exceptions.dart'; +import '../../../../core/error/failures.dart'; +import '../../../../core/network/network_info.dart'; +import '../../domain/repositories/procurement_repository.dart'; +import '../datasources/nemesis_remote_datasource.dart'; +import '../models/bootstrap_model.dart'; +import '../models/package_model.dart'; +import '../models/paginated_response.dart'; + +class ProcurementRepositoryImpl implements ProcurementRepository { + final NemesisRemoteDataSource remoteDataSource; + final NetworkInfo networkInfo; + + // Simple in-memory cache for bootstrap + BootstrapModel? _cachedBootstrap; + DateTime? _bootstrapCachedAt; + static const _bootstrapTtl = Duration(hours: 24); + + ProcurementRepositoryImpl({ + required this.remoteDataSource, + required this.networkInfo, + }); + + @override + Future> getBootstrap() async { + // Check cache first + if (_cachedBootstrap != null && _bootstrapCachedAt != null) { + final age = DateTime.now().difference(_bootstrapCachedAt!); + if (age < _bootstrapTtl) return Right(_cachedBootstrap!); + } + + if (!await networkInfo.isConnected) { + if (_cachedBootstrap != null) return Right(_cachedBootstrap!); + return const Left(NetworkFailure()); + } + + try { + final result = await remoteDataSource.getBootstrap(); + _cachedBootstrap = result; + _bootstrapCachedAt = DateTime.now(); + return Right(result); + } on RateLimitException { + if (_cachedBootstrap != null) return Right(_cachedBootstrap!); + return const Left(RateLimitFailure()); + } on TimeoutException { + // FIX #4: Handle timeout separately from server errors + if (_cachedBootstrap != null) return Right(_cachedBootstrap!); + return const Left(TimeoutFailure()); + } on NetworkException { + if (_cachedBootstrap != null) return Right(_cachedBootstrap!); + return const Left(NetworkFailure()); + } on ServerException catch (e) { + if (_cachedBootstrap != null) return Right(_cachedBootstrap!); + // FIX #3: Use sanitized message from datasource (already cleaned) + return Left(ServerFailure(e.message, e.statusCode)); + } catch (_) { + // FIX #3: Never leak raw exception details — use generic message + if (_cachedBootstrap != null) return Right(_cachedBootstrap!); + return const Left(ServerFailure('Gagal memuat data dashboard')); + } + } + + @override + Future>> + getRegionPackages({ + required String regionKey, + int page = 1, + int pageSize = 25, + String? search, + String? ownerType, + String? severity, + bool? priorityOnly, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure()); + } + try { + final result = await remoteDataSource.getRegionPackages( + regionKey: regionKey, + page: page, + pageSize: pageSize, + search: search, + ownerType: ownerType, + severity: severity, + priorityOnly: priorityOnly, + ); + return Right(result); + } on RateLimitException { + return const Left(RateLimitFailure()); + } on TimeoutException { + return const Left(TimeoutFailure()); + } on NetworkException { + return const Left(NetworkFailure()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (_) { + return const Left(ServerFailure('Gagal memuat data paket')); + } + } + + @override + Future>> + getProvincePackages({ + required String provinceKey, + int page = 1, + int pageSize = 25, + String? severity, + bool? priorityOnly, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure()); + } + try { + final result = await remoteDataSource.getProvincePackages( + provinceKey: provinceKey, + page: page, + pageSize: pageSize, + severity: severity, + priorityOnly: priorityOnly, + ); + return Right(result); + } on RateLimitException { + return const Left(RateLimitFailure()); + } on TimeoutException { + return const Left(TimeoutFailure()); + } on NetworkException { + return const Left(NetworkFailure()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (_) { + return const Left(ServerFailure('Gagal memuat data paket provinsi')); + } + } +} diff --git a/lib/features/procurement/domain/repositories/procurement_repository.dart b/lib/features/procurement/domain/repositories/procurement_repository.dart new file mode 100644 index 0000000..5b8e506 --- /dev/null +++ b/lib/features/procurement/domain/repositories/procurement_repository.dart @@ -0,0 +1,27 @@ +import 'package:dartz/dartz.dart'; +import '../../../../core/error/failures.dart'; +import '../../data/models/bootstrap_model.dart'; +import '../../data/models/package_model.dart'; +import '../../data/models/paginated_response.dart'; + +abstract class ProcurementRepository { + Future> getBootstrap(); + Future>> + getRegionPackages({ + required String regionKey, + int page = 1, + int pageSize = 25, + String? search, + String? ownerType, + String? severity, + bool? priorityOnly, + }); + Future>> + getProvincePackages({ + required String provinceKey, + int page = 1, + int pageSize = 25, + String? severity, + bool? priorityOnly, + }); +} diff --git a/lib/features/procurement/presentation/screens/procurement_dashboard_screen.dart b/lib/features/procurement/presentation/screens/procurement_dashboard_screen.dart new file mode 100644 index 0000000..e02dcdb --- /dev/null +++ b/lib/features/procurement/presentation/screens/procurement_dashboard_screen.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../theme/app_typography.dart'; +import '../../../../theme/app_spacing.dart'; +import '../../../../widgets/data/neo_stat_card.dart'; +import '../../../../widgets/core/neo_badge.dart'; +import '../../../../widgets/feedback/neo_error.dart'; +import '../../../../widgets/feedback/neo_skeleton.dart'; +import '../../../../core/error/failures.dart'; +import '../../../../core/network/network_info.dart'; +import '../../data/datasources/nemesis_remote_datasource.dart'; +import '../../data/repositories/procurement_repository_impl.dart'; +import '../../data/models/bootstrap_model.dart'; +import '../../data/models/region_model.dart'; + +// ─── Providers (Singleton Hierarchy) ───────────────────────────────────────── +// +// FIX #5: Provider hierarchy ensures single instances. +// Repository is created ONCE → in-memory cache actually works. + +/// Singleton NetworkInfo provider +final _networkInfoProvider = Provider((ref) { + return NetworkInfoImpl(Connectivity()); +}); + +/// Singleton Dio instance configured for Nemesis API +/// with timeouts, retry interceptor, gzip, and logging. +final _nemesisDioProvider = Provider((ref) { + // FIX #2: Use factory that configures Dio with 30s timeout, + // retry interceptor, gzip, and debug logging. + return NemesisRemoteDataSourceImpl.createConfiguredDio(); +}); + +/// Singleton data source — reuses the configured Dio +final _nemesisDataSourceProvider = Provider((ref) { + return NemesisRemoteDataSourceImpl(dio: ref.watch(_nemesisDioProvider)); +}); + +/// Singleton repository — cache persists across rebuilds +final _procurementRepoProvider = Provider((ref) { + return ProcurementRepositoryImpl( + remoteDataSource: ref.watch(_nemesisDataSourceProvider), + networkInfo: ref.watch(_networkInfoProvider), + ); +}); + +/// Bootstrap data provider — uses the singleton repo so cache works +final _bootstrapProvider = FutureProvider((ref) async { + final repo = ref.watch(_procurementRepoProvider); + final result = await repo.getBootstrap(); + return result.fold( + // FIX #3: Throw typed ProcurementException with user-friendly message + // instead of raw Exception(failure.message) which leaks internals. + (failure) => throw ProcurementException.fromFailure(failure), + (data) => data, + ); +}); + +// ─── Exception Wrapper ─────────────────────────────────────────────────────── + +/// User-facing exception that maps Failure types to friendly messages. +/// Never exposes internal details like URLs, stack traces, or Dio messages. +class ProcurementException implements Exception { + final String userMessage; + final String? actionHint; + + const ProcurementException(this.userMessage, {this.actionHint}); + + factory ProcurementException.fromFailure(Failure failure) { + if (failure is NetworkFailure) { + return const ProcurementException( + 'Tidak ada koneksi internet', + actionHint: 'Periksa koneksi WiFi atau data seluler Anda', + ); + } + if (failure is TimeoutFailure) { + return const ProcurementException( + 'Server tidak merespons', + actionHint: 'Coba lagi dalam beberapa saat', + ); + } + if (failure is RateLimitFailure) { + return const ProcurementException( + 'Terlalu banyak permintaan', + actionHint: 'Tunggu sebentar lalu coba lagi', + ); + } + if (failure is ServerFailure) { + return ProcurementException( + failure.message, + actionHint: 'Coba lagi atau hubungi admin', + ); + } + return const ProcurementException( + 'Terjadi kesalahan', + actionHint: 'Coba lagi', + ); + } + + @override + String toString() => userMessage; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +String _formatRupiah(num value) { + if (value >= 1e12) return 'Rp ${(value / 1e12).toStringAsFixed(1)} T'; + if (value >= 1e9) return 'Rp ${(value / 1e9).toStringAsFixed(1)} M'; + if (value >= 1e6) return 'Rp ${(value / 1e6).toStringAsFixed(0)} Jt'; + return 'Rp ${value.toStringAsFixed(0)}'; +} + +NeoBadgeVariant _riskVariant(double score) { + if (score >= 0.7) return NeoBadgeVariant.error; + if (score >= 0.4) return NeoBadgeVariant.warning; + return NeoBadgeVariant.success; +} + +// ─── Screen ────────────────────────────────────────────────────────────────── + +class ProcurementDashboardScreen extends ConsumerStatefulWidget { + const ProcurementDashboardScreen({super.key}); + + @override + ConsumerState createState() => + _ProcurementDashboardScreenState(); +} + +class _ProcurementDashboardScreenState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final asyncBootstrap = ref.watch(_bootstrapProvider); + final screenWidth = MediaQuery.sizeOf(context).width; + final isTablet = screenWidth >= AppSpacing.breakpointLg; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Text('Procurement Dashboard', style: AppTypography.headlineMedium), + backgroundColor: AppColors.surface, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded, size: 20), + onPressed: () => ref.invalidate(_bootstrapProvider), + tooltip: 'Refresh data', + ), + ], + ), + body: asyncBootstrap.when( + loading: () => _buildLoadingState(isTablet), + // FIX #3: Show sanitized user-friendly error message + error: (error, _) => NeoError( + message: _extractUserMessage(error), + onRetry: () => ref.invalidate(_bootstrapProvider), + ), + data: (data) => _buildDataState(data, isTablet), + ), + ); + } + + /// Extract user-friendly message from error. + /// Never shows raw exception details, stack traces, or internal URLs. + String _extractUserMessage(Object error) { + if (error is ProcurementException) { + final hint = error.actionHint; + if (hint != null) return '${error.userMessage}\n$hint'; + return error.userMessage; + } + // Fallback — should never reach here if provider is correct + return 'Terjadi kesalahan. Coba lagi.'; + } + + // ─── Loading State ─────────────────────────────────────────────────────── + + Widget _buildLoadingState(bool isTablet) { + return SingleChildScrollView( + padding: AppSpacing.screenPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GridView.count( + crossAxisCount: isTablet ? 2 : 1, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: AppSpacing.md2, + crossAxisSpacing: AppSpacing.md2, + childAspectRatio: isTablet ? 2.2 : 2.8, + children: List.generate(4, (_) => NeoSkeleton.card()), + ), + const SizedBox(height: AppSpacing.xl), + NeoSkeleton.text(width: 180), + const SizedBox(height: AppSpacing.md), + ...List.generate( + 5, + (_) => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: NeoSkeleton.card(), + ), + ), + ], + ), + ); + } + + // ─── Data State ────────────────────────────────────────────────────────── + + Widget _buildDataState(BootstrapModel data, bool isTablet) { + final summary = data.summary; + final topRegions = List.from(data.regions) + ..sort((a, b) => b.avgRiskScore.compareTo(a.avgRiskScore)); + final top5 = topRegions.take(5).toList(); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(_bootstrapProvider), + color: AppColors.primary, + backgroundColor: AppColors.surface, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: AppSpacing.screenPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Summary Stats ────────────────────────────────────────────── + GridView.count( + crossAxisCount: isTablet ? 2 : 1, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: AppSpacing.md2, + crossAxisSpacing: AppSpacing.md2, + childAspectRatio: isTablet ? 2.2 : 2.8, + children: [ + NeoStatCard( + label: 'Total Paket', + value: '${summary?.totalPackages ?? 0}', + icon: Icons.inventory_2_outlined, + color: AppColors.info, + subtitle: 'paket', + ), + NeoStatCard( + label: 'Potensi Pemborosan', + value: _formatRupiah(summary?.totalPotentialWaste ?? 0), + icon: Icons.warning_amber_rounded, + color: AppColors.error, + ), + NeoStatCard( + label: 'Total Anggaran', + value: _formatRupiah(summary?.totalBudget ?? 0), + icon: Icons.account_balance_wallet_outlined, + color: AppColors.secondary, + ), + NeoStatCard( + label: 'Paket Absurd', + value: '${summary?.totalPriorityPackages ?? 0}', + icon: Icons.flag_rounded, + color: AppColors.warning, + subtitle: 'prioritas', + ), + ], + ), + + const SizedBox(height: AppSpacing.xl), + + // ── Top Wilayah Berisiko ─────────────────────────────────────── + Text( + 'Top Wilayah Berisiko', + style: AppTypography.headlineMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Peringkat berdasarkan rata-rata skor risiko', + style: AppTypography.bodySmall, + ), + const SizedBox(height: AppSpacing.md), + + if (top5.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Text( + 'Belum ada data wilayah', + style: AppTypography.bodySmall, + ), + ), + ) + else + ...top5.asMap().entries.map( + (entry) => _buildRegionCard(entry.key + 1, entry.value), + ), + + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ); + } + + // ─── Region Card ───────────────────────────────────────────────────────── + + Widget _buildRegionCard(int rank, RegionModel region) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.md2), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + // Rank indicator + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + ), + alignment: Alignment.center, + child: Text( + '#$rank', + style: AppTypography.labelLarge.copyWith( + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: AppSpacing.md2), + + // Region info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + region.regionName ?? region.regionKey, + style: AppTypography.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + region.provinceName ?? '-', + style: AppTypography.bodySmall, + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Text( + '${region.totalPackages} paket', + style: AppTypography.codeSmall, + ), + const SizedBox(width: AppSpacing.md2), + Text( + _formatRupiah(region.totalPotentialWaste), + style: AppTypography.codeSmall.copyWith( + color: AppColors.error, + ), + ), + ], + ), + ], + ), + ), + + // Risk badge + NeoBadge( + label: '${(region.avgRiskScore * 100).toStringAsFixed(0)}%', + variant: _riskVariant(region.avgRiskScore), + icon: Icons.shield_outlined, + ), + ], + ), + ); + } +} diff --git a/lib/features/statistics/data/datasources/ckan_remote_datasource.dart b/lib/features/statistics/data/datasources/ckan_remote_datasource.dart new file mode 100644 index 0000000..ba3370d --- /dev/null +++ b/lib/features/statistics/data/datasources/ckan_remote_datasource.dart @@ -0,0 +1,185 @@ +import 'package:dio/dio.dart'; +import '../../../../core/error/exceptions.dart'; +import '../models/statistics_models.dart'; + +/// Supported CKAN portal base URLs. +enum CkanPortal { + dataGoId('https://data.go.id/api/3/action'), + jakarta('https://satudata.jakarta.go.id/api/3/action'), + jabar('https://opendata.jabarprov.go.id/api/3/action'), + bnpb('https://data.bnpb.go.id/api/3/action'); + + final String baseUrl; + const CkanPortal(this.baseUrl); +} + +/// Unified CKAN API client supporting multiple Indonesian open data portals. +abstract class CkanRemoteDataSource { + /// Search packages/datasets across a CKAN portal. + Future> searchPackages({ + required String baseUrl, + required String query, + int rows = 10, + int start = 0, + }); + + /// Get a single package/dataset by ID or name. + Future getPackage({ + required String baseUrl, + required String id, + }); + + /// Query the DataStore API for tabular data. + Future> queryDatastore({ + required String baseUrl, + required String resourceId, + int limit = 100, + int offset = 0, + String? filters, + String? sort, + }); +} + +class CkanRemoteDataSourceImpl implements CkanRemoteDataSource { + final Dio _dio; + + CkanRemoteDataSourceImpl({required Dio dio}) : _dio = dio; + + @override + Future> searchPackages({ + required String baseUrl, + required String query, + int rows = 10, + int start = 0, + }) async { + try { + final response = await _dio.get( + '$baseUrl/package_search', + queryParameters: { + 'q': query, + 'rows': rows, + 'start': start, + }, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final result = body['result'] as Map?; + if (result == null) return []; + + final results = result['results'] as List? ?? []; + return results + .map((e) => CkanDatasetModel.fromJson(e as Map)) + .toList(); + } + + throw ServerException( + 'CKAN search failed', + response.statusCode, + ); + } on DioException catch (e) { + throw _handleError(e, 'searchPackages'); + } + } + + @override + Future getPackage({ + required String baseUrl, + required String id, + }) async { + try { + final response = await _dio.get( + '$baseUrl/package_show', + queryParameters: {'id': id}, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final result = body['result'] as Map?; + if (result == null) { + throw const ServerException('Package not found', 404); + } + return CkanDatasetModel.fromJson(result); + } + + throw ServerException( + 'CKAN package_show failed', + response.statusCode, + ); + } on DioException catch (e) { + throw _handleError(e, 'getPackage'); + } + } + + @override + Future> queryDatastore({ + required String baseUrl, + required String resourceId, + int limit = 100, + int offset = 0, + String? filters, + String? sort, + }) async { + try { + final response = await _dio.get( + '$baseUrl/datastore_search', + queryParameters: { + 'resource_id': resourceId, + 'limit': limit, + 'offset': offset, + if (filters != null) 'filters': filters, + if (sort != null) 'sort': sort, + }, + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final result = body['result'] as Map?; + if (result == null) { + return {'records': [], 'fields': [], 'total': 0}; + } + + return { + 'records': result['records'] as List? ?? [], + 'fields': result['fields'] as List? ?? [], + 'total': result['total'] as int? ?? 0, + }; + } + + throw ServerException( + 'CKAN datastore_search failed', + response.statusCode, + ); + } on DioException catch (e) { + throw _handleError(e, 'queryDatastore'); + } + } + + /// Centralized DioException → typed exception mapping. + Exception _handleError(DioException e, String method) { + final statusCode = e.response?.statusCode; + + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout) { + return const TimeoutException('CKAN request timeout'); + } + + if (e.type == DioExceptionType.connectionError) { + return const NetworkException('Tidak dapat terhubung ke CKAN portal'); + } + + if (statusCode == 429) { + return const RateLimitException('CKAN rate limit exceeded'); + } + + if (statusCode == 404) { + return ServerException('CKAN resource not found ($method)', 404); + } + + return ServerException( + e.response?.statusMessage ?? 'CKAN error in $method', + statusCode, + ); + } +} diff --git a/lib/features/statistics/data/models/statistics_models.dart b/lib/features/statistics/data/models/statistics_models.dart new file mode 100644 index 0000000..1dd66d4 --- /dev/null +++ b/lib/features/statistics/data/models/statistics_models.dart @@ -0,0 +1,260 @@ +/// Data models for BPS strategic indicators and CKAN open data platform. + +class StrategicIndicatorModel { + final String title; + final double value; + final String unit; + final String period; + final String domain; + final String source; + + const StrategicIndicatorModel({ + required this.title, + required this.value, + required this.unit, + required this.period, + required this.domain, + required this.source, + }); + + factory StrategicIndicatorModel.fromJson(Map json) { + return StrategicIndicatorModel( + title: json['title'] as String? ?? '', + value: (json['value'] as num?)?.toDouble() ?? 0, + unit: json['unit'] as String? ?? '', + period: json['period'] as String? ?? '', + domain: json['domain'] as String? ?? '', + source: json['source'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'title': title, + 'value': value, + 'unit': unit, + 'period': period, + 'domain': domain, + 'source': source, + }; + } + + StrategicIndicatorModel copyWith({ + String? title, + double? value, + String? unit, + String? period, + String? domain, + String? source, + }) { + return StrategicIndicatorModel( + title: title ?? this.title, + value: value ?? this.value, + unit: unit ?? this.unit, + period: period ?? this.period, + domain: domain ?? this.domain, + source: source ?? this.source, + ); + } + + @override + String toString() => + 'StrategicIndicatorModel(title: $title, value: $value $unit)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StrategicIndicatorModel && + runtimeType == other.runtimeType && + title == other.title && + period == other.period && + domain == other.domain; + + @override + int get hashCode => title.hashCode ^ period.hashCode ^ domain.hashCode; +} + +class CkanDatasetModel { + final String id; + final String name; + final String title; + final String? notes; + final String? organization; + final List resources; + final List tags; + final int numResources; + final String? metadataModified; + + const CkanDatasetModel({ + required this.id, + required this.name, + required this.title, + this.notes, + this.organization, + this.resources = const [], + this.tags = const [], + this.numResources = 0, + this.metadataModified, + }); + + factory CkanDatasetModel.fromJson(Map json) { + return CkanDatasetModel( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + title: json['title'] as String? ?? '', + notes: json['notes'] as String?, + organization: _extractOrganization(json['organization']), + resources: _parseResources(json['resources']), + tags: _parseTags(json['tags']), + numResources: json['num_resources'] as int? ?? 0, + metadataModified: json['metadata_modified'] as String?, + ); + } + + static String? _extractOrganization(dynamic org) { + if (org == null) return null; + if (org is String) return org; + if (org is Map) return org['title'] as String?; + return null; + } + + static List _parseResources(dynamic resources) { + if (resources == null) return []; + if (resources is! List) return []; + return resources + .map((e) => CkanResourceModel.fromJson(e as Map)) + .toList(); + } + + static List _parseTags(dynamic tags) { + if (tags == null) return []; + if (tags is! List) return []; + return tags.map((e) { + if (e is String) return e; + if (e is Map) return e['display_name'] as String? ?? ''; + return ''; + }).where((t) => t.isNotEmpty).toList(); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'title': title, + if (notes != null) 'notes': notes, + if (organization != null) 'organization': organization, + 'resources': resources.map((r) => r.toJson()).toList(), + 'tags': tags, + 'num_resources': numResources, + if (metadataModified != null) 'metadata_modified': metadataModified, + }; + } + + CkanDatasetModel copyWith({ + String? id, + String? name, + String? title, + String? notes, + String? organization, + List? resources, + List? tags, + int? numResources, + String? metadataModified, + }) { + return CkanDatasetModel( + id: id ?? this.id, + name: name ?? this.name, + title: title ?? this.title, + notes: notes ?? this.notes, + organization: organization ?? this.organization, + resources: resources ?? this.resources, + tags: tags ?? this.tags, + numResources: numResources ?? this.numResources, + metadataModified: metadataModified ?? this.metadataModified, + ); + } + + @override + String toString() => 'CkanDatasetModel(id: $id, title: $title)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CkanDatasetModel && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} + +class CkanResourceModel { + final String id; + final String name; + final String format; + final String url; + final int? size; + final bool datastoreActive; + + const CkanResourceModel({ + required this.id, + required this.name, + required this.format, + required this.url, + this.size, + this.datastoreActive = false, + }); + + factory CkanResourceModel.fromJson(Map json) { + return CkanResourceModel( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + format: (json['format'] as String? ?? '').toUpperCase(), + url: json['url'] as String? ?? '', + size: json['size'] as int?, + datastoreActive: json['datastore_active'] as bool? ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'format': format, + 'url': url, + if (size != null) 'size': size, + 'datastore_active': datastoreActive, + }; + } + + CkanResourceModel copyWith({ + String? id, + String? name, + String? format, + String? url, + int? size, + bool? datastoreActive, + }) { + return CkanResourceModel( + id: id ?? this.id, + name: name ?? this.name, + format: format ?? this.format, + url: url ?? this.url, + size: size ?? this.size, + datastoreActive: datastoreActive ?? this.datastoreActive, + ); + } + + @override + String toString() => 'CkanResourceModel(id: $id, name: $name, format: $format)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CkanResourceModel && + runtimeType == other.runtimeType && + id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/lib/features/statistics/presentation/screens/statistics_dashboard_screen.dart b/lib/features/statistics/presentation/screens/statistics_dashboard_screen.dart new file mode 100644 index 0000000..c1c814f --- /dev/null +++ b/lib/features/statistics/presentation/screens/statistics_dashboard_screen.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; + +import '../../../../theme/app_colors.dart'; +import '../../../../theme/app_typography.dart'; +import '../../../../theme/app_spacing.dart'; +import '../../../../widgets/core/neo_card.dart'; +import '../../../../widgets/core/neo_badge.dart'; +import '../../../../widgets/search/neo_search_bar.dart'; +import '../../../../widgets/feedback/neo_empty.dart'; +import '../../../../widgets/feedback/neo_error.dart'; +import '../../../../widgets/feedback/neo_skeleton.dart'; + +// ─── Provider ──────────────────────────────────────────────────────────────── + +final _ckanSearchProvider = + FutureProvider.family>, String>((ref, query) async { + if (query.trim().isEmpty) return []; + final dio = Dio(); + final response = await dio.get( + 'https://data.go.id/api/3/action/package_search', + queryParameters: {'q': query, 'rows': 15}, + ); + final results = response.data['result']['results'] as List; + return results.cast>(); +}); + +// ─── Screen ────────────────────────────────────────────────────────────────── + +class StatisticsDashboardScreen extends ConsumerStatefulWidget { + const StatisticsDashboardScreen({super.key}); + + @override + ConsumerState createState() => + _StatisticsDashboardScreenState(); +} + +class _StatisticsDashboardScreenState + extends ConsumerState { + final _searchController = TextEditingController(); + String _query = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isTablet = MediaQuery.sizeOf(context).width >= AppSpacing.breakpointLg; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: Text('Statistics Dashboard', style: AppTypography.headlineMedium), + backgroundColor: AppColors.surface, + elevation: 0, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, AppSpacing.md, AppSpacing.md, AppSpacing.sm, + ), + child: NeoSearchBar( + controller: _searchController, + hintText: 'Cari dataset di data.go.id...', + onSubmitted: (val) => setState(() => _query = val), + onClear: () => setState(() => _query = ''), + ), + ), + Expanded(child: _buildBody(isTablet)), + ], + ), + ); + } + + Widget _buildBody(bool isTablet) { + if (_query.isEmpty) { + return const NeoEmpty( + icon: Icons.bar_chart_rounded, + title: 'Cari Dataset CKAN', + subtitle: 'Ketik kata kunci lalu tekan enter untuk mencari dataset publik', + ); + } + + final asyncResults = ref.watch(_ckanSearchProvider(_query)); + + return asyncResults.when( + loading: () => _buildLoading(), + error: (err, _) => NeoError( + message: 'Gagal memuat data: $err', + onRetry: () => ref.invalidate(_ckanSearchProvider(_query)), + ), + data: (results) { + if (results.isEmpty) { + return NeoEmpty( + icon: Icons.search_off_rounded, + title: 'Tidak ditemukan', + subtitle: 'Tidak ada dataset untuk "$_query"', + ); + } + return ListView.builder( + padding: AppSpacing.screenPadding, + itemCount: results.length, + itemBuilder: (_, i) => _buildResultCard(results[i]), + ); + }, + ); + } + + Widget _buildLoading() { + return SingleChildScrollView( + padding: AppSpacing.screenPadding, + child: Column( + children: List.generate( + 5, + (_) => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md2), + child: NeoSkeleton.card(), + ), + ), + ), + ); + } + + Widget _buildResultCard(Map dataset) { + final title = dataset['title'] ?? 'Untitled'; + final org = dataset['organization']?['title'] ?? '-'; + final resources = (dataset['resources'] as List?) ?? []; + final formats = resources + .map((r) => (r['format'] ?? '').toString().toUpperCase()) + .where((f) => f.isNotEmpty) + .toSet() + .take(3) + .toList(); + final date = dataset['metadata_modified']?.toString().split('T').first ?? '-'; + + return NeoCard( + margin: const EdgeInsets.only(bottom: AppSpacing.md2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTypography.headlineSmall, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Icon(Icons.business_rounded, size: 14, color: AppColors.textTertiary), + const SizedBox(width: 4), + Expanded( + child: Text(org, style: AppTypography.bodySmall, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + ...formats.map((f) => Padding( + padding: const EdgeInsets.only(right: AppSpacing.sm), + child: NeoBadge(label: f, variant: _formatVariant(f)), + )), + const Spacer(), + Text(date, style: AppTypography.codeSmall), + ], + ), + ], + ), + ); + } + + NeoBadgeVariant _formatVariant(String format) { + switch (format) { + case 'CSV': + return NeoBadgeVariant.success; + case 'JSON': + return NeoBadgeVariant.info; + case 'PDF': + return NeoBadgeVariant.warning; + default: + return NeoBadgeVariant.neutral; + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 1392c31..0f0bb66 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,133 +1,45 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'screens/home_screen.dart'; -import 'screens/prodi_detail_screen.dart'; -import 'screens/prodi_search_screen.dart'; -import 'screens/pt_detail_screen.dart'; -import 'screens/dosen_search_screen_new.dart'; -import 'screens/dosen_detail_screen.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:provider/provider.dart' as legacy_provider; +import 'theme/app_theme.dart'; +import 'theme/app_colors.dart'; +import 'core/router/app_router.dart'; import 'api/api_factory.dart'; -import 'utils/constants.dart'; void main() { - // Enable Flutter Web error logging in console + WidgetsFlutterBinding.ensureInitialized(); + + // Set system UI overlay style + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: AppColors.background, + systemNavigationBarIconBrightness: Brightness.light, + )); + + // Enable Flutter error logging in debug FlutterError.onError = (FlutterErrorDetails details) { FlutterError.presentError(details); - print('Flutter error: ${details.exception}'); + if (kDebugMode) debugPrint('Flutter error: ${details.exception}'); }; - runApp(const MyApp()); + runApp(const ProviderScope(child: DBCrackerApp())); } -class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); +class DBCrackerApp extends StatelessWidget { + const DBCrackerApp({super.key}); @override Widget build(BuildContext context) { - // Poco X3 Pro specific dimensions - const double screenWidth = 1080; - const double screenHeight = 2400; - - return Provider( + return legacy_provider.Provider( create: (_) => ApiFactory(), - child: MaterialApp( - title: 'DB Cracker - Tamaengs', + child: MaterialApp.router( + title: 'DB Cracker', debugShowCheckedModeBanner: false, - theme: ThemeData( - primaryColor: CtOSColors.primary, - scaffoldBackgroundColor: CtOSColors.background, - colorScheme: const ColorScheme.dark( - primary: CtOSColors.primary, - secondary: CtOSColors.secondary, - surface: CtOSColors.surface, - error: CtOSColors.error, - ), - textTheme: const TextTheme( - bodyLarge: TextStyle(color: HackerColors.text), - bodyMedium: TextStyle(color: HackerColors.text), - displayLarge: TextStyle(color: HackerColors.primary), - displayMedium: TextStyle(color: HackerColors.primary), - displaySmall: TextStyle(color: HackerColors.primary), - ), - fontFamily: 'Courier', - // Perbaiki cardTheme dengan menghapus properti cardTheme - cardColor: HackerColors.surface, - // Ini yang menyebabkan error - menghapus cardTheme - // Menggunakan cara yang lebih aman dengan fitur Material 3 - // (Versi Flutter yang lebih baru memiliki struktur ThemeData yang berbeda) - // Dengan menghapus properti cardTheme dan menggunakan Material 3, Card akan mengambil - // properti dari colorScheme yang sudah didefinisikan - appBarTheme: const AppBarTheme( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: HackerColors.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: HackerColors.accent), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: HackerColors.accent), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: - const BorderSide(color: HackerColors.primary, width: 2), - ), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - foregroundColor: HackerColors.text, - backgroundColor: HackerColors.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - visualDensity: VisualDensity.adaptivePlatformDensity, - useMaterial3: true, - ), - home: const HomeScreen(), - // Tambahkan routes untuk navigasi - routes: { - '/prodi/search': (context) => const ProdiSearchScreen(), - '/dosen/search': (context) => const DosenSearchScreenNew(), - }, - // Untuk routes yang membutuhkan parameter - onGenerateRoute: (settings) { - if (settings.name?.startsWith('/prodi/detail/') ?? false) { - final prodiId = settings.name!.split('/').last; - final args = settings.arguments as Map?; - return MaterialPageRoute( - builder: (context) => ProdiDetailScreen( - prodiId: prodiId, - prodiName: args?['prodiName'] ?? 'Program Studi', - ), - ); - } else if (settings.name?.startsWith('/pt/detail/') ?? false) { - final ptId = settings.name!.split('/').last; - final args = settings.arguments as Map?; - return MaterialPageRoute( - builder: (context) => PTDetailScreen( - ptId: ptId, - ptName: args?['ptName'] ?? 'Institusi', - ), - ); - } else if (settings.name?.startsWith('/dosen/detail/') ?? false) { - final dosenId = settings.name!.split('/').last; - final args = settings.arguments as Map?; - return MaterialPageRoute( - builder: (context) => DosenDetailScreen( - dosenId: dosenId, - dosenName: args?['dosenName'] ?? 'Dosen', - ), - ); - } - return null; - }, + theme: AppTheme.darkTheme, + routerConfig: appRouter, ), ); } diff --git a/lib/mixins/console_message_mixin.dart b/lib/mixins/console_message_mixin.dart new file mode 100644 index 0000000..8277ce8 --- /dev/null +++ b/lib/mixins/console_message_mixin.dart @@ -0,0 +1,43 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; + +/// Mixin untuk shared console message logic yang dipakai di semua screen +/// Menggantikan duplikasi _addConsoleMessageWithDelay di 6 screens +mixin ConsoleMessageMixin on State { + final List consoleMessages = []; + final List activeTimers = []; + late final bool statusDotIsGreen; + + /// Inisialisasi mixin — panggil di initState() + void initConsoleMessageMixin() { + statusDotIsGreen = Random().nextBool(); + } + + /// Tambah console message dengan delay + void addConsoleMessage(String message, int delayMs) { + final timer = Timer(Duration(milliseconds: delayMs), () { + if (mounted) { + setState(() { + consoleMessages.add(message); + }); + } + }); + activeTimers.add(timer); + } + + /// Cancel semua active timers — panggil di dispose() + void disposeConsoleTimers() { + for (final timer in activeTimers) { + timer.cancel(); + } + activeTimers.clear(); + } + + /// Generate random hex value untuk visual effect + String getRandomHexValue(int length) { + const chars = '0123456789ABCDEF'; + final random = Random(); + return List.generate(length, (_) => chars[random.nextInt(chars.length)]).join(); + } +} diff --git a/lib/models/dosen.dart b/lib/models/dosen.dart index b53f75b..8e19278 100644 --- a/lib/models/dosen.dart +++ b/lib/models/dosen.dart @@ -1,3 +1,5 @@ +import 'dart:math' show min; +import 'package:flutter/foundation.dart'; class Dosen { final String id; final String nama; @@ -22,12 +24,12 @@ class Dosen { nama: _getStringValue(json, 'nama'), nidn: _getStringValue(json, 'nidn'), namaPt: _getStringValue(json, 'nama_pt'), - singkatanPt: _getStringValue(json, 'singkatan_pt'), + singkatanPt: _getStringValue(json, 'singkatan_pt').isNotEmpty ? _getStringValue(json, 'singkatan_pt') : _getStringValue(json, 'sinkatan_pt'), namaProdi: _getStringValue(json, 'nama_prodi'), ); } catch (e) { - print('Error parsing Dosen: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing Dosen: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return Dosen( id: '', @@ -42,16 +44,22 @@ class Dosen { } // Helper method untuk mengambil nilai string dengan aman - static String _getStringValue(Map json, String key) { + + Map toJson() => { + 'id': id, 'nama': nama, 'nidn': nidn, + 'nama_pt': namaPt, 'singkatan_pt': singkatanPt, 'nama_prodi': namaProdi, + }; + + @override + String toString() => 'Dosen(id: $id, nama: $nama, nidn: $nidn)'; + + static String _getStringValue(Map json, String key) { final value = json[key]; if (value == null) return ''; return value.toString(); } - // Helper function to limit string length - static int min(int a, int b) { - return (a < b) ? a : b; - } + // M2-FIX: Custom min() removed — using dart:math min() } class DosenDetail { @@ -148,20 +156,38 @@ class DosenDetail { factory DosenDetail.fromJson(Map json) { try { - // Data dosen dasar + // Data dosen dasar — mapping SEMUA field dari JSON final dosenDetail = DosenDetail( idSdm: _getStringValue(json, 'id_sdm'), namaDosen: _getStringValue(json, 'nama_dosen'), + nidn: _getStringValue(json, 'nidn'), + nidk: _getStringValue(json, 'nidk'), + gelarDepan: _getStringValue(json, 'gelar_depan'), + gelarBelakang: _getStringValue(json, 'gelar_belakang'), + jenisKelamin: _getStringValue(json, 'jenis_kelamin'), + tempatLahir: _getStringValue(json, 'tempat_lahir'), + tanggalLahir: _getStringValue(json, 'tanggal_lahir'), + agama: _getStringValue(json, 'agama'), namaPt: _getStringValue(json, 'nama_pt'), namaProdi: _getStringValue(json, 'nama_prodi'), - jenisKelamin: _getStringValue(json, 'jenis_kelamin'), + homePt: _getStringValue(json, 'home_pt'), + homeProdi: _getStringValue(json, 'home_prodi'), + rasioHomebase: _getStringValue(json, 'rasio_homebase'), + statusHomebase: _getStringValue(json, 'status_homebase'), jabatanAkademik: _getStringValue(json, 'jabatan_akademik'), + tanggalSk: _getStringValue(json, 'tanggal_sk'), + tmtJabatan: _getStringValue(json, 'tmt_jabatan'), + nomorSk: _getStringValue(json, 'nomor_sk'), pendidikanTertinggi: _getStringValue(json, 'pendidikan_tertinggi'), + bidangIlmu: _getStringValue(json, 'bidang_ilmu'), + institusiPendidikan: _getStringValue(json, 'institusi_pendidikan'), + tahunLulusTertinggi: _getStringValue(json, 'tahun_lulus_tertinggi'), statusIkatanKerja: _getStringValue(json, 'status_ikatan_kerja'), statusAktivitas: _getStringValue(json, 'status_aktivitas'), - homePt: _getStringValue(json, 'home_pt'), - homeProdi: _getStringValue(json, 'home_prodi'), - rasioHomebase: _getStringValue(json, 'rasio_homebase'), + statusSertifikasi: _getStringValue(json, 'status_sertifikasi'), + tahunSertifikasi: _getStringValue(json, 'tahun_sertifikasi'), + nomorSertifikat: _getStringValue(json, 'nomor_sertifikat'), + bidangSertifikasi: _getStringValue(json, 'bidang_sertifikasi'), // Data portofolio akan diisi nanti jika ada penelitian: [], @@ -170,12 +196,14 @@ class DosenDetail { paten: [], riwayatStudi: [], riwayatMengajar: [], + riwayatPenugasan: [], + riwayatJabatan: [], ); return dosenDetail; } catch (e) { - print('Error parsing DosenDetail: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenDetail: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenDetail( idSdm: '', @@ -227,8 +255,8 @@ class DosenDetail { riwayatMengajar: riwayatMengajar ?? const [], ); } catch (e) { - print('Error parsing DosenDetail with portfolio: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenDetail with portfolio: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenDetail( idSdm: '', @@ -244,6 +272,10 @@ class DosenDetail { } } + Map toJson() => {'id_sdm': idSdm, 'nama_dosen': namaDosen, 'nidn': nidn, 'nidk': nidk, 'gelar_depan': gelarDepan, 'gelar_belakang': gelarBelakang, 'jenis_kelamin': jenisKelamin, 'tempat_lahir': tempatLahir, 'tanggal_lahir': tanggalLahir, 'agama': agama, 'nama_pt': namaPt, 'nama_prodi': namaProdi, 'jabatan_akademik': jabatanAkademik, 'pendidikan_tertinggi': pendidikanTertinggi, 'status_ikatan_kerja': statusIkatanKerja, 'status_aktivitas': statusAktivitas}; + @override + String toString() => 'DosenDetail(id: $idSdm, nama: $namaDosen, nidn: $nidn)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -280,8 +312,8 @@ class DosenPortofolio { statusKegiatan: _getStringValue(json, 'status_kegiatan'), ); } catch (e) { - print('Error parsing DosenPortofolio: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenPortofolio: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenPortofolio( idSdm: '', @@ -292,6 +324,10 @@ class DosenPortofolio { } } + Map toJson() => {'id_sdm': idSdm, 'jenis_kegiatan': jenisKegiatan, 'judul_kegiatan': judulKegiatan, 'tahun_kegiatan': tahunKegiatan, 'detail_kegiatan': detailKegiatan, 'status_kegiatan': statusKegiatan}; + @override + String toString() => 'DosenPortofolio(judul: $judulKegiatan, tahun: $tahunKegiatan)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -328,8 +364,8 @@ class DosenRiwayatStudi { tahunLulus: _getStringValue(json, 'tahun_lulus'), ); } catch (e) { - print('Error parsing DosenRiwayatStudi: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenRiwayatStudi: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenRiwayatStudi( idSdm: '', @@ -342,6 +378,10 @@ class DosenRiwayatStudi { } } + Map toJson() => {'id_sdm': idSdm, 'jenjang': jenjang, 'gelar': gelar, 'bidang_studi': bidangStudi, 'perguruan': perguruan, 'tahun_lulus': tahunLulus}; + @override + String toString() => 'DosenRiwayatStudi(jenjang: $jenjang, perguruan: $perguruan)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -378,8 +418,8 @@ class DosenRiwayatMengajar { namaPt: _getStringValue(json, 'nama_pt'), ); } catch (e) { - print('Error parsing DosenRiwayatMengajar: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenRiwayatMengajar: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenRiwayatMengajar( idSdm: '', @@ -392,6 +432,10 @@ class DosenRiwayatMengajar { } } + Map toJson() => {'id_sdm': idSdm, 'nama_semester': namaSemester, 'kode_matkul': kodeMatkul, 'nama_matkul': namaMatkul, 'nama_kelas': namaKelas, 'nama_pt': namaPt}; + @override + String toString() => 'DosenRiwayatMengajar(matkul: $namaMatkul, semester: $namaSemester)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -431,8 +475,8 @@ class DosenPenugasan { keterangan: _getStringValue(json, 'keterangan'), ); } catch (e) { - print('Error parsing DosenPenugasan: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenPenugasan: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenPenugasan( idSdm: '', @@ -444,6 +488,10 @@ class DosenPenugasan { } } + Map toJson() => {'id_sdm': idSdm, 'nama_pt': namaPt, 'nama_prodi': namaProdi, 'status_penugasan': statusPenugasan, 'tahun_mulai': tahunMulai, 'tahun_selesai': tahunSelesai, 'keterangan': keterangan}; + @override + String toString() => 'DosenPenugasan(pt: $namaPt, status: $statusPenugasan)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -483,8 +531,8 @@ class DosenJabatanFungsional { keterangan: _getStringValue(json, 'keterangan'), ); } catch (e) { - print('Error parsing DosenJabatanFungsional: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing DosenJabatanFungsional: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return DosenJabatanFungsional( idSdm: '', @@ -496,6 +544,10 @@ class DosenJabatanFungsional { } } + Map toJson() => {'id_sdm': idSdm, 'jabatan': jabatan, 'tanggal_sk': tanggalSk, 'nomor_sk': nomorSk, 'tmt_jabatan': tmtJabatan, 'status_jabatan': statusJabatan, 'keterangan': keterangan}; + @override + String toString() => 'DosenJabatanFungsional(jabatan: $jabatan, tmt: $tmtJabatan)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; diff --git a/lib/models/mahasiswa.dart b/lib/models/mahasiswa.dart index f460e9a..dbf217a 100644 --- a/lib/models/mahasiswa.dart +++ b/lib/models/mahasiswa.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; class Mahasiswa { final String id; final String nama; @@ -22,16 +23,27 @@ class Mahasiswa { nama: _ensureString(json['nama']), nim: _ensureString(json['nim']), namaPt: _ensureString(json['nama_pt']), - singkatanPt: _ensureString(json['singkatan_pt']), + singkatanPt: _ensureString(json['singkatan_pt'] ?? json['sinkatan_pt']), namaProdi: _ensureString(json['nama_prodi']), ); } catch (e) { - print('Error parsing Mahasiswa: $e'); - print('JSON data: $json'); - throw Exception('Failed to parse Mahasiswa data: $e'); + if (kDebugMode) debugPrint('Error parsing Mahasiswa: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); + return Mahasiswa( + id: '', + nama: 'Error: $e', + nim: '', + namaPt: '', + singkatanPt: '', + namaProdi: '', + ); } } + Map toJson() => {'id': id, 'nama': nama, 'nim': nim, 'nama_pt': namaPt, 'singkatan_pt': singkatanPt, 'nama_prodi': namaProdi}; + @override + String toString() => 'Mahasiswa(id: $id, nama: $nama, nim: $nim)'; + // Helper method to ensure all values are strings static String _ensureString(dynamic value) { if (value == null) return ''; @@ -122,7 +134,7 @@ class MahasiswaDetail { factory MahasiswaDetail.fromJson(Map json) { try { // Print keys for debugging - print('Keys in MahasiswaDetail.fromJson: ${json.keys.toList()}'); + if (kDebugMode) debugPrint('Keys in MahasiswaDetail.fromJson: ${json.keys.toList()}'); // More flexible field handling // Use alternative field names if primary ones don't exist @@ -144,7 +156,7 @@ class MahasiswaDetail { json['nomor_mahasiswa'] ?? ''), jenisDaftar: _ensureString( - json['jenis_daftar'] ?? json['jalur_daftar'] ?? 'Reguler'), + json['jenis_daftar'] ?? json['jalur_daftar'] ?? ''), idPt: _ensureString(json['id_pt'] ?? json['pt_id'] ?? ''), idSms: _ensureString(json['id_sms'] ?? json['sms_id'] ?? ''), jenisKelamin: @@ -154,13 +166,30 @@ class MahasiswaDetail { statusSaatIni: _ensureString(json['status_saat_ini'] ?? json['status'] ?? json['status_mahasiswa'] ?? - 'Aktif'), + ''), tahunMasuk: _ensureString(json['tahun_masuk'] ?? json['angkatan'] ?? ''), + // Fields yang sebelumnya ga diisi — sekarang di-mapping + semesterSaatIni: _ensureString(json['semester_saat_ini'] ?? json['semester'] ?? ''), + tempatLahir: _ensureString(json['tempat_lahir'] ?? ''), + tanggalLahir: _ensureString(json['tanggal_lahir'] ?? ''), + agama: _ensureString(json['agama'] ?? ''), + alamat: _ensureString(json['alamat'] ?? ''), + akreditasiProdi: _ensureString(json['akreditasi_prodi'] ?? json['akreditasi'] ?? ''), + jalurMasuk: _ensureString(json['jalur_masuk'] ?? ''), + tahunLulus: _ensureString(json['tahun_lulus'] ?? ''), + semesterAktifTerakhir: _ensureString(json['semester_aktif_terakhir'] ?? ''), + statusAkhir: _ensureString(json['status_akhir'] ?? ''), + tanggalLulus: _ensureString(json['tanggal_lulus'] ?? ''), + nomorIjazah: _ensureString(json['nomor_ijazah'] ?? ''), + ipk: _ensureString(json['ipk'] ?? ''), + totalSks: _ensureString(json['total_sks'] ?? json['sks_total'] ?? ''), + predikatKelulusan: _ensureString(json['predikat_kelulusan'] ?? ''), + judulSkripsi: _ensureString(json['judul_skripsi'] ?? json['judul_tugas_akhir'] ?? ''), ); } catch (e) { - print('Error parsing MahasiswaDetail: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing MahasiswaDetail: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Create a minimal valid object instead of throwing an exception return MahasiswaDetail( @@ -182,6 +211,10 @@ class MahasiswaDetail { } } + Map toJson() => {'id': id, 'nama': nama, 'nim': nim, 'jenis_kelamin': jenisKelamin, 'status_saat_ini': statusSaatIni, 'nama_pt': namaPt, 'kode_pt': kodePt, 'prodi': prodi, 'kode_prodi': kodeProdi, 'tahun_masuk': tahunMasuk, 'ipk': ipk, 'total_sks': totalSks, 'judul_skripsi': judulSkripsi}; + @override + String toString() => 'MahasiswaDetail(id: $id, nama: $nama, nim: $nim, prodi: $prodi)'; + // Helper method to ensure all values are strings static String _ensureString(dynamic value) { if (value == null) return ''; @@ -223,7 +256,7 @@ class MahasiswaRiwayatSemester { sksLulus: _ensureString(json['sks_lulus']), ); } catch (e) { - print('Error parsing MahasiswaRiwayatSemester: $e'); + if (kDebugMode) debugPrint('Error parsing MahasiswaRiwayatSemester: $e'); return MahasiswaRiwayatSemester( idSms: '', namaSemester: 'Error: $e', @@ -232,6 +265,10 @@ class MahasiswaRiwayatSemester { } } + Map toJson() => {'id_sms': idSms, 'nama_semester': namaSemester, 'status_semester': statusSemester, 'ips': ips, 'ipk': ipk, 'sks_total': sksTotal, 'sks_diambil': sksDiambil, 'sks_lulus': sksLulus}; + @override + String toString() => 'MahasiswaRiwayatSemester(semester: $namaSemester, ips: $ips)'; + static String _ensureString(dynamic value) { if (value == null) return ''; return value.toString(); @@ -269,7 +306,7 @@ class MahasiswaNilai { namaSemester: _ensureString(json['nama_semester']), ); } catch (e) { - print('Error parsing MahasiswaNilai: $e'); + if (kDebugMode) debugPrint('Error parsing MahasiswaNilai: $e'); return MahasiswaNilai( idSms: '', kodeMatkul: '', @@ -282,6 +319,10 @@ class MahasiswaNilai { } } + Map toJson() => {'id_sms': idSms, 'kode_matkul': kodeMatkul, 'nama_matkul': namaMatkul, 'sks': sks, 'nilai_huruf': nilaiHuruf, 'nilai_angka': nilaiAngka, 'nama_semester': namaSemester}; + @override + String toString() => 'MahasiswaNilai(matkul: $namaMatkul, nilai: $nilaiHuruf)'; + static String _ensureString(dynamic value) { if (value == null) return ''; return value.toString(); @@ -316,7 +357,7 @@ class MahasiswaKelas { namaSemester: _ensureString(json['nama_semester']), ); } catch (e) { - print('Error parsing MahasiswaKelas: $e'); + if (kDebugMode) debugPrint('Error parsing MahasiswaKelas: $e'); return MahasiswaKelas( idSms: '', kodeMatkul: '', @@ -328,6 +369,10 @@ class MahasiswaKelas { } } + Map toJson() => {'id_sms': idSms, 'kode_matkul': kodeMatkul, 'nama_matkul': namaMatkul, 'nama_kelas': namaKelas, 'nama_dosen': namaDosen, 'nama_semester': namaSemester}; + @override + String toString() => 'MahasiswaKelas(matkul: $namaMatkul, dosen: $namaDosen)'; + static String _ensureString(dynamic value) { if (value == null) return ''; return value.toString(); diff --git a/lib/models/prodi.dart b/lib/models/prodi.dart index 9dfd726..c93e625 100644 --- a/lib/models/prodi.dart +++ b/lib/models/prodi.dart @@ -1,3 +1,5 @@ +import 'dart:math' show min; +import 'package:flutter/foundation.dart'; class Prodi { final String id; final String nama; @@ -23,8 +25,8 @@ class Prodi { ptSingkat: _getStringValue(json, 'pt_singkat'), ); } catch (e) { - print('Error parsing Prodi: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing Prodi: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return Prodi( id: '', @@ -36,6 +38,10 @@ class Prodi { } } + Map toJson() => {'id': id, 'nama': nama, 'jenjang': jenjang, 'pt': pt, 'pt_singkat': ptSingkat}; + @override + String toString() => 'Prodi(id: $id, nama: $nama, jenjang: $jenjang)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -43,10 +49,6 @@ class Prodi { return value.toString(); } - // Helper function to limit string length - static int min(int a, int b) { - return (a < b) ? a : b; - } } class ProdiDetail { @@ -155,8 +157,8 @@ class ProdiDetail { rataMasaStudi: descJson != null ? _getStringValue(descJson, 'rata_masa_studi') : '', ); } catch (e) { - print('Error parsing ProdiDetail: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing ProdiDetail: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan data minimal untuk mencegah error return ProdiDetail( idSp: '', @@ -194,6 +196,10 @@ class ProdiDetail { } } + Map toJson() => {'id_sp': idSp, 'id_sms': idSms, 'nama_pt': namaPt, 'kode_pt': kodePt, 'nama_prodi': namaProdi, 'kode_prodi': kodeProdi, 'akreditasi': akreditasi, 'status': status, 'visi': visi, 'misi': misi}; + @override + String toString() => 'ProdiDetail(id: $idSms, nama: $namaProdi, akreditasi: $akreditasi)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; diff --git a/lib/models/pt.dart b/lib/models/pt.dart index b0a89d9..6ba3aff 100644 --- a/lib/models/pt.dart +++ b/lib/models/pt.dart @@ -1,3 +1,5 @@ +import 'dart:math' show min; +import 'package:flutter/foundation.dart'; class PerguruanTinggi { final String id; final String kode; @@ -20,8 +22,8 @@ class PerguruanTinggi { nama: _getStringValue(json, 'nama'), ); } catch (e) { - print('Error parsing PerguruanTinggi: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing PerguruanTinggi: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return PerguruanTinggi( id: '', @@ -32,6 +34,10 @@ class PerguruanTinggi { } } + Map toJson() => {'id': id, 'kode': kode, 'nama_singkat': namaSingkat, 'nama': nama}; + @override + String toString() => 'PerguruanTinggi(id: $id, kode: $kode, nama: $nama)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -39,10 +45,6 @@ class PerguruanTinggi { return value.toString(); } - // Helper function to limit string length - static int min(int a, int b) { - return (a < b) ? a : b; - } } class PerguruanTinggiDetail { @@ -152,8 +154,8 @@ class PerguruanTinggiDetail { jumlahProdi: prodiJson != null ? _getStringValue(prodiJson, 'jumlah_prodi') : '', ); } catch (e) { - print('Error parsing PerguruanTinggiDetail: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing PerguruanTinggiDetail: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan data minimal untuk mencegah error return PerguruanTinggiDetail( kelompok: '', @@ -183,6 +185,10 @@ class PerguruanTinggiDetail { } } + Map toJson() => {'id_sp': idSp, 'kode_pt': kodePt, 'nama_pt': namaPt, 'nm_singkat': nmSingkat, 'status_pt': statusPt, 'akreditasi_pt': akreditasiPt, 'alamat': alamat, 'email': email, 'website': website}; + @override + String toString() => 'PerguruanTinggiDetail(id: $idSp, nama: $namaPt, akreditasi: $akreditasiPt)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; @@ -240,8 +246,8 @@ class ProdiPt { indikatorKelengkapanData: _getStringValue(json, 'indikator_kelengkapan_data'), ); } catch (e) { - print('Error parsing ProdiPt: $e'); - print('JSON data: $json'); + if (kDebugMode) debugPrint('Error parsing ProdiPt: $e'); + if (kDebugMode) debugPrint('JSON data: $json'); // Return objek dengan field kosong daripada melempar error return ProdiPt( idSms: '', @@ -261,10 +267,14 @@ class ProdiPt { } } + Map toJson() => {'id_sms': idSms, 'kode_prodi': kodeProdi, 'nama_prodi': namaProdi, 'akreditasi': akreditasi, 'jenjang_prodi': jenjangProdi, 'status_prodi': statusProdi, 'jumlah_dosen': jumlahDosen, 'jumlah_mahasiswa': jumlahMahasiswa, 'rasio': rasio}; + @override + String toString() => 'ProdiPt(nama: $namaProdi, jenjang: $jenjangProdi, akreditasi: $akreditasi)'; + // Helper method untuk mengambil nilai string dengan aman static String _getStringValue(Map json, String key) { final value = json[key]; if (value == null) return ''; return value.toString(); } -} \ No newline at end of file +} diff --git a/lib/screens/BACKUP_dosen_detail_screen.dart b/lib/screens/BACKUP_dosen_detail_screen.dart deleted file mode 100644 index 28787e4..0000000 --- a/lib/screens/BACKUP_dosen_detail_screen.dart +++ /dev/null @@ -1,617 +0,0 @@ -import 'dart:async'; -import 'dart:math'; -import 'package:flutter/material.dart'; -import '../api/multi_api_factory.dart'; -import '../models/dosen.dart'; -import '../widgets/hacker_loading_indicator.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; - -/// Screen untuk menampilkan detail dosen -class DosenDetailScreen extends StatefulWidget { - final String dosenId; - final String dosenName; - - const DosenDetailScreen({ - Key? key, - required this.dosenId, - required this.dosenName, - }) : super(key: key); - - @override - _DosenDetailScreenState createState() => _DosenDetailScreenState(); -} - -class _DosenDetailScreenState extends State with SingleTickerProviderStateMixin { - late Future _dosenFuture; - bool _isLoading = true; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _loadTimer; - late AnimationController _animationController; - - // Tambahkan instance MultiApiFactory - late MultiApiFactory _multiApiFactory; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory - _multiApiFactory = MultiApiFactory(); - - // Mulai sequence loading - _simulateLoading(); - } - - void _simulateLoading() { - setState(() { - _consoleMessages = []; - _isLoading = true; - }); - - _addConsoleMessageWithDelay("AKSES DATABASE AMAN...", 300); - _addConsoleMessageWithDelay("MENCARI SUBJEK: ${widget.dosenName}", 800); - _addConsoleMessageWithDelay("DEKRIPSI RIWAYAT AKADEMIK...", 1400); - _addConsoleMessageWithDelay("MELEWATI ENKRIPSI...", 2000); - _addConsoleMessageWithDelay("EKSTRAKSI CATATAN INSTANSI...", 2600); - _addConsoleMessageWithDelay("MEMBUAT PROFIL DOSEN...", 3200); - - // Fetch data setelah simulasi - _loadTimer = Timer(const Duration(milliseconds: 4000), () { - _fetchDosenDetail(); - }); - } - - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } - }); - } - - void _fetchDosenDetail() { - // Gunakan MultiApiFactory untuk mencari data dosen - _dosenFuture = _multiApiFactory.getDosenDetailFromAllSources(widget.dosenId); - - _dosenFuture.then((_) { - setState(() { - _isLoading = false; - }); - _addConsoleMessageWithDelay("EKSTRAKSI DATA SELESAI", 300); - _addConsoleMessageWithDelay("AKSES DIBERIKAN", 600); - }).catchError((error) { - setState(() { - _isLoading = false; - }); - _addConsoleMessageWithDelay("ERROR: EKSTRAKSI DATA GAGAL", 300); - _addConsoleMessageWithDelay("AKSES DITOLAK", 600); - }); - } - - @override - void dispose() { - _loadTimer?.cancel(); - _animationController.dispose(); - super.dispose(); - } - - String _getRandomHexValue(int length) { - const chars = '0123456789ABCDEF'; - return List.generate( - length, - (_) => chars[_random.nextInt(chars.length)], - ).join(); - } - - @override - Widget build(BuildContext context) { - // Pastikan ScreenUtils diinisialisasi - if (ScreenUtils.screenWidth == 0) { - ScreenUtils.init(context); - } - - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - - return Scaffold( - backgroundColor: HackerColors.background, - appBar: AppBar( - title: Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.accent, - ), - ); - }, - ), - const Text( - "PROFIL DOSEN", - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16, - ), - ), - ], - ), - backgroundColor: HackerColors.surface, - iconTheme: const IconThemeData( - color: HackerColors.primary, - ), - ), - body: Container( - color: HackerColors.background, - child: Column( - children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Text( - 'SUBJEK: ${widget.dosenName}', - style: const TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - Expanded( - child: _isLoading - ? TerminalWindow( - title: "DEKRIPSI DATA", - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - bool isSuccess = index == _consoleMessages.length - 1 && - _consoleMessages[index].contains("SELESAI"); - bool isError = index == _consoleMessages.length - 1 && - _consoleMessages[index].contains("ERROR"); - - return ConsoleText( - text: _consoleMessages[index], - isSuccess: isSuccess, - isError: isError, - ); - }, - ), - ), - ], - ), - ) - : FutureBuilder( - future: _dosenFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: HackerLoadingIndicator()); - } else if (snapshot.hasError) { - return TerminalWindow( - title: "ERROR", - child: Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48, - ), - const SizedBox(height: 16), - Text( - 'Error: ${snapshot.error}', - style: const TextStyle( - color: HackerColors.error, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - maxLines: 3, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _simulateLoading, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 - ), - side: const BorderSide(color: HackerColors.primary), - ), - child: const Text( - "COBA LAGI", - style: TextStyle( - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), - ); - } else if (!snapshot.hasData) { - return const Center( - child: Text( - 'Data Dosen tidak tersedia', - style: TextStyle( - color: HackerColors.error, - fontFamily: 'Courier', - fontSize: 16, - ), - ), - ); - } - - final dosen = snapshot.data!; - return _buildDosenDetailView(dosen); - }, - ), - ), - Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Text( - 'KUNCI: ${_getRandomHexValue(8)}-${_getRandomHexValue(4)}-${_getRandomHexValue(4)}', - style: const TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - ), - ), - ], - ), - const Text( - 'BY: TAMAENGS', - style: TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildDosenDetailView(DosenDetail dosen) { - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - - return Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Expanded( - child: isMobile - // Layout mobile: data pribadi di atas, data institusi di bawah - ? Column( - children: [ - Expanded( - child: _buildDataTerminal( - title: "DATA PRIBADI", - icon: Icons.person, - content: [ - _buildDataRow("NAMA", dosen.namaDosen), - _buildDataRow("ID SDM", dosen.idSdm), - _buildDataRow("JENIS KELAMIN", dosen.jenisKelamin), - _buildDataRow("PENDIDIKAN", dosen.pendidikanTertinggi), - _buildDataRow("JABATAN", dosen.jabatanAkademik), - _buildDataRow("STATUS", dosen.statusAktivitas), - ], - ), - ), - const SizedBox(height: 8), - Expanded( - child: _buildDataTerminal( - title: "DATA INSTITUSI", - icon: Icons.school, - content: [ - _buildDataRow("INSTITUSI", dosen.namaPt), - _buildDataRow("PROGRAM STUDI", dosen.namaProdi), - _buildDataRow("STATUS KERJA", dosen.statusIkatanKerja), - ], - ), - ), - ], - ) - // Layout tablet/desktop: data pribadi di kiri, data institusi di kanan - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 1, - child: _buildDataTerminal( - title: "DATA PRIBADI", - icon: Icons.person, - content: [ - _buildDataRow("NAMA", dosen.namaDosen), - _buildDataRow("ID SDM", dosen.idSdm), - _buildDataRow("JENIS KELAMIN", dosen.jenisKelamin), - _buildDataRow("PENDIDIKAN", dosen.pendidikanTertinggi), - _buildDataRow("JABATAN", dosen.jabatanAkademik), - _buildDataRow("STATUS", dosen.statusAktivitas), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: _buildDataTerminal( - title: "DATA INSTITUSI", - icon: Icons.school, - content: [ - _buildDataRow("INSTITUSI", dosen.namaPt), - _buildDataRow("PROGRAM STUDI", dosen.namaProdi), - _buildDataRow("STATUS KERJA", dosen.statusIkatanKerja), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 12), - _buildSecurityTerminal(dosen), - ], - ), - ); - } - - Widget _buildDataTerminal({ - required String title, - required IconData icon, - required List content, - }) { - return Container( - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent, width: 1), - ), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - icon, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const Divider( - color: HackerColors.accent, - height: 24, - ), - Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - children: content, - ), - ), - ], - ), - ); - } - - Widget _buildSecurityTerminal(DosenDetail dosen) { - // Adaptasi berdasarkan ukuran layar - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - final double terminalHeight = isMobile ? 100 : 120; - - return Container( - height: terminalHeight, - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent), - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - ), - child: const Text( - "ANALISIS PROFIL", - style: TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 8), - Expanded( - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return Text( - _generateRandomSecurityInfo(dosen, index), - style: TextStyle( - color: _getSecurityColor(index), - fontFamily: 'Courier', - fontSize: 10, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, - ), - ), - ], - ), - ); - } - - String _generateRandomSecurityInfo(DosenDetail dosen, int index) { - final hexCode = _getRandomHexValue(16); - - switch (index) { - case 0: - return "LEVEL AKSES: ${_random.nextInt(3) + 3} | IP: 192.168.${_random.nextInt(255)}.${_random.nextInt(255)} | PORT: ${_random.nextInt(9000) + 1000}"; - case 1: - return "INTEGRITAS DATA: ${_random.nextInt(30) + 70}% | ENKRIPSI: AES-256 | HASH: SHA3-${_random.nextInt(2) == 0 ? "256" : "512"}"; - case 2: - return "SISTEM: PROF-DB-SEC | NODE: ${_getRandomHexValue(4)}-${_getRandomHexValue(4)} | SESI: $hexCode"; - case 3: - return "UPDATE TERAKHIR: ${DateTime.now().toString().substring(0, 16)} | ID RECORD: ${dosen.idSdm.substring(0, min(10, dosen.idSdm.length))}..."; - case 4: - return "STATUS: ${_random.nextBool() ? "AMAN" : "MONITOR"} | CHECKSUM: ${_getRandomHexValue(8)} | AUTH: ${_getRandomHexValue(6)}"; - default: - return ""; - } - } - - Color _getSecurityColor(int index) { - switch (index) { - case 0: - return HackerColors.primary; - case 1: - return HackerColors.accent; - case 2: - return HackerColors.text; - case 3: - return HackerColors.warning; - case 4: - return HackerColors.primary; - default: - return HackerColors.text; - } - } - - Widget _buildDataRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: HackerColors.text.withOpacity(0.7), - fontFamily: 'Courier', - fontSize: 10, - ), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - width: 1, - ), - ), - child: Text( - value.isNotEmpty ? value : "-DISENSOR-", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } - - // Helper function to limit string length - int min(int a, int b) { - return (a < b) ? a : b; - } -} \ No newline at end of file diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index f5c2147..dbb99b9 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -1,13 +1,18 @@ -import 'dart:async'; -import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../api/multi_api_factory.dart'; -import '../api/api_services_integration.dart'; import '../models/mahasiswa.dart'; -import '../widgets/hacker_loading_indicator.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../utils/constants.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../theme/app_gradients.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/data/neo_data_row.dart'; +import '../widgets/navigation/neo_tab_bar.dart'; +import '../widgets/feedback/neo_error.dart'; +import '../widgets/feedback/neo_skeleton.dart'; +import '../widgets/feedback/neo_empty.dart'; class DetailScreen extends StatefulWidget { final String mahasiswaId; @@ -20,635 +25,363 @@ class DetailScreen extends StatefulWidget { }) : super(key: key); @override - _DetailScreenState createState() => _DetailScreenState(); + State createState() => _DetailScreenState(); } -class _DetailScreenState extends State with SingleTickerProviderStateMixin { +class _DetailScreenState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; late Future _mahasiswaFuture; - bool _isDecrypting = true; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _decryptTimer; - late AnimationController _animationController; - - // Tambahkan instance MultiApiFactory late MultiApiFactory _multiApiFactory; - - // Flag untuk menampilkan informasi eksternal - bool _showExternalInfo = false; - Map _externalData = {}; - + @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory + _tabController = TabController(length: 3, vsync: this); _multiApiFactory = MultiApiFactory(); - - // Mulai sequence dekripsi - _simulateDecryption(); - - // Coba dapatkan data tambahan - _fetchExternalData(); - } - - void _simulateDecryption() { - setState(() { - _consoleMessages = []; - _isDecrypting = true; - }); - - _addConsoleMessageWithDelay("AKSES DATABASE AMAN...", 300); - _addConsoleMessageWithDelay("MENCARI SUBJEK: ${widget.subjectName}", 800); - _addConsoleMessageWithDelay("DEKRIPSI DATA PRIBADI...", 1400); - _addConsoleMessageWithDelay("MELEWATI ENKRIPSI...", 2000); - _addConsoleMessageWithDelay("EKSTRAKSI CATATAN INSTITUSI...", 2600); - _addConsoleMessageWithDelay("MEMBERSIHKAN DATA...", 3200); - _addConsoleMessageWithDelay("KORELASI DATA DENGAN DATABASE EKSTERNAL...", 3800); // Pesan baru - - // Fetch data setelah simulasi - _decryptTimer = Timer(const Duration(milliseconds: 4000), () { - _fetchMahasiswaDetail(); - }); - } - - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } - }); - } - - void _fetchMahasiswaDetail() { - // Gunakan MultiApiFactory - _mahasiswaFuture = _multiApiFactory.getMahasiswaDetail(widget.mahasiswaId); - - _mahasiswaFuture.then((_) { - setState(() { - _isDecrypting = false; - }); - _addConsoleMessageWithDelay("EKSTRAKSI DATA SELESAI", 300); - _addConsoleMessageWithDelay("AKSES DIBERIKAN", 600); - }).catchError((error) { - setState(() { - _isDecrypting = false; - }); - _addConsoleMessageWithDelay("ERROR: EKSTRAKSI DATA GAGAL", 300); - _addConsoleMessageWithDelay("AKSES DITOLAK", 600); - }); - } - - // Metode untuk mengambil data tambahan dari API eksternal - Future _fetchExternalData() async { - try { - // Delay untuk simulasi pencarian - await Future.delayed(Duration(seconds: 2)); - - // Coba cari di Wikipedia - final apiServices = ApiServicesIntegration(); - final wikipediaData = await apiServices.searchWikipedia(widget.subjectName); - - if (wikipediaData.isNotEmpty) { - setState(() { - _externalData = wikipediaData; - _showExternalInfo = true; - }); - } - } catch (e) { - print('Error fetching external data: $e'); - } + _mahasiswaFuture = _multiApiFactory + .getMahasiswaDetail(widget.mahasiswaId); } @override void dispose() { - _decryptTimer?.cancel(); - _animationController.dispose(); + _tabController.dispose(); super.dispose(); } - String _getRandomHexValue(int length) { - const chars = '0123456789ABCDEF'; - return List.generate( - length, - (_) => chars[_random.nextInt(chars.length)], - ).join(); - } - @override Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - - return Scaffold( - backgroundColor: HackerColors.background, + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: AppColors.background, + resizeToAvoidBottomInset: false, appBar: AppBar( - title: Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.accent, - ), - ); - }, - ), - const Text( - AppStrings.detailTitle, - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16, - ), - ), - ], + backgroundColor: AppColors.surface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_rounded, color: AppColors.textPrimary), + onPressed: () => Navigator.of(context).pop(), ), - backgroundColor: HackerColors.surface, - iconTheme: const IconThemeData( - color: HackerColors.primary, + title: Text( + widget.subjectName, + style: AppTypography.headlineSmall, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), - actions: [ - // Toggle untuk menampilkan info eksternal - IconButton( - icon: Icon( - _showExternalInfo ? Icons.visibility : Icons.visibility_off, - color: HackerColors.primary, - size: 20, - ), - onPressed: () { - setState(() { - _showExternalInfo = !_showExternalInfo; - }); - }, - ), - ], + centerTitle: false, ), - body: Container( - color: HackerColors.background, - child: Column( - children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Text( - 'RAHASIA - LEVEL AKSES 3 - SUBJEK: ${widget.subjectName}', - style: const TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - Expanded( - child: _isDecrypting - ? TerminalWindow( - title: "DEKRIPSI DATA", - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - bool isSuccess = index == _consoleMessages.length - 1 && - _consoleMessages[index].contains("SELESAI"); - bool isError = index == _consoleMessages.length - 1 && - _consoleMessages[index].contains("ERROR"); - - return ConsoleText( - text: _consoleMessages[index], - isSuccess: isSuccess, - isError: isError, - ); - }, - ), - ), - ], - ), - ) - : FutureBuilder( - future: _mahasiswaFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: HackerLoadingIndicator()); - } else if (snapshot.hasError) { - return TerminalWindow( - title: "ERROR", - child: Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48, - ), - const SizedBox(height: 16), - Text( - '${AppStrings.errorLoadingData} ${snapshot.error}', - style: const TextStyle( - color: HackerColors.error, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - maxLines: 3, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _simulateDecryption, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 - ), - side: const BorderSide(color: HackerColors.primary), - ), - child: const Text( - AppStrings.retry, - style: TextStyle( - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), - ); - } else if (!snapshot.hasData) { - return const Center( - child: Text( - AppStrings.noDataAvailable, - style: TextStyle( - color: HackerColors.error, - fontFamily: 'Courier', - fontSize: 16, - ), - ), - ); - } - - final mahasiswa = snapshot.data!; - return _buildHackerDetailView(mahasiswa); - }, - ), - ), - Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Text( - 'KUNCI: ${_getRandomHexValue(8)}-${_getRandomHexValue(4)}-${_getRandomHexValue(4)}', - style: const TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - ), - maxLines: 1, - ), - ], - ), - const Text( - 'BY: TAMAENGS', - style: TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), + body: FutureBuilder( + future: _mahasiswaFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildLoadingSkeleton(); + } + if (snapshot.hasError) { + return NeoError( + message: snapshot.error.toString(), + onRetry: () { + setState(() { + _mahasiswaFuture = _multiApiFactory + .getMahasiswaDetail(widget.mahasiswaId); + }); + }, + ); + } + if (!snapshot.hasData) { + return const NeoEmpty( + icon: Icons.person_off_rounded, + title: 'Data tidak ditemukan', + subtitle: 'Mahasiswa dengan ID tersebut tidak tersedia.', + ); + } + return _buildContent(snapshot.data!); + }, ), + ), ); } - Widget _buildHackerDetailView(MahasiswaDetail mahasiswa) { - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - + // ─── Loading Skeleton ──────────────────────────────────────────────────────── + + Widget _buildLoadingSkeleton() { return Padding( - padding: const EdgeInsets.all(12), + padding: AppSpacing.screenPadding, child: Column( children: [ + NeoSkeleton.card(), + const SizedBox(height: AppSpacing.md), + const NeoSkeleton(width: double.infinity, height: 42, borderRadius: 8), + const SizedBox(height: AppSpacing.md), Expanded( - child: isMobile - // Layout mobile: data pribadi di atas, data institusi di bawah - ? Column( - children: [ - Expanded( - child: _buildDataTerminal( - title: "DATA PRIBADI", - icon: Icons.person, - content: [ - _buildDataRow("IDENTITAS", mahasiswa.nama), - _buildDataRow("ID SUBJEK", mahasiswa.nim), - _buildDataRow("JENIS KELAMIN", mahasiswa.jenisKelamin), - _buildDataRow("TAHUN MASUK", mahasiswa.tahunMasuk), - _buildDataRow("JENIS DAFTAR", mahasiswa.jenisDaftar), - _buildDataRow("STATUS", mahasiswa.statusSaatIni), - ], - ), - ), - const SizedBox(height: 8), - Expanded( - child: _buildDataTerminal( - title: "DATA INSTITUSI", - icon: Icons.school, - content: [ - _buildDataRow("INSTITUSI", mahasiswa.namaPt), - _buildDataRow("KODE PT", mahasiswa.kodePt), - _buildDataRow("PROGRAM", mahasiswa.prodi), - _buildDataRow("KODE PRODI", mahasiswa.kodeProdi), - _buildDataRow("JENJANG", mahasiswa.jenjang), - ], - ), - ), - ], - ) - // Layout tablet/desktop: data pribadi di kiri, data institusi di kanan - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 1, - child: _buildDataTerminal( - title: "DATA PRIBADI", - icon: Icons.person, - content: [ - _buildDataRow("IDENTITAS", mahasiswa.nama), - _buildDataRow("ID SUBJEK", mahasiswa.nim), - _buildDataRow("JENIS KELAMIN", mahasiswa.jenisKelamin), - _buildDataRow("TAHUN MASUK", mahasiswa.tahunMasuk), - _buildDataRow("JENIS DAFTAR", mahasiswa.jenisDaftar), - _buildDataRow("STATUS", mahasiswa.statusSaatIni), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: _buildDataTerminal( - title: "DATA INSTITUSI", - icon: Icons.school, - content: [ - _buildDataRow("INSTITUSI", mahasiswa.namaPt), - _buildDataRow("KODE PT", mahasiswa.kodePt), - _buildDataRow("PROGRAM", mahasiswa.prodi), - _buildDataRow("KODE PRODI", mahasiswa.kodeProdi), - _buildDataRow("JENJANG", mahasiswa.jenjang), - ], - ), - ), - ], - ), + child: Column( + children: List.generate( + 6, + (_) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: NeoSkeleton.text(width: double.infinity), + ), + ), + ), ), - const SizedBox(height: 12), - _buildSecurityTerminal(mahasiswa), - - // Tambahkan bagian informasi eksternal jika ada - if (_showExternalInfo && _externalData.isNotEmpty) ...[ - const SizedBox(height: 12), - _buildExternalDataTerminal(), - ], ], ), ); } - Widget _buildDataTerminal({ - required String title, - required IconData icon, - required List content, - }) { - return Container( - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent, width: 1), - ), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + // ─── Main Content ──────────────────────────────────────────────────────────── + + Widget _buildContent(MahasiswaDetail detail) { + return Column( + children: [ + _buildProfileCard(detail), + NeoTabBar( + controller: _tabController, + tabs: const ['Biodata', 'Akademik', 'Riwayat'], + ), + Expanded( + child: TabBarView( + controller: _tabController, children: [ - Icon( - icon, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), + _buildBiodataTab(detail), + _buildAkademikTab(detail), + _buildRiwayatTab(detail), ], ), - const Divider( - color: HackerColors.accent, - height: 24, - ), - Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - children: content, - ), - ), - ], - ), + ), + ], ); } - - // Terminal untuk menampilkan data eksternal seperti Wikipedia - Widget _buildExternalDataTerminal() { - // Mengekstrak informasi dari Wikipedia - final String title = _externalData['title'] ?? 'DATA EKSTERNAL'; - final String extract = _externalData['extract'] ?? 'Tidak ada data yang tersedia.'; - final String source = _externalData['source'] ?? 'SUMBER TIDAK DIKETAHUI'; - + + // ─── Profile Card ──────────────────────────────────────────────────────────── + + Widget _buildProfileCard(MahasiswaDetail detail) { + final initials = detail.nama.isNotEmpty + ? detail.nama.trim().split(' ').take(2).map((w) => w[0]).join().toUpperCase() + : '?'; + return Container( - height: 150, // Tetapkan tinggi yang jelas + margin: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent, width: 1), + gradient: AppGradients.primary, + borderRadius: AppSpacing.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.25), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], ), - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - const Icon( - Icons.language, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - "DATA EKSTERNAL: $title", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + // Avatar + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.3)), + ), + alignment: Alignment.center, + child: Text( + initials, + style: AppTypography.headlineLarge.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, ), - ], - ), - const Divider( - color: HackerColors.accent, - height: 24, + ), ), + const SizedBox(width: 14), + // Info Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - extract, - style: const TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 12, + detail.nama, + style: AppTypography.headlineMedium.copyWith( + color: Colors.white, ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 12), + const SizedBox(height: 4), Text( - "SUMBER: $source", - style: const TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 10, - fontStyle: FontStyle.italic, + detail.nim, + style: AppTypography.codeMedium.copyWith( + color: Colors.white.withOpacity(0.8), ), ), + const SizedBox(height: 6), + Text( + detail.namaPt.isNotEmpty ? detail.namaPt : '-', + style: AppTypography.bodySmall.copyWith( + color: Colors.white.withOpacity(0.7), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ), + const SizedBox(width: 8), + // Status badge + _buildStatusBadge(detail.statusSaatIni), ], ), ); } - Widget _buildSecurityTerminal(MahasiswaDetail mahasiswa) { - // Adaptasi berdasarkan ukuran layar - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - final double terminalHeight = isMobile ? 100 : 120; - - return Container( - height: terminalHeight, - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent), + Widget _buildStatusBadge(String status) { + final lower = status.toLowerCase(); + NeoBadgeVariant variant; + if (lower.contains('aktif')) { + variant = NeoBadgeVariant.success; + } else if (lower.contains('lulus')) { + variant = NeoBadgeVariant.info; + } else if (lower.contains('cuti') || lower.contains('non')) { + variant = NeoBadgeVariant.warning; + } else if (lower.contains('drop') || lower.contains('keluar')) { + variant = NeoBadgeVariant.error; + } else { + variant = NeoBadgeVariant.neutral; + } + + return NeoBadge( + label: status.isNotEmpty ? status : 'N/A', + variant: variant, + ); + } + + // ─── Biodata Tab ───────────────────────────────────────────────────────────── + + Widget _buildBiodataTab(MahasiswaDetail detail) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: NeoCard( + variant: NeoCardVariant.flat, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Data Pribadi', style: AppTypography.labelLarge), + const SizedBox(height: 8), + const Divider(color: AppColors.divider, height: 1), + NeoDataRow( + icon: Icons.badge_outlined, + label: 'NIM', + value: detail.nim, + isCode: true, + copyable: true, + ), + NeoDataRow( + icon: Icons.person_outline_rounded, + label: 'Nama', + value: detail.nama, + ), + NeoDataRow( + icon: Icons.wc_rounded, + label: 'Jenis Kelamin', + value: detail.jenisKelamin, + ), + NeoDataRow( + icon: Icons.location_city_rounded, + label: 'Tempat Lahir', + value: detail.tempatLahir, + ), + NeoDataRow( + icon: Icons.cake_outlined, + label: 'Tanggal Lahir', + value: detail.tanggalLahir, + ), + NeoDataRow( + icon: Icons.auto_awesome_outlined, + label: 'Agama', + value: detail.agama, + ), + NeoDataRow( + icon: Icons.home_outlined, + label: 'Alamat', + value: detail.alamat, + ), + ], + ), ), - padding: const EdgeInsets.all(8), + ); + } + + // ─── Akademik Tab ──────────────────────────────────────────────────────────── + + Widget _buildAkademikTab(MahasiswaDetail detail) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - ), - child: const Text( - "ANALISIS KEAMANAN", - style: TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), + NeoCard( + variant: NeoCardVariant.flat, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Informasi Akademik', style: AppTypography.labelLarge), + const SizedBox(height: 8), + const Divider(color: AppColors.divider, height: 1), + NeoDataRow( + icon: Icons.school_outlined, + label: 'Perguruan Tinggi', + value: detail.namaPt, + ), + NeoDataRow( + icon: Icons.menu_book_rounded, + label: 'Program Studi', + value: detail.prodi, + ), + NeoDataRow( + icon: Icons.layers_outlined, + label: 'Jenjang', + value: detail.jenjang, + ), + NeoDataRow( + icon: Icons.calendar_today_outlined, + label: 'Tahun Masuk', + value: detail.tahunMasuk, + ), + NeoDataRow( + icon: Icons.info_outline_rounded, + label: 'Status', + value: detail.statusSaatIni, + ), + ], ), ), - const SizedBox(height: 8), - Expanded( - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return Text( - _generateRandomSecurityInfo(mahasiswa, index), - style: TextStyle( - color: _getSecurityColor(index), - fontFamily: 'Courier', - fontSize: 10, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }, + const SizedBox(height: 12), + NeoCard( + variant: NeoCardVariant.flat, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Prestasi Akademik', style: AppTypography.labelLarge), + const SizedBox(height: 8), + const Divider(color: AppColors.divider, height: 1), + NeoDataRow( + icon: Icons.trending_up_rounded, + label: 'IPK', + value: detail.ipk, + isCode: true, + ), + NeoDataRow( + icon: Icons.assignment_outlined, + label: 'Total SKS', + value: detail.totalSks, + isCode: true, + ), + NeoDataRow( + icon: Icons.description_outlined, + label: 'Judul Skripsi', + value: detail.judulSkripsi, + ), + ], ), ), ], @@ -656,83 +389,139 @@ class _DetailScreenState extends State with SingleTickerProviderSt ); } - String _generateRandomSecurityInfo(MahasiswaDetail mahasiswa, int index) { - final hexCode = _getRandomHexValue(16); - - switch (index) { - case 0: - return "LEVEL AKSES: ${_random.nextInt(3) + 2} | IP: 192.168.${_random.nextInt(255)}.${_random.nextInt(255)} | PORT: ${_random.nextInt(9000) + 1000}"; - case 1: - return "INTEGRITAS DATA: ${_random.nextInt(30) + 70}% | ENKRIPSI: AES-256 | HASH: SHA3-${_random.nextInt(2) == 0 ? "256" : "512"}"; - case 2: - return "SISTEM: MULTI-DB-SEC | NODE: ${_getRandomHexValue(4)}-${_getRandomHexValue(4)} | SESI: $hexCode"; // Updated - case 3: - int length = min(10, mahasiswa.id.length); - String idPrefix = length > 0 ? mahasiswa.id.substring(0, length) : "UNKNOWN"; - return "UPDATE TERAKHIR: ${DateTime.now().toString().substring(0, 16)} | ID RECORD: $idPrefix..."; - case 4: - return "STATUS: ${_random.nextBool() ? "AMAN" : "MONITOR"} | CHECKSUM: ${_getRandomHexValue(8)} | AUTH: ${_getRandomHexValue(6)}"; - default: - return ""; - } - } + // ─── Riwayat Tab ───────────────────────────────────────────────────────────── - Color _getSecurityColor(int index) { - switch (index) { - case 0: - return HackerColors.primary; - case 1: - return HackerColors.accent; - case 2: - return HackerColors.text; - case 3: - return HackerColors.warning; - case 4: - return HackerColors.primary; - default: - return HackerColors.text; - } + Widget _buildRiwayatTab(MahasiswaDetail detail) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Riwayat Pendidikan Sebelumnya + NeoCard( + variant: NeoCardVariant.flat, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Riwayat Pendidikan', style: AppTypography.labelLarge), + const SizedBox(height: 8), + const Divider(color: AppColors.divider, height: 1), + NeoDataRow( + icon: Icons.school_outlined, + label: 'Jenjang Saat Ini', + value: detail.jenjang.isNotEmpty ? detail.jenjang : '-', + ), + NeoDataRow( + icon: Icons.login_rounded, + label: 'Jenis Pendaftaran', + value: detail.jenisDaftar.isNotEmpty ? detail.jenisDaftar : '-', + ), + NeoDataRow( + icon: Icons.route_rounded, + label: 'Jalur Masuk', + value: detail.jalurMasuk.isNotEmpty ? detail.jalurMasuk : '-', + ), + NeoDataRow( + icon: Icons.calendar_month_outlined, + label: 'Tahun Masuk', + value: detail.tahunMasuk.isNotEmpty ? detail.tahunMasuk : '-', + ), + NeoDataRow( + icon: Icons.emoji_events_outlined, + label: 'Tahun Lulus', + value: detail.tahunLulus.isNotEmpty ? detail.tahunLulus : '-', + ), + NeoDataRow( + icon: Icons.verified_outlined, + label: 'Status Akhir', + value: detail.statusAkhir.isNotEmpty ? detail.statusAkhir : detail.statusSaatIni, + ), + ], + ), + ), + const SizedBox(height: 12), + // Riwayat Semester + if (detail.riwayatSemester.isNotEmpty) ...[ + Text('Riwayat Semester', style: AppTypography.labelLarge), + const SizedBox(height: 8), + ...detail.riwayatSemester.map((semester) => _buildSemesterCard(semester)), + ] else + const NeoEmpty( + icon: Icons.history_rounded, + title: 'Belum ada riwayat semester', + subtitle: 'Data riwayat semester belum tersedia dari PDDIKTI.', + ), + ], + ), + ); } - Widget _buildDataRow(String label, String value) { + Widget _buildSemesterCard(MahasiswaRiwayatSemester semester) { return Padding( padding: const EdgeInsets.only(bottom: 10), + child: NeoCard( + variant: NeoCardVariant.flat, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + semester.namaSemester.isNotEmpty + ? semester.namaSemester + : 'Semester', + style: AppTypography.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + NeoBadge( + label: semester.statusSemester.isNotEmpty + ? semester.statusSemester + : '-', + variant: semester.statusSemester.toLowerCase().contains('aktif') + ? NeoBadgeVariant.success + : NeoBadgeVariant.neutral, + ), + ], + ), + const SizedBox(height: 10), + const Divider(color: AppColors.divider, height: 1), + const SizedBox(height: 8), + Row( + children: [ + _buildSemesterStat('IPS', semester.ips), + _buildSemesterStat('IPK', semester.ipk), + _buildSemesterStat('SKS Ambil', semester.sksDiambil), + _buildSemesterStat('SKS Lulus', semester.sksLulus), + ], + ), + ], + ), + ), + ); + } + + Widget _buildSemesterStat(String label, String value) { + return Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - label, - style: TextStyle( - color: HackerColors.text.withOpacity(0.7), - fontFamily: 'Courier', - fontSize: 10, + value.isNotEmpty ? value : '-', + style: AppTypography.codeMedium.copyWith( + color: AppColors.primaryLight, + fontWeight: FontWeight.w600, ), ), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - width: 1, - ), - ), - child: Text( - value.isNotEmpty ? value : "-DISENSOR-", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + const SizedBox(height: 2), + Text( + label, + style: AppTypography.labelSmall, ), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/dosen_detail_screen.dart b/lib/screens/dosen_detail_screen.dart index 9e9a036..db554bb 100644 --- a/lib/screens/dosen_detail_screen.dart +++ b/lib/screens/dosen_detail_screen.dart @@ -1,342 +1,495 @@ -import 'dart:async'; -import 'dart:math'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../api/multi_api_factory.dart'; +import '../api/enrichment/external_links.dart'; import '../models/dosen.dart'; -import '../widgets/hacker_loading_indicator.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../theme/app_gradients.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/data/neo_data_row.dart'; +import '../widgets/navigation/neo_tab_bar.dart'; +import '../widgets/feedback/neo_error.dart'; +import '../widgets/feedback/neo_skeleton.dart'; +import '../widgets/feedback/neo_empty.dart'; -/// Screen untuk menampilkan detail dosen class DosenDetailScreen extends StatefulWidget { final String dosenId; final String dosenName; const DosenDetailScreen({ - Key? key, + super.key, required this.dosenId, required this.dosenName, - }) : super(key: key); + }); @override - _DosenDetailScreenState createState() => _DosenDetailScreenState(); + State createState() => _DosenDetailScreenState(); } -class _DosenDetailScreenState extends State with SingleTickerProviderStateMixin { - late Future _dosenFuture; - bool _isLoading = true; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _loadTimer; - late AnimationController _animationController; - - // Tab yang aktif - int _activeTabIndex = 0; - - // Tambahkan instance MultiApiFactory - late MultiApiFactory _multiApiFactory; - +class _DosenDetailScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final Future _dosenFuture; + @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory - _multiApiFactory = MultiApiFactory(); - - // Mulai sequence loading - _simulateLoading(); - } - - void _simulateLoading() { - setState(() { - _consoleMessages = []; - _isLoading = true; - }); - - _addConsoleMessageWithDelay("AKSES DATABASE AMAN...", 300); - _addConsoleMessageWithDelay("MENCARI DATA DOSEN: ${widget.dosenName}", 800); - _addConsoleMessageWithDelay("DEKRIPSI RIWAYAT AKADEMIK...", 1400); - _addConsoleMessageWithDelay("SCRAPING DATA INSTITUSI...", 2000); - _addConsoleMessageWithDelay("EKSTRAKSI RIWAYAT MENGAJAR...", 2600); - _addConsoleMessageWithDelay("AKSES KARYA ILMIAH...", 3200); - _addConsoleMessageWithDelay("KOMPILASI PROFIL DOSEN...", 3800); - - // Fetch data setelah simulasi - _loadTimer = Timer(const Duration(milliseconds: 4200), () { - _fetchDosenDetail(); - }); - } - - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } - }); - } - - void _fetchDosenDetail() { - // Gunakan MultiApiFactory untuk mencari data dosen - _dosenFuture = _multiApiFactory.getDosenDetailFromAllSources(widget.dosenId); - - _dosenFuture.then((_) { - setState(() { - _isLoading = false; - }); - _addConsoleMessageWithDelay("EKSTRAKSI DATA SELESAI", 300); - _addConsoleMessageWithDelay("AKSES DIBERIKAN", 600); - }).catchError((error) { - setState(() { - _isLoading = false; - }); - _addConsoleMessageWithDelay("ERROR: EKSTRAKSI DATA GAGAL", 300); - _addConsoleMessageWithDelay("AKSES DITOLAK", 600); - }); + _tabController = TabController(length: 4, vsync: this); + _dosenFuture = MultiApiFactory().getDosenDetailFromAllSources(widget.dosenId); } @override void dispose() { - _loadTimer?.cancel(); - _animationController.dispose(); + _tabController.dispose(); super.dispose(); } - String _getRandomHexValue(int length) { - const chars = '0123456789ABCDEF'; - return List.generate( - length, - (_) => chars[_random.nextInt(chars.length)], - ).join(); + Future _launchUrl(Uri url) async { + try { + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Tidak dapat membuka tautan')), + ); + } + } + } catch (e) { + if (kDebugMode) debugPrint('Error launching URL: $e'); + } } @override Widget build(BuildContext context) { - // Pastikan ScreenUtils diinisialisasi - if (ScreenUtils.screenWidth == 0) { - ScreenUtils.init(context); - } - return Scaffold( - backgroundColor: HackerColors.background, + backgroundColor: AppColors.background, appBar: AppBar( - title: Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.accent, - ), - ); - }, + backgroundColor: AppColors.surface, + title: Text( + 'Detail Dosen', + style: AppTypography.headlineMedium, + ), + centerTitle: true, + elevation: 0, + ), + body: FutureBuilder( + future: _dosenFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildSkeleton(); + } + if (snapshot.hasError) { + return NeoError( + message: snapshot.error.toString(), + onRetry: () => setState(() {}), + ); + } + if (!snapshot.hasData) { + return const NeoEmpty(title: 'Data dosen tidak ditemukan'); + } + return _buildContent(snapshot.data!); + }, + ), + ); + } + + Widget _buildSkeleton() { + return Padding( + padding: AppSpacing.screenPadding, + child: Column( + children: [ + NeoSkeleton.card(), + const SizedBox(height: 16), + NeoSkeleton.card(), + const SizedBox(height: 12), + NeoSkeleton.text(width: 200), + const SizedBox(height: 8), + NeoSkeleton.text(), + ], + ), + ); + } + + Widget _buildContent(DosenDetail dosen) { + return Column( + children: [ + _buildProfileHeader(dosen), + NeoTabBar( + controller: _tabController, + tabs: const ['Profil', 'Mengajar', 'Penelitian', 'Riwayat'], + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildProfilTab(dosen), + _buildMengajarTab(dosen), + _buildPenelitianTab(dosen), + _buildRiwayatTab(dosen), + ], + ), + ), + ], + ); + } + + Widget _buildProfileHeader(DosenDetail dosen) { + final fullName = dosen.namaDosen; + final initial = fullName.isNotEmpty ? fullName[0].toUpperCase() : '?'; + final gelar = [dosen.gelarDepan, dosen.gelarBelakang] + .where((g) => g.isNotEmpty) + .join(' '); + + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppGradients.card, + borderRadius: BorderRadius.circular(AppSpacing.radiusXl), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + gradient: AppGradients.primary, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), ), - const Text( - "PROFIL DOSEN", - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16, + child: Center( + child: Text( + initial, + style: AppTypography.displayMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), ), ), + ), + const SizedBox(height: 14), + Text( + fullName, + style: AppTypography.headlineLarge, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (gelar.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + gelar, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + ), ], - ), - backgroundColor: HackerColors.surface, - iconTheme: const IconThemeData( - color: HackerColors.primary, - ), - ), - body: SafeArea( - child: Container( - color: HackerColors.background, - child: Column( + const SizedBox(height: 6), + Text( + dosen.nidn.isNotEmpty ? 'NIDN: ${dosen.nidn}' : 'NIDK: ${dosen.nidk}', + style: AppTypography.codeMedium.copyWith(color: AppColors.secondary), + ), + const SizedBox(height: 10), + Text( + dosen.namaPt, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Text( - 'SUBJEK: ${widget.dosenName}', - style: const TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], + if (dosen.jabatanAkademik.isNotEmpty) + NeoBadge( + label: dosen.jabatanAkademik, + variant: NeoBadgeVariant.info, + icon: Icons.school_rounded, ), + NeoBadge( + label: dosen.statusAktivitas.isNotEmpty + ? dosen.statusAktivitas + : 'Tidak Diketahui', + variant: dosen.statusAktivitas.toLowerCase().contains('aktif') + ? NeoBadgeVariant.success + : NeoBadgeVariant.warning, ), - Expanded( - child: _isLoading - ? TerminalWindow( - title: "DEKRIPSI DATA", - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - bool isSuccess = index == _consoleMessages.length - 1 && - _consoleMessages[index].contains("SELESAI"); - bool isError = index == _consoleMessages.length - 1 && - _consoleMessages[index].contains("ERROR"); - - return ConsoleText( - text: _consoleMessages[index], - isSuccess: isSuccess, - isError: isError, - ); - }, - ), - ), - ], - ), - ) - : FutureBuilder( - future: _dosenFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: HackerLoadingIndicator()); - } else if (snapshot.hasError) { - return _buildErrorView(); - } else if (!snapshot.hasData) { - return const Center( - child: Text( - 'Data Dosen tidak tersedia', - style: TextStyle( - color: HackerColors.error, - fontFamily: 'Courier', - fontSize: 16, - ), - ), - ); - } + ], + ), + ], + ), + ); + } - final dosen = snapshot.data!; - return _buildDosenDetailView(dosen); - }, - ), - ), - _buildFooter(), + Widget _buildProfilTab(DosenDetail dosen) { + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + children: [ + NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Informasi Pribadi', style: AppTypography.headlineSmall), + const SizedBox(height: 8), + NeoDataRow(label: 'NIDN', value: dosen.nidn, isCode: true, copyable: true), + NeoDataRow(label: 'NIDK', value: dosen.nidk, isCode: true, copyable: true), + NeoDataRow(label: 'Nama Lengkap', value: dosen.namaDosen), + NeoDataRow(label: 'Jenis Kelamin', value: dosen.jenisKelamin), + NeoDataRow(label: 'Tempat Lahir', value: dosen.tempatLahir), + NeoDataRow(label: 'Tanggal Lahir', value: dosen.tanggalLahir), + NeoDataRow(label: 'Agama', value: dosen.agama), ], ), ), - ), + const SizedBox(height: 12), + NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Status Kepegawaian', style: AppTypography.headlineSmall), + const SizedBox(height: 8), + NeoDataRow(label: 'Ikatan Kerja', value: dosen.statusIkatanKerja), + NeoDataRow(label: 'Status Aktivitas', value: dosen.statusAktivitas), + NeoDataRow(label: 'Jabatan Akademik', value: dosen.jabatanAkademik), + NeoDataRow(label: 'Pendidikan', value: dosen.pendidikanTertinggi), + NeoDataRow(label: 'Bidang Ilmu', value: dosen.bidangIlmu), + ], + ), + ), + const SizedBox(height: 12), + if (dosen.statusSertifikasi.isNotEmpty) + NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Sertifikasi', style: AppTypography.headlineSmall), + const SizedBox(height: 8), + NeoDataRow(label: 'Status', value: dosen.statusSertifikasi), + NeoDataRow(label: 'Tahun', value: dosen.tahunSertifikasi), + NeoDataRow(label: 'No. Sertifikat', value: dosen.nomorSertifikat, isCode: true), + NeoDataRow(label: 'Bidang', value: dosen.bidangSertifikasi), + ], + ), + ), + const SizedBox(height: 12), + _buildExternalLinks(dosen), + const SizedBox(height: 24), + ], ); } - Widget _buildErrorView() { - return TerminalWindow( - title: "ERROR", - child: Center( - child: Padding( - padding: const EdgeInsets.all(16), + Widget _buildMengajarTab(DosenDetail dosen) { + final items = dosen.riwayatMengajar; + if (items.isEmpty) { + return const NeoEmpty( + icon: Icons.menu_book_rounded, + title: 'Belum Ada Data Mengajar', + subtitle: 'Riwayat mengajar belum tersedia', + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = items[index]; + return NeoCard( child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48, + Text( + item.namaMatkul, + style: AppTypography.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 16), - const Text( - 'Gagal memuat data dosen', - style: TextStyle( - color: HackerColors.error, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, + const SizedBox(height: 8), + Wrap( + spacing: 12, + runSpacing: 6, + children: [ + if (item.namaSemester.isNotEmpty) + _infoChip(Icons.calendar_today_rounded, item.namaSemester), + if (item.kodeMatkul.isNotEmpty) + _infoChip(Icons.code_rounded, item.kodeMatkul), + ], ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _simulateLoading, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - side: const BorderSide(color: HackerColors.primary), + if (item.namaKelas.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Kelas: ${item.namaKelas}', + style: AppTypography.bodySmall, ), - child: const Text("COBA LAGI", style: TextStyle(fontSize: 14)), + ], + if (item.namaPt.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.namaPt, + style: AppTypography.bodySmall, + ), + ], + ], + ), + ); + }, + ); + } + + Widget _buildPenelitianTab(DosenDetail dosen) { + final items = dosen.penelitian; + if (items.isEmpty) { + return const NeoEmpty( + icon: Icons.science_rounded, + title: 'Belum Ada Data Penelitian', + subtitle: 'Data penelitian belum tersedia', + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = items[index]; + return NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.judulKegiatan, + style: AppTypography.headlineSmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 8), + if (item.tahunKegiatan.isNotEmpty) + _infoChip(Icons.calendar_today_rounded, item.tahunKegiatan), + if (item.detailKegiatan.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + item.detailKegiatan, + style: AppTypography.bodySmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], ), - ), - ), + ); + }, ); } - Widget _buildFooter() { - return Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( + Widget _buildRiwayatTab(DosenDetail dosen) { + final items = dosen.riwayatStudi; + if (items.isEmpty) { + return const NeoEmpty( + icon: Icons.history_edu_rounded, + title: 'Belum Ada Riwayat Pendidikan', + subtitle: 'Data riwayat pendidikan belum tersedia', + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = items[index]; + return NeoCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 8, - height: 8, + width: 44, + height: 44, decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() ? HackerColors.primary : HackerColors.accent, + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + item.jenjang, + style: AppTypography.labelLarge.copyWith( + color: AppColors.primary, + ), + ), ), ), - const SizedBox(width: 8), - Text( - 'KUNCI: ${_getRandomHexValue(8)}-${_getRandomHexValue(4)}-${_getRandomHexValue(4)}', - style: const TextStyle(color: HackerColors.text, fontSize: 10, fontFamily: 'Courier'), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.perguruan, + style: AppTypography.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (item.bidangStudi.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(item.bidangStudi, style: AppTypography.bodySmall), + ], + if (item.tahunLulus.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + 'Lulus: ${item.tahunLulus}', + style: AppTypography.codeSmall, + ), + ], + ], + ), ), ], ), - const Text( - 'BY: TAMAENGS', - style: TextStyle(color: HackerColors.text, fontSize: 10, fontFamily: 'Courier', fontWeight: FontWeight.bold), + ); + }, + ); + } + + Widget _buildExternalLinks(DosenDetail dosen) { + final links = getDosenEnrichmentLinks( + dosenName: dosen.namaDosen, + institutionName: dosen.namaPt, + ); + + return NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tautan Eksternal', style: AppTypography.headlineSmall), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: links.map((link) { + return OutlinedButton.icon( + onPressed: () => _launchUrl(link.url), + icon: const Icon(Icons.open_in_new_rounded, size: 16), + label: Text(link.title, style: const TextStyle(fontSize: 12)), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.secondary, + side: const BorderSide(color: AppColors.border), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ); + }).toList(), ), ], ), ); } - Widget _buildDosenDetailView(DosenDetail dosen) { - return Center( - child: Text( - 'Detail Dosen: ${dosen.namaDosen}', - style: const TextStyle(color: HackerColors.text, fontFamily: 'Courier', fontSize: 16), - ), + Widget _infoChip(IconData icon, String text) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: AppColors.textTertiary), + const SizedBox(width: 4), + Text(text, style: AppTypography.codeSmall), + ], ); } } diff --git a/lib/screens/dosen_search_screen_new.dart b/lib/screens/dosen_search_screen_new.dart index 31129a1..8e5a4df 100644 --- a/lib/screens/dosen_search_screen_new.dart +++ b/lib/screens/dosen_search_screen_new.dart @@ -1,65 +1,70 @@ +import 'package:flutter/foundation.dart'; import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; -import '../api/multi_api_factory.dart'; +import 'package:provider/provider.dart'; +import '../api/api_factory.dart'; import '../models/dosen.dart'; -import '../widgets/ctos_container.dart'; -import '../widgets/ctos_layout.dart'; -import '../utils/constants.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../widgets/search/neo_search_bar.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/feedback/neo_skeleton.dart'; +import '../widgets/feedback/neo_empty.dart'; +import '../widgets/feedback/neo_error.dart'; -/// Screen untuk melakukan pencarian dosen dengan tema ctOS yang elegan +/// Screen pencarian dosen — Neo-Violet Academic theme. class DosenSearchScreenNew extends StatefulWidget { const DosenSearchScreenNew({Key? key}) : super(key: key); @override - _DosenSearchScreenNewState createState() => _DosenSearchScreenNewState(); + State createState() => _DosenSearchScreenNewState(); } -class _DosenSearchScreenNewState extends State - with TickerProviderStateMixin { +enum _SearchState { initial, loading, empty, error, results } + +class _DosenSearchScreenNewState extends State { final TextEditingController _searchController = TextEditingController(); - final MultiApiFactory _apiFactory = MultiApiFactory(); - final Random _random = Random(); List _searchResults = []; List _filteredResults = []; List _ptList = []; String? _selectedPt; String? _errorMessage; - bool _isLoading = false; - - late AnimationController _animationController; - late AnimationController _glowController; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 1000), - vsync: this, - ); - _glowController = AnimationController( - duration: const Duration(milliseconds: 2000), - vsync: this, - ); - _animationController.repeat(); - _glowController.repeat(reverse: true); - } + _SearchState _state = _SearchState.initial; @override void dispose() { - _animationController.dispose(); - _glowController.dispose(); _searchController.dispose(); super.dispose(); } - Future _searchDosen() async { - final query = _searchController.text.trim(); - if (query.isEmpty) return; + // ─── Search Logic ────────────────────────────────────────────────────────── + + Future _searchDosen(String query) async { + final trimmed = query.trim(); + + if (trimmed.isEmpty) { + setState(() => _errorMessage = 'Masukkan nama dosen untuk mencari'); + return; + } + if (trimmed.length < 2) { + setState(() => _errorMessage = 'Nama dosen minimal 2 karakter'); + return; + } + + final sanitized = trimmed + .replaceAll('<', '') + .replaceAll('>', '') + .replaceAll('"', '') + .replaceAll("'", ''); + if (sanitized.isEmpty) { + setState(() => _errorMessage = 'Nama dosen tidak valid'); + return; + } setState(() { - _isLoading = true; + _state = _SearchState.loading; _errorMessage = null; _searchResults.clear(); _filteredResults.clear(); @@ -68,507 +73,316 @@ class _DosenSearchScreenNewState extends State }); try { - final results = await _apiFactory.searchAllDosen(query); + final apiFactory = Provider.of(context, listen: false); + final results = await apiFactory + .searchDosen(sanitized) + .timeout(const Duration(seconds: 30)); + + if (!mounted) return; setState(() { _searchResults = results; _filteredResults = results; _ptList = results.map((d) => d.namaPt).toSet().toList()..sort(); - _isLoading = false; + _state = results.isEmpty ? _SearchState.empty : _SearchState.results; }); } catch (e) { + if (!mounted) return; + if (kDebugMode) debugPrint('Search error: $e'); setState(() { - _errorMessage = 'Error: ${e.toString()}'; - _isLoading = false; + _state = _SearchState.error; + _errorMessage = e.toString().replaceAll('Exception: ', ''); }); } } - void _filterResults(String? pt) { + void _filterByPt(String? pt) { setState(() { _selectedPt = pt; - if (pt == null) { - _filteredResults = _searchResults; - } else { - _filteredResults = _searchResults.where((d) => d.namaPt == pt).toList(); - } + _filteredResults = pt == null + ? _searchResults + : _searchResults.where((d) => d.namaPt == pt).toList(); }); } - void _clearFilter() { - _filterResults(null); - } + // ─── Build ───────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: CtOSColors.background, - appBar: _buildAppBar(), + backgroundColor: AppColors.background, body: SafeArea( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildHeader(), - _buildSearchSection(), - if (_searchResults.isNotEmpty && _ptList.isNotEmpty) - _buildFilterSection(), + // Header + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, AppSpacing.md, AppSpacing.md, 0, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_rounded), + color: AppColors.textPrimary, + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: AppSpacing.sm), + Text('Cari Dosen', style: AppTypography.headlineMedium), + ], + ), + ), + + // Search bar + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md2, + ), + child: NeoSearchBar( + controller: _searchController, + autofocus: true, + hintText: 'Masukkan nama dosen...', + isLoading: _state == _SearchState.loading, + onSubmitted: _searchDosen, + ), + ), + + // PT filter chips + if (_ptList.length > 1) _buildPtFilter(), + + // Content area Expanded( - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: _buildMainContent(), + child: AnimatedSwitcher( + duration: AppSpacing.durationNormal, + child: _buildContent(), ), ), - _buildFooter(), ], ), ), ); } - PreferredSizeWidget _buildAppBar() { - return AppBar( - title: Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12.0, - height: 12.0, - margin: const EdgeInsets.only(right: 12.0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? CtOSColors.primary - : CtOSColors.error, - boxShadow: [ - BoxShadow( - color: (_animationController.value > 0.5 - ? CtOSColors.primary - : CtOSColors.error) - .withValues(alpha: 0.6), - blurRadius: 8.0, - spreadRadius: 2.0, - ), - ], - ), - ); - }, - ), - const CtOSText( - "ctOS DATABASE SCANNER", - fontSize: 16.0, - fontWeight: FontWeight.bold, - color: CtOSColors.primary, - ), - ], - ), - backgroundColor: CtOSColors.surface, - elevation: 0, - actions: [ - if (_searchResults.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Center( - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0), - decoration: BoxDecoration( - color: CtOSColors.background, - borderRadius: BorderRadius.circular(4.0), - border: Border.all(color: CtOSColors.primary), - boxShadow: [ - BoxShadow( - color: CtOSColors.primary.withValues(alpha: 0.3), - blurRadius: 4.0, - ), - ], - ), - child: CtOSText( - '${_filteredResults.length}/${_searchResults.length}', - fontSize: 12.0, - color: CtOSColors.primary, - fontWeight: FontWeight.bold, - ), + // ─── PT Filter ───────────────────────────────────────────────────────────── + + Widget _buildPtFilter() { + return SizedBox( + height: 40, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + itemCount: _ptList.length + 1, + separatorBuilder: (_, __) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + if (index == 0) { + final isActive = _selectedPt == null; + return FilterChip( + label: Text('Semua', style: AppTypography.labelMedium.copyWith( + color: isActive ? AppColors.textPrimary : AppColors.textSecondary, + )), + selected: isActive, + onSelected: (_) => _filterByPt(null), + backgroundColor: AppColors.surface, + selectedColor: AppColors.primaryDark, + side: BorderSide( + color: isActive ? AppColors.primary : AppColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + ), + ); + } + final pt = _ptList[index - 1]; + final isActive = _selectedPt == pt; + return FilterChip( + label: Text( + pt.length > 25 ? '${pt.substring(0, 25)}...' : pt, + style: AppTypography.labelMedium.copyWith( + color: isActive ? AppColors.textPrimary : AppColors.textSecondary, ), ), - ), - ], - ); - } - - Widget _buildHeader() { - return CtOSContainer( - margin: const EdgeInsets.all(0), - padding: const EdgeInsets.all(16.0), - backgroundColor: CtOSColors.surfaceVariant, - showBorder: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CtOSStatusIndicator( - isActive: _isLoading, - label: _isLoading ? "SCANNING" : "READY", - ), - const SizedBox(width: 24.0), - const CtOSText( - 'ctOS FACULTY DATABASE ACCESS', - fontSize: 14.0, - color: CtOSColors.textAccent, - fontWeight: FontWeight.bold, - ), - ], + selected: isActive, + onSelected: (_) => _filterByPt(pt), + backgroundColor: AppColors.surface, + selectedColor: AppColors.primaryDark, + side: BorderSide( + color: isActive ? AppColors.primary : AppColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + ), + ); + }, ), ); } - Widget _buildSearchSection() { - return CtOSContainer( - child: CtOSSearchBar( - controller: _searchController, - hintText: "masukkan nama dosen...", - onSearch: _searchDosen, - isLoading: _isLoading, - ), - ); + // ─── Content States ──────────────────────────────────────────────────────── + + Widget _buildContent() { + switch (_state) { + case _SearchState.initial: + return _buildInitial(); + case _SearchState.loading: + return _buildLoading(); + case _SearchState.empty: + return NeoEmpty( + key: const ValueKey('empty'), + icon: Icons.person_search_rounded, + title: 'Dosen tidak ditemukan', + subtitle: 'Coba kata kunci lain atau periksa ejaan', + ); + case _SearchState.error: + return NeoError( + key: const ValueKey('error'), + message: _errorMessage ?? 'Terjadi kesalahan', + onRetry: () => _searchDosen(_searchController.text), + ); + case _SearchState.results: + return _buildResults(); + } } - Widget _buildFilterSection() { - return CtOSContainer( + Widget _buildInitial() { + return Center( + key: const ValueKey('initial'), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - CtOSHeader( - title: "FILTER PERGURUAN TINGGI", - showDivider: false, - ), - const SizedBox(height: 12.0), Container( - padding: const EdgeInsets.symmetric(horizontal: 12.0), + width: 72, + height: 72, decoration: BoxDecoration( - color: CtOSColors.background, - borderRadius: BorderRadius.circular(4.0), - border: Border.all(color: CtOSColors.border), + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(20), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - value: _selectedPt, - hint: const CtOSText( - "PILIH PERGURUAN TINGGI", - fontSize: 12.0, - color: CtOSColors.textSecondary, - ), - dropdownColor: CtOSColors.surface, - style: const TextStyle( - color: CtOSColors.primary, - fontFamily: 'Courier', - fontSize: 12.0, - ), - icon: const Icon( - Icons.keyboard_arrow_down, - color: CtOSColors.textSecondary, - ), - onChanged: _filterResults, - items: [ - const DropdownMenuItem( - value: null, - child: CtOSText( - "SEMUA PERGURUAN TINGGI", - fontSize: 12.0, - color: CtOSColors.textPrimary, - ), - ), - ..._ptList.map>((String pt) { - return DropdownMenuItem( - value: pt, - child: CtOSText( - pt, - fontSize: 12.0, - color: CtOSColors.primary, - maxLines: 1, - ), - ); - }).toList(), - ], - ), + child: const Icon( + Icons.school_rounded, + size: 32, + color: AppColors.primary, ), ), - ], - ), - ); - } - - Widget _buildMainContent() { - if (_isLoading) { - return _buildLoadingState(); - } else if (_errorMessage != null) { - return _buildErrorState(); - } else if (_searchResults.isEmpty) { - return _buildEmptyState(); - } else if (_filteredResults.isEmpty && _selectedPt != null) { - return _buildNoFilterResultsState(); - } else { - return _buildResultsList(); - } - } - - Widget _buildLoadingState() { - return CtOSContainer( - showGlow: true, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedBuilder( - animation: _glowController, - builder: (context, child) { - return Container( - width: 80.0, - height: 80.0, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: CtOSColors.primary.withValues( - alpha: 0.3 + 0.7 * _glowController.value, - ), - width: 2.0, - ), - ), - child: const Center( - child: CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(CtOSColors.primary), - ), - ), - ); - }, - ), - const SizedBox(height: 24.0), - const CtOSText( - "SCANNING DATABASE...", - fontSize: 16.0, - color: CtOSColors.primary, - fontWeight: FontWeight.bold, - ), - const SizedBox(height: 8.0), - const CtOSText( - "Mengakses server PDDIKTI", - fontSize: 12.0, - color: CtOSColors.textSecondary, + const SizedBox(height: AppSpacing.lg2), + Text( + 'Cari dosen di seluruh Indonesia', + style: AppTypography.bodyMedium.copyWith( + color: AppColors.textSecondary, + ), ), ], ), ); } - Widget _buildErrorState() { - return CtOSContainer( - borderColor: CtOSColors.error, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - color: CtOSColors.error, - size: 64.0, - ), - const SizedBox(height: 16.0), - CtOSText( - _errorMessage!, - fontSize: 14.0, - color: CtOSColors.error, - textAlign: TextAlign.center, - maxLines: 3, - ), - const SizedBox(height: 24.0), - CtOSButton( - text: "COBA LAGI", - onPressed: _searchDosen, - icon: Icons.refresh, - isPrimary: false, - ), - ], - ), + Widget _buildLoading() { + return ListView.separated( + key: const ValueKey('loading'), + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: 6, + separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.md2), + itemBuilder: (_, __) => _buildSkeletonCard(), ); } - Widget _buildEmptyState() { - return CtOSContainer( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + Widget _buildSkeletonCard() { + return NeoCard( + child: Row( children: [ - Icon( - Icons.person_search, - color: CtOSColors.textSecondary.withValues(alpha: 0.5), - size: 80.0, - ), - const SizedBox(height: 24.0), - const CtOSText( - "MASUKKAN NAMA DOSEN", - fontSize: 18.0, - color: CtOSColors.textPrimary, - fontWeight: FontWeight.bold, - ), - const SizedBox(height: 8.0), - const CtOSText( - "Sistem siap untuk memulai pencarian", - fontSize: 12.0, - color: CtOSColors.textSecondary, - textAlign: TextAlign.center, + NeoSkeleton.circle(size: 44), + const SizedBox(width: AppSpacing.md2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NeoSkeleton.text(width: 180), + const SizedBox(height: AppSpacing.sm), + NeoSkeleton.text(width: 120), + ], + ), ), ], ), ); } - Widget _buildNoFilterResultsState() { - return CtOSContainer( - borderColor: CtOSColors.warning, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.filter_alt_off, - color: CtOSColors.warning, - size: 64.0, - ), - const SizedBox(height: 16.0), - const CtOSText( - "TIDAK ADA HASIL UNTUK FILTER INI", - fontSize: 16.0, - color: CtOSColors.warning, - fontWeight: FontWeight.bold, - textAlign: TextAlign.center, - ), - const SizedBox(height: 24.0), - CtOSButton( - text: "HAPUS FILTER", - onPressed: _clearFilter, - icon: Icons.clear, - isPrimary: false, - ), - ], - ), + Widget _buildResults() { + return ListView.separated( + key: const ValueKey('results'), + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: _filteredResults.length, + separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm), + itemBuilder: (_, index) => _buildResultCard(_filteredResults[index]), ); } - Widget _buildResultsList() { - return CtOSContainer( - padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + // ─── Result Card ─────────────────────────────────────────────────────────── + + Widget _buildResultCard(Dosen dosen) { + final initial = dosen.nama.isNotEmpty ? dosen.nama[0].toUpperCase() : '?'; + + return NeoCard( + onTap: () { + Navigator.of(context).pushNamed( + '/dosen/detail/${dosen.id}', + arguments: dosen, + ); + }, + child: Row( children: [ - CtOSHeader( - title: "HASIL PENCARIAN", - subtitle: _selectedPt != null - ? 'Filter: $_selectedPt (${_filteredResults.length})' - : 'Ditemukan ${_searchResults.length} dosen', - trailing: _selectedPt != null - ? IconButton( - icon: const Icon(Icons.clear, color: CtOSColors.warning), - onPressed: _clearFilter, - ) - : null, + // Avatar + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.primaryDark.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: AppColors.primary.withValues(alpha: 0.4)), + ), + alignment: Alignment.center, + child: Text( + initial, + style: AppTypography.headlineSmall.copyWith( + color: AppColors.primaryLight, + ), + ), ), + const SizedBox(width: AppSpacing.md2), + + // Info Expanded( - child: _filteredResults.isEmpty - ? const Center( - child: CtOSText( - "Tidak ada data dosen", - fontSize: 14.0, - color: CtOSColors.textSecondary, - ), - ) - : ListView.builder( - physics: const BouncingScrollPhysics(), - itemCount: _filteredResults.length, - itemBuilder: (context, index) => _buildDosenCard(index), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dosen.nama, + style: AppTypography.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + dosen.namaPt, + style: AppTypography.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (dosen.nidn.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + 'NIDN: ${dosen.nidn}', + style: AppTypography.codeSmall, ), - ), - ], - ), - ); - } - - Widget _buildDosenCard(int index) { - final dosen = _filteredResults[index]; - final isEven = index % 2 == 0; - - return Container( - margin: const EdgeInsets.only(bottom: 8.0), - child: CtOSListItem( - title: dosen.nama, - subtitle: 'NIDN: ${dosen.nidn}\n${dosen.namaProdi}', - trailing: dosen.namaPt, - leadingIcon: Container( - width: 40.0, - height: 40.0, - decoration: BoxDecoration( - color: CtOSColors.background, - borderRadius: BorderRadius.circular(4.0), - border: Border.all( - color: isEven ? CtOSColors.primary : CtOSColors.secondary, - ), - ), - child: Center( - child: CtOSText( - dosen.nama.isNotEmpty ? dosen.nama[0].toUpperCase() : 'D', - fontSize: 18.0, - fontWeight: FontWeight.bold, - color: isEven ? CtOSColors.primary : CtOSColors.secondary, + ], + ], ), ), - ), - onTap: () { - Navigator.pushNamed( - context, - '/dosen/detail/${dosen.id}', - arguments: {'dosenName': dosen.nama}, - ); - }, - ), - ); - } - Widget _buildFooter() { - return CtOSContainer( - margin: const EdgeInsets.all(0), - padding: const EdgeInsets.all(12.0), - backgroundColor: CtOSColors.surface, - showBorder: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 8.0, - height: 8.0, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? CtOSColors.primary - : CtOSColors.secondary, - ), - ); - }, - ), - const SizedBox(width: 8.0), - CtOSText( - DateTime.now().toString().substring(0, 19), - fontSize: 10.0, - color: CtOSColors.textSecondary, - ), - ], - ), - const CtOSText( - 'BY: TAMAENGS', - fontSize: 10.0, - color: CtOSColors.textSecondary, - fontWeight: FontWeight.bold, + // Chevron + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textTertiary, + size: 20, ), ], ), diff --git a/lib/screens/health_screen.dart b/lib/screens/health_screen.dart new file mode 100644 index 0000000..4c6b86e --- /dev/null +++ b/lib/screens/health_screen.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import '../api/health/health_service.dart'; +import '../api/core/provider_registry.dart'; +import '../api/cache/in_memory_cache_store.dart'; +import '../api/cache/cache_store.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/data/neo_stat_card.dart'; +import '../widgets/feedback/neo_error.dart'; + +/// API Health Monitor — Neo-Violet Academic theme +/// Displays provider health status, latency, and cache stats. +class HealthScreen extends StatefulWidget { + const HealthScreen({super.key}); + + @override + State createState() => _HealthScreenState(); +} + +class _HealthScreenState extends State { + AppHealthReport? _report; + bool _isLoading = false; + String? _error; + + // BUG-002/003 fix: create once, reuse across calls + late final http.Client _httpClient; + late final CacheStore _cacheStore; + + @override + void initState() { + super.initState(); + _httpClient = http.Client(); + _cacheStore = InMemoryCacheStore(); + _runHealthCheck(); + } + + @override + void dispose() { + _httpClient.close(); + super.dispose(); + } + + Future _runHealthCheck() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final service = HealthService( + httpClient: _httpClient, + cacheStore: _cacheStore, + ); + final report = await service.checkAll(); + if (mounted) { + setState(() { + _report = report; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.surface, + elevation: 0, + title: const Text('API Health Monitor', style: AppTypography.headlineMedium), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded, color: AppColors.primary), + onPressed: _isLoading ? null : _runHealthCheck, + tooltip: 'Refresh', + ), + ], + ), + body: RefreshIndicator( + color: AppColors.primary, + backgroundColor: AppColors.surface, + onRefresh: _runHealthCheck, + child: _buildBody(), + ), + ); + } + + Widget _buildBody() { + if (_isLoading && _report == null) { + return const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ); + } + + if (_error != null && _report == null) { + return NeoError( + message: _error!, + onRetry: _runHealthCheck, + ); + } + + if (_report == null) { + return const SizedBox.shrink(); + } + + final report = _report!; + final allHealthy = report.unavailableCount == 0 && report.degradedCount == 0; + + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: AppSpacing.screenPadding, + children: [ + // Overall status card + _buildOverallStatusCard(report, allHealthy), + const SizedBox(height: AppSpacing.lg), + + // Stats row + _buildStatsRow(report), + const SizedBox(height: AppSpacing.lg), + + // Endpoint list header + const Text('Endpoints', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.md2), + + // Provider cards + ...report.providers.map(_buildProviderCard), + + const SizedBox(height: AppSpacing.lg), + + // Last check timestamp + Center( + child: Text( + 'Terakhir dicek: ${_formatTimestamp(report.generatedAt)}', + style: AppTypography.codeSmall, + ), + ), + const SizedBox(height: AppSpacing.md), + ], + ); + } + + Widget _buildOverallStatusCard(AppHealthReport report, bool allHealthy) { + return NeoCard( + variant: NeoCardVariant.gradient, + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: (allHealthy ? AppColors.success : AppColors.warning) + .withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + allHealthy + ? Icons.check_circle_rounded + : Icons.warning_amber_rounded, + color: allHealthy ? AppColors.success : AppColors.warning, + size: 24, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + allHealthy ? 'Semua Sistem Normal' : 'Ada Gangguan', + style: AppTypography.headlineMedium, + ), + const SizedBox(height: 4), + Text( + '${report.providers.length} provider terpantau', + style: AppTypography.bodySmall, + ), + ], + ), + ), + NeoBadge( + label: allHealthy ? 'OK' : '${report.unavailableCount} down', + variant: + allHealthy ? NeoBadgeVariant.success : NeoBadgeVariant.warning, + icon: allHealthy ? Icons.verified_rounded : Icons.error_outline, + ), + ], + ), + ); + } + + Widget _buildStatsRow(AppHealthReport report) { + return Row( + children: [ + Expanded( + child: NeoStatCard( + label: 'Healthy', + value: report.healthyCount.toString(), + icon: Icons.check_circle_outline_rounded, + color: AppColors.success, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: NeoStatCard( + label: 'Degraded', + value: report.degradedCount.toString(), + icon: Icons.warning_amber_rounded, + color: AppColors.warning, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: NeoStatCard( + label: 'Down', + value: report.unavailableCount.toString(), + icon: Icons.cancel_outlined, + color: AppColors.error, + ), + ), + ], + ); + } + + Widget _buildProviderCard(ProviderHealthResult result) { + final color = _statusColor(result.status); + final latencyText = result.latency != null + ? '${result.latency!.inMilliseconds}ms' + : '-'; + + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: NeoCard( + variant: NeoCardVariant.flat, + child: Row( + children: [ + // Status dot + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 6, + spreadRadius: 1, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.md2), + // Name + status + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + result.providerName, + style: AppTypography.bodyMedium.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + result.message ?? _statusLabel(result.status), + style: AppTypography.bodySmall.copyWith(color: color), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Latency + Text( + latencyText, + style: AppTypography.codeMedium.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + } + + Color _statusColor(ProviderStatus status) { + switch (status) { + case ProviderStatus.healthy: + return AppColors.success; + case ProviderStatus.degraded: + case ProviderStatus.rateLimited: + return AppColors.warning; + case ProviderStatus.unavailable: + case ProviderStatus.malformed: + case ProviderStatus.timeout: + return AppColors.error; + case ProviderStatus.unknown: + return AppColors.textTertiary; + } + } + + String _statusLabel(ProviderStatus status) { + switch (status) { + case ProviderStatus.healthy: + return 'Healthy'; + case ProviderStatus.degraded: + return 'Degraded'; + case ProviderStatus.rateLimited: + return 'Rate Limited'; + case ProviderStatus.unavailable: + return 'Unavailable'; + case ProviderStatus.malformed: + return 'Malformed Response'; + case ProviderStatus.timeout: + return 'Timeout'; + case ProviderStatus.unknown: + return 'Unknown'; + } + } + + String _formatTimestamp(DateTime dt) { + final h = dt.hour.toString().padLeft(2, '0'); + final m = dt.minute.toString().padLeft(2, '0'); + final s = dt.second.toString().padLeft(2, '0'); + return '${dt.day}/${dt.month}/${dt.year} $h:$m:$s'; + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 35e779d..8da4b14 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,18 +1,21 @@ +import 'package:flutter/foundation.dart'; import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../api/api_factory.dart'; import '../api/multi_api_factory.dart'; import '../models/mahasiswa.dart'; -import '../widgets/hacker_search_bar.dart'; -import '../widgets/hacker_result_item.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../widgets/filter_search_bar.dart'; -import '../widgets/filter_status.dart'; -import '../widgets/filter_overlay.dart'; -import '../widgets/dosen_search_button.dart'; // Tambahkan import +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../theme/app_gradients.dart'; +import '../widgets/search/neo_search_bar.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/feedback/neo_skeleton.dart'; +import '../widgets/feedback/neo_empty.dart'; +import '../widgets/navigation/neo_quick_action.dart'; import '../utils/constants.dart'; import 'detail_screen.dart'; @@ -23,26 +26,18 @@ class HomeScreen extends StatefulWidget { _HomeScreenState createState() => _HomeScreenState(); } -class _HomeScreenState extends State with SingleTickerProviderStateMixin { +class _HomeScreenState extends State { final TextEditingController _searchController = TextEditingController(); final TextEditingController _filterController = TextEditingController(); List _searchResults = []; List _filteredResults = []; bool _isLoading = false; + bool _isSearchInProgress = false; String? _errorMessage; - late AnimationController _animationController; - bool _showIntro = true; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _consoleTimer; - - // Tambahkan instance MultiApiFactory + late MultiApiFactory _multiApiFactory; - - // Tambahkan flag untuk menunjukkan pencarian multi-sumber bool _useMultiSource = true; - // Tambahkan variabel untuk filter universitas List _universities = []; String? _selectedUniversity; Timer? _filterDebounce; @@ -50,95 +45,85 @@ class _HomeScreenState extends State with SingleTickerProviderStateM @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2000), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory _multiApiFactory = MultiApiFactory(); - - // Tampilkan intro - _runIntroSequence(); - - // Untuk menunda filter saat pengetikan _filterController.addListener(_onFilterChanged); } - + + @override + void dispose() { + _searchController.dispose(); + _filterController.dispose(); + _filterDebounce?.cancel(); + super.dispose(); + } + + // ─── Filter Logic ────────────────────────────────────────────────────────── + void _onFilterChanged() { if (_filterDebounce?.isActive ?? false) { _filterDebounce!.cancel(); } - _filterDebounce = Timer(const Duration(milliseconds: 500), () { if (_filterController.text.isNotEmpty) { _autoFilterResults(_filterController.text); } }); } - + void _autoFilterResults(String query) { - // Cari universitas yang sesuai dengan query final matchingUniversities = _universities - .where((university) => - university.toLowerCase().contains(query.toLowerCase())) + .where((u) => u.toLowerCase().contains(query.toLowerCase())) .toList(); - if (matchingUniversities.isNotEmpty) { _filterResults(matchingUniversities.first); } } - void _runIntroSequence() { + void _extractUniversities(List results) { + final Set unique = {}; + for (var m in results) { + if (m.namaPt.isNotEmpty) unique.add(m.namaPt); + } setState(() { - _consoleMessages = []; + _universities = unique.toList()..sort(); }); + } - _addConsoleMessageWithDelay("MEMULAI SISTEM DB CRACKER...", 300); - _addConsoleMessageWithDelay("MENGHUBUNGKAN KE SERVER...", 800); - _addConsoleMessageWithDelay("MELEWATI PROTOKOL KEAMANAN...", 1500); - _addConsoleMessageWithDelay("MEMBUAT KONEKSI DATABASE...", 2300); - _addConsoleMessageWithDelay("MEMINDAI CELAH FIREWALL...", 3000); - _addConsoleMessageWithDelay("MENGAKTIFKAN SUMBER DATA TAMBAHAN...", 3500); - _addConsoleMessageWithDelay("AKSES DIBERIKAN KE MULTIPLE DATABASE", 4000); - _addConsoleMessageWithDelay("DB CRACKER v3.0 SIAP - Author: Tamaengs", 4500); - - // Sembunyikan intro setelah selesai - Timer(const Duration(milliseconds: 5000), () { - if (mounted) { - setState(() { - _showIntro = false; - }); + void _filterResults(String? university) { + setState(() { + _selectedUniversity = university; + if (university == null) { + _filteredResults = _searchResults; + } else { + _filteredResults = + _searchResults.where((m) => m.namaPt == university).toList(); } }); } - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } + void _clearFilter() { + setState(() { + _selectedUniversity = null; + _filteredResults = _searchResults; + _filterController.clear(); }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppStrings.filterCleared, style: AppTypography.bodySmall.copyWith(color: AppColors.textPrimary)), + backgroundColor: AppColors.surface, + duration: const Duration(seconds: 1), + ), + ); } - @override - void dispose() { - _searchController.dispose(); - _filterController.dispose(); - _animationController.dispose(); - _consoleTimer?.cancel(); - _filterDebounce?.cancel(); - super.dispose(); - } + // ─── Search Logic ────────────────────────────────────────────────────────── - void _simulateHacking() { + void _performSearch([String? _]) { + if (_isSearchInProgress) return; + _isSearchInProgress = true; setState(() { - _consoleMessages = []; _isLoading = true; - // Reset filter saat melakukan pencarian baru + _errorMessage = null; _selectedUniversity = null; _filterController.clear(); _universities = []; @@ -146,87 +131,63 @@ class _HomeScreenState extends State with SingleTickerProviderStateM }); final String query = _searchController.text.trim(); - - _addConsoleMessageWithDelay("MEMULAI PEMINDAIAN DATABASE UNTUK TARGET: $query", 300); - _addConsoleMessageWithDelay("MELEWATI LAPISAN KEAMANAN 1...", 800); - _addConsoleMessageWithDelay("MENYUNTIKKAN QUERY SQL...", 1200); - _addConsoleMessageWithDelay("MENCOBA MEMECAHKAN ENKRIPSI...", 1800); - _addConsoleMessageWithDelay("MENEMBUS FIREWALL...", 2400); - - if (_useMultiSource) { - _addConsoleMessageWithDelay("MENGAKSES BERBAGAI DATABASE PENDIDIKAN...", 3000); - _addConsoleMessageWithDelay("MENGGABUNGKAN HASIL DARI MULTIPLE SUMBER...", 3600); - } else { - _addConsoleMessageWithDelay("MENGAKSES DATABASE MAHASISWA...", 3000); + final sanitizedQuery = + query.replaceAll(RegExp(r'[<>"' "'" r']'), '').trim(); + if (sanitizedQuery.length < 2) { + setState(() { + _errorMessage = 'Minimal 2 karakter untuk pencarian'; + _isLoading = false; + }); + _isSearchInProgress = false; + return; } - - _actuallyPerformSearch(); + + _actuallyPerformSearch(sanitizedQuery); } - Future _actuallyPerformSearch() async { - final String query = _searchController.text.trim(); - if (query.isEmpty) { + Future _actuallyPerformSearch(String sanitizedQuery) async { + if (sanitizedQuery.isEmpty) { setState(() { _searchResults = []; _filteredResults = []; _errorMessage = AppStrings.pleaseEnterSearchTerm; _isLoading = false; }); - _addConsoleMessageWithDelay("ERROR: TARGET TIDAK DITENTUKAN", 500); + _isSearchInProgress = false; return; } try { - // Tambahan indikator loading - _addConsoleMessageWithDelay("MENGAKSES SERVER DATABASE...", 1000); - _addConsoleMessageWithDelay("MENCOBA KONEKSI AMAN...", 2000); - - // Cari data dengan error handling List results = []; try { if (_useMultiSource) { - // Gunakan multi-source pencarian - results = await _multiApiFactory.searchAllSources(query); - _addConsoleMessageWithDelay("MENGGABUNGKAN DATA DARI MULTIPLE SUMBER...", 2500); + results = await _multiApiFactory.searchAllSources(sanitizedQuery); } else { - // Gunakan hanya API PDDIKTI final api = Provider.of(context, listen: false); - results = await api.searchMahasiswa(query); + results = await api.searchMahasiswa(sanitizedQuery); } } catch (e) { - print('Error dalam pencarian: $e'); + if (kDebugMode) debugPrint('Error dalam pencarian: $e'); String errorMsg = e.toString(); - if (errorMsg.contains('XMLHttpRequest')) { throw Exception('Gagal terhubung ke server. Periksa koneksi internet atau coba lagi nanti.'); } else if (errorMsg.contains('Timeout')) { throw Exception('Koneksi timeout. Server sibuk, silakan coba lagi.'); } else if (errorMsg.contains('403')) { - throw Exception('Akses ditolak oleh server (403 Forbidden). Menggunakan data offline.'); + throw Exception('Akses ditolak oleh server (403 Forbidden).'); } else { throw Exception('Error: $e'); } } - - // Delay untuk simulasi hacking - await Future.delayed(const Duration(milliseconds: 3000)); - + setState(() { _searchResults = results; - _filteredResults = results; // Awalnya, hasil filter sama dengan hasil pencarian + _filteredResults = results; _isLoading = false; - if (results.isEmpty) { - _errorMessage = 'TIDAK DITEMUKAN HASIL UNTUK "$query"'; - _addConsoleMessageWithDelay("TIDAK ADA DATA YANG COCOK", 300); - _addConsoleMessageWithDelay("AKSES DITOLAK", 600); + _errorMessage = '${AppStrings.noResultsFound} "$sanitizedQuery"'; } else { _errorMessage = null; - _addConsoleMessageWithDelay("DATA DITEMUKAN: ${results.length}", 300); - _addConsoleMessageWithDelay("MENDEKRIPSI DATA...", 600); - _addConsoleMessageWithDelay("AKSES DIBERIKAN", 900); - - // Ekstrak daftar universitas dari hasil _extractUniversities(results); } }); @@ -235,504 +196,516 @@ class _HomeScreenState extends State with SingleTickerProviderStateM _isLoading = false; _searchResults = []; _filteredResults = []; - // Bersihkan pesan error - String errorMsg = e.toString().replaceAll("Exception: ", ""); - _errorMessage = errorMsg; + _errorMessage = e.toString().replaceAll('Exception: ', ''); }); - _addConsoleMessageWithDelay("KONEKSI TERPUTUS", 300); - _addConsoleMessageWithDelay("PERINGATAN KEAMANAN: DISCONNECT...", 600); + } finally { + _isSearchInProgress = false; } } - // Ekstrak daftar universitas unik dari hasil pencarian - void _extractUniversities(List results) { - Set uniqueUniversities = {}; - - for (var mahasiswa in results) { - if (mahasiswa.namaPt.isNotEmpty) { - uniqueUniversities.add(mahasiswa.namaPt); - } - } - - setState(() { - _universities = uniqueUniversities.toList()..sort(); - }); + void _viewMahasiswaDetail(BuildContext context, Mahasiswa mahasiswa) { + context.push( + '/mahasiswa/${Uri.encodeComponent(mahasiswa.id)}?name=${Uri.encodeComponent(mahasiswa.nama)}', + ); } - // Filter hasil berdasarkan universitas yang dipilih - void _filterResults(String? university) { - setState(() { - _selectedUniversity = university; - }); - - // Simulasi proses filtering dengan menampilkan overlay - // Ini membuat UX lebih menarik dengan visual hacking - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const FilterOverlay( - message: AppStrings.filteringInProgress, + // ─── UI Helpers ──────────────────────────────────────────────────────────── + + bool get _hasResults => _searchResults.isNotEmpty && !_isLoading; + + // ─── Build ───────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + resizeToAvoidBottomInset: true, + body: SafeArea( + child: Column( + children: [ + _buildGradientHeader(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: NeoSearchBar( + controller: _searchController, + onSubmitted: _performSearch, + isLoading: _isLoading, + hintText: AppStrings.searchHint, + ), + ), + Expanded( + child: _isLoading + ? _buildLoadingSkeleton() + : _hasResults + ? _buildSearchResults() + : _errorMessage != null + ? _buildErrorState() + : _buildDefaultContent(), + ), + ], + ), ), ); - - // Delay proses untuk efek visual - Future.delayed(const Duration(milliseconds: 800), () { - setState(() { - if (university == null) { - _filteredResults = _searchResults; - } else { - _filteredResults = _searchResults - .where((mahasiswa) => mahasiswa.namaPt == university) - .toList(); - } - }); - - // Tutup overlay dialog - Navigator.of(context).pop(); - }); } - void _clearFilter() { - // Tampilkan overlay untuk simulasi proses - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const FilterOverlay( - message: "MEMBERSIHKAN FILTER...", + // ─── Gradient Header ─────────────────────────────────────────────────────── + + Widget _buildGradientHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), + decoration: const BoxDecoration( + gradient: AppGradients.primary, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), ), - ); - - // Delay untuk simulasi proses - Future.delayed(const Duration(milliseconds: 600), () { - setState(() { - _selectedUniversity = null; - _filteredResults = _searchResults; - _filterController.clear(); - }); - - // Tutup overlay dialog - Navigator.of(context).pop(); - - // Tampilkan konfirmasi - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - AppStrings.filterCleared, - style: TextStyle( - fontFamily: 'Courier', - fontSize: 14, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppStrings.homeTitle, + style: AppTypography.displayMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'PDDIKTI Data Explorer', + style: AppTypography.bodySmall.copyWith( + color: Colors.white70, + ), + ), + ], ), ), - backgroundColor: HackerColors.surface, - duration: Duration(seconds: 2), - ), - ); - }); - } - - void _viewMahasiswaDetail(BuildContext context, Mahasiswa mahasiswa) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => DetailScreen(mahasiswaId: mahasiswa.id, subjectName: mahasiswa.nama), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + NeoBadge( + label: AppStrings.appVersion, + variant: NeoBadgeVariant.info, + ), + const SizedBox(height: 8), + _buildMultiSourceToggle(), + ], + ), + ], ), ); } - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - - if (_showIntro) { - return TerminalWindow( - title: "BOOT SEQUENCE DB CRACKER", - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - return ConsoleText(text: _consoleMessages[index]); - }, + Widget _buildMultiSourceToggle() { + return GestureDetector( + onTap: () { + setState(() => _useMultiSource = !_useMultiSource); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _useMultiSource + ? 'Mode Multi-Source diaktifkan' + : 'Mode PDDIKTI saja', + style: AppTypography.bodySmall.copyWith(color: AppColors.textPrimary), + ), + backgroundColor: AppColors.surface, + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(AppSpacing.radiusFull), + border: Border.all(color: Colors.white24), ), - ); - } - - return Scaffold( - backgroundColor: HackerColors.background, - appBar: AppBar( - title: Row( + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.error, - ), - ); - }, + Icon( + _useMultiSource ? Icons.cloud_sync_rounded : Icons.cloud_outlined, + size: 14, + color: Colors.white, ), - const Text( - AppStrings.homeTitle, - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16, + const SizedBox(width: 6), + Text( + _useMultiSource ? 'MULTI-DB' : 'PDDIKTI', + style: AppTypography.labelSmall.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, ), ), ], ), - backgroundColor: HackerColors.surface, - actions: [ - // Toggle switch untuk mengaktifkan/menonaktifkan multi-source - Switch( - value: _useMultiSource, - activeColor: HackerColors.primary, - inactiveThumbColor: HackerColors.accent, - onChanged: (bool value) { - setState(() { - _useMultiSource = value; - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - value - ? "MODE MULTI-SOURCE DIAKTIFKAN" - : "MODE HANYA PDDIKTI DIAKTIFKAN", - style: const TextStyle( - fontFamily: 'Courier', - fontSize: 14, - ), - ), - backgroundColor: HackerColors.surface, - duration: Duration(seconds: 2), - ), - ); - }, - ), - Padding( - padding: EdgeInsets.only(right: isMobile ? 8 : 16), - child: Text( - _useMultiSource ? "MULTI-DB" : "PDDIKTI", - style: const TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 12, + ), + ); + } + + // ─── Default Content (Quick Actions) ─────────────────────────────────────── + + Widget _buildDefaultContent() { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text('Akses Cepat', style: AppTypography.headlineSmall), + const SizedBox(height: 12), + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.95, + children: [ + NeoQuickAction( + icon: Icons.school_rounded, + label: 'Mahasiswa', + color: AppColors.primary, + // fix: tampilkan snackbar jika search kosong, bukan silent null - 2026-05-05 + onTap: () { + if (_searchController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ketik nama mahasiswa di search bar terlebih dahulu', + style: AppTypography.bodySmall.copyWith(color: AppColors.textPrimary)), + backgroundColor: AppColors.surface, + duration: const Duration(seconds: 2), + ), + ); + } else { + _performSearch(); + } + }, ), - ), + NeoQuickAction( + icon: Icons.person_rounded, + label: 'Dosen', + color: AppColors.secondary, + onTap: () => context.push('/dosen/search'), + ), + NeoQuickAction( + icon: Icons.menu_book_rounded, + label: 'Prodi', + color: AppColors.success, + onTap: () => context.push('/prodi/search'), + ), + NeoQuickAction( + icon: Icons.account_balance_rounded, + label: 'Kampus', + color: AppColors.warning, + onTap: () => context.push('/sekolah'), + ), + NeoQuickAction( + icon: Icons.monitor_heart_rounded, + label: 'Health', + color: AppColors.error, + onTap: () => context.push('/health'), + ), + NeoQuickAction( + icon: Icons.domain_rounded, + label: 'Sekolah', + color: AppColors.info, + onTap: () => context.push('/sekolah'), + ), + ], + ), + const SizedBox(height: 24), + NeoEmpty( + icon: Icons.search_rounded, + title: AppStrings.emptySearchPrompt, + subtitle: 'Gunakan search bar di atas untuk mencari data mahasiswa dari PDDIKTI', ), ], ), - body: SafeArea( - child: Container( - color: HackerColors.background, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + ); + } + + // ─── Loading Skeleton ────────────────────────────────────────────────────── + + Widget _buildLoadingSkeleton() { + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: 6, + itemBuilder: (_, __) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: NeoCard( + child: Row( children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8), - child: const Text( - 'KONEKSI AMAN TERSEDIA', - style: TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12, - ), - textAlign: TextAlign.center, + NeoSkeleton.circle(size: 44), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NeoSkeleton.text(width: 160), + const SizedBox(height: 8), + NeoSkeleton.text(width: 120), + const SizedBox(height: 6), + NeoSkeleton.text(width: 80), + ], ), ), - Padding( - padding: const EdgeInsets.all(16), - child: HackerSearchBar( - controller: _searchController, - hintText: AppStrings.searchHint, - onSearch: _simulateHacking, - ), + ], + ), + ), + ), + ); + } + + // ─── Error State ─────────────────────────────────────────────────────────── + + Widget _buildErrorState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.errorSurface, + borderRadius: BorderRadius.circular(20), ), - // Tambahkan filter universitas jika ada hasil - if (_searchResults.isNotEmpty && _universities.isNotEmpty) - Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: FilterSearchBar( - universities: _universities, - selectedUniversity: _selectedUniversity, - onFilter: _filterResults, - onClear: _clearFilter, - controller: _filterController, - ), - ), - // Tampilkan status filter jika filter aktif - if (_selectedUniversity != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: FilterStatus( - university: _selectedUniversity!, - count: _filteredResults.length, - onClear: _clearFilter, + child: const Icon(Icons.error_outline_rounded, size: 32, color: AppColors.error), + ), + const SizedBox(height: 20), + Text( + _errorMessage!, + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _performSearch, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Coba Lagi'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } + + // ─── Search Results ──────────────────────────────────────────────────────── + + Widget _buildSearchResults() { + final displayResults = _filteredResults; + + return Column( + children: [ + // Filter dropdown/search universitas + if (_universities.isNotEmpty) + Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( + children: [ + Container( + height: 44, + decoration: BoxDecoration( + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedUniversity, + isExpanded: true, + hint: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + 'Filter berdasarkan universitas...', + style: AppTypography.bodyMedium.copyWith(color: AppColors.textTertiary), ), ), - ], - ), - Expanded( - child: _isLoading - ? TerminalWindow( - title: "HACKING SEDANG BERJALAN", - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - return ConsoleText(text: _consoleMessages[index]); - }, + icon: Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon(Icons.filter_list_rounded, size: 20, color: AppColors.textSecondary), ), - ) - : _errorMessage != null - ? TerminalWindow( - title: "PERINGATAN SISTEM", - child: Center( + dropdownColor: AppColors.surface, + borderRadius: BorderRadius.circular(12), + items: [ + DropdownMenuItem( + value: null, child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48, - ), - const SizedBox(height: 16), - Text( - _errorMessage!, - style: const TextStyle( - color: HackerColors.error, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _simulateHacking, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 - ), - side: const BorderSide(color: HackerColors.primary), - ), - child: const Text( - 'COBA LAGI', - style: TextStyle( - fontSize: 14, - fontFamily: 'Courier', - ), - ), - ), - ], - ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text('Semua Universitas', style: AppTypography.bodyMedium.copyWith(fontWeight: FontWeight.w600)), ), ), - ) - : _searchResults.isEmpty - ? TerminalWindow( - title: "MENUNGGU INPUT", - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search, - color: HackerColors.accent.withOpacity(0.5), - size: 64, - ), - const SizedBox(height: 16), - const Text( - AppStrings.emptySearchPrompt, - style: TextStyle( - fontSize: 16, - color: HackerColors.text, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - maxLines: 2, - ), - - // Tambahkan tombol-tombol menu disini - const SizedBox(height: 24), - - // Tambahkan tombol pencarian dosen - const DosenSearchButton(), - - const SizedBox(height: 8), - const Text( - "SIAP UNTUK MEMULAI PERETASAN", - style: TextStyle( - fontSize: 12, - color: HackerColors.accent, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - ), - ], - ), - ) - : TerminalWindow( - title: _selectedUniversity != null - ? AppStrings.filterResults - : "REKAMAN TEREKSTRAK", - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Icon( - _selectedUniversity != null - ? Icons.filter_list - : Icons.person_search, - color: _selectedUniversity != null - ? HackerColors.warning - : HackerColors.primary, - size: 16), - const SizedBox(width: 8), - Expanded( - child: Text( - _selectedUniversity != null - ? 'DITEMUKAN ${_filteredResults.length} DARI ${_searchResults.length} SUBJEK' - : 'DITEMUKAN ${_searchResults.length} SUBJEK YANG COCOK', - style: TextStyle( - color: _selectedUniversity != null - ? HackerColors.warning - : HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - ), - maxLines: 1, - ), - ), - ], - ), - ), - if (_filteredResults.isEmpty && _selectedUniversity != null) - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.filter_alt_off, - color: HackerColors.warning, - size: 40, - ), - const SizedBox(height: 16), - const Text( - AppStrings.noFilterResultsFound, - style: TextStyle( - color: HackerColors.warning, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _clearFilter, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.warning, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 - ), - side: const BorderSide(color: HackerColors.warning), - ), - child: const Text( - AppStrings.clearFilter, - style: TextStyle( - fontSize: 14, - fontFamily: 'Courier', - ), - ), - ), - ], - ), - ), - ) - else - Expanded( - child: ListView.builder( - itemCount: _filteredResults.length, - itemBuilder: (context, index) { - final mahasiswa = _filteredResults[index]; - return HackerResultItem( - mahasiswa: mahasiswa, - onTap: () => _viewMahasiswaDetail(context, mahasiswa), - isFiltered: _selectedUniversity != null, - ); - }, - ), - ), - ], + ..._universities.map((uni) => DropdownMenuItem( + value: uni, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + uni, + style: AppTypography.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), - ), - Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 + )), + ], + onChanged: (value) { + if (value == null) { + _clearFilter(); + } else { + _filterResults(value); + } + }, + ), + ), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( + if (_selectedUniversity != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() ? HackerColors.primary : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Text( - DateTime.now().toString().substring(0, 19), - style: const TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', + Icon(Icons.filter_alt_rounded, size: 14, color: AppColors.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + '${displayResults.length} hasil dari $_selectedUniversity', + style: AppTypography.labelMedium.copyWith(color: AppColors.primary), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, ), ], ), - const Text( - 'BY: TAMAENGS', - style: TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), + ), + ], + ), + ), + // Results count + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Text( + '${displayResults.length} hasil ditemukan', + style: AppTypography.labelMedium, ), + const Spacer(), + if (_useMultiSource) + NeoBadge(label: 'Multi-Source', variant: NeoBadgeVariant.info, icon: Icons.cloud_sync_rounded), ], ), ), + // Results list + Expanded( + child: displayResults.isEmpty + ? NeoEmpty( + icon: Icons.filter_alt_off_rounded, + title: AppStrings.noFilterResultsFound, + actionLabel: AppStrings.clearFilter, + onAction: _clearFilter, + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + itemCount: displayResults.length, + itemBuilder: (context, index) { + final m = displayResults[index]; + return _buildResultItem(m); + }, + ), + ), + ], + ); + } + + Widget _buildFilterChip(String label, bool isActive, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: AppSpacing.durationFast, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? AppColors.primary : AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusFull), + border: Border.all( + color: isActive ? AppColors.primary : AppColors.border, + ), + ), + child: Text( + label, + style: AppTypography.labelMedium.copyWith( + color: isActive ? Colors.white : AppColors.textSecondary, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), ), ); } -} \ No newline at end of file + + Widget _buildResultItem(Mahasiswa m) { + final initial = m.nama.isNotEmpty ? m.nama[0].toUpperCase() : '?'; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: NeoCard( + variant: NeoCardVariant.flat, + onTap: () => _viewMahasiswaDetail(context, m), + child: Row( + children: [ + // Avatar + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: AppGradients.primary, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text( + initial, + style: AppTypography.headlineMedium.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + m.nama, + style: AppTypography.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + m.namaPt, + style: AppTypography.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + // NIM badge + if (m.nim.isNotEmpty) + NeoBadge(label: m.nim, variant: NeoBadgeVariant.neutral), + ], + ), + ), + ); + } +} diff --git a/lib/screens/prodi_detail_screen.dart b/lib/screens/prodi_detail_screen.dart index f88d90a..f0e9064 100644 --- a/lib/screens/prodi_detail_screen.dart +++ b/lib/screens/prodi_detail_screen.dart @@ -1,858 +1,302 @@ -// lib/screens/prodi_detail_screen.dart -import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; import '../api/multi_api_factory.dart'; import '../models/prodi.dart'; -import '../widgets/hacker_loading_indicator.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../widgets/flexible_text.dart'; -import '../widgets/responsive_card.dart'; -import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../theme/app_gradients.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/data/neo_data_row.dart'; +import '../widgets/data/neo_stat_card.dart'; +import '../widgets/feedback/neo_error.dart'; +import '../widgets/feedback/neo_skeleton.dart'; -/// Screen untuk menampilkan detail program studi class ProdiDetailScreen extends StatefulWidget { final String prodiId; final String prodiName; const ProdiDetailScreen({ - Key? key, + super.key, required this.prodiId, required this.prodiName, - }) : super(key: key); + }); @override - _ProdiDetailScreenState createState() => _ProdiDetailScreenState(); + State createState() => _ProdiDetailScreenState(); } -class _ProdiDetailScreenState extends State with SingleTickerProviderStateMixin { - late Future _prodiFuture; - bool _isLoading = true; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _loadTimer; - late AnimationController _animationController; - - // Tambahkan instance MultiApiFactory - late MultiApiFactory _multiApiFactory; - +class _ProdiDetailScreenState extends State { + late final Future _prodiFuture; + @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory - _multiApiFactory = MultiApiFactory(); - - // Mulai sequence loading - _simulateLoading(); - } - - void _simulateLoading() { - setState(() { - _consoleMessages = []; - _isLoading = true; - }); - - _addConsoleMessageWithDelay("AKSES DATABASE AMAN...", 300); - _addConsoleMessageWithDelay("MENCARI PROGRAM STUDI: ${widget.prodiName}", 800); - _addConsoleMessageWithDelay("EKSTRAKSI KURIKULUM...", 1400); - _addConsoleMessageWithDelay("ANALISIS AKREDITASI...", 2000); - _addConsoleMessageWithDelay("MENGAMBIL DATA KOMPETENSI...", 2600); - - // Fetch data setelah simulasi - _loadTimer = Timer(const Duration(milliseconds: 3000), () { - _fetchProdiDetail(); - }); - } - - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } - }); - } - - void _fetchProdiDetail() { - // Gunakan MultiApiFactory untuk mendapatkan detail Prodi - _prodiFuture = _multiApiFactory.getDetailProdi(widget.prodiId); - - _prodiFuture.then((_) { - if (mounted) { - setState(() { - _isLoading = false; - }); - } - }).catchError((error) { - if (mounted) { - setState(() { - _isLoading = false; - }); - } - }); - } - - @override - void dispose() { - _loadTimer?.cancel(); - _animationController.dispose(); - super.dispose(); - } - - String _getRandomHexValue(int length) { - const chars = '0123456789ABCDEF'; - return List.generate( - length, - (_) => chars[_random.nextInt(chars.length)], - ).join(); + _prodiFuture = MultiApiFactory().getDetailProdi(widget.prodiId); } @override Widget build(BuildContext context) { - // Pastikan ScreenUtils diinisialisasi - if (ScreenUtils.screenWidth == 0) { - ScreenUtils.init(context); - } - - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - return Scaffold( - backgroundColor: HackerColors.background, + backgroundColor: AppColors.background, appBar: AppBar( - title: Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.accent, - ), - ); - }, - ), - FlexibleText( - "PROGRAM STUDI", - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16, - ), - ), - ], - ), - backgroundColor: HackerColors.surface, - iconTheme: const IconThemeData( - color: HackerColors.primary, + backgroundColor: AppColors.surface, + title: Text( + 'Detail Program Studi', + style: AppTypography.headlineMedium, ), + centerTitle: true, + elevation: 0, ), - body: SafeArea( - child: Container( - color: HackerColors.background, - child: Column( - children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Expanded( - child: FlexibleText( - 'PROGRAM: ${widget.prodiName}', - style: const TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12, - ), - textAlign: TextAlign.center, - maxLines: 1, - ), - ), - ], - ), - ), - Expanded( - child: _isLoading - ? TerminalWindow( - title: "DATA LOADING", - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - return ConsoleText(text: _consoleMessages[index]); - }, - ), - ), - ], - ), - ) - : FutureBuilder( - future: _prodiFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: HackerLoadingIndicator()); - } else if (snapshot.hasError) { - return TerminalWindow( - title: "ERROR", - child: Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48, - ), - const SizedBox(height: 16), - FlexibleText( - 'Error: ${snapshot.error}', - style: const TextStyle( - color: HackerColors.error, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - maxLines: 3, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _simulateLoading, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 - ), - side: const BorderSide(color: HackerColors.primary), - ), - child: const FlexibleText( - "COBA LAGI", - style: TextStyle( - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), - ); - } else if (!snapshot.hasData) { - return const Center( - child: FlexibleText( - 'Data Program Studi tidak tersedia', - style: TextStyle( - color: HackerColors.error, - fontFamily: 'Courier', - fontSize: 16, - ), - ), - ); - } - - final prodi = snapshot.data!; - return _buildProdiDetailView(prodi); - }, - ), - ), - Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - FlexibleText( - 'KODE: ${_getRandomHexValue(8)}-${_getRandomHexValue(4)}', - style: const TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - ), - maxLines: 1, - ), - ], - ), - const FlexibleText( - 'BY: TAMAENGS', - style: TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), + body: FutureBuilder( + future: _prodiFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildSkeleton(); + } + if (snapshot.hasError) { + return NeoError( + message: snapshot.error.toString(), + onRetry: () => setState(() {}), + ); + } + final data = snapshot.data; + if (data == null) { + return const Center( + child: Text( + 'Data program studi tidak ditemukan', + style: AppTypography.bodyMedium, ), - ], - ), - ), + ); + } + return _buildContent(data); + }, ), ); } - Widget _buildProdiDetailView(ProdiDetail prodi) { - final bool isMobile = ScreenUtils.isMobileScreen(); - + Widget _buildSkeleton() { return Padding( - padding: const EdgeInsets.all(12), + padding: AppSpacing.screenPadding, child: Column( children: [ - Expanded( - child: isMobile - // Layout mobile: info umum di atas, info detail di bawah - ? Column( - children: [ - Expanded( - child: _buildInfoSection( - title: "INFO UMUM", - icon: Icons.school, - content: [ - _buildDataRow("NAMA", prodi.namaProdi), - _buildDataRow("KODE", prodi.kodeProdi), - _buildDataRow("JENJANG", prodi.jenjangDidik), - _buildDataRow("PERGURUAN TINGGI", prodi.namaPt), - _buildDataRow("AKREDITASI", prodi.akreditasi), - ], - ), - ), - const SizedBox(height: 8), - Expanded( - child: _buildInfoSection( - title: "DETAIL", - icon: Icons.info, - content: [ - _buildDataRow("ALAMAT", prodi.alamat), - _buildDataRow("KOTA", prodi.kabKota), - _buildDataRow("PROVINSI", prodi.provinsi), - _buildDataRow("WEBSITE", prodi.website), - _buildDataRow("EMAIL", prodi.email), - _buildDataRow("KONTAK", prodi.noTel), - ], - ), - ), - ], - ) - // Layout tablet/desktop: info umum di kiri, info detail di kanan - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 1, - child: _buildInfoSection( - title: "INFO UMUM", - icon: Icons.school, - content: [ - _buildDataRow("NAMA", prodi.namaProdi), - _buildDataRow("KODE", prodi.kodeProdi), - _buildDataRow("JENJANG", prodi.jenjangDidik), - _buildDataRow("PERGURUAN TINGGI", prodi.namaPt), - _buildDataRow("AKREDITASI", prodi.akreditasi), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: _buildInfoSection( - title: "DETAIL", - icon: Icons.info, - content: [ - _buildDataRow("ALAMAT", prodi.alamat), - _buildDataRow("KOTA", prodi.kabKota), - _buildDataRow("PROVINSI", prodi.provinsi), - _buildDataRow("WEBSITE", prodi.website), - _buildDataRow("EMAIL", prodi.email), - _buildDataRow("KONTAK", prodi.noTel), - ], - ), - ), - ], - ), + NeoSkeleton.card(), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: NeoSkeleton(height: 90, borderRadius: 12)), + const SizedBox(width: 12), + Expanded(child: NeoSkeleton(height: 90, borderRadius: 12)), + ], ), - const SizedBox(height: 12), - isMobile - ? Column( - children: [ - _buildVisiMisiSection(prodi), - const SizedBox(height: 8), - _buildKompetensiSection(prodi), - ], - ) - : Row( - children: [ - Expanded( - flex: 1, - child: _buildVisiMisiSection(prodi), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: _buildKompetensiSection(prodi), - ), - ], - ), - const SizedBox(height: 12), - _buildSecuritySection(prodi), + const SizedBox(height: 16), + NeoSkeleton.card(), ], ), ); } - Widget _buildInfoSection({ - required String title, - required IconData icon, - required List content, - }) { - return ResponsiveCard( - color: HackerColors.surface, - borderColor: HackerColors.accent, - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - icon, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: FlexibleText( - title, - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - ), - ], - ), - const Divider( - color: HackerColors.accent, - height: 24, - ), - Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - children: content, - ), - ), + Widget _buildContent(ProdiDetail prodi) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildProfileHeader(prodi), + const SizedBox(height: 16), + _buildStatsRow(prodi), + const SizedBox(height: 16), + _buildInfoSection(prodi), + const SizedBox(height: 16), + _buildContactSection(prodi), + if (prodi.visi.isNotEmpty || prodi.misi.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildVisiMisiSection(prodi), ], - ), + const SizedBox(height: 24), + ], ); } - Widget _buildVisiMisiSection(ProdiDetail prodi) { - return ResponsiveCard( - color: HackerColors.surface, - borderColor: HackerColors.accent, - padding: const EdgeInsets.all(12), + Widget _buildProfileHeader(ProdiDetail prodi) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: AppGradients.card, + borderRadius: BorderRadius.circular(AppSpacing.radiusXl), + border: Border.all(color: AppColors.border), + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - const Icon( - Icons.remove_red_eye, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: FlexibleText( - "VISI & MISI", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - ), - ], + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: AppGradients.primary, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + ), + child: const Icon( + Icons.school_rounded, + color: Colors.white, + size: 28, + ), ), - const Divider( - color: HackerColors.accent, - height: 24, + const SizedBox(height: 14), + Text( + prodi.namaProdi, + style: AppTypography.headlineLarge, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - children: [ - const FlexibleText( - "VISI:", - style: TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - ), - ), - child: FlexibleText( - prodi.visi.isNotEmpty ? prodi.visi : "Data visi tidak tersedia", - style: const TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 12, - ), - maxLines: 10, - ), + const SizedBox(height: 6), + Text( + prodi.namaPt, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (prodi.jenjangDidik.isNotEmpty) + NeoBadge( + label: prodi.jenjangDidik, + variant: NeoBadgeVariant.info, + icon: Icons.layers_rounded, ), - const SizedBox(height: 16), - const FlexibleText( - "MISI:", - style: TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.bold, - ), - maxLines: 1, + if (prodi.akreditasi.isNotEmpty) + NeoBadge( + label: 'Akreditasi ${prodi.akreditasi}', + variant: _akreditasiVariant(prodi.akreditasi), + icon: Icons.verified_rounded, ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - ), - ), - child: FlexibleText( - prodi.misi.isNotEmpty ? prodi.misi : "Data misi tidak tersedia", - style: const TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 12, - ), - maxLines: 15, - ), + if (prodi.status.isNotEmpty) + NeoBadge( + label: prodi.status, + variant: prodi.status.toLowerCase().contains('aktif') + ? NeoBadgeVariant.success + : NeoBadgeVariant.warning, ), - ], - ), + ], ), ], ), ); } - Widget _buildKompetensiSection(ProdiDetail prodi) { - return ResponsiveCard( - color: HackerColors.surface, - borderColor: HackerColors.accent, - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.psychology, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: FlexibleText( - "KOMPETENSI & CAPAIAN", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - ), - ], - ), - const Divider( - color: HackerColors.accent, - height: 24, + Widget _buildStatsRow(ProdiDetail prodi) { + // Only show stats row if we have meaningful data + final hasRataMasa = prodi.rataMasaStudi.isNotEmpty; + if (!hasRataMasa && prodi.kelBidang.isEmpty) return const SizedBox.shrink(); + + return Row( + children: [ + if (hasRataMasa) + Expanded( + child: NeoStatCard( + label: 'Rata-rata Masa Studi', + value: prodi.rataMasaStudi, + icon: Icons.timer_rounded, + color: AppColors.secondary, + ), ), + if (hasRataMasa && prodi.kelBidang.isNotEmpty) + const SizedBox(width: 12), + if (prodi.kelBidang.isNotEmpty) Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - children: [ - const FlexibleText( - "KOMPETENSI LULUSAN:", - style: TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - ), - ), - child: FlexibleText( - prodi.kompetensi.isNotEmpty ? prodi.kompetensi : "Data kompetensi tidak tersedia", - style: const TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 12, - ), - maxLines: 10, - ), - ), - const SizedBox(height: 16), - const FlexibleText( - "CAPAIAN PEMBELAJARAN:", - style: TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - ), - ), - child: FlexibleText( - prodi.capaianBelajar.isNotEmpty ? prodi.capaianBelajar : "Data capaian pembelajaran tidak tersedia", - style: const TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 12, - ), - maxLines: 15, - ), - ), - const SizedBox(height: 8), - FlexibleText( - "RATA-RATA MASA STUDI: ${prodi.rataMasaStudi.isNotEmpty ? prodi.rataMasaStudi : 'Tidak tersedia'} tahun", - style: const TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - ], + child: NeoStatCard( + label: 'Kelompok Bidang', + value: prodi.kelBidang, + icon: Icons.category_rounded, + color: AppColors.primary, ), ), - ], - ), + ], ); } - Widget _buildSecuritySection(ProdiDetail prodi) { - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - final double terminalHeight = isMobile ? 100 : 120; - - return Container( - height: terminalHeight, - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent), - ), - padding: const EdgeInsets.all(8), + Widget _buildInfoSection(ProdiDetail prodi) { + return NeoCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - ), - child: const FlexibleText( - "ANALISIS PRODI", - style: TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), + Text('Informasi Program Studi', style: AppTypography.headlineSmall), const SizedBox(height: 8), - Expanded( - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return FlexibleText( - _generateRandomProdiInfo(prodi, index), - style: TextStyle( - color: _getInfoColor(index), - fontFamily: 'Courier', - fontSize: 10, - ), - maxLines: 1, - ); - }, - ), - ), + NeoDataRow(label: 'Kode Prodi', value: prodi.kodeProdi, isCode: true, copyable: true), + NeoDataRow(label: 'Jenjang', value: prodi.jenjangDidik), + NeoDataRow(label: 'Status', value: prodi.status), + NeoDataRow(label: 'Akreditasi', value: prodi.akreditasi), + if (prodi.akreditasiInternasional.isNotEmpty) + NeoDataRow(label: 'Akred. Intl', value: prodi.akreditasiInternasional), + NeoDataRow(label: 'Tanggal Berdiri', value: prodi.tglBerdiri), + NeoDataRow(label: 'SK Selenggara', value: prodi.skSelenggara, isCode: true), + NeoDataRow(label: 'Perguruan Tinggi', value: prodi.namaPt), + NeoDataRow(label: 'Kode PT', value: prodi.kodePt, isCode: true), ], ), ); } - String _generateRandomProdiInfo(ProdiDetail prodi, int index) { - final hexCode = _getRandomHexValue(8); - - switch (index) { - case 0: - return "STATUS: ${prodi.status} | AKREDITASI: ${prodi.akreditasi} | BIDANG: ${prodi.kelBidang.isNotEmpty ? prodi.kelBidang : 'N/A'}"; - case 1: - return "DIDIRIKAN: ${prodi.tglBerdiri} | SK: ${prodi.skSelenggara.isNotEmpty ? prodi.skSelenggara.substring(0, min(prodi.skSelenggara.length, 15)) : '-'}..."; - case 2: - return "LOKASI: LAT ${prodi.lintang}, LONG ${prodi.bujur} | PROV: ${prodi.provinsi}"; - case 3: - return "AKREDIT. INT'L: ${prodi.akreditasiInternasional.isNotEmpty ? prodi.akreditasiInternasional : 'TIDAK ADA'} | STATUS AKRED: ${prodi.statusAkreditasi}"; - case 4: - return "SISTEM: PRODI-ANALYZER | CODE: ${hexCode} | TIME: ${DateTime.now().toString().substring(0, 16)}"; - default: - return ""; - } - } + Widget _buildContactSection(ProdiDetail prodi) { + final hasContact = prodi.alamat.isNotEmpty || + prodi.noTel.isNotEmpty || + prodi.email.isNotEmpty || + prodi.website.isNotEmpty; + + if (!hasContact) return const SizedBox.shrink(); - Color _getInfoColor(int index) { - switch (index) { - case 0: - return HackerColors.primary; - case 1: - return HackerColors.accent; - case 2: - return HackerColors.text; - case 3: - return HackerColors.warning; - case 4: - return HackerColors.primary; - default: - return HackerColors.text; - } + return NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Kontak & Lokasi', style: AppTypography.headlineSmall), + const SizedBox(height: 8), + if (prodi.alamat.isNotEmpty) + NeoDataRow(label: 'Alamat', value: prodi.alamat, icon: Icons.location_on_rounded), + if (prodi.kabKota.isNotEmpty) + NeoDataRow(label: 'Kota', value: prodi.kabKota), + if (prodi.provinsi.isNotEmpty) + NeoDataRow(label: 'Provinsi', value: prodi.provinsi), + if (prodi.noTel.isNotEmpty) + NeoDataRow(label: 'Telepon', value: prodi.noTel, icon: Icons.phone_rounded), + if (prodi.email.isNotEmpty) + NeoDataRow(label: 'Email', value: prodi.email, icon: Icons.email_rounded, copyable: true), + if (prodi.website.isNotEmpty) + NeoDataRow(label: 'Website', value: prodi.website, icon: Icons.language_rounded, copyable: true), + ], + ), + ); } - Widget _buildDataRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), + Widget _buildVisiMisiSection(ProdiDetail prodi) { + return NeoCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlexibleText( - label, - style: TextStyle( - color: HackerColors.text.withOpacity(0.7), - fontFamily: 'Courier', - fontSize: 10, - ), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - width: 1, - ), - ), - child: FlexibleText( - value.isNotEmpty ? value : "-DISENSOR-", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - ), - ), + Text('Visi & Misi', style: AppTypography.headlineSmall), + const SizedBox(height: 12), + if (prodi.visi.isNotEmpty) ...[ + Text('Visi', style: AppTypography.labelLarge.copyWith(color: AppColors.primary)), + const SizedBox(height: 4), + Text(prodi.visi, style: AppTypography.bodyMedium), + const SizedBox(height: 12), + ], + if (prodi.misi.isNotEmpty) ...[ + Text('Misi', style: AppTypography.labelLarge.copyWith(color: AppColors.primary)), + const SizedBox(height: 4), + Text(prodi.misi, style: AppTypography.bodyMedium), + ], ], ), ); } -} \ No newline at end of file + + NeoBadgeVariant _akreditasiVariant(String akreditasi) { + final upper = akreditasi.toUpperCase().trim(); + if (upper == 'A' || upper == 'UNGGUL') return NeoBadgeVariant.success; + if (upper == 'B' || upper == 'BAIK SEKALI') return NeoBadgeVariant.info; + if (upper == 'C' || upper == 'BAIK') return NeoBadgeVariant.warning; + return NeoBadgeVariant.neutral; + } +} diff --git a/lib/screens/prodi_search_screen.dart b/lib/screens/prodi_search_screen.dart index 664031a..31c41d5 100644 --- a/lib/screens/prodi_search_screen.dart +++ b/lib/screens/prodi_search_screen.dart @@ -1,412 +1,341 @@ +import 'package:flutter/foundation.dart'; import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; import '../api/multi_api_factory.dart'; import '../models/prodi.dart'; -import '../widgets/prodi_navigation_button.dart'; -import '../widgets/hacker_search_bar.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../widgets/flexible_text.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../widgets/search/neo_search_bar.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/feedback/neo_skeleton.dart'; +import '../widgets/feedback/neo_empty.dart'; +import '../widgets/feedback/neo_error.dart'; import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; -/// Screen untuk melakukan pencarian program studi +/// Screen pencarian program studi — Neo-Violet Academic theme. class ProdiSearchScreen extends StatefulWidget { const ProdiSearchScreen({Key? key}) : super(key: key); @override - _ProdiSearchScreenState createState() => _ProdiSearchScreenState(); + State createState() => _ProdiSearchScreenState(); } -class _ProdiSearchScreenState extends State with SingleTickerProviderStateMixin { +enum _ProdiSearchState { initial, loading, empty, error, results } + +class _ProdiSearchScreenState extends State { final TextEditingController _searchController = TextEditingController(); + late final MultiApiFactory _multiApiFactory; + List _searchResults = []; - bool _isLoading = false; String? _errorMessage; - late AnimationController _animationController; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _consoleTimer; - - // Tambahkan instance MultiApiFactory - late MultiApiFactory _multiApiFactory; + bool _isSearchInProgress = false; + _ProdiSearchState _state = _ProdiSearchState.initial; @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2000), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory _multiApiFactory = MultiApiFactory(); } - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } - }); - } - @override void dispose() { _searchController.dispose(); - _animationController.dispose(); - _consoleTimer?.cancel(); super.dispose(); } - void _simulateHacking() { - setState(() { - _consoleMessages = []; - _isLoading = true; - }); + // ─── Search Logic ────────────────────────────────────────────────────────── - final String query = _searchController.text.trim(); - - _addConsoleMessageWithDelay("MEMULAI PEMINDAIAN DATABASE PRODI UNTUK: $query", 300); - _addConsoleMessageWithDelay("MELEWATI LAPISAN KEAMANAN 1...", 800); - _addConsoleMessageWithDelay("MENYUNTIKKAN QUERY SQL...", 1200); - _addConsoleMessageWithDelay("MENCOBA MEMECAHKAN ENKRIPSI...", 1800); - _addConsoleMessageWithDelay("MENEMBUS FIREWALL...", 2400); - _addConsoleMessageWithDelay("MENGAKSES DATABASE PROGRAM STUDI...", 3000); - - _actuallyPerformSearch(); - } + Future _searchProdi(String query) async { + final trimmed = query.trim(); - Future _actuallyPerformSearch() async { - final String query = _searchController.text.trim(); - if (query.isEmpty) { - setState(() { - _searchResults = []; - _errorMessage = AppStrings.pleaseEnterSearchTerm; - _isLoading = false; - }); - _addConsoleMessageWithDelay("ERROR: TARGET TIDAK DITENTUKAN", 500); + if (trimmed.isEmpty) { + setState(() => _errorMessage = AppStrings.pleaseEnterSearchTerm); return; } + final sanitized = trimmed + .replaceAll('<', '') + .replaceAll('>', '') + .replaceAll('"', '') + .replaceAll("'", ''); + if (sanitized.length < 2) { + setState(() => _errorMessage = 'Minimal 2 karakter untuk pencarian'); + return; + } + + if (_isSearchInProgress) return; + _isSearchInProgress = true; + + setState(() { + _state = _ProdiSearchState.loading; + _errorMessage = null; + _searchResults.clear(); + }); + try { - // Tambahan indikator loading - _addConsoleMessageWithDelay("MENGAKSES SERVER DATABASE...", 1000); - _addConsoleMessageWithDelay("MENCOBA KONEKSI AMAN...", 2000); - - // Cari data dengan error handling - List results = []; - try { - // Gunakan API MultiApiFactory untuk mencari Prodi - results = await _multiApiFactory.searchProdi(query); - _addConsoleMessageWithDelay("MENDAPATKAN DATA PROGRAM STUDI...", 2500); - } catch (e) { - print('Error dalam pencarian: $e'); - String errorMsg = e.toString(); - - if (errorMsg.contains('XMLHttpRequest')) { - throw Exception('Gagal terhubung ke server. Periksa koneksi internet atau coba lagi nanti.'); - } else if (errorMsg.contains('Timeout')) { - throw Exception('Koneksi timeout. Server sibuk, silakan coba lagi.'); - } else if (errorMsg.contains('403')) { - throw Exception('Akses ditolak oleh server (403 Forbidden). Menggunakan data offline.'); - } else { - throw Exception('Error: $e'); - } - } - - // Delay untuk simulasi hacking - await Future.delayed(const Duration(milliseconds: 3000)); - + final results = await _multiApiFactory + .searchProdi(sanitized) + .timeout(const Duration(seconds: 30)); + + if (!mounted) return; + setState(() { _searchResults = results; - _isLoading = false; - - if (results.isEmpty) { - _errorMessage = 'TIDAK DITEMUKAN HASIL UNTUK "$query"'; - _addConsoleMessageWithDelay("TIDAK ADA DATA YANG COCOK", 300); - _addConsoleMessageWithDelay("AKSES DITOLAK", 600); - } else { - _errorMessage = null; - _addConsoleMessageWithDelay("DATA DITEMUKAN: ${results.length}", 300); - _addConsoleMessageWithDelay("MENDEKRIPSI DATA...", 600); - _addConsoleMessageWithDelay("AKSES DIBERIKAN", 900); - } + _state = results.isEmpty + ? _ProdiSearchState.empty + : _ProdiSearchState.results; }); } catch (e) { + if (!mounted) return; + if (kDebugMode) debugPrint('Prodi search error: $e'); + + String errorMsg = e.toString().replaceAll('Exception: ', ''); + if (errorMsg.contains('XMLHttpRequest')) { + errorMsg = 'Gagal terhubung ke server. Periksa koneksi internet.'; + } else if (errorMsg.contains('Timeout')) { + errorMsg = 'Koneksi timeout. Server sibuk, silakan coba lagi.'; + } else if (errorMsg.contains('403')) { + errorMsg = 'Akses ditolak oleh server (403 Forbidden).'; + } + setState(() { - _isLoading = false; - _searchResults = []; - // Bersihkan pesan error - String errorMsg = e.toString().replaceAll("Exception: ", ""); + _state = _ProdiSearchState.error; _errorMessage = errorMsg; }); - _addConsoleMessageWithDelay("KONEKSI TERPUTUS", 300); - _addConsoleMessageWithDelay("PERINGATAN KEAMANAN: DISCONNECT...", 600); + } finally { + _isSearchInProgress = false; } } + // ─── Build ───────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { - // Initialize ScreenUtils for responsive design - ScreenUtils.init(context); - - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - return Scaffold( - backgroundColor: HackerColors.background, - appBar: AppBar( - title: Row( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12.0, - height: 12.0, - margin: const EdgeInsets.only(right: 8.0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.error, + // Header + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, AppSpacing.md, AppSpacing.md, 0, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_rounded), + color: AppColors.textPrimary, + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: AppSpacing.sm), + Text( + 'Cari Program Studi', + style: AppTypography.headlineMedium, ), - ); - }, + ], + ), + ), + + // Search bar + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md2, + ), + child: NeoSearchBar( + controller: _searchController, + autofocus: true, + hintText: 'Nama prodi atau universitas...', + isLoading: _state == _ProdiSearchState.loading, + onSubmitted: _searchProdi, + ), ), - const FlexibleText( - "PROGRAM STUDI SCANNER", - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16.0, + + // Content area + Expanded( + child: AnimatedSwitcher( + duration: AppSpacing.durationNormal, + child: _buildContent(), ), ), ], ), - backgroundColor: HackerColors.surface, ), - body: SafeArea( - child: Container( - color: HackerColors.background, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8.0), - child: const FlexibleText( - 'AKSES DATABASE PROGRAM STUDI', - style: TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12.0, - ), - textAlign: TextAlign.center, - ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: HackerSearchBar( - controller: _searchController, - hintText: "masukkan nama program studi...", - onSearch: _simulateHacking, - ), - ), - Expanded( - child: _isLoading - ? TerminalWindow( - title: "PENCARIAN SEDANG BERJALAN", - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - return ConsoleText(text: _consoleMessages[index]); - }, - ), - ), - ], - ), - ) - : _errorMessage != null - ? TerminalWindow( - title: "PERINGATAN SISTEM", - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48.0, - ), - const SizedBox(height: 16.0), - FlexibleText( - _errorMessage!, - style: const TextStyle( - color: HackerColors.error, - fontSize: 16.0, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24.0), - ElevatedButton( - onPressed: _simulateHacking, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0 - ), - side: const BorderSide(color: HackerColors.primary), - ), - child: const FlexibleText( - 'COBA LAGI', - style: TextStyle( - fontSize: 14.0, - fontFamily: 'Courier', - ), - ), - ), - ], - ), - ), - ), - ) - : _searchResults.isEmpty - ? TerminalWindow( - title: "MENUNGGU INPUT", - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.school, - color: HackerColors.accent.withOpacity(0.5), - size: 64.0, - ), - const SizedBox(height: 16.0), - const FlexibleText( - "MASUKKAN NAMA PROGRAM STUDI UNTUK MENCARI", - style: TextStyle( - fontSize: 16.0, - color: HackerColors.text, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - maxLines: 2, - ), - const SizedBox(height: 8.0), - const FlexibleText( - "SIAP UNTUK MEMULAI PENCARIAN", - style: TextStyle( - fontSize: 12.0, - color: HackerColors.accent, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - ), - ], - ), - ) - : TerminalWindow( - title: "HASIL PENCARIAN", - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const Icon(Icons.school, - color: HackerColors.primary, - size: 16.0), - const SizedBox(width: 8.0), - Expanded( - child: FlexibleText( - 'DITEMUKAN ${_searchResults.length} PROGRAM STUDI', - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14.0, - ), - maxLines: 1, - ), - ), - ], - ), - ), - Expanded( - child: ListView.builder( - itemCount: _searchResults.length, - itemBuilder: (context, index) { - final prodi = _searchResults[index]; - return ProdiNavigationButton(prodi: prodi); - }, - ), - ), - ], - ), - ), - ), - Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0 + ); + } + + // ─── Content States ──────────────────────────────────────────────────────── + + Widget _buildContent() { + switch (_state) { + case _ProdiSearchState.initial: + return _buildInitial(); + case _ProdiSearchState.loading: + return _buildLoading(); + case _ProdiSearchState.empty: + return NeoEmpty( + key: const ValueKey('empty'), + icon: Icons.search_off_rounded, + title: 'Program studi tidak ditemukan', + subtitle: 'Coba kata kunci lain atau periksa ejaan', + ); + case _ProdiSearchState.error: + return NeoError( + key: const ValueKey('error'), + message: _errorMessage ?? 'Terjadi kesalahan', + onRetry: () => _searchProdi(_searchController.text), + ); + case _ProdiSearchState.results: + return _buildResults(); + } + } + + Widget _buildInitial() { + return Center( + key: const ValueKey('initial'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.account_balance_rounded, + size: 32, + color: AppColors.secondary, + ), + ), + const SizedBox(height: AppSpacing.lg2), + Text( + 'Cari program studi di seluruh Indonesia', + style: AppTypography.bodyMedium.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } + + Widget _buildLoading() { + return ListView.separated( + key: const ValueKey('loading'), + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: 6, + separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.md2), + itemBuilder: (_, __) => _buildSkeletonCard(), + ); + } + + Widget _buildSkeletonCard() { + return NeoCard( + child: Row( + children: [ + NeoSkeleton.circle(size: 44), + const SizedBox(width: AppSpacing.md2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NeoSkeleton.text(width: 200), + const SizedBox(height: AppSpacing.sm), + NeoSkeleton.text(width: 140), + const SizedBox(height: AppSpacing.sm), + NeoSkeleton.text(width: 60), + ], + ), + ), + ], + ), + ); + } + + Widget _buildResults() { + return ListView.separated( + key: const ValueKey('results'), + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: _searchResults.length, + separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm), + itemBuilder: (_, index) => _buildResultCard(_searchResults[index]), + ); + } + + // ─── Result Card ─────────────────────────────────────────────────────────── + + Widget _buildResultCard(Prodi prodi) { + return NeoCard( + onTap: () { + Navigator.of(context).pushNamed( + '/prodi/detail/${prodi.id}', + arguments: prodi, + ); + }, + child: Row( + children: [ + // Icon circle + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.secondaryDark.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all(color: AppColors.secondary.withValues(alpha: 0.4)), + ), + alignment: Alignment.center, + child: const Icon( + Icons.account_balance_rounded, + size: 20, + color: AppColors.secondaryLight, + ), + ), + const SizedBox(width: AppSpacing.md2), + + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + prodi.nama, + style: AppTypography.headlineSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 8.0, - height: 8.0, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() ? HackerColors.primary : HackerColors.accent, - ), - ), - const SizedBox(width: 8.0), - FlexibleText( - DateTime.now().toString().substring(0, 19), - style: const TextStyle( - color: HackerColors.text, - fontSize: 10.0, - fontFamily: 'Courier', - ), - maxLines: 1, - ), - ], - ), - const FlexibleText( - 'BY: TAMAENGS', - style: TextStyle( - color: HackerColors.text, - fontSize: 10.0, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], + const SizedBox(height: 2), + Text( + prodi.pt, + style: AppTypography.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - ], + if (prodi.jenjang.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + NeoBadge( + label: prodi.jenjang, + variant: NeoBadgeVariant.info, + icon: Icons.school_rounded, + ), + ], + ], + ), ), - ), + + // Chevron + const Icon( + Icons.chevron_right_rounded, + color: AppColors.textTertiary, + size: 20, + ), + ], ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/pt_detail_screen.dart b/lib/screens/pt_detail_screen.dart index ea0a136..106eff3 100644 --- a/lib/screens/pt_detail_screen.dart +++ b/lib/screens/pt_detail_screen.dart @@ -1,606 +1,312 @@ -import 'dart:async'; -import 'dart:math'; import 'package:flutter/material.dart'; import '../api/multi_api_factory.dart'; import '../models/pt.dart'; -import '../widgets/hacker_loading_indicator.dart'; -import '../widgets/console_text.dart'; -import '../widgets/terminal_window.dart'; -import '../widgets/flexible_text.dart'; -import '../widgets/responsive_card.dart'; -import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../theme/app_gradients.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/core/neo_badge.dart'; +import '../widgets/data/neo_data_row.dart'; +import '../widgets/data/neo_stat_card.dart'; +import '../widgets/feedback/neo_error.dart'; +import '../widgets/feedback/neo_skeleton.dart'; -/// Screen untuk menampilkan detail perguruan tinggi -class PTDetailScreen extends StatefulWidget { +class PtDetailScreen extends StatefulWidget { final String ptId; final String ptName; - const PTDetailScreen({ - Key? key, + const PtDetailScreen({ + super.key, required this.ptId, required this.ptName, - }) : super(key: key); + }); @override - _PTDetailScreenState createState() => _PTDetailScreenState(); + State createState() => _PtDetailScreenState(); } -class _PTDetailScreenState extends State with SingleTickerProviderStateMixin { - late Future _ptFuture; - bool _isLoading = true; - List _consoleMessages = []; - final Random _random = Random(); - Timer? _loadTimer; - late AnimationController _animationController; - - // Tambahkan instance MultiApiFactory - late MultiApiFactory _multiApiFactory; - +class _PtDetailScreenState extends State { + late final Future _ptFuture; + @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1500), - ); - _animationController.repeat(reverse: true); - - // Inisialisasi MultiApiFactory - _multiApiFactory = MultiApiFactory(); - - // Mulai sequence loading - _simulateLoading(); - } - - void _simulateLoading() { - setState(() { - _consoleMessages = []; - _isLoading = true; - }); - - _addConsoleMessageWithDelay("AKSES DATABASE AMAN...", 300); - _addConsoleMessageWithDelay("MENCARI INSTITUSI: ${widget.ptName}", 800); - _addConsoleMessageWithDelay("MENDAPATKAN DATA INSTITUSI...", 1400); - _addConsoleMessageWithDelay("EKSTRAKSI INFO AKREDITASI...", 2000); - _addConsoleMessageWithDelay("MEMBUAT PETA LOKASI...", 2600); - - // Fetch data setelah simulasi - _loadTimer = Timer(const Duration(milliseconds: 3000), () { - _fetchPTDetail(); - }); - } - - void _addConsoleMessageWithDelay(String message, int delay) { - Timer(Duration(milliseconds: delay), () { - if (mounted) { - setState(() { - _consoleMessages.add(message); - }); - } - }); - } - - void _fetchPTDetail() { - // Gunakan MultiApiFactory untuk mendapatkan detail PT - _ptFuture = _multiApiFactory.getDetailPT(widget.ptId); - - _ptFuture.then((_) { - if (mounted) { - setState(() { - _isLoading = false; - }); - } - }).catchError((error) { - if (mounted) { - setState(() { - _isLoading = false; - }); - } - }); - } - - @override - void dispose() { - _loadTimer?.cancel(); - _animationController.dispose(); - super.dispose(); - } - - String _getRandomHexValue(int length) { - const chars = '0123456789ABCDEF'; - return List.generate( - length, - (_) => chars[_random.nextInt(chars.length)], - ).join(); + _ptFuture = MultiApiFactory().getDetailPT(widget.ptId); } @override Widget build(BuildContext context) { - // Pastikan ScreenUtils diinisialisasi - if (ScreenUtils.screenWidth == 0) { - ScreenUtils.init(context); - } - - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - return Scaffold( - backgroundColor: HackerColors.background, + backgroundColor: AppColors.background, appBar: AppBar( - title: Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _animationController.value > 0.5 - ? HackerColors.primary - : HackerColors.accent, - ), - ); - }, - ), - FlexibleText( - "PROFIL INSTITUSI", - style: TextStyle( - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - color: HackerColors.primary, - fontSize: 16, - ), - ), - ], - ), - backgroundColor: HackerColors.surface, - iconTheme: const IconThemeData( - color: HackerColors.primary, + backgroundColor: AppColors.surface, + title: Text( + 'Detail Perguruan Tinggi', + style: AppTypography.headlineMedium, ), + centerTitle: true, + elevation: 0, ), - body: SafeArea( - child: Container( - color: HackerColors.background, - child: Column( - children: [ - Container( - color: HackerColors.surface.withOpacity(0.7), - padding: const EdgeInsets.all(8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - Expanded( - child: FlexibleText( - 'INFO SISTEM: ${widget.ptName}', - style: const TextStyle( - color: HackerColors.highlight, - fontFamily: 'Courier', - fontSize: 12, - ), - textAlign: TextAlign.center, - maxLines: 1, - ), - ), - ], - ), - ), - Expanded( - child: _isLoading - ? TerminalWindow( - title: "DATA LOADING", - child: Column( - children: [ - Expanded( - child: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _consoleMessages.length, - itemBuilder: (context, index) { - return ConsoleText(text: _consoleMessages[index]); - }, - ), - ), - ], - ), - ) - : FutureBuilder( - future: _ptFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: HackerLoadingIndicator()); - } else if (snapshot.hasError) { - return TerminalWindow( - title: "ERROR", - child: Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.warning_amber_rounded, - color: HackerColors.error, - size: 48, - ), - const SizedBox(height: 16), - FlexibleText( - 'Error: ${snapshot.error}', - style: const TextStyle( - color: HackerColors.error, - fontSize: 16, - fontFamily: 'Courier', - ), - textAlign: TextAlign.center, - maxLines: 3, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: _simulateLoading, - style: ElevatedButton.styleFrom( - backgroundColor: HackerColors.surface, - foregroundColor: HackerColors.primary, - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8 - ), - side: const BorderSide(color: HackerColors.primary), - ), - child: const FlexibleText( - "COBA LAGI", - style: TextStyle( - fontSize: 14, - ), - ), - ), - ], - ), - ), - ), - ); - } else if (!snapshot.hasData) { - return Center( - child: const FlexibleText( - 'Data PT tidak tersedia', - style: TextStyle( - color: HackerColors.error, - fontFamily: 'Courier', - fontSize: 16, - ), - ), - ); - } - - final pt = snapshot.data!; - return _buildPTDetailView(pt); - }, - ), + body: FutureBuilder( + future: _ptFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildSkeleton(); + } + if (snapshot.hasError) { + return NeoError( + message: snapshot.error.toString(), + onRetry: () => setState(() {}), + ); + } + final data = snapshot.data; + if (data == null) { + return const Center( + child: Text( + 'Data perguruan tinggi tidak ditemukan', + style: AppTypography.bodyMedium, ), - Container( - color: HackerColors.surface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _random.nextBool() - ? HackerColors.primary - : HackerColors.accent, - ), - ), - const SizedBox(width: 8), - FlexibleText( - 'KODE: ${_getRandomHexValue(8)}-${_getRandomHexValue(4)}', - style: const TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - ), - maxLines: 1, - ), - ], - ), - const FlexibleText( - 'BY: TAMAENGS', - style: TextStyle( - color: HackerColors.text, - fontSize: 10, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - ), + ); + } + return _buildContent(data); + }, ), ); } - Widget _buildPTDetailView(PerguruanTinggiDetail pt) { - final bool isMobile = ScreenUtils.isMobileScreen(); - + Widget _buildSkeleton() { return Padding( - padding: const EdgeInsets.all(12), + padding: AppSpacing.screenPadding, child: Column( children: [ - Expanded( - child: isMobile - // Layout mobile: info umum di atas, info detail di bawah - ? Column( - children: [ - Expanded( - child: _buildInfoSection( - title: "INFO UMUM", - icon: Icons.info, - content: [ - _buildDataRow("NAMA", pt.namaPt), - _buildDataRow("KODE", pt.kodePt), - _buildDataRow("SINGKATAN", pt.nmSingkat), - _buildDataRow("STATUS", pt.statusPt), - _buildDataRow("AKREDITASI", pt.akreditasiPt), - ], - ), - ), - const SizedBox(height: 8), - Expanded( - child: _buildInfoSection( - title: "DETAIL", - icon: Icons.school, - content: [ - _buildDataRow("ALAMAT", pt.alamat), - _buildDataRow("KOTA", pt.kabKotaPt), - _buildDataRow("PROVINSI", pt.provinsiPt), - _buildDataRow("KONTAK", pt.noTel), - _buildDataRow("WEBSITE", pt.website), - _buildDataRow("EMAIL", pt.email), - ], - ), - ), - ], - ) - // Layout tablet/desktop: info umum di kiri, info detail di kanan - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 1, - child: _buildInfoSection( - title: "INFO UMUM", - icon: Icons.info, - content: [ - _buildDataRow("NAMA", pt.namaPt), - _buildDataRow("KODE", pt.kodePt), - _buildDataRow("SINGKATAN", pt.nmSingkat), - _buildDataRow("STATUS", pt.statusPt), - _buildDataRow("AKREDITASI", pt.akreditasiPt), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: _buildInfoSection( - title: "DETAIL", - icon: Icons.school, - content: [ - _buildDataRow("ALAMAT", pt.alamat), - _buildDataRow("KOTA", pt.kabKotaPt), - _buildDataRow("PROVINSI", pt.provinsiPt), - _buildDataRow("KONTAK", pt.noTel), - _buildDataRow("WEBSITE", pt.website), - _buildDataRow("EMAIL", pt.email), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 12), - _buildSecuritySection(pt), - ], - ), - ); - } - - Widget _buildInfoSection({ - required String title, - required IconData icon, - required List content, - }) { - return ResponsiveCard( - color: HackerColors.surface, - borderColor: HackerColors.accent, - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + NeoSkeleton.card(), + const SizedBox(height: 16), Row( children: [ - Icon( - icon, - color: HackerColors.primary, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: FlexibleText( - title, - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - ), + Expanded(child: NeoSkeleton(height: 90, borderRadius: 12)), + const SizedBox(width: 10), + Expanded(child: NeoSkeleton(height: 90, borderRadius: 12)), + const SizedBox(width: 10), + Expanded(child: NeoSkeleton(height: 90, borderRadius: 12)), ], ), - const Divider( - color: HackerColors.accent, - height: 24, - ), - Expanded( - child: ListView( - physics: const BouncingScrollPhysics(), - children: content, - ), - ), + const SizedBox(height: 16), + NeoSkeleton.card(), ], ), ); } - Widget _buildSecuritySection(PerguruanTinggiDetail pt) { - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - final double terminalHeight = isMobile ? 100 : 120; - + Widget _buildContent(PerguruanTinggiDetail pt) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildProfileHeader(pt), + const SizedBox(height: 16), + _buildStatsGrid(pt), + const SizedBox(height: 16), + _buildInfoSection(pt), + const SizedBox(height: 16), + _buildContactSection(pt), + const SizedBox(height: 24), + ], + ); + } + + Widget _buildProfileHeader(PerguruanTinggiDetail pt) { + final location = [pt.kabKotaPt, pt.provinsiPt] + .where((s) => s.isNotEmpty) + .join(', '); + return Container( - height: terminalHeight, + padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent), + gradient: AppGradients.card, + borderRadius: BorderRadius.circular(AppSpacing.radiusXl), + border: Border.all(color: AppColors.border), ), - padding: const EdgeInsets.all(8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), + width: 56, + height: 56, decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), + gradient: AppGradients.primary, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), ), - child: const FlexibleText( - "DATA INSTITUSI", - style: TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), + child: const Icon( + Icons.account_balance_rounded, + color: Colors.white, + size: 28, ), ), - const SizedBox(height: 8), - Expanded( - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) { - return FlexibleText( - _generateRandomPTInfo(pt, index), - style: TextStyle( - color: _getInfoColor(index), - fontFamily: 'Courier', - fontSize: 10, - ), - maxLines: 1, - ); - }, + const SizedBox(height: 14), + Text( + pt.namaPt, + style: AppTypography.headlineLarge, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (pt.nmSingkat.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + pt.nmSingkat, + style: AppTypography.codeMedium.copyWith(color: AppColors.secondary), ), + ], + if (location.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + location, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (pt.kelompok.isNotEmpty) + NeoBadge( + label: pt.kelompok, + variant: NeoBadgeVariant.info, + icon: Icons.domain_rounded, + ), + if (pt.akreditasiPt.isNotEmpty) + NeoBadge( + label: 'Akreditasi ${pt.akreditasiPt}', + variant: _akreditasiVariant(pt.akreditasiPt), + icon: Icons.verified_rounded, + ), + if (pt.statusPt.isNotEmpty) + NeoBadge( + label: pt.statusPt, + variant: pt.statusPt.toLowerCase().contains('aktif') + ? NeoBadgeVariant.success + : NeoBadgeVariant.warning, + ), + ], ), ], ), ); } - String _generateRandomPTInfo(PerguruanTinggiDetail pt, int index) { - final hexCode = _getRandomHexValue(8); - - switch (index) { - case 0: - return "STATUS: ${pt.statusPt} | AKREDITASI: ${pt.akreditasiPt} | KODE: ${pt.kodePt}"; - case 1: - return "TAHUN BERDIRI: ${pt.tglBerdiriPt} | SK PENDIRIAN: ${pt.skPendirianSp.isNotEmpty ? pt.skPendirianSp.substring(0, min(pt.skPendirianSp.length, 15)) : '-'}..."; - case 2: - return "LOKASI: LAT ${pt.lintangPt}, LONG ${pt.bujurPt} | KODE POS: ${pt.kodePos}"; - case 3: - return "KONTAK: ${pt.noTel} | FAX: ${pt.noFax} | WEBSITE: ${pt.website}"; - case 4: - return "SISTEM: PT-SCANNER | ID: ${hexCode} | WAKTU: ${DateTime.now().toString().substring(0, 16)}"; - default: - return ""; - } + Widget _buildStatsGrid(PerguruanTinggiDetail pt) { + final hasProdi = pt.jumlahProdi.isNotEmpty && pt.jumlahProdi != '0'; + final hasMhs = pt.jumlahMahasiswa.isNotEmpty && pt.jumlahMahasiswa != '0'; + final hasDosen = pt.jumlahDosen.isNotEmpty && pt.jumlahDosen != '0'; + + if (!hasProdi && !hasMhs && !hasDosen) return const SizedBox.shrink(); + + return Column( + children: [ + Row( + children: [ + if (hasProdi) + Expanded( + child: NeoStatCard( + label: 'Program Studi', + value: pt.jumlahProdi, + icon: Icons.school_rounded, + color: AppColors.primary, + ), + ), + if (hasProdi && hasMhs) const SizedBox(width: 10), + if (hasMhs) + Expanded( + child: NeoStatCard( + label: 'Mahasiswa', + value: pt.jumlahMahasiswa, + icon: Icons.people_rounded, + color: AppColors.secondary, + ), + ), + if ((hasProdi || hasMhs) && hasDosen) const SizedBox(width: 10), + if (hasDosen) + Expanded( + child: NeoStatCard( + label: 'Dosen', + value: pt.jumlahDosen, + icon: Icons.person_rounded, + color: AppColors.success, + ), + ), + ], + ), + ], + ); } - Color _getInfoColor(int index) { - switch (index) { - case 0: - return HackerColors.primary; - case 1: - return HackerColors.accent; - case 2: - return HackerColors.text; - case 3: - return HackerColors.warning; - case 4: - return HackerColors.primary; - default: - return HackerColors.text; - } + Widget _buildInfoSection(PerguruanTinggiDetail pt) { + return NeoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Informasi Umum', style: AppTypography.headlineSmall), + const SizedBox(height: 8), + NeoDataRow(label: 'Kode PT', value: pt.kodePt, isCode: true, copyable: true), + NeoDataRow(label: 'Kelompok', value: pt.kelompok), + NeoDataRow(label: 'Pembina', value: pt.pembina), + NeoDataRow(label: 'Akreditasi', value: pt.akreditasiPt), + NeoDataRow(label: 'Status', value: pt.statusPt), + NeoDataRow(label: 'Tanggal Berdiri', value: pt.tglBerdiriPt), + NeoDataRow(label: 'SK Pendirian', value: pt.skPendirianSp, isCode: true), + if (pt.rasio.isNotEmpty) + NeoDataRow(label: 'Rasio Dosen/Mhs', value: pt.rasio), + if (pt.rangeBiayaKuliah.isNotEmpty) + NeoDataRow(label: 'Biaya Kuliah', value: pt.rangeBiayaKuliah), + if (pt.graduationRate.isNotEmpty) + NeoDataRow(label: 'Graduation Rate', value: pt.graduationRate), + ], + ), + ); } - Widget _buildDataRow(String label, String value) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), + Widget _buildContactSection(PerguruanTinggiDetail pt) { + final hasContact = pt.alamat.isNotEmpty || + pt.noTel.isNotEmpty || + pt.email.isNotEmpty || + pt.website.isNotEmpty; + + if (!hasContact) return const SizedBox.shrink(); + + return NeoCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - FlexibleText( - label, - style: TextStyle( - color: HackerColors.text.withOpacity(0.7), - fontFamily: 'Courier', - fontSize: 10, - ), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.accent.withOpacity(0.5), - width: 1, - ), - ), - child: FlexibleText( - value.isNotEmpty ? value : "-DISENSOR-", - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - ), - ), + Text('Kontak & Lokasi', style: AppTypography.headlineSmall), + const SizedBox(height: 8), + if (pt.alamat.isNotEmpty) + NeoDataRow(label: 'Alamat', value: pt.alamat, icon: Icons.location_on_rounded), + if (pt.kabKotaPt.isNotEmpty) + NeoDataRow(label: 'Kota', value: pt.kabKotaPt), + if (pt.provinsiPt.isNotEmpty) + NeoDataRow(label: 'Provinsi', value: pt.provinsiPt), + if (pt.kodePos.isNotEmpty) + NeoDataRow(label: 'Kode Pos', value: pt.kodePos), + if (pt.noTel.isNotEmpty) + NeoDataRow(label: 'Telepon', value: pt.noTel, icon: Icons.phone_rounded), + if (pt.noFax.isNotEmpty) + NeoDataRow(label: 'Fax', value: pt.noFax), + if (pt.email.isNotEmpty) + NeoDataRow(label: 'Email', value: pt.email, icon: Icons.email_rounded, copyable: true), + if (pt.website.isNotEmpty) + NeoDataRow(label: 'Website', value: pt.website, icon: Icons.language_rounded, copyable: true), ], ), ); } -} \ No newline at end of file + + NeoBadgeVariant _akreditasiVariant(String akreditasi) { + final upper = akreditasi.toUpperCase().trim(); + if (upper == 'A' || upper == 'UNGGUL') return NeoBadgeVariant.success; + if (upper == 'B' || upper == 'BAIK SEKALI') return NeoBadgeVariant.info; + if (upper == 'C' || upper == 'BAIK') return NeoBadgeVariant.warning; + return NeoBadgeVariant.neutral; + } +} diff --git a/lib/screens/sekolah_screen.dart b/lib/screens/sekolah_screen.dart new file mode 100644 index 0000000..e7f47fe --- /dev/null +++ b/lib/screens/sekolah_screen.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import '../api/sekolah/sekolah_service.dart'; +import '../api/sekolah/sekolah_models.dart'; +import '../api/cache/in_memory_cache_store.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; +import '../theme/app_spacing.dart'; +import '../widgets/search/neo_search_bar.dart'; +import '../widgets/core/neo_card.dart'; +import '../widgets/feedback/neo_empty.dart'; +import '../widgets/feedback/neo_error.dart'; +import '../widgets/feedback/neo_skeleton.dart'; +import '../widgets/data/neo_data_row.dart'; +import '../widgets/core/neo_badge.dart'; + +/// Cari Sekolah — Neo-Violet Academic theme +/// NPSN lookup with clean card-based result display. +class SekolahLookupScreen extends StatefulWidget { + const SekolahLookupScreen({super.key}); + + @override + State createState() => _SekolahLookupScreenState(); +} + +class _SekolahLookupScreenState extends State { + final _npsnController = TextEditingController(); + Sekolah? _result; + bool _isLoading = false; + String? _error; + + + // BUG-002/003 fix: create once, reuse across calls + late final http.Client _httpClient; + late final InMemoryCacheStore _cacheStore; + + @override + void initState() { + super.initState(); + _httpClient = http.Client(); + _cacheStore = InMemoryCacheStore(); + } + + @override + void dispose() { + _npsnController.dispose(); + _httpClient.close(); + super.dispose(); + } + + Future _lookup() async { + final npsn = _npsnController.text.trim(); + if (npsn.isEmpty || npsn.length < 6) { + setState(() => _error = 'NPSN harus minimal 6 digit angka'); + return; + } + if (!RegExp(r'^\d+$').hasMatch(npsn)) { + setState(() => _error = 'NPSN harus berupa angka'); + return; + } + + setState(() { + _isLoading = true; + _error = null; + _result = null; + }); + + try { + final service = SekolahService( + httpClient: _httpClient, + cacheStore: _cacheStore, + ); + final result = await service.lookupByNpsn(npsn); + if (mounted) { + setState(() { + _result = result; + _isLoading = false; + if (result == null) { + _error = 'Sekolah dengan NPSN "$npsn" tidak ditemukan'; + } + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _error = e.toString(); + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Custom AppBar row + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xs, + AppSpacing.md2, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_rounded, + color: AppColors.textPrimary, + ), + onPressed: () => Navigator.of(context).pop(), + ), + const SizedBox(width: AppSpacing.sm), + const Text('Cari Sekolah', style: AppTypography.headlineMedium), + ], + ), + ), + + // Search bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: NeoSearchBar( + controller: _npsnController, + hintText: 'Masukkan NPSN (min 6 digit)...', + isLoading: _isLoading, + onSubmitted: (_) => _lookup(), + onClear: () { + setState(() { + _result = null; + _error = null; + }); + }, + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Content area with AnimatedSwitcher + Expanded( + child: AnimatedSwitcher( + duration: AppSpacing.durationNormal, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: _buildContent(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildContent() { + // Loading state + if (_isLoading) { + return Padding( + key: const ValueKey('loading'), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Column( + children: [ + NeoSkeleton.card(), + const SizedBox(height: AppSpacing.md2), + NeoSkeleton.card(), + ], + ), + ); + } + + // Error state + if (_error != null) { + return NeoError( + key: const ValueKey('error'), + message: _error!, + onRetry: _lookup, + ); + } + + // Result state + if (_result != null) { + return _buildResultCard(_result!); + } + + // Initial state — no search yet + return const NeoEmpty( + key: ValueKey('initial'), + icon: Icons.school_rounded, + title: 'Cari Sekolah', + subtitle: 'Masukkan NPSN untuk mencari data sekolah dari database nasional', + ); + } + + Widget _buildResultCard(Sekolah sekolah) { + return SingleChildScrollView( + key: const ValueKey('result'), + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: NeoCard( + variant: NeoCardVariant.elevated, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.school_rounded, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: AppSpacing.md2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sekolah.nama, + style: AppTypography.headlineSmall, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + NeoBadge( + label: sekolah.bentukPendidikan.isNotEmpty + ? sekolah.bentukPendidikan + : 'Sekolah', + variant: NeoBadgeVariant.info, + icon: Icons.category_rounded, + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + const Divider(color: AppColors.divider, height: 1), + const SizedBox(height: AppSpacing.sm), + + // Data rows + NeoDataRow( + label: 'NPSN', + value: sekolah.npsn, + icon: Icons.tag_rounded, + isCode: true, + copyable: true, + ), + NeoDataRow( + label: 'Nama', + value: sekolah.nama, + icon: Icons.business_rounded, + ), + NeoDataRow( + label: 'Alamat', + value: sekolah.alamat, + icon: Icons.location_on_outlined, + ), + NeoDataRow( + label: 'Kab/Kota', + value: sekolah.kabupatenKota, + icon: Icons.location_city_rounded, + ), + NeoDataRow( + label: 'Provinsi', + value: sekolah.provinsi, + icon: Icons.map_outlined, + ), + NeoDataRow( + label: 'Jenjang', + value: sekolah.bentukPendidikan, + icon: Icons.school_outlined, + ), + if (sekolah.statusSekolah.isNotEmpty) + NeoDataRow( + label: 'Status', + value: sekolah.statusSekolah, + icon: Icons.verified_outlined, + ), + if (sekolah.kecamatan.isNotEmpty) + NeoDataRow( + label: 'Kecamatan', + value: sekolah.kecamatan, + icon: Icons.place_outlined, + ), + if (sekolah.kelurahan.isNotEmpty) + NeoDataRow( + label: 'Kelurahan', + value: sekolah.kelurahan, + icon: Icons.pin_drop_outlined, + ), + if (sekolah.lintang.isNotEmpty) + NeoDataRow( + label: 'Koordinat', + value: '${sekolah.lintang}, ${sekolah.bujur}', + icon: Icons.gps_fixed_rounded, + isCode: true, + copyable: true, + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart deleted file mode 100644 index 0762641..0000000 --- a/lib/services/api_service.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:http/http.dart' as http; - -/// API Service class that handles different HTTP implementations for web and mobile -class ApiService { - /// Make a GET request with appropriate handling for web and mobile platforms - static Future get(Uri url, { - Map? headers, - Duration? timeout, - }) async { - if (kIsWeb) { - // Web implementation with CORS handling - return _webGet(url, headers: headers, timeout: timeout); - } else { - // Mobile implementation - return _mobileGet(url, headers: headers, timeout: timeout); - } - } - - /// Web-specific GET implementation with CORS handling - static Future _webGet(Uri url, { - Map? headers, - Duration? timeout, - }) async { - try { - // Option 1: Use cors-anywhere proxy (for development only) - // final String corsProxyUrl = 'https://cors-anywhere.herokuapp.com/'; - // final Uri requestUrl = Uri.parse('$corsProxyUrl${url.toString()}'); - - // Option 2: Use your own backend proxy (recommended for production) - // For this to work, you need to set up a backend proxy service - // final Uri requestUrl = Uri.parse('https://your-backend-proxy.com/proxy?url=${url.toString()}'); - - // Option 3: Direct request (will work if the API enables CORS) - final Uri requestUrl = url; - - Map webHeaders = headers ?? {}; - // Add specific headers that might help with CORS - webHeaders['Access-Control-Allow-Origin'] = '*'; - - final request = http.Request('GET', requestUrl); - request.headers.addAll(webHeaders); - - final streamedResponse = await request.send().timeout( - timeout ?? const Duration(seconds: 15), - ); - - return http.Response.fromStream(streamedResponse); - } catch (e) { - print('Web API request error: $e'); - if (e.toString().contains('XMLHttpRequest')) { - throw Exception('CORS error detected: Unable to connect to the server from web. Try using a backend proxy.'); - } - rethrow; - } - } - - /// Mobile-specific GET implementation - static Future _mobileGet(Uri url, { - Map? headers, - Duration? timeout, - }) async { - try { - return await http.get( - url, - headers: headers, - ).timeout( - timeout ?? const Duration(seconds: 15), - ); - } on SocketException { - throw Exception('Tidak dapat terhubung ke internet. Periksa koneksi anda.'); - } on http.ClientException { - throw Exception('Gagal terhubung ke server. Silakan coba lagi nanti.'); - } on TimeoutException { - throw Exception('Koneksi timeout. Server mungkin sibuk, silakan coba lagi.'); - } catch (e) { - print('Mobile API request error: $e'); - rethrow; - } - } -} \ No newline at end of file diff --git a/lib/services/mock_pddikti_service.dart b/lib/services/mock_pddikti_service.dart index caee79f..ff6b43d 100644 --- a/lib/services/mock_pddikti_service.dart +++ b/lib/services/mock_pddikti_service.dart @@ -134,7 +134,7 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for web'); + if (kDebugMode) debugPrint('Using mock data for web'); } // Filter berdasarkan keyword @@ -160,7 +160,7 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for prodi search (web)'); + if (kDebugMode) debugPrint('Using mock data for prodi search (web)'); } // Filter berdasarkan keyword @@ -186,7 +186,7 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for PT search (web)'); + if (kDebugMode) debugPrint('Using mock data for PT search (web)'); } // Filter berdasarkan keyword @@ -210,7 +210,7 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for mahasiswa detail (web)'); + if (kDebugMode) debugPrint('Using mock data for mahasiswa detail (web)'); } // Cari mahasiswa berdasarkan ID di data sample @@ -219,7 +219,7 @@ class MockPddiktiService { orElse: () { // If not found by exact ID, create a sample data for any ID // This ensures we always return something for any ID - print('Creating sample data for unknown mahasiswa ID: $mahasiswaId'); + if (kDebugMode) debugPrint('Creating sample data for unknown mahasiswa ID: $mahasiswaId'); return { "id": mahasiswaId, "nama": "Mahasiswa ${mahasiswaId.substring(0, min(5, mahasiswaId.length))}", @@ -255,7 +255,7 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for prodi detail (web)'); + if (kDebugMode) debugPrint('Using mock data for prodi detail (web)'); } // Cari prodi berdasarkan ID di data sample @@ -263,7 +263,7 @@ class MockPddiktiService { (item) => item['id'] == prodiId, orElse: () { // Jika tidak ditemukan dengan ID yang tepat, buat data sampel - print('Creating sample data for unknown prodi ID: $prodiId'); + if (kDebugMode) debugPrint('Creating sample data for unknown prodi ID: $prodiId'); return { "id": prodiId, "nama": "Program Studi ${prodiId.substring(0, min(5, prodiId.length))}", @@ -318,7 +318,7 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for PT detail (web)'); + if (kDebugMode) debugPrint('Using mock data for PT detail (web)'); } // Cari PT berdasarkan ID di data sample @@ -326,7 +326,7 @@ class MockPddiktiService { (item) => item['id'] == ptId, orElse: () { // Jika tidak ditemukan dengan ID yang tepat, buat data sampel - print('Creating sample data for unknown PT ID: $ptId'); + if (kDebugMode) debugPrint('Creating sample data for unknown PT ID: $ptId'); return { "id": ptId, "kode": "${100000 + _random.nextInt(900000)}", @@ -372,18 +372,12 @@ class MockPddiktiService { await Future.delayed(Duration(milliseconds: 800 + _random.nextInt(1200))); if (kIsWeb) { - print('Using mock data for PT prodi list (web)'); + if (kDebugMode) debugPrint('Using mock data for PT prodi list (web)'); } // Buat 5 program studi acak untuk PT ini List prodiList = []; - // Ambil nama PT dari sample - final ptData = _samplePT.firstWhere( - (item) => item['id'] == ptId, - orElse: () => {"nama": "Perguruan Tinggi Sample"}, - ); - for (int i = 0; i < 5; i++) { // Buat data prodi acak final String idSms = "SMS${_random.nextInt(100000)}"; @@ -573,8 +567,4 @@ class MockPddiktiService { return filteredData.map((item) => Dosen.fromJson(item)).toList(); } - // Helper function to limit string length - int min(int a, int b) { - return (a < b) ? a : b; - } } \ No newline at end of file diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart new file mode 100644 index 0000000..8d699be --- /dev/null +++ b/lib/theme/app_colors.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +/// Neo-Violet Academic color palette. +/// +/// All colors are defined as static constants to enable tree-shaking +/// and compile-time const usage throughout the app. +class AppColors { + AppColors._(); + + // ─── Primary ─────────────────────────────────────────────────────────────── + static const Color primary = Color(0xFF7C3AED); + static const Color primaryLight = Color(0xFF8B5CF6); + static const Color primaryDark = Color(0xFF6D28D9); + + // ─── Secondary ───────────────────────────────────────────────────────────── + static const Color secondary = Color(0xFF06B6D4); + static const Color secondaryLight = Color(0xFF22D3EE); + static const Color secondaryDark = Color(0xFF0891B2); + + // ─── Background & Surface ────────────────────────────────────────────────── + static const Color background = Color(0xFF0F0F23); + static const Color surface = Color(0xFF1A1A2E); + static const Color surfaceHigh = Color(0xFF252547); + static const Color surfaceHighest = Color(0xFF2F2F5C); + + // ─── Border & Divider ────────────────────────────────────────────────────── + static const Color border = Color(0xFF2E2E52); + static const Color borderLight = Color(0xFF3D3D6B); + static const Color divider = Color(0xFF1F1F3D); + + // ─── Text ────────────────────────────────────────────────────────────────── + static const Color textPrimary = Color(0xFFE2E8F0); + static const Color textSecondary = Color(0xFF94A3B8); + static const Color textTertiary = Color(0xFF64748B); + static const Color textDisabled = Color(0xFF475569); + static const Color textInverse = Color(0xFF0F172A); + + // ─── Semantic: Success ───────────────────────────────────────────────────── + static const Color success = Color(0xFF10B981); + static const Color successLight = Color(0xFF34D399); + static const Color successSurface = Color(0xFF052E16); + + // ─── Semantic: Warning ───────────────────────────────────────────────────── + static const Color warning = Color(0xFFF59E0B); + static const Color warningLight = Color(0xFFFBBF24); + static const Color warningSurface = Color(0xFF422006); + + // ─── Semantic: Error ─────────────────────────────────────────────────────── + static const Color error = Color(0xFFEF4444); + static const Color errorLight = Color(0xFFF87171); + static const Color errorSurface = Color(0xFF450A0A); + + // ─── Semantic: Info ──────────────────────────────────────────────────────── + static const Color info = Color(0xFF3B82F6); + static const Color infoLight = Color(0xFF60A5FA); + static const Color infoSurface = Color(0xFF172554); + + // ─── Special ─────────────────────────────────────────────────────────────── + static const Color shimmerBase = Color(0xFF1A1A2E); + static const Color shimmerHighlight = Color(0xFF252547); + static const Color overlay = Color(0xCC0F0F23); + + // ─── Material 3 ColorScheme ──────────────────────────────────────────────── + static ColorScheme get colorScheme => const ColorScheme( + brightness: Brightness.dark, + primary: primary, + onPrimary: textPrimary, + primaryContainer: primaryDark, + onPrimaryContainer: primaryLight, + secondary: secondary, + onSecondary: textInverse, + secondaryContainer: secondaryDark, + onSecondaryContainer: secondaryLight, + tertiary: primaryLight, + onTertiary: textInverse, + tertiaryContainer: surfaceHighest, + onTertiaryContainer: textPrimary, + error: error, + onError: textPrimary, + errorContainer: errorSurface, + onErrorContainer: errorLight, + surface: surface, + onSurface: textPrimary, + surfaceContainerHighest: surfaceHighest, + onSurfaceVariant: textSecondary, + outline: border, + outlineVariant: borderLight, + shadow: Colors.black, + scrim: overlay, + inverseSurface: textPrimary, + onInverseSurface: textInverse, + inversePrimary: primaryDark, + ); +} diff --git a/lib/theme/app_gradients.dart b/lib/theme/app_gradients.dart new file mode 100644 index 0000000..ceffd53 --- /dev/null +++ b/lib/theme/app_gradients.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +/// Neo-Violet Academic gradient definitions. +/// +/// Provides reusable gradient presets for backgrounds, cards, +/// overlays, and shimmer effects. +class AppGradients { + AppGradients._(); + + // ─── Primary Gradient (Violet → Cyan, diagonal) ──────────────────────────── + static const LinearGradient primary = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary, + AppColors.secondary, + ], + ); + + // ─── Primary Vertical (Violet → Cyan, top to bottom) ────────────────────── + static const LinearGradient primaryVertical = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.primary, + AppColors.secondary, + ], + ); + + // ─── Surface Gradient (Surface → Background) ────────────────────────────── + static const LinearGradient surface = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.surface, + AppColors.background, + ], + ); + + // ─── Card Gradient (SurfaceHigh → Surface, diagonal) ────────────────────── + static const LinearGradient card = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surfaceHigh, + AppColors.surface, + ], + ); + + // ─── Dark Overlay (Transparent → Background) ────────────────────────────── + static const LinearGradient darkOverlay = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + AppColors.background, + ], + ); + + // ─── Shimmer Gradient (Base → Highlight → Base) ──────────────────────────── + static const LinearGradient shimmer = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + AppColors.shimmerBase, + AppColors.shimmerHighlight, + AppColors.shimmerBase, + ], + stops: [0.0, 0.5, 1.0], + ); +} diff --git a/lib/theme/app_spacing.dart b/lib/theme/app_spacing.dart new file mode 100644 index 0000000..99fa918 --- /dev/null +++ b/lib/theme/app_spacing.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +/// Neo-Violet Academic spacing, radius, shadow, and animation system. +/// +/// Provides a consistent spatial rhythm across the entire application. +class AppSpacing { + AppSpacing._(); + + // ─── Spacing Scale ───────────────────────────────────────────────────────── + static const double xs2 = 2; + static const double xs = 4; + static const double sm2 = 6; + static const double sm = 8; + static const double sm3 = 10; + static const double md2 = 12; + static const double md = 16; + static const double lg2 = 20; + static const double lg = 24; + static const double xl = 32; + static const double xl2 = 40; + static const double xl3 = 48; + static const double xl4 = 56; + static const double xl5 = 64; + + // ─── Padding Presets ─────────────────────────────────────────────────────── + static const EdgeInsets paddingSm = EdgeInsets.all(sm); + static const EdgeInsets paddingMd = EdgeInsets.all(md); + static const EdgeInsets paddingLg = EdgeInsets.all(lg); + static const EdgeInsets paddingXl = EdgeInsets.all(xl); + + static const EdgeInsets screenPadding = EdgeInsets.symmetric( + horizontal: md, + vertical: lg2, + ); + + static const EdgeInsets cardPadding = EdgeInsets.symmetric( + horizontal: md, + vertical: md2, + ); + + // ─── Border Radius Values ────────────────────────────────────────────────── + static const double radiusXs = 4; + static const double radiusSm = 6; + static const double radiusMd = 8; + static const double radiusLg = 12; + static const double radiusXl = 16; + static const double radius2xl = 20; + static const double radiusFull = 999; + + // ─── BorderRadius Constants ──────────────────────────────────────────────── + static const BorderRadius borderRadiusXs = + BorderRadius.all(Radius.circular(radiusXs)); + static const BorderRadius borderRadiusSm = + BorderRadius.all(Radius.circular(radiusSm)); + static const BorderRadius borderRadiusMd = + BorderRadius.all(Radius.circular(radiusMd)); + static const BorderRadius borderRadiusLg = + BorderRadius.all(Radius.circular(radiusLg)); + static const BorderRadius borderRadiusXl = + BorderRadius.all(Radius.circular(radiusXl)); + static const BorderRadius borderRadius2xl = + BorderRadius.all(Radius.circular(radius2xl)); + static const BorderRadius borderRadiusFull = + BorderRadius.all(Radius.circular(radiusFull)); + + // ─── Shadows ─────────────────────────────────────────────────────────────── + static const List shadowNone = []; + + static const List shadowSm = [ + BoxShadow( + color: Color(0x1A000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ]; + + static const List shadowMd = [ + BoxShadow( + color: Color(0x26000000), + blurRadius: 8, + offset: Offset(0, 4), + ), + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ]; + + static const List shadowLg = [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 16, + offset: Offset(0, 8), + ), + BoxShadow( + color: Color(0x1A000000), + blurRadius: 6, + offset: Offset(0, 4), + ), + ]; + + static List shadowGlow = [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.3), + blurRadius: 16, + spreadRadius: 2, + offset: const Offset(0, 0), + ), + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.1), + blurRadius: 32, + spreadRadius: 4, + offset: const Offset(0, 0), + ), + ]; + + // ─── Animation Durations ─────────────────────────────────────────────────── + static const Duration durationFast = Duration(milliseconds: 150); + static const Duration durationNormal = Duration(milliseconds: 250); + static const Duration durationSlow = Duration(milliseconds: 350); + + // ─── Breakpoints ─────────────────────────────────────────────────────────── + static const double breakpointSm = 360; + static const double breakpointMd = 400; + static const double breakpointLg = 600; + static const double breakpointXl = 900; +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..3a2f373 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,225 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; +import 'app_spacing.dart'; +import 'app_typography.dart'; + +/// Neo-Violet Academic ThemeData builder. +/// +/// Assembles all theme tokens (colors, typography, spacing) into a +/// complete Material 3 [ThemeData] ready for use in [MaterialApp]. +class AppTheme { + AppTheme._(); + + static ThemeData get darkTheme { + final colorScheme = AppColors.colorScheme; + + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: colorScheme, + textTheme: AppTypography.textTheme, + fontFamily: AppTypography.fontBody, + scaffoldBackgroundColor: AppColors.background, + + // ─── AppBar ────────────────────────────────────────────────────────── + appBarTheme: const AppBarTheme( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: true, + titleTextStyle: AppTypography.headlineMedium, + iconTheme: IconThemeData(color: AppColors.textPrimary, size: 22), + ), + + // ─── Card ──────────────────────────────────────────────────────────── + cardTheme: CardThemeData( + color: AppColors.surface, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusLg, + side: const BorderSide(color: AppColors.border, width: 1), + ), + ), + + // ─── Elevated Button ───────────────────────────────────────────────── + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textPrimary, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md2, + ), + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusMd, + ), + textStyle: AppTypography.labelLarge.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + + // ─── Outlined Button ───────────────────────────────────────────────── + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md2, + ), + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusMd, + ), + side: const BorderSide(color: AppColors.border, width: 1), + textStyle: AppTypography.labelLarge, + ), + ), + + // ─── Text Button ───────────────────────────────────────────────────── + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusMd, + ), + textStyle: AppTypography.labelLarge, + ), + ), + + // ─── Input Decoration ──────────────────────────────────────────────── + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surfaceHigh, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md2, + ), + hintStyle: AppTypography.bodyMedium.copyWith( + color: AppColors.textTertiary, + ), + labelStyle: AppTypography.labelMedium.copyWith( + color: AppColors.textSecondary, + ), + border: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: const BorderSide(color: AppColors.border, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: const BorderSide(color: AppColors.border, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: const BorderSide(color: AppColors.error, width: 1), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: const BorderSide(color: AppColors.error, width: 1.5), + ), + disabledBorder: OutlineInputBorder( + borderRadius: AppSpacing.borderRadiusMd, + borderSide: const BorderSide(color: AppColors.divider, width: 1), + ), + ), + + // ─── TabBar ────────────────────────────────────────────────────────── + tabBarTheme: TabBarThemeData( + labelColor: AppColors.textPrimary, + unselectedLabelColor: AppColors.textTertiary, + labelStyle: AppTypography.labelLarge, + unselectedLabelStyle: AppTypography.labelLarge, + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.15), + borderRadius: AppSpacing.borderRadiusFull, + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + width: 1, + ), + ), + dividerColor: Colors.transparent, + ), + + // ─── Divider ──────────────────────────────────────────────────────── + dividerTheme: const DividerThemeData( + color: AppColors.divider, + thickness: 1, + space: 1, + ), + + // ─── SnackBar ─────────────────────────────────────────────────────── + snackBarTheme: SnackBarThemeData( + backgroundColor: AppColors.surfaceHighest, + contentTextStyle: AppTypography.bodyMedium, + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusMd, + side: const BorderSide(color: AppColors.border, width: 1), + ), + behavior: SnackBarBehavior.floating, + elevation: 0, + ), + + // ─── Bottom Navigation ────────────────────────────────────────────── + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: AppColors.surface, + selectedItemColor: AppColors.primary, + unselectedItemColor: AppColors.textTertiary, + type: BottomNavigationBarType.fixed, + elevation: 0, + ), + + // ─── Dialog ───────────────────────────────────────────────────────── + dialogTheme: DialogThemeData( + backgroundColor: AppColors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppSpacing.borderRadiusXl, + side: const BorderSide(color: AppColors.border, width: 1), + ), + titleTextStyle: AppTypography.headlineMedium, + contentTextStyle: AppTypography.bodyMedium, + ), + + // ─── Bottom Sheet ─────────────────────────────────────────────────── + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: AppColors.surface, + modalBackgroundColor: AppColors.surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppSpacing.radiusXl), + ), + ), + ), + + // ─── Page Transitions ─────────────────────────────────────────────── + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: CupertinoPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.linux: CupertinoPageTransitionsBuilder(), + }, + ), + + // ─── Misc ─────────────────────────────────────────────────────────── + splashColor: AppColors.primary.withValues(alpha: 0.08), + highlightColor: AppColors.primary.withValues(alpha: 0.05), + visualDensity: VisualDensity.adaptivePlatformDensity, + ); + } +} diff --git a/lib/theme/app_typography.dart b/lib/theme/app_typography.dart new file mode 100644 index 0000000..c0c824c --- /dev/null +++ b/lib/theme/app_typography.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +/// Neo-Violet Academic typography system. +/// +/// Uses JetBrains Mono for display/code elements and Inter for body text, +/// creating a technical-academic aesthetic. +class AppTypography { + AppTypography._(); + + // ─── Font Families ───────────────────────────────────────────────────────── + static const String fontDisplay = 'JetBrainsMono'; + static const String fontBody = 'Inter'; + static const String fontCode = 'JetBrainsMono'; + + // ─── Display Styles (JetBrains Mono) ─────────────────────────────────────── + static const TextStyle displayLarge = TextStyle( + fontFamily: fontDisplay, + fontSize: 28, + fontWeight: FontWeight.w700, + height: 1.3, + letterSpacing: -0.5, + color: AppColors.textPrimary, + ); + + static const TextStyle displayMedium = TextStyle( + fontFamily: fontDisplay, + fontSize: 22, + fontWeight: FontWeight.w700, + height: 1.35, + letterSpacing: -0.3, + color: AppColors.textPrimary, + ); + + static const TextStyle displaySmall = TextStyle( + fontFamily: fontDisplay, + fontSize: 18, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: -0.2, + color: AppColors.textPrimary, + ); + + // ─── Headline Styles (Inter) ─────────────────────────────────────────────── + static const TextStyle headlineLarge = TextStyle( + fontFamily: fontBody, + fontSize: 20, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: -0.2, + color: AppColors.textPrimary, + ); + + static const TextStyle headlineMedium = TextStyle( + fontFamily: fontBody, + fontSize: 17, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: -0.1, + color: AppColors.textPrimary, + ); + + static const TextStyle headlineSmall = TextStyle( + fontFamily: fontBody, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.45, + letterSpacing: 0, + color: AppColors.textPrimary, + ); + + // ─── Body Styles (Inter) ─────────────────────────────────────────────────── + static const TextStyle bodyLarge = TextStyle( + fontFamily: fontBody, + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.5, + letterSpacing: 0, + color: AppColors.textPrimary, + ); + + static const TextStyle bodyMedium = TextStyle( + fontFamily: fontBody, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.5, + letterSpacing: 0.1, + color: AppColors.textPrimary, + ); + + static const TextStyle bodySmall = TextStyle( + fontFamily: fontBody, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.5, + letterSpacing: 0.2, + color: AppColors.textSecondary, + ); + + // ─── Label Styles (Inter) ────────────────────────────────────────────────── + static const TextStyle labelLarge = TextStyle( + fontFamily: fontBody, + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.4, + letterSpacing: 0.3, + color: AppColors.textPrimary, + ); + + static const TextStyle labelMedium = TextStyle( + fontFamily: fontBody, + fontSize: 11, + fontWeight: FontWeight.w500, + height: 1.4, + letterSpacing: 0.4, + color: AppColors.textSecondary, + ); + + static const TextStyle labelSmall = TextStyle( + fontFamily: fontBody, + fontSize: 10, + fontWeight: FontWeight.w500, + height: 1.4, + letterSpacing: 0.5, + color: AppColors.textSecondary, + ); + + // ─── Code Styles (JetBrains Mono) ───────────────────────────────────────── + static const TextStyle codeLarge = TextStyle( + fontFamily: fontCode, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1.6, + letterSpacing: 0, + color: AppColors.textSecondary, + ); + + static const TextStyle codeMedium = TextStyle( + fontFamily: fontCode, + fontSize: 13, + fontWeight: FontWeight.w400, + height: 1.6, + letterSpacing: 0, + color: AppColors.textSecondary, + ); + + static const TextStyle codeSmall = TextStyle( + fontFamily: fontCode, + fontSize: 11, + fontWeight: FontWeight.w400, + height: 1.6, + letterSpacing: 0, + color: AppColors.textSecondary, + ); + + // ─── Material 3 TextTheme ────────────────────────────────────────────────── + static TextTheme get textTheme => const TextTheme( + displayLarge: displayLarge, + displayMedium: displayMedium, + displaySmall: displaySmall, + headlineLarge: headlineLarge, + headlineMedium: headlineMedium, + headlineSmall: headlineSmall, + bodyLarge: bodyLarge, + bodyMedium: bodyMedium, + bodySmall: bodySmall, + labelLarge: labelLarge, + labelMedium: labelMedium, + labelSmall: labelSmall, + titleLarge: headlineLarge, + titleMedium: headlineMedium, + titleSmall: headlineSmall, + ); +} diff --git a/lib/utils/animations.dart b/lib/utils/animations.dart new file mode 100644 index 0000000..6f6b1d2 --- /dev/null +++ b/lib/utils/animations.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +/// Fade + slide entrance animation widget. +/// Use for staggered list item animations. +class FadeSlideIn extends StatefulWidget { + final Widget child; + final Duration delay; + final Duration duration; + final Offset offset; + + const FadeSlideIn({ + super.key, + required this.child, + this.delay = Duration.zero, + this.duration = const Duration(milliseconds: 400), + this.offset = const Offset(0, 20), + }); + + @override + State createState() => _FadeSlideInState(); +} + +class _FadeSlideInState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + late Animation _position; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: widget.duration); + _opacity = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + _position = Tween(begin: widget.offset, end: Offset.zero).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic), + ); + + if (widget.delay == Duration.zero) { + _controller.forward(); + } else { + Future.delayed(widget.delay, () { + if (mounted) _controller.forward(); + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (_, child) => Opacity( + opacity: _opacity.value, + child: Transform.translate(offset: _position.value, child: child), + ), + child: widget.child, + ); + } +} + +/// Gradient text widget using ShaderMask. +class GradientText extends StatelessWidget { + final String text; + final TextStyle style; + final Gradient gradient; + + const GradientText({ + super.key, + required this.text, + required this.style, + required this.gradient, + }); + + @override + Widget build(BuildContext context) { + return ShaderMask( + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + blendMode: BlendMode.srcIn, + child: Text(text, style: style.copyWith(color: Colors.white)), + ); + } +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 53aec30..dd56a38 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -1,119 +1,152 @@ import 'package:flutter/material.dart'; class AppStrings { - // App - static const String appName = 'DB Cracker - Tamaengs'; - static const String appAuthor = 'Tamaengs'; + AppStrings._(); - // Screens - static const String homeTitle = 'DATABASE CRACKER v3.0'; - static const String detailTitle = 'PROFIL SUBJEK'; + // App + static const String appName = 'DB Cracker'; + static const String appVersion = 'v2.0'; + static const String homeTitle = 'DB Cracker'; // Search - static const String searchHint = 'masukkan nama target...'; - static const String filterHint = 'filter universitas...'; - static const String emptySearchPrompt = - 'MASUKKAN NAMA TARGET UNTUK MEMULAI SCAN'; - static const String scanningMessage = 'MEMINDAI DATABASE...'; - static const String accessGranted = 'AKSES DIBERIKAN'; - static const String accessDenied = 'AKSES DITOLAK'; - static const String noResultsFound = 'DATA TIDAK DITEMUKAN UNTUK TARGET'; - static const String noFilterResults = 'TIDAK ADA HASIL UNTUK FILTER'; - static const String pleaseEnterSearchTerm = 'ERROR: TARGET BELUM DITENTUKAN'; - static const String errorSearching = 'KONEKSI TERPUTUS:'; - static const String clearFilter = 'BERSIHKAN FILTER'; + static const String searchHint = 'Cari mahasiswa, dosen, atau prodi...'; + static const String filterHint = 'Filter universitas...'; + static const String emptySearchPrompt = 'Masukkan nama untuk memulai pencarian'; + static const String scanningMessage = 'Mencari data...'; + static const String noResultsFound = 'Tidak ditemukan hasil untuk'; + static const String noFilterResults = 'Tidak ada hasil untuk filter ini'; + static const String noFilterResultsFound = 'Tidak ada hasil untuk filter ini'; + static const String pleaseEnterSearchTerm = 'Masukkan kata kunci pencarian'; + static const String errorSearching = 'Gagal mencari:'; + static const String clearFilter = 'Hapus Filter'; // Details - static const String personalInfoTitle = 'DATA PRIBADI'; - static const String academicInfoTitle = 'DATA INSTITUSI'; - static const String errorLoadingData = 'GAGAL EKSTRAK DATA:'; - static const String noDataAvailable = 'DATA AMAN - AKSES DIBATASI'; - static const String retry = 'COBA LAGI'; + static const String personalInfoTitle = 'Data Pribadi'; + static const String academicInfoTitle = 'Data Akademik'; + static const String errorLoadingData = 'Gagal memuat data:'; + static const String noDataAvailable = 'Data tidak tersedia'; + static const String retry = 'Coba Lagi'; // Student Info Labels - static const String name = 'Nama Subjek'; - static const String studentId = 'Nomor ID'; + static const String name = 'Nama'; + static const String studentId = 'NIM'; static const String gender = 'Jenis Kelamin'; static const String entryYear = 'Tahun Masuk'; static const String registrationType = 'Jenis Pendaftaran'; - static const String currentStatus = 'Status Aktif'; + static const String currentStatus = 'Status'; static const String university = 'Perguruan Tinggi'; static const String universityCode = 'Kode PT'; - static const String studyProgram = 'Program'; - static const String studyProgramCode = 'Kode Program'; + static const String studyProgram = 'Program Studi'; + static const String studyProgramCode = 'Kode Prodi'; static const String educationLevel = 'Jenjang'; - // Hacker theme elements - static const String initiateSearch = 'MULAI SCAN'; - static const String connecting = 'MENGHUBUNGKAN...'; - static const String decrypting = 'DEKRIPSI DATA...'; - static const String securingConnection = 'MENGAMANKAN TUNNEL...'; - static const String bypassingFirewall = 'BYPASS FIREWALL...'; - static const String extractingData = 'EKSTRAK DATA...'; - static const String hackingComplete = 'HACK BERHASIL'; - // Filter - static const String filterTitle = 'FILTER UNIVERSITAS'; - static const String selectUniversity = 'PILIH UNIVERSITAS'; - static const String filteringInProgress = 'MENERAPKAN FILTER...'; - static const String filterCleared = 'FILTER DIBERSIHKAN'; - static const String filterResults = 'HASIL FILTER'; - static const String reset = 'RESET'; - static const String noFilterResultsFound = 'TIDAK ADA HASIL UNTUK FILTER INI'; + static const String filterTitle = 'Filter Universitas'; + static const String selectUniversity = 'Pilih Universitas'; + static const String filterCleared = 'Filter dihapus'; + static const String filterResults = 'Hasil Filter'; + static const String reset = 'Reset'; } -/// Konstanta warna untuk tema ctOS Watch Dogs +/// LEGACY: CtOSColors kept temporarily for backward compatibility +/// during migration. Screens being migrated should use AppColors instead. +/// TODO: Remove after all screens migrated to Neo-Violet theme. class CtOSColors { - // Primary ctOS colors - static const Color primary = Color(0xFF00E5FF); // ctOS cyan blue - static const Color secondary = Color(0xFF0091EA); // Darker cyan - static const Color accent = Color(0xFFFFFFFF); // Pure white - static const Color warning = Color(0xFFFF6D00); // Orange warning - - // Background colors - static const Color background = Color(0xFF000000); // Pure black - static const Color surface = Color(0xFF0D1117); // Dark surface - static const Color surfaceVariant = Color(0xFF161B22); // Lighter surface - static const Color overlay = Color(0xFF21262D); // Overlay surface - - // Text colors - static const Color textPrimary = Color(0xFFFFFFFF); // White text - static const Color textSecondary = Color(0xFFB3B3B3); // Gray text - static const Color textTertiary = Color(0xFF7D8590); // Darker gray - static const Color textAccent = Color(0xFF00E5FF); // Cyan text - - // Status colors - static const Color success = Color(0xFF00E676); // Green success - static const Color error = Color(0xFFFF1744); // Red error - static const Color info = Color(0xFF00B0FF); // Blue info - - // Border and divider colors - static const Color border = Color(0xFF30363D); // Border gray - static const Color divider = Color(0xFF21262D); // Divider - - // Special effects - static const Color glow = Color(0xFF00E5FF); // Glow effect - static const Color shadow = Color(0x80000000); // Shadow + CtOSColors._(); + + // Primary ctOS colors — DEPRECATED, use AppColors.primary + static const Color primary = Color(0xFF7C3AED); + static const Color secondary = Color(0xFF06B6D4); + static const Color accent = Color(0xFFE2E8F0); + static const Color warning = Color(0xFFF59E0B); + + // Background colors — DEPRECATED, use AppColors.background + static const Color background = Color(0xFF0F0F23); + static const Color surface = Color(0xFF1A1A2E); + static const Color surfaceVariant = Color(0xFF252547); + static const Color overlay = Color(0xFF2F2F5C); + + // Text colors — DEPRECATED, use AppColors.textPrimary + static const Color textPrimary = Color(0xFFE2E8F0); + static const Color textSecondary = Color(0xFF94A3B8); + static const Color textTertiary = Color(0xFF64748B); + static const Color textAccent = Color(0xFF7C3AED); + + // Status colors — DEPRECATED, use AppColors.success/error + static const Color success = Color(0xFF10B981); + static const Color error = Color(0xFFEF4444); + static const Color info = Color(0xFF3B82F6); + + // Border and divider — DEPRECATED, use AppColors.border + static const Color border = Color(0xFF2E2E52); + static const Color divider = Color(0xFF1F1F3D); + + // Special effects — DEPRECATED + static const Color glow = Color(0xFF7C3AED); + static const Color shadow = Color(0x80000000); } -// Backward compatibility -class HackerColors { - static const Color primary = CtOSColors.primary; - static const Color accent = CtOSColors.secondary; - static const Color background = CtOSColors.background; - static const Color surface = CtOSColors.surface; - static const Color text = CtOSColors.textPrimary; - static const Color error = CtOSColors.error; - static const Color warning = CtOSColors.warning; - static const Color success = CtOSColors.success; - static const Color infoBox = CtOSColors.surfaceVariant; - static const Color highlight = CtOSColors.textAccent; +// DEPRECATED — use AppTypography instead +class AppTextStyles { + static const String fontFamily = 'Inter'; + + static const TextStyle heading = TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFFE2E8F0), + ); + + static const TextStyle body = TextStyle( + fontFamily: fontFamily, + fontSize: 14, + color: Color(0xFFE2E8F0), + ); + + static const TextStyle caption = TextStyle( + fontFamily: fontFamily, + fontSize: 12, + color: Color(0xFF94A3B8), + ); + + static const TextStyle small = TextStyle( + fontFamily: fontFamily, + fontSize: 10, + color: Color(0xFF94A3B8), + ); } -// Durasi animasi +// DEPRECATED — use AppSpacing instead class AnimationDurations { static const Duration fast = Duration(milliseconds: 150); - static const Duration medium = Duration(milliseconds: 300); - static const Duration slow = Duration(milliseconds: 500); + static const Duration medium = Duration(milliseconds: 250); + static const Duration slow = Duration(milliseconds: 350); static const Duration verySlow = Duration(milliseconds: 800); } + +// DEPRECATED — use AppSpacing instead +class AppDimensions { + static const double xs = 4.0; + static const double sm = 8.0; + static const double md = 12.0; + static const double lg = 16.0; + static const double xl = 24.0; + static const double xxl = 32.0; + + static const double radiusSm = 6.0; + static const double radiusMd = 8.0; + static const double radiusLg = 12.0; + static const double radiusXl = 20.0; +} + +/// API configuration constants — KEEP, do not modify +class ApiConstants { + ApiConstants._(); + + static const String pddiktiBaseUrl = 'https://api-pddikti.kemdiktisaintek.go.id'; + /// DEAD: domain no longer resolves (NXDOMAIN). Kept for reference only. + @Deprecated('Domain api-frontend.kemdikbud.go.id is dead since ~2025') + static const String kemdikbudBaseUrl = 'https://api-frontend.kemdikbud.go.id'; + static const Duration defaultTimeout = Duration(seconds: 20); + static const Duration searchTimeout = Duration(seconds: 30); +} diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart new file mode 100644 index 0000000..9acf7d8 --- /dev/null +++ b/lib/utils/json_utils.dart @@ -0,0 +1,27 @@ +/// Shared utility class untuk JSON parsing yang aman +/// Menggantikan duplikasi _ensureString dan _getStringValue di semua model +class JsonUtils { + /// Konversi dynamic value ke String, return '' jika null + static String ensureString(dynamic value) { + if (value == null) return ''; + return value.toString(); + } + + /// Ambil value dari Map dan konversi ke String, return '' jika null/tidak ada + static String getStringValue(Map json, String key) { + final value = json[key]; + if (value == null) return ''; + return value.toString(); + } + + /// Ambil value dari Map dengan multiple possible keys + static String getStringFromKeys(Map json, List keys) { + for (final key in keys) { + final value = json[key]; + if (value != null && value.toString().isNotEmpty) { + return value.toString(); + } + } + return ''; + } +} diff --git a/lib/utils/responsive.dart b/lib/utils/responsive.dart new file mode 100644 index 0000000..9c674cc --- /dev/null +++ b/lib/utils/responsive.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Responsive utility for adaptive layouts across screen sizes. +class Responsive { + Responsive._(); + + static bool isMobile(BuildContext context) => + MediaQuery.of(context).size.width < 600; + + static bool isTablet(BuildContext context) => + MediaQuery.of(context).size.width >= 600 && + MediaQuery.of(context).size.width < 900; + + static bool isDesktop(BuildContext context) => + MediaQuery.of(context).size.width >= 900; + + static EdgeInsets screenPadding(BuildContext context) { + final w = MediaQuery.of(context).size.width; + if (w >= 1200) return const EdgeInsets.symmetric(horizontal: 200); + if (w >= 900) return const EdgeInsets.symmetric(horizontal: 80); + if (w >= 600) return const EdgeInsets.symmetric(horizontal: 32); + return const EdgeInsets.symmetric(horizontal: 16); + } + + static int gridColumns(BuildContext context) { + if (isDesktop(context)) return 4; + if (isTablet(context)) return 3; + return 3; + } + + static double maxContentWidth(BuildContext context) { + if (isDesktop(context)) return 800; + if (isTablet(context)) return 600; + return double.infinity; + } +} diff --git a/lib/utils/screen_utils.dart b/lib/utils/screen_utils.dart index fd2ea55..16ee4dd 100644 --- a/lib/utils/screen_utils.dart +++ b/lib/utils/screen_utils.dart @@ -1,96 +1,87 @@ import 'package:flutter/material.dart'; -/// Utility class untuk mengatur responsivitas UI dan mencegah overflow +/// Utility class untuk responsive design yang beneran jalan +/// Harus panggil init(context) di build() sebelum pake method lain class ScreenUtils { - // Spesifik untuk Poco X3 Pro (1080 x 2400 pixels) - static double screenWidth = 1080; - static double screenHeight = 2400; - static double blockSizeHorizontal = 1080 / 100; // Tetap 10.8 - static double blockSizeVertical = 2400 / 100; // Tetap 24.0 + static double screenWidth = 0; + static double screenHeight = 0; + static double blockSizeHorizontal = 0; + static double blockSizeVertical = 0; - // Batasan ukuran untuk mencegah overflow static const double maxFontSize = 24.0; static const double maxIconSize = 48.0; - /// Mengembalikan ukuran relatif terhadap lebar layar dengan batasan - static double w(double width) { - return width; // Gunakan ukuran tetap untuk mencegah masalah layout + /// Inisialisasi dengan MediaQuery dari context + static void init(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + screenWidth = mediaQuery.size.width; + screenHeight = mediaQuery.size.height; + blockSizeHorizontal = screenWidth / 100; + blockSizeVertical = screenHeight / 100; } - /// Mengembalikan ukuran relatif terhadap tinggi layar dengan batasan - static double h(double height) { - return height; // Gunakan ukuran tetap untuk mencegah masalah layout - } + /// Cek apakah layar mobile (< 600px) + static bool isMobileScreen() => screenWidth > 0 && screenWidth < 600; - /// Skala font sesuai ukuran layar dengan batasan maksimum - static double sp(double size) { - return size.clamp(0.0, maxFontSize); // Terapkan batasan untuk font - } + /// Cek apakah layar tablet (600-1024px) + static bool isTabletScreen() => screenWidth >= 600 && screenWidth < 1024; - /// Ukuran ikon dengan batasan maksimum - static double iconSize(double size) { - return size.clamp(0.0, maxIconSize); // Terapkan batasan untuk ikon - } - - /// Menentukan apakah layar berukuran kecil (mobile) - static bool isMobileScreen() { - return true; // Selalu true karena ini app mobile - } + /// Cek apakah layar desktop (>= 1024px) + static bool isDesktopScreen() => screenWidth >= 1024; - /// Mendapatkan faktor skala yang sesuai + /// Scale factor berdasarkan ukuran layar static double getScaleFactor() { - return 1.0; // Gunakan skala 1.0 untuk konsistensi + if (screenWidth <= 0) return 1.0; + if (screenWidth < 360) return 0.8; + if (screenWidth < 600) return 1.0; + if (screenWidth < 1024) return 1.2; + return 1.4; } - /// Membuat ukuran padding yang responsif dan aman - static EdgeInsets responsivePadding( - {double horizontal = 0.0, - double vertical = 0.0, - double all = 0.0, - double left = 0.0, - double right = 0.0, - double top = 0.0, - double bottom = 0.0}) { - if (all > 0) { - return EdgeInsets.all(all); - } + /// Font size yang di-clamp antara min dan max + static double sp(double size) { + final scaled = size * getScaleFactor(); + return scaled.clamp(8.0, maxFontSize); + } - return EdgeInsets.fromLTRB( - left > 0 - ? left - : horizontal > 0 - ? horizontal - : 0.0, - top > 0 - ? top - : vertical > 0 - ? vertical - : 0.0, - right > 0 - ? right - : horizontal > 0 - ? horizontal - : 0.0, - bottom > 0 - ? bottom - : vertical > 0 - ? vertical - : 0.0); + /// Icon size yang di-clamp + static double iconSize(double size) { + return size.clamp(12.0, maxIconSize); } - /// Mendapatkan font size yang aman berdasarkan ukuran layar + /// Adaptive font size static double getAdaptiveFontSize(double size) { return size.clamp(8.0, maxFontSize); } - static void init(BuildContext context) {} + /// Responsive padding helper + static EdgeInsets responsivePadding({ + double all = 0, + double horizontal = 0, + double vertical = 0, + double left = 0, + double top = 0, + double right = 0, + double bottom = 0, + }) { + if (all > 0) return EdgeInsets.all(all); + if (horizontal > 0 || vertical > 0) { + return EdgeInsets.symmetric(horizontal: horizontal, vertical: vertical); + } + return EdgeInsets.only( + left: left > 0 ? left : horizontal, + top: top > 0 ? top : vertical, + right: right > 0 ? right : horizontal, + bottom: bottom > 0 ? bottom : vertical, + ); + } } -/// Extension untuk memudahkan penggunaan +/// Extension buat num supaya bisa pake .w, .h, .sp langsung extension SizeExtension on num { - double get w => toDouble(); // Ubah ke nilai tetap - double get h => toDouble(); // Ubah ke nilai tetap - double get sp => toDouble(); // Ubah ke nilai tetap + double get w => toDouble() * (ScreenUtils.screenWidth > 0 ? ScreenUtils.screenWidth / 375 : 1.0); + double get h => toDouble() * (ScreenUtils.screenHeight > 0 ? ScreenUtils.screenHeight / 812 : 1.0); + double get sp => ScreenUtils.sp(toDouble()); double get iconSize => ScreenUtils.iconSize(toDouble()); double get adaptiveFont => ScreenUtils.getAdaptiveFontSize(toDouble()); } diff --git a/lib/widgets/console_text.dart b/lib/widgets/console_text.dart deleted file mode 100644 index aad1029..0000000 --- a/lib/widgets/console_text.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -class ConsoleText extends StatefulWidget { - final String text; - final bool isInput; - final bool isError; - final bool isSuccess; - - const ConsoleText({ - Key? key, - required this.text, - this.isInput = false, - this.isError = false, - this.isSuccess = false, - }) : super(key: key); - - @override - _ConsoleTextState createState() => _ConsoleTextState(); -} - -class _ConsoleTextState extends State with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _fadeInAnimation; - late Animation _textLengthAnimation; - bool _isComplete = false; - - @override - void initState() { - super.initState(); - - // Hitung durasi berdasarkan panjang teks, tapi dengan batasan maksimum - final int textLength = widget.text.length; - final int maxDuration = 2000; // ms - final int baseDuration = 500; // ms - final int calculatedDuration = baseDuration + (textLength * 20); - final int safeDuration = calculatedDuration > maxDuration ? maxDuration : calculatedDuration; - - _animationController = AnimationController( - duration: Duration(milliseconds: safeDuration), - vsync: this, - ); - - _fadeInAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.2, curve: Curves.easeOut), - )); - - _textLengthAnimation = IntTween( - begin: 0, - end: widget.text.length, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOut, - )); - - _animationController.forward().then((_) { - if (mounted) { - setState(() { - _isComplete = true; - }); - } - }); - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - Color textColor = HackerColors.text; - - if (widget.isError) { - textColor = HackerColors.error; - } else if (widget.isSuccess) { - textColor = HackerColors.success; - } else if (widget.isInput) { - textColor = HackerColors.primary; - } - - return AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - // Cegah overflow dengan substring yang aman - final int safeEnd = _textLengthAnimation.value < widget.text.length - ? _textLengthAnimation.value - : widget.text.length; - - final String displayedText = widget.text.substring(0, safeEnd); - - return Opacity( - opacity: _fadeInAnimation.value, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.isInput) - const Text( - ">", - style: TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - ), - ), - if (widget.isInput) - const SizedBox(width: 8), - Expanded( - child: Text( - displayedText, - style: TextStyle( - color: textColor, - fontFamily: 'Courier', - fontSize: 14, - height: 1.5, - ), - overflow: TextOverflow.ellipsis, - maxLines: 5, // Batasi jumlah baris untuk mencegah overflow - ), - ), - if (!_isComplete && _textLengthAnimation.value < widget.text.length) - const Text( - "█", - style: TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - ), - ), - ], - ), - ), - ); - }, - ); - } -} \ No newline at end of file diff --git a/lib/widgets/core/neo_badge.dart b/lib/widgets/core/neo_badge.dart new file mode 100644 index 0000000..44ff994 --- /dev/null +++ b/lib/widgets/core/neo_badge.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; + +enum NeoBadgeVariant { success, warning, error, info, neutral } + +class NeoBadge extends StatelessWidget { + final String label; + final NeoBadgeVariant variant; + final IconData? icon; + + const NeoBadge({ + super.key, + required this.label, + this.variant = NeoBadgeVariant.neutral, + this.icon, + }); + + Color get _color { + switch (variant) { + case NeoBadgeVariant.success: + return AppColors.success; + case NeoBadgeVariant.warning: + return AppColors.warning; + case NeoBadgeVariant.error: + return AppColors.error; + case NeoBadgeVariant.info: + return AppColors.info; + case NeoBadgeVariant.neutral: + return AppColors.textSecondary; + } + } + + Color get _bgColor { + switch (variant) { + case NeoBadgeVariant.success: + return AppColors.successSurface; + case NeoBadgeVariant.warning: + return AppColors.warningSurface; + case NeoBadgeVariant.error: + return AppColors.errorSurface; + case NeoBadgeVariant.info: + return AppColors.infoSurface; + case NeoBadgeVariant.neutral: + return AppColors.surfaceHigh; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _bgColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _color.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: _color), + const SizedBox(width: 4), + ], + Text( + label, + style: AppTypography.labelSmall.copyWith( + color: _color, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/core/neo_card.dart b/lib/widgets/core/neo_card.dart new file mode 100644 index 0000000..4cb93a0 --- /dev/null +++ b/lib/widgets/core/neo_card.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_spacing.dart'; +import '../../theme/app_gradients.dart'; + +enum NeoCardVariant { flat, elevated, gradient, outlined } + +class NeoCard extends StatelessWidget { + final Widget child; + final NeoCardVariant variant; + final EdgeInsets? padding; + final EdgeInsets? margin; + final double? borderRadius; + final VoidCallback? onTap; + + const NeoCard({ + super.key, + required this.child, + this.variant = NeoCardVariant.flat, + this.padding, + this.margin, + this.borderRadius, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final radius = borderRadius ?? AppSpacing.radiusLg; + + final decoration = BoxDecoration( + color: variant == NeoCardVariant.gradient + ? null + : variant == NeoCardVariant.elevated + ? AppColors.surfaceHigh + : AppColors.surface, + gradient: variant == NeoCardVariant.gradient ? AppGradients.card : null, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: AppColors.border, width: 1), + boxShadow: variant == NeoCardVariant.elevated + ? AppSpacing.shadowMd + : AppSpacing.shadowNone, + ); + + Widget card = Container( + margin: margin, + decoration: decoration, + child: Padding( + padding: padding ?? AppSpacing.cardPadding, + child: child, + ), + ); + + if (onTap != null) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(radius), + splashColor: AppColors.primary.withOpacity(0.08), + highlightColor: AppColors.primary.withOpacity(0.04), + child: card, + ), + ); + } + return card; + } +} diff --git a/lib/widgets/ctos_container.dart b/lib/widgets/ctos_container.dart deleted file mode 100644 index 9b4c482..0000000 --- a/lib/widgets/ctos_container.dart +++ /dev/null @@ -1,333 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -/// Container dengan styling ctOS yang elegan dan responsif -class CtOSContainer extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final double? width; - final double? height; - final bool showBorder; - final bool showGlow; - final Color? backgroundColor; - final Color? borderColor; - - const CtOSContainer({ - Key? key, - required this.child, - this.padding, - this.margin, - this.width, - this.height, - this.showBorder = true, - this.showGlow = false, - this.backgroundColor, - this.borderColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - width: width, - height: height, - margin: margin ?? const EdgeInsets.all(8.0), - padding: padding ?? const EdgeInsets.all(16.0), - decoration: BoxDecoration( - color: backgroundColor ?? CtOSColors.surface, - border: showBorder - ? Border.all( - color: borderColor ?? CtOSColors.border, - width: 1.0, - ) - : null, - borderRadius: BorderRadius.circular(4.0), - boxShadow: showGlow - ? [ - BoxShadow( - color: CtOSColors.glow.withOpacity(0.3), - blurRadius: 8.0, - spreadRadius: 2.0, - ), - ] - : [ - BoxShadow( - color: CtOSColors.shadow, - blurRadius: 4.0, - offset: const Offset(0, 2), - ), - ], - ), - child: child, - ); - } -} - -/// Text widget dengan styling ctOS -class CtOSText extends StatelessWidget { - final String text; - final TextStyle? style; - final Color? color; - final double? fontSize; - final FontWeight? fontWeight; - final TextAlign? textAlign; - final int? maxLines; - final TextOverflow overflow; - final bool isMonospace; - - const CtOSText( - this.text, { - Key? key, - this.style, - this.color, - this.fontSize, - this.fontWeight, - this.textAlign, - this.maxLines, - this.overflow = TextOverflow.ellipsis, - this.isMonospace = true, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Text( - text, - style: (style ?? const TextStyle()).copyWith( - color: color ?? CtOSColors.textPrimary, - fontSize: fontSize ?? 14.0, - fontWeight: fontWeight ?? FontWeight.normal, - fontFamily: isMonospace ? 'Courier' : null, - letterSpacing: isMonospace ? 0.5 : null, - ), - textAlign: textAlign, - maxLines: maxLines, - overflow: overflow, - ); - } -} - -/// Header dengan styling ctOS -class CtOSHeader extends StatelessWidget { - final String title; - final String? subtitle; - final Widget? trailing; - final bool showDivider; - - const CtOSHeader({ - Key? key, - required this.title, - this.subtitle, - this.trailing, - this.showDivider = true, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CtOSText( - title, - fontSize: 18.0, - fontWeight: FontWeight.bold, - color: CtOSColors.primary, - ), - if (subtitle != null) ...[ - const SizedBox(height: 4.0), - CtOSText( - subtitle!, - fontSize: 12.0, - color: CtOSColors.textSecondary, - ), - ], - ], - ), - ), - if (trailing != null) trailing!, - ], - ), - if (showDivider) ...[ - const SizedBox(height: 12.0), - Container( - height: 1.0, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - CtOSColors.primary.withOpacity(0.0), - CtOSColors.primary, - CtOSColors.primary.withOpacity(0.0), - ], - ), - ), - ), - const SizedBox(height: 12.0), - ], - ], - ); - } -} - -/// Button dengan styling ctOS -class CtOSButton extends StatelessWidget { - final String text; - final VoidCallback? onPressed; - final bool isLoading; - final bool isPrimary; - final IconData? icon; - final double? width; - - const CtOSButton({ - Key? key, - required this.text, - this.onPressed, - this.isLoading = false, - this.isPrimary = true, - this.icon, - this.width, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: width, - height: 48.0, - child: ElevatedButton( - onPressed: isLoading ? null : onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: isPrimary ? CtOSColors.primary : CtOSColors.surface, - foregroundColor: isPrimary ? CtOSColors.background : CtOSColors.textPrimary, - side: BorderSide( - color: isPrimary ? CtOSColors.primary : CtOSColors.border, - width: 1.0, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4.0), - ), - elevation: 0, - ), - child: isLoading - ? SizedBox( - width: 20.0, - height: 20.0, - child: CircularProgressIndicator( - strokeWidth: 2.0, - valueColor: AlwaysStoppedAnimation( - isPrimary ? CtOSColors.background : CtOSColors.primary, - ), - ), - ) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: 18.0), - const SizedBox(width: 8.0), - ], - CtOSText( - text, - color: isPrimary ? CtOSColors.background : CtOSColors.textPrimary, - fontWeight: FontWeight.bold, - ), - ], - ), - ), - ); - } -} - -/// Status indicator dengan styling ctOS -class CtOSStatusIndicator extends StatefulWidget { - final bool isActive; - final String label; - final double size; - - const CtOSStatusIndicator({ - Key? key, - required this.isActive, - required this.label, - this.size = 12.0, - }) : super(key: key); - - @override - _CtOSStatusIndicatorState createState() => _CtOSStatusIndicatorState(); -} - -class _CtOSStatusIndicatorState extends State - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 1000), - vsync: this, - ); - if (widget.isActive) { - _animationController.repeat(reverse: true); - } - } - - @override - void didUpdateWidget(CtOSStatusIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.isActive != oldWidget.isActive) { - if (widget.isActive) { - _animationController.repeat(reverse: true); - } else { - _animationController.stop(); - } - } - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.isActive - ? CtOSColors.primary.withOpacity(0.3 + 0.7 * _animationController.value) - : CtOSColors.textTertiary, - boxShadow: widget.isActive - ? [ - BoxShadow( - color: CtOSColors.primary.withOpacity(0.5), - blurRadius: 4.0, - spreadRadius: 1.0, - ), - ] - : null, - ), - ); - }, - ), - const SizedBox(width: 8.0), - CtOSText( - widget.label, - fontSize: 12.0, - color: widget.isActive ? CtOSColors.textPrimary : CtOSColors.textSecondary, - ), - ], - ); - } -} diff --git a/lib/widgets/ctos_layout.dart b/lib/widgets/ctos_layout.dart deleted file mode 100644 index 0f56f90..0000000 --- a/lib/widgets/ctos_layout.dart +++ /dev/null @@ -1,362 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; -import 'ctos_container.dart'; - -/// Layout responsif untuk mengatasi overflow -class CtOSResponsiveLayout extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry? padding; - final bool enableScroll; - - const CtOSResponsiveLayout({ - Key? key, - required this.child, - this.padding, - this.enableScroll = true, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - Widget content = Container( - width: double.infinity, - constraints: const BoxConstraints( - minHeight: 0, - maxHeight: double.infinity, - ), - padding: padding ?? const EdgeInsets.all(16.0), - child: child, - ); - - if (enableScroll) { - content = SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: MediaQuery.of(context).size.height - - MediaQuery.of(context).padding.top - - kToolbarHeight, - ), - child: content, - ), - ); - } - - return content; - } -} - -/// Grid layout yang responsif untuk card -class CtOSResponsiveGrid extends StatelessWidget { - final List children; - final double maxCrossAxisExtent; - final double mainAxisSpacing; - final double crossAxisSpacing; - final double childAspectRatio; - - const CtOSResponsiveGrid({ - Key? key, - required this.children, - this.maxCrossAxisExtent = 400.0, - this.mainAxisSpacing = 16.0, - this.crossAxisSpacing = 16.0, - this.childAspectRatio = 1.0, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: maxCrossAxisExtent, - mainAxisSpacing: mainAxisSpacing, - crossAxisSpacing: crossAxisSpacing, - childAspectRatio: childAspectRatio, - ), - itemCount: children.length, - itemBuilder: (context, index) => children[index], - ); - } -} - -/// List item dengan styling ctOS -class CtOSListItem extends StatelessWidget { - final String title; - final String? subtitle; - final String? trailing; - final Widget? leadingIcon; - final VoidCallback? onTap; - final bool showDivider; - - const CtOSListItem({ - Key? key, - required this.title, - this.subtitle, - this.trailing, - this.leadingIcon, - this.onTap, - this.showDivider = true, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(4.0), - child: Container( - padding: - const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), - child: Row( - children: [ - if (leadingIcon != null) ...[ - leadingIcon!, - const SizedBox(width: 12.0), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CtOSText( - title, - fontSize: 14.0, - fontWeight: FontWeight.w500, - maxLines: 2, - ), - if (subtitle != null) ...[ - const SizedBox(height: 4.0), - CtOSText( - subtitle!, - fontSize: 12.0, - color: CtOSColors.textSecondary, - maxLines: 3, - ), - ], - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 12.0), - CtOSText( - trailing!, - fontSize: 12.0, - color: CtOSColors.textAccent, - fontWeight: FontWeight.bold, - ), - ], - if (onTap != null) ...[ - const SizedBox(width: 8.0), - Icon( - Icons.chevron_right, - color: CtOSColors.textSecondary, - size: 18.0, - ), - ], - ], - ), - ), - ), - if (showDivider) - Container( - height: 1.0, - margin: const EdgeInsets.symmetric(horizontal: 16.0), - color: CtOSColors.divider, - ), - ], - ); - } -} - -/// Data row untuk menampilkan informasi -class CtOSDataRow extends StatelessWidget { - final String label; - final String value; - final bool isHighlighted; - - const CtOSDataRow({ - Key? key, - required this.label, - required this.value, - this.isHighlighted = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120.0, - child: CtOSText( - "$label:", - fontSize: 12.0, - color: CtOSColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 12.0), - Expanded( - child: CtOSText( - value.isNotEmpty ? value : "N/A", - fontSize: 12.0, - color: isHighlighted - ? CtOSColors.textAccent - : (value.isNotEmpty - ? CtOSColors.textPrimary - : CtOSColors.textTertiary), - maxLines: 3, - ), - ), - ], - ), - ); - } -} - -/// Search bar dengan styling ctOS -class CtOSSearchBar extends StatelessWidget { - final TextEditingController controller; - final String hintText; - final VoidCallback? onSearch; - final ValueChanged? onChanged; - final bool isLoading; - - const CtOSSearchBar({ - Key? key, - required this.controller, - required this.hintText, - this.onSearch, - this.onChanged, - this.isLoading = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: CtOSColors.surface, - border: Border.all(color: CtOSColors.border), - borderRadius: BorderRadius.circular(4.0), - boxShadow: [ - BoxShadow( - color: CtOSColors.shadow, - blurRadius: 4.0, - offset: const Offset(0, 2), - ), - ], - ), - child: TextField( - controller: controller, - onChanged: onChanged, - onSubmitted: onSearch != null ? (_) => onSearch!() : null, - style: const TextStyle( - color: CtOSColors.textPrimary, - fontFamily: 'Courier', - fontSize: 14.0, - ), - decoration: InputDecoration( - hintText: hintText, - hintStyle: TextStyle( - color: CtOSColors.textTertiary, - fontFamily: 'Courier', - fontSize: 14.0, - ), - prefixIcon: Icon( - Icons.search, - color: CtOSColors.textSecondary, - size: 20.0, - ), - suffixIcon: isLoading - ? Container( - width: 20.0, - height: 20.0, - padding: const EdgeInsets.all(12.0), - child: CircularProgressIndicator( - strokeWidth: 2.0, - valueColor: - AlwaysStoppedAnimation(CtOSColors.primary), - ), - ) - : (onSearch != null - ? IconButton( - icon: Icon( - Icons.play_arrow, - color: CtOSColors.primary, - size: 20.0, - ), - onPressed: onSearch, - ) - : null), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), - ), - ), - ); - } -} - -/// Terminal window dengan styling ctOS -class CtOSTerminal extends StatelessWidget { - final String title; - final List messages; - final bool isActive; - final double? height; - - const CtOSTerminal({ - Key? key, - required this.title, - required this.messages, - this.isActive = false, - this.height, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return CtOSContainer( - backgroundColor: CtOSColors.background, - borderColor: isActive ? CtOSColors.primary : CtOSColors.border, - showGlow: isActive, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CtOSHeader( - title: title, - trailing: CtOSStatusIndicator( - isActive: isActive, - label: isActive ? "ACTIVE" : "IDLE", - ), - ), - Container( - height: height ?? 200.0, - width: double.infinity, - padding: const EdgeInsets.all(12.0), - decoration: BoxDecoration( - color: CtOSColors.background, - border: Border.all(color: CtOSColors.border), - borderRadius: BorderRadius.circular(4.0), - ), - child: ListView.builder( - itemCount: messages.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: CtOSText( - "> ${messages[index]}", - fontSize: 12.0, - color: CtOSColors.textAccent, - ), - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/lib/widgets/data/neo_data_row.dart b/lib/widgets/data/neo_data_row.dart new file mode 100644 index 0000000..a59c4fb --- /dev/null +++ b/lib/widgets/data/neo_data_row.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; + +class NeoDataRow extends StatelessWidget { + final String label; + final String value; + final IconData? icon; + final bool isCode; + final bool copyable; + + const NeoDataRow({ + super.key, + required this.label, + required this.value, + this.icon, + this.isCode = false, + this.copyable = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: AppColors.textTertiary), + const SizedBox(width: 10), + ], + SizedBox( + width: 110, + child: Text( + label, + style: AppTypography.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value.isNotEmpty ? value : '-', + style: isCode ? AppTypography.codeMedium : AppTypography.bodyMedium, + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + ), + if (copyable && value.isNotEmpty) + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Disalin: $value'), + duration: const Duration(seconds: 1), + ), + ); + }, + child: const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon(Icons.copy_rounded, size: 14, color: AppColors.textTertiary), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/data/neo_stat_card.dart b/lib/widgets/data/neo_stat_card.dart new file mode 100644 index 0000000..00657aa --- /dev/null +++ b/lib/widgets/data/neo_stat_card.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; +import '../../theme/app_spacing.dart'; + +class NeoStatCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color? color; + final String? subtitle; + + const NeoStatCard({ + super.key, + required this.label, + required this.value, + required this.icon, + this.color, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final accentColor = color ?? AppColors.primary; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: accentColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: accentColor, size: 18), + ), + const Spacer(), + if (subtitle != null) + Text(subtitle!, style: AppTypography.codeSmall), + ], + ), + const SizedBox(height: 14), + Text( + value, + style: AppTypography.displaySmall.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: AppTypography.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/dosen_navigation_button.dart b/lib/widgets/dosen_navigation_button.dart index 323d4da..dd798ff 100644 --- a/lib/widgets/dosen_navigation_button.dart +++ b/lib/widgets/dosen_navigation_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../models/dosen.dart'; import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; import 'flexible_text.dart'; /// Widget tombol untuk navigasi ke layar detail dosen @@ -17,20 +17,9 @@ class DosenNavigationButton extends StatelessWidget { @override Widget build(BuildContext context) { - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - return InkWell( onTap: () { - // Navigasi ke detail dosen - // Gunakan pushNamed agar lebih konsisten dengan navigasi lainnya - Navigator.pushNamed( - context, - '/dosen/detail/${dosen.id}', - arguments: { - 'dosenName': dosen.nama, - }, - ); + context.push('/dosen/${Uri.encodeComponent(dosen.id)}?name=${Uri.encodeComponent(dosen.nama)}'); }, child: Container( margin: EdgeInsets.symmetric( @@ -39,12 +28,12 @@ class DosenNavigationButton extends StatelessWidget { ), padding: EdgeInsets.all(isCompact ? 8.0 : 12.0), decoration: BoxDecoration( - color: HackerColors.surface, + color: CtOSColors.surface, borderRadius: BorderRadius.circular(4.0), - border: Border.all(color: HackerColors.accent, width: 1.0), + border: Border.all(color: CtOSColors.secondary, width: 1.0), boxShadow: [ BoxShadow( - color: HackerColors.primary.withOpacity(0.1), + color: CtOSColors.primary.withValues(alpha: 0.1), blurRadius: 4, offset: Offset(0, 2), ), @@ -54,7 +43,7 @@ class DosenNavigationButton extends StatelessWidget { children: [ Icon( Icons.person, - color: HackerColors.primary, + color: CtOSColors.primary, size: isCompact ? 16.0 : 20.0, ), SizedBox(width: 8.0), @@ -65,7 +54,7 @@ class DosenNavigationButton extends StatelessWidget { FlexibleText( dosen.nama, style: TextStyle( - color: HackerColors.primary, + color: CtOSColors.primary, fontFamily: 'Courier', fontSize: isCompact ? 12.0 : 14.0, fontWeight: FontWeight.bold, @@ -77,7 +66,7 @@ class DosenNavigationButton extends StatelessWidget { FlexibleText( 'NIDN: ${dosen.nidn} | ${dosen.namaPt}', style: TextStyle( - color: HackerColors.text.withOpacity(0.8), + color: CtOSColors.textPrimary.withValues(alpha: 0.8), fontFamily: 'Courier', fontSize: 12.0, ), @@ -89,7 +78,7 @@ class DosenNavigationButton extends StatelessWidget { ), Icon( Icons.arrow_forward_ios, - color: HackerColors.accent, + color: CtOSColors.secondary, size: isCompact ? 14.0 : 16.0, ), ], diff --git a/lib/widgets/dosen_search_button.dart b/lib/widgets/dosen_search_button.dart deleted file mode 100644 index e92deab..0000000 --- a/lib/widgets/dosen_search_button.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; -import 'flexible_text.dart'; -import '../screens/dosen_search_screen_new.dart'; - -/// Widget tombol untuk navigasi ke layar pencarian dosen -class DosenSearchButton extends StatelessWidget { - final bool isCompact; - - const DosenSearchButton({ - Key? key, - this.isCompact = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - - return InkWell( - onTap: () { - // Navigasi ke pencarian dosen - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DosenSearchScreenNew(), - ), - ); - }, - child: Container( - margin: EdgeInsets.symmetric( - vertical: 8.0, - horizontal: isCompact ? 4.0 : 16.0, - ), - padding: const EdgeInsets.symmetric( - vertical: 12.0, - horizontal: 16.0, - ), - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4.0), - border: Border.all(color: HackerColors.primary, width: 1.0), - boxShadow: [ - BoxShadow( - color: HackerColors.primary.withOpacity(0.2), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.person_search, - color: HackerColors.primary, - size: 20.0, - ), - const SizedBox(width: 12.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const FlexibleText( - "PENCARIAN DOSEN", - style: TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14.0, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - ), - if (!isCompact) - const FlexibleText( - "Cari dan akses data dosen seluruh Indonesia", - style: TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 12.0, - ), - maxLines: 1, - ), - ], - ), - ), - const Icon( - Icons.search, - color: HackerColors.primary, - size: 20.0, - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/error_boundary.dart b/lib/widgets/error_boundary.dart new file mode 100644 index 0000000..ea62db6 --- /dev/null +++ b/lib/widgets/error_boundary.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; +import '../theme/app_typography.dart'; + +/// Widget untuk menangani error state +class CtOSErrorBoundary extends StatelessWidget { + final Widget child; + final String? errorMessage; + final VoidCallback? onRetry; + final bool showRetryButton; + + const CtOSErrorBoundary({ + Key? key, + required this.child, + this.errorMessage, + this.onRetry, + this.showRetryButton = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (errorMessage != null) { + return _buildErrorWidget(); + } + return child; + } + + Widget _buildErrorWidget() { + return Container( + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.errorSurface, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.error_outline_rounded, + color: AppColors.error, + size: 32, + ), + ), + const SizedBox(height: 20), + Text( + 'Terjadi Kesalahan', + style: AppTypography.headlineSmall.copyWith(color: AppColors.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'Terjadi kesalahan yang tidak diketahui', + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 20), + if (showRetryButton && onRetry != null) + OutlinedButton.icon( + onPressed: onRetry!, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Coba Lagi'), + ), + ], + ), + ); + } +} + +/// Widget untuk loading state +class CtOSLoadingWidget extends StatefulWidget { + final String? message; + final List? consoleMessages; + + const CtOSLoadingWidget({ + Key? key, + this.message, + this.consoleMessages, + }) : super(key: key); + + @override + State createState() => _CtOSLoadingWidgetState(); +} + +class _CtOSLoadingWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + _animationController.repeat(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + strokeWidth: 3, + color: AppColors.primary, + ), + ), + const SizedBox(height: 20), + Text( + widget.message ?? 'Memuat data...', + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +/// Widget untuk empty state +class CtOSEmptyWidget extends StatelessWidget { + final String title; + final String message; + final IconData? icon; + final VoidCallback? onAction; + final String? actionText; + + const CtOSEmptyWidget({ + Key? key, + required this.title, + required this.message, + this.icon, + this.onAction, + this.actionText, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + icon ?? Icons.inbox_rounded, + color: AppColors.textTertiary, + size: 32, + ), + ), + const SizedBox(height: 20), + Text( + title, + style: AppTypography.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 20), + if (onAction != null && actionText != null) + ElevatedButton( + onPressed: onAction!, + child: Text(actionText!), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/feedback/neo_empty.dart b/lib/widgets/feedback/neo_empty.dart new file mode 100644 index 0000000..59fcd4f --- /dev/null +++ b/lib/widgets/feedback/neo_empty.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; + +class NeoEmpty extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final String? actionLabel; + final VoidCallback? onAction; + + const NeoEmpty({ + super.key, + this.icon = Icons.inbox_rounded, + required this.title, + this.subtitle, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Icon(icon, size: 32, color: AppColors.textTertiary), + ), + const SizedBox(height: 20), + Text( + title, + style: AppTypography.headlineSmall, + textAlign: TextAlign.center, + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + ), + ], + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 20), + ElevatedButton( + onPressed: onAction, + child: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/feedback/neo_error.dart b/lib/widgets/feedback/neo_error.dart new file mode 100644 index 0000000..d34aea2 --- /dev/null +++ b/lib/widgets/feedback/neo_error.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; + +class NeoError extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const NeoError({ + super.key, + required this.message, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.errorSurface, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.error_outline_rounded, + size: 32, + color: AppColors.error, + ), + ), + const SizedBox(height: 20), + Text( + 'Terjadi Kesalahan', + style: AppTypography.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: AppTypography.bodySmall, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + if (onRetry != null) ...[ + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Coba Lagi'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/feedback/neo_skeleton.dart b/lib/widgets/feedback/neo_skeleton.dart new file mode 100644 index 0000000..56cc2ad --- /dev/null +++ b/lib/widgets/feedback/neo_skeleton.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; + +class NeoSkeleton extends StatefulWidget { + final double? width; + final double height; + final double borderRadius; + + const NeoSkeleton({ + super.key, + this.width, + this.height = 16, + this.borderRadius = 4, + }); + + factory NeoSkeleton.card() => const NeoSkeleton( + width: double.infinity, + height: 120, + borderRadius: 12, + ); + + factory NeoSkeleton.circle({double size = 40}) => NeoSkeleton( + width: size, + height: size, + borderRadius: size / 2, + ); + + factory NeoSkeleton.text({double? width}) => NeoSkeleton( + width: width, + height: 14, + borderRadius: 4, + ); + + @override + State createState() => _NeoSkeletonState(); +} + +class _NeoSkeletonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + gradient: LinearGradient( + colors: const [ + AppColors.shimmerBase, + AppColors.shimmerHighlight, + AppColors.shimmerBase, + ], + stops: const [0.0, 0.5, 1.0], + begin: Alignment(_ctrl.value * 3 - 1, 0), + end: Alignment(_ctrl.value * 3, 0), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/filter_overlay.dart b/lib/widgets/filter_overlay.dart deleted file mode 100644 index 4524dd9..0000000 --- a/lib/widgets/filter_overlay.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:math'; -import '../utils/constants.dart'; - -/// Widget overlay untuk menampilkan animasi proses filtering -class FilterOverlay extends StatefulWidget { - final String message; - - const FilterOverlay({ - Key? key, - this.message = 'MENERAPKAN FILTER...', - }) : super(key: key); - - @override - _FilterOverlayState createState() => _FilterOverlayState(); -} - -class _FilterOverlayState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - final Random _random = Random(); - List _hexLines = []; - int _currentDots = 0; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - )..repeat(); - - _controller.addListener(_updateDots); - _generateHexCode(); - } - - @override - void dispose() { - _controller.removeListener(_updateDots); - _controller.dispose(); - super.dispose(); - } - - void _updateDots() { - if (_controller.value < 0.33) { - if (_currentDots != 1) { - setState(() { - _currentDots = 1; - _generateHexCode(); - }); - } - } else if (_controller.value < 0.66) { - if (_currentDots != 2) { - setState(() { - _currentDots = 2; - _generateHexCode(); - }); - } - } else { - if (_currentDots != 3) { - setState(() { - _currentDots = 3; - _generateHexCode(); - }); - } - } - } - - void _generateHexCode() { - final hexChars = '0123456789ABCDEF'; - _hexLines = List.generate(4, (_) { - final length = _random.nextInt(16) + 8; - return List.generate(length, (_) { - return hexChars[_random.nextInt(hexChars.length)]; - }).join(''); - }); - } - - @override - Widget build(BuildContext context) { - final dots = '.' * _currentDots; - - return Container( - color: HackerColors.background.withOpacity(0.85), - child: Center( - child: Container( - width: 200, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.warning), - boxShadow: [ - BoxShadow( - color: HackerColors.warning.withOpacity(0.2), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 40, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: HackerColors.warning, - width: 2, - ), - ), - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(HackerColors.warning), - strokeWidth: 3, - ), - ), - const SizedBox(height: 16), - Text( - "${widget.message}$dots", - style: const TextStyle( - color: HackerColors.warning, - fontSize: 14, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Container( - width: 160, - height: 40, - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all(color: HackerColors.warning.withOpacity(0.5)), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: _hexLines.map((line) => Text( - line, - style: TextStyle( - color: HackerColors.warning.withOpacity(0.7), - fontSize: 8, - fontFamily: 'Courier', - height: 1.2, - ), - )).toList(), - ), - ), - ], - ), - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/filter_search_bar.dart b/lib/widgets/filter_search_bar.dart deleted file mode 100644 index 8e5df38..0000000 --- a/lib/widgets/filter_search_bar.dart +++ /dev/null @@ -1,306 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -/// Widget search bar untuk filter universitas -class FilterSearchBar extends StatefulWidget { - final List universities; - final String? selectedUniversity; - final Function(String?) onFilter; - final VoidCallback onClear; - final TextEditingController controller; - - const FilterSearchBar({ - Key? key, - required this.universities, - required this.selectedUniversity, - required this.onFilter, - required this.onClear, - required this.controller, - }) : super(key: key); - - @override - _FilterSearchBarState createState() => _FilterSearchBarState(); -} - -class _FilterSearchBarState extends State { - List _filteredUniversities = []; - bool _showSuggestions = false; - final FocusNode _focusNode = FocusNode(); - - @override - void initState() { - super.initState(); - _filteredUniversities = widget.universities; - - _focusNode.addListener(() { - if (_focusNode.hasFocus) { - setState(() { - _showSuggestions = true; - }); - } - }); - } - - @override - void didUpdateWidget(FilterSearchBar oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.universities != widget.universities) { - _filteredUniversities = widget.universities; - } - } - - void _filterUniversities(String query) { - if (query.isEmpty) { - setState(() { - _filteredUniversities = widget.universities; - }); - return; - } - - setState(() { - _filteredUniversities = widget.universities - .where((university) => - university.toLowerCase().contains(query.toLowerCase())) - .toList(); - _showSuggestions = true; - }); - } - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.warning, width: 1), - boxShadow: [ - BoxShadow( - color: HackerColors.warning.withOpacity(0.2), - spreadRadius: 1, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: HackerColors.surface.withOpacity(0.8), - border: const Border( - bottom: BorderSide( - color: HackerColors.warning, - width: 1, - ), - ), - ), - child: Row( - children: const [ - Icon( - Icons.filter_list, - color: HackerColors.warning, - size: 14, - ), - SizedBox(width: 4), - Text( - AppStrings.filterTitle, - style: TextStyle( - color: HackerColors.warning, - fontSize: 12, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - - // Search input - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.warning.withOpacity(0.5), - ), - ), - child: TextField( - controller: widget.controller, - focusNode: _focusNode, - style: const TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - ), - decoration: InputDecoration( - hintText: AppStrings.filterHint, - hintStyle: TextStyle( - color: HackerColors.text.withOpacity(0.5), - fontFamily: 'Courier', - fontSize: 12, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10 - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - prefixIcon: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Icon( - Icons.search, - color: HackerColors.warning, - size: 14, - ), - ), - suffixIcon: widget.controller.text.isNotEmpty - ? IconButton( - icon: const Icon( - Icons.clear, - color: HackerColors.warning, - size: 14, - ), - onPressed: () { - widget.controller.clear(); - _filterUniversities(''); - }, - ) - : null, - ), - onChanged: _filterUniversities, - onSubmitted: (value) { - if (value.isNotEmpty && _filteredUniversities.isNotEmpty) { - widget.onFilter(_filteredUniversities.first); - setState(() { - _showSuggestions = false; - }); - } - }, - ), - ), - ), - const SizedBox(width: 8), - InkWell( - onTap: () { - widget.onClear(); - widget.controller.clear(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.warning.withOpacity(0.5), - ), - ), - child: const Text( - AppStrings.reset, - style: TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), - - // Suggestions dropdown - if (_showSuggestions && _filteredUniversities.isNotEmpty && _focusNode.hasFocus) - Container( - constraints: const BoxConstraints(maxHeight: 200), - margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.warning.withOpacity(0.5), - ), - ), - child: ListView.builder( - shrinkWrap: true, - padding: EdgeInsets.zero, - itemCount: _filteredUniversities.length, - itemBuilder: (context, index) { - final university = _filteredUniversities[index]; - final isSelected = university == widget.selectedUniversity; - - return InkWell( - onTap: () { - widget.controller.text = university; - widget.onFilter(university); - setState(() { - _showSuggestions = false; - }); - _focusNode.unfocus(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isSelected - ? HackerColors.warning.withOpacity(0.2) - : Colors.transparent, - border: Border( - bottom: BorderSide( - color: HackerColors.warning.withOpacity(0.3), - width: 0.5, - ), - ), - ), - child: Text( - university, - style: TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ); - }, - ), - ), - - // Indicator of current filter - if (widget.selectedUniversity != null) - Padding( - padding: const EdgeInsets.only(left: 12, right: 12, bottom: 6), - child: Text( - "UNIVERSITAS: ${widget.selectedUniversity}", - style: const TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 10, - fontStyle: FontStyle.italic, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } - - @override - void dispose() { - _focusNode.dispose(); - super.dispose(); - } -} \ No newline at end of file diff --git a/lib/widgets/filter_status.dart b/lib/widgets/filter_status.dart deleted file mode 100644 index a4cd176..0000000 --- a/lib/widgets/filter_status.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -/// Widget untuk menampilkan informasi status filter yang sedang aktif -class FilterStatus extends StatelessWidget { - final String university; - final int count; - final VoidCallback onClear; - - const FilterStatus({ - Key? key, - required this.university, - required this.count, - required this.onClear, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: HackerColors.warning.withOpacity(0.7), - width: 1, - ), - ), - child: Row( - children: [ - const Icon( - Icons.filter_alt, - color: HackerColors.warning, - size: 14, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'FILTER AKTIF: $university', - style: const TextStyle( - color: HackerColors.warning, - fontFamily: 'Courier', - fontSize: 12, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - 'HASIL: $count MAHASISWA', - style: const TextStyle( - color: HackerColors.text, - fontFamily: 'Courier', - fontSize: 10, - ), - ), - ], - ), - ), - InkWell( - onTap: onClear, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(2), - ), - child: const Icon( - Icons.close, - color: HackerColors.warning, - size: 14, - ), - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/hacker_card.dart b/lib/widgets/hacker_card.dart deleted file mode 100644 index 3ef0c7e..0000000 --- a/lib/widgets/hacker_card.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -/// Custom Card Widget yang konsisten styling-nya di seluruh aplikasi -/// Digunakan sebagai pengganti Card theme yang menimbulkan error -class HackerCard extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final double? elevation; - final VoidCallback? onTap; - final Color? borderColor; - final Color? backgroundColor; - - const HackerCard({ - Key? key, - required this.child, - this.padding, - this.margin, - this.elevation = 0, - this.onTap, - this.borderColor, - this.backgroundColor, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Card( - margin: margin ?? const EdgeInsets.all(8), - elevation: elevation, - color: backgroundColor ?? HackerColors.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: borderColor ?? HackerColors.accent, - width: 1, - ), - ), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: padding ?? const EdgeInsets.all(12), - child: child, - ), - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/hacker_loading_indicator.dart b/lib/widgets/hacker_loading_indicator.dart deleted file mode 100644 index 586f877..0000000 --- a/lib/widgets/hacker_loading_indicator.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'dart:math'; -import '../utils/constants.dart'; - -class HackerLoadingIndicator extends StatefulWidget { - final String message; - - const HackerLoadingIndicator({ - Key? key, - this.message = 'HACKING IN PROGRESS...', - }) : super(key: key); - - @override - _HackerLoadingIndicatorState createState() => _HackerLoadingIndicatorState(); -} - -class _HackerLoadingIndicatorState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - final Random _random = Random(); - List _hexLines = []; - int _currentDots = 0; - late final int _totalHexLines = 8; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - )..repeat(); - - _controller.addListener(_updateDots); - _generateHexCode(); - } - - @override - void dispose() { - _controller.removeListener(_updateDots); - _controller.dispose(); - super.dispose(); - } - - void _updateDots() { - if (_controller.value < 0.25) { - if (_currentDots != 1) { - setState(() { - _currentDots = 1; - _generateHexCode(); - }); - } - } else if (_controller.value < 0.5) { - if (_currentDots != 2) { - setState(() { - _currentDots = 2; - _generateHexCode(); - }); - } - } else if (_controller.value < 0.75) { - if (_currentDots != 3) { - setState(() { - _currentDots = 3; - _generateHexCode(); - }); - } - } else { - if (_currentDots != 0) { - setState(() { - _currentDots = 0; - _generateHexCode(); - }); - } - } - } - - void _generateHexCode() { - final hexChars = '0123456789ABCDEF'; - _hexLines = List.generate(_totalHexLines, (_) { - final length = _random.nextInt(32) + 16; - return List.generate(length, (_) { - return hexChars[_random.nextInt(hexChars.length)]; - }).join(''); - }); - } - - @override - Widget build(BuildContext context) { - final dots = '.' * _currentDots; - - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent), - boxShadow: [ - BoxShadow( - color: HackerColors.primary.withOpacity(0.2), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 48, - height: 48, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: HackerColors.primary, - width: 2, - ), - ), - child: const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(HackerColors.primary), - strokeWidth: 3, - ), - ), - const SizedBox(height: 16), - Text( - "${widget.message}$dots", - style: const TextStyle( - color: HackerColors.primary, - fontSize: 16, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 24), - Container( - width: 280, - height: 120, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent.withOpacity(0.5)), - ), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: _hexLines.length, - itemBuilder: (context, index) { - final opacity = 1.0 - (index / _totalHexLines * 0.7); - return Text( - _hexLines[index], - style: TextStyle( - color: HackerColors.accent.withOpacity(opacity), - fontSize: 10, - fontFamily: 'Courier', - height: 1.2, - ), - ); - }, - ), - ), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/hacker_result_item.dart b/lib/widgets/hacker_result_item.dart deleted file mode 100644 index 873a825..0000000 --- a/lib/widgets/hacker_result_item.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/mahasiswa.dart'; -import '../utils/constants.dart'; -import '../widgets/hacker_card.dart'; -import 'dart:math'; - -class HackerResultItem extends StatefulWidget { - final Mahasiswa mahasiswa; - final VoidCallback onTap; - final bool isFiltered; - - const HackerResultItem({ - Key? key, - required this.mahasiswa, - required this.onTap, - this.isFiltered = false, - }) : super(key: key); - - @override - _HackerResultItemState createState() => _HackerResultItemState(); -} - -class _HackerResultItemState extends State with SingleTickerProviderStateMixin { - late AnimationController _animationController; - bool _isHovering = false; - final Random _random = Random(); - - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 500), - vsync: this, - ); - } - - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - String _generateHackerCode() { - const chars = '0123456789ABCDEF'; - return String.fromCharCodes( - Iterable.generate( - 8, - (_) => chars.codeUnitAt(_random.nextInt(chars.length)), - ), - ); - } - - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - final double avatarSize = isMobile ? 36 : 42; - - // Ubah warna berdasarkan status filter - final Color primaryColor = widget.isFiltered - ? HackerColors.warning - : HackerColors.primary; - - final Color accentColor = widget.isFiltered - ? HackerColors.warning.withOpacity(0.8) - : HackerColors.accent; - - // Menggunakan HackerCard alih-alih Card normal - return HackerCard( - margin: EdgeInsets.symmetric( - vertical: isMobile ? 4 : 6, - horizontal: isMobile ? 6 : 8 - ), - backgroundColor: HackerColors.surface, - borderColor: _isHovering ? primaryColor : accentColor, - onTap: widget.onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: avatarSize, - height: avatarSize, - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: primaryColor, - width: 1, - ), - ), - child: Center( - child: Text( - widget.mahasiswa.nama.isNotEmpty - ? widget.mahasiswa.nama[0].toUpperCase() - : '?', - style: TextStyle( - color: primaryColor, - fontWeight: FontWeight.bold, - fontSize: 18, - fontFamily: 'Courier', - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.person, - size: 14, - color: primaryColor, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - "SUBJECT: ${widget.mahasiswa.nama.toUpperCase()}", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: primaryColor, - fontFamily: 'Courier', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - Icon( - Icons.numbers, - size: 12, - color: accentColor, - ), - const SizedBox(width: 6), - Expanded( - child: Text( - "ID: ${widget.mahasiswa.nim}", - style: TextStyle( - color: accentColor, - fontSize: 12, - fontFamily: 'Courier', - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ), - ), - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: HackerColors.background, - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: accentColor.withOpacity(0.5), - width: 1, - ), - ), - child: Text( - _generateHackerCode(), - style: TextStyle( - color: accentColor.withOpacity(0.8), - fontSize: 8, - fontFamily: 'Courier', - ), - ), - ), - ], - ), - Divider( - color: accentColor, - height: 20, - thickness: 1, - ), - Row( - children: [ - Expanded( - child: _buildInfoRow( - icon: Icons.school, - label: "INSTITUTION", - value: widget.mahasiswa.namaPt, - labelColor: accentColor, - valueColor: widget.isFiltered - ? HackerColors.warning - : HackerColors.text, - highlight: widget.isFiltered, - ), - ), - const SizedBox(width: 8), - Icon( - Icons.arrow_forward, - color: _isHovering ? primaryColor : accentColor, - size: 16, - ), - ], - ), - const SizedBox(height: 8), - _buildInfoRow( - icon: Icons.book, - label: "PROGRAM", - value: widget.mahasiswa.namaProdi, - labelColor: accentColor, - valueColor: HackerColors.text, - ), - // Tambahkan sumber data jika tersedia - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - "SOURCE: " + (widget.mahasiswa.id.contains("=") ? "PDDIKTI" : "MULTI-DB"), - style: TextStyle( - color: accentColor.withOpacity(0.7), - fontFamily: 'Courier', - fontSize: 8, - fontStyle: FontStyle.italic, - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildInfoRow({ - required IconData icon, - required String label, - required String value, - Color? labelColor, - Color? valueColor, - bool highlight = false, - }) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - icon, - size: 12, - color: labelColor ?? HackerColors.accent, - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - color: (labelColor ?? HackerColors.accent).withOpacity(0.7), - fontSize: 10, - fontFamily: 'Courier', - ), - ), - Container( - decoration: highlight ? BoxDecoration( - color: HackerColors.warning.withOpacity(0.1), - borderRadius: BorderRadius.circular(2), - border: Border.all( - color: HackerColors.warning.withOpacity(0.3), - width: 1, - ), - ) : null, - padding: highlight ? const EdgeInsets.symmetric(horizontal: 4, vertical: 1) : null, - child: Text( - value, - style: TextStyle( - color: valueColor ?? HackerColors.text, - fontSize: 12, - fontFamily: 'Courier', - fontWeight: highlight ? FontWeight.bold : FontWeight.normal, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ], - ); - } -} \ No newline at end of file diff --git a/lib/widgets/hacker_search_bar.dart b/lib/widgets/hacker_search_bar.dart deleted file mode 100644 index 53fa177..0000000 --- a/lib/widgets/hacker_search_bar.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -class HackerSearchBar extends StatelessWidget { - final TextEditingController controller; - final String hintText; - final VoidCallback onSearch; - - const HackerSearchBar({ - Key? key, - required this.controller, - required this.hintText, - required this.onSearch, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final bool isMobile = size.width < 600; - - return Container( - decoration: BoxDecoration( - color: HackerColors.surface, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: HackerColors.accent, width: 1), - boxShadow: [ - BoxShadow( - color: HackerColors.primary.withOpacity(0.3), - spreadRadius: 1, - blurRadius: 6, - offset: const Offset(0, 3), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4 - ), - decoration: BoxDecoration( - color: HackerColors.surface.withOpacity(0.8), - border: const Border( - bottom: BorderSide( - color: HackerColors.accent, - width: 1, - ), - ), - ), - child: Row( - children: const [ - Icon( - Icons.search, - color: HackerColors.accent, - size: 14, - ), - SizedBox(width: 4), - Text( - "TARGET LOCATOR", - style: TextStyle( - color: HackerColors.accent, - fontSize: 12, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller, - style: const TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontSize: 14, - ), - decoration: InputDecoration( - hintText: hintText, - hintStyle: TextStyle( - color: HackerColors.text.withOpacity(0.5), - fontFamily: 'Courier', - fontSize: 12, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14 - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - prefixIcon: const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text( - ">", - style: TextStyle( - color: HackerColors.primary, - fontFamily: 'Courier', - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ), - prefixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0), - ), - cursorColor: HackerColors.primary, - textInputAction: TextInputAction.search, - onSubmitted: (_) => onSearch(), - ), - ), - Container( - height: 48, - width: 1, - color: HackerColors.accent.withOpacity(0.5), - margin: const EdgeInsets.symmetric(vertical: 4), - ), - InkWell( - onTap: onSearch, - child: Container( - padding: EdgeInsets.symmetric(horizontal: isMobile ? 12 : 16), - height: 56, - decoration: const BoxDecoration( - color: HackerColors.surface, - ), - child: const Center( - child: Text( - "HACK", - style: TextStyle( - color: HackerColors.primary, - fontWeight: FontWeight.bold, - fontFamily: 'Courier', - fontSize: 14, - ), - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/navigation/neo_quick_action.dart b/lib/widgets/navigation/neo_quick_action.dart new file mode 100644 index 0000000..65d15f3 --- /dev/null +++ b/lib/widgets/navigation/neo_quick_action.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; +import '../../theme/app_spacing.dart'; + +class NeoQuickAction extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + + const NeoQuickAction({ + super.key, + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + splashColor: color.withOpacity(0.1), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all(color: AppColors.border, width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(height: 10), + Text( + label, + style: AppTypography.labelMedium.copyWith( + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/navigation/neo_tab_bar.dart b/lib/widgets/navigation/neo_tab_bar.dart new file mode 100644 index 0000000..3ad57f9 --- /dev/null +++ b/lib/widgets/navigation/neo_tab_bar.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; +import '../../theme/app_spacing.dart'; + +class NeoTabBar extends StatelessWidget { + final TabController controller; + final List tabs; + final ValueChanged? onTap; + + const NeoTabBar({ + super.key, + required this.controller, + required this.tabs, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(AppSpacing.radiusMd), + ), + child: TabBar( + controller: controller, + onTap: onTap, + indicator: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(AppSpacing.radiusSm), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + labelColor: Colors.white, + unselectedLabelColor: AppColors.textSecondary, + labelStyle: AppTypography.labelLarge.copyWith(fontSize: 12), + unselectedLabelStyle: AppTypography.labelMedium, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + tabs: tabs + .map((t) => Tab( + height: 34, + child: Text(t, maxLines: 1, overflow: TextOverflow.ellipsis), + )) + .toList(), + ), + ); + } +} diff --git a/lib/widgets/prodi_navigation_button.dart b/lib/widgets/prodi_navigation_button.dart index d460cb9..7cb5919 100644 --- a/lib/widgets/prodi_navigation_button.dart +++ b/lib/widgets/prodi_navigation_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../models/prodi.dart'; import '../utils/constants.dart'; -import '../utils/screen_utils.dart'; import 'flexible_text.dart'; /// Widget tombol untuk navigasi ke layar detail program studi @@ -17,19 +17,9 @@ class ProdiNavigationButton extends StatelessWidget { @override Widget build(BuildContext context) { - // Adaptasi berdasarkan ukuran layar - final bool isMobile = ScreenUtils.isMobileScreen(); - return InkWell( onTap: () { - // Gunakan named routes untuk navigasi - Navigator.pushNamed( - context, - '/prodi/detail/${prodi.id}', - arguments: { - 'prodiName': prodi.nama, - }, - ); + context.push('/prodi/${Uri.encodeComponent(prodi.id)}?name=${Uri.encodeComponent(prodi.nama)}'); }, child: Container( margin: EdgeInsets.symmetric( @@ -38,12 +28,12 @@ class ProdiNavigationButton extends StatelessWidget { ), padding: EdgeInsets.all(isCompact ? 8.0 : 12.0), decoration: BoxDecoration( - color: HackerColors.surface, + color: CtOSColors.surface, borderRadius: BorderRadius.circular(4.0), - border: Border.all(color: HackerColors.accent, width: 1.0), + border: Border.all(color: CtOSColors.secondary, width: 1.0), boxShadow: [ BoxShadow( - color: HackerColors.primary.withOpacity(0.1), + color: CtOSColors.primary.withValues(alpha: 0.1), blurRadius: 4, offset: Offset(0, 2), ), @@ -53,7 +43,7 @@ class ProdiNavigationButton extends StatelessWidget { children: [ Icon( Icons.school, - color: HackerColors.primary, + color: CtOSColors.primary, size: isCompact ? 16.0 : 20.0, ), SizedBox(width: 8.0), @@ -64,7 +54,7 @@ class ProdiNavigationButton extends StatelessWidget { FlexibleText( prodi.nama, style: TextStyle( - color: HackerColors.primary, + color: CtOSColors.primary, fontFamily: 'Courier', fontSize: isCompact ? 12.0 : 14.0, fontWeight: FontWeight.bold, @@ -76,7 +66,7 @@ class ProdiNavigationButton extends StatelessWidget { FlexibleText( '${prodi.jenjang} | ${prodi.pt}', style: TextStyle( - color: HackerColors.text.withOpacity(0.8), + color: CtOSColors.textPrimary.withValues(alpha: 0.8), fontFamily: 'Courier', fontSize: 12.0, ), @@ -88,7 +78,7 @@ class ProdiNavigationButton extends StatelessWidget { ), Icon( Icons.arrow_forward_ios, - color: HackerColors.accent, + color: CtOSColors.secondary, size: isCompact ? 14.0 : 16.0, ), ], diff --git a/lib/widgets/responsive_card.dart b/lib/widgets/responsive_card.dart index 58b6aa4..059490f 100644 --- a/lib/widgets/responsive_card.dart +++ b/lib/widgets/responsive_card.dart @@ -61,15 +61,15 @@ class ResponsiveCard extends StatelessWidget { maxHeight: maxHeight?.h ?? double.infinity, ), decoration: BoxDecoration( - color: color ?? HackerColors.surface, + color: color ?? CtOSColors.surface, borderRadius: BorderRadius.circular(4), border: Border.all( - color: borderColor ?? HackerColors.accent, + color: borderColor ?? CtOSColors.secondary, width: isMobile ? 0.5 : 1.0, ), boxShadow: hoverable ? [ BoxShadow( - color: HackerColors.primary.withOpacity(0.2), + color: CtOSColors.primary.withValues(alpha: 0.2), blurRadius: 4, spreadRadius: 0, offset: Offset(0, 2), diff --git a/lib/widgets/search/neo_search_bar.dart b/lib/widgets/search/neo_search_bar.dart new file mode 100644 index 0000000..7f6dad6 --- /dev/null +++ b/lib/widgets/search/neo_search_bar.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import '../../theme/app_colors.dart'; +import '../../theme/app_typography.dart'; +import '../../theme/app_spacing.dart'; + +class NeoSearchBar extends StatefulWidget { + final TextEditingController? controller; + final String hintText; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onClear; + final bool autofocus; + final bool isLoading; + + const NeoSearchBar({ + super.key, + this.controller, + this.hintText = 'Cari mahasiswa, dosen, atau prodi...', + this.onChanged, + this.onSubmitted, + this.onClear, + this.autofocus = false, + this.isLoading = false, + }); + + @override + State createState() => _NeoSearchBarState(); +} + +class _NeoSearchBarState extends State { + late final TextEditingController _controller; + bool _hasText = false; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? TextEditingController(); + _hasText = _controller.text.isNotEmpty; + _controller.addListener(_onTextChanged); + } + + void _onTextChanged() { + final has = _controller.text.isNotEmpty; + if (has != _hasText) setState(() => _hasText = has); + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + if (widget.controller == null) _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: AppSpacing.durationFast, + height: 52, + decoration: BoxDecoration( + color: AppColors.surfaceHigh, + borderRadius: BorderRadius.circular(AppSpacing.radiusLg), + border: Border.all( + color: _isFocused ? AppColors.primary : AppColors.border, + width: _isFocused ? 1.5 : 1, + ), + boxShadow: _isFocused ? AppSpacing.shadowGlow : AppSpacing.shadowNone, + ), + child: Row( + children: [ + const SizedBox(width: 16), + Icon( + Icons.search_rounded, + size: 20, + color: _isFocused ? AppColors.primary : AppColors.textTertiary, + ), + const SizedBox(width: 12), + Expanded( + child: Focus( + onFocusChange: (f) => setState(() => _isFocused = f), + child: TextField( + controller: _controller, + autofocus: widget.autofocus, + style: AppTypography.bodyMedium, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: AppTypography.bodyMedium.copyWith( + color: AppColors.textTertiary, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + isDense: true, + ), + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + textInputAction: TextInputAction.search, + ), + ), + ), + if (widget.isLoading) + const Padding( + padding: EdgeInsets.only(right: 12), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ) + else if (_hasText) + IconButton( + icon: const Icon(Icons.close_rounded, size: 18), + color: AppColors.textTertiary, + onPressed: () { + _controller.clear(); + widget.onClear?.call(); + }, + ) + else + const SizedBox(width: 12), + ], + ), + ); + } +} diff --git a/lib/widgets/source_badge.dart b/lib/widgets/source_badge.dart new file mode 100644 index 0000000..4ef47f0 --- /dev/null +++ b/lib/widgets/source_badge.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import '../api/core/data_result.dart'; +import '../utils/constants.dart'; + +/// Badge kecil yang menampilkan sumber data (live/cache/stale/external) +/// Sesuai tema ctOS — compact dan informatif +class SourceBadge extends StatelessWidget { + final DataSourceType sourceType; + final String? providerName; + final bool compact; + + const SourceBadge({ + super.key, + required this.sourceType, + this.providerName, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 6 : 8, + vertical: compact ? 2 : 4, + ), + decoration: BoxDecoration( + color: _backgroundColor, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: _borderColor, width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_icon, size: compact ? 10 : 12, color: _textColor), + SizedBox(width: compact ? 3 : 4), + Text( + _label, + style: TextStyle( + color: _textColor, + fontSize: compact ? 9 : 10, + fontFamily: 'Courier', + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + String get _label { + switch (sourceType) { + case DataSourceType.live: + return compact ? 'LIVE' : 'LIVE${providerName != null ? " • $providerName" : ""}'; + case DataSourceType.memoryCache: + return 'CACHE'; + case DataSourceType.persistentCache: + return 'SAVED'; + case DataSourceType.staleCache: + return compact ? 'STALE' : 'STALE CACHE'; + case DataSourceType.mock: + return 'DEMO'; + case DataSourceType.externalLink: + return compact ? 'LINK' : 'EXTERNAL'; + case DataSourceType.unavailable: + return 'N/A'; + } + } + + IconData get _icon { + switch (sourceType) { + case DataSourceType.live: + return Icons.cloud_done; + case DataSourceType.memoryCache: + case DataSourceType.persistentCache: + return Icons.storage; + case DataSourceType.staleCache: + return Icons.history; + case DataSourceType.mock: + return Icons.science; + case DataSourceType.externalLink: + return Icons.open_in_new; + case DataSourceType.unavailable: + return Icons.cloud_off; + } + } + + Color get _textColor { + switch (sourceType) { + case DataSourceType.live: + return CtOSColors.primary; + case DataSourceType.memoryCache: + case DataSourceType.persistentCache: + return CtOSColors.secondary; + case DataSourceType.staleCache: + return CtOSColors.warning; + case DataSourceType.mock: + return CtOSColors.error; + case DataSourceType.externalLink: + return CtOSColors.textPrimary; + case DataSourceType.unavailable: + return CtOSColors.error; + } + } + + Color get _backgroundColor => _textColor.withValues(alpha: 0.1); + Color get _borderColor => _textColor.withValues(alpha: 0.3); +} diff --git a/lib/widgets/terminal_window.dart b/lib/widgets/terminal_window.dart deleted file mode 100644 index 5f165e3..0000000 --- a/lib/widgets/terminal_window.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import '../utils/constants.dart'; - -class TerminalWindow extends StatelessWidget { - final Widget child; - final String title; - final List? actions; - final bool scrollable; - - const TerminalWindow({ - Key? key, - required this.child, - required this.title, - this.actions, - this.scrollable = true, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - // Gunakan ukuran tetap untuk mencegah error layout - const double headerHeight = 32.0; - const double buttonSize = 10.0; - const double buttonSpacing = 4.0; - const double margin = 8.0; - - return Container( - decoration: BoxDecoration( - color: HackerColors.background, - border: Border.all( - color: HackerColors.accent, - width: 1, - ), - borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow( - color: HackerColors.primary.withOpacity(0.1), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ], - ), - margin: const EdgeInsets.all(margin), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: headerHeight, - decoration: const BoxDecoration( - color: HackerColors.surface, - border: Border( - bottom: BorderSide( - color: HackerColors.accent, - width: 1, - ), - ), - ), - child: Row( - children: [ - const SizedBox(width: buttonSpacing * 2), - _buildWindowButton(HackerColors.error, buttonSize), - const SizedBox(width: buttonSpacing), - _buildWindowButton(HackerColors.warning, buttonSize), - const SizedBox(width: buttonSpacing), - _buildWindowButton(HackerColors.success, buttonSize), - const SizedBox(width: buttonSpacing * 2), - Expanded( - child: Center( - child: Text( - title, - style: const TextStyle( - color: HackerColors.accent, - fontFamily: 'Courier', - fontSize: 14.0, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - if (actions != null) ...actions!, - const SizedBox(width: buttonSpacing * 2), - ], - ), - ), - // Kunci perbaikan: Tambahkan SizedBox dengan Expanded di dalamnya untuk ListView - // agar ListView diberi batasan tinggi yang jelas - Expanded( - child: scrollable - ? Container( - // Pastikan Container memiliki batas tinggi - child: child) - : child, - ), - ], - ), - ); - } - - Widget _buildWindowButton(Color color, double size) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: color.withOpacity(0.7), - shape: BoxShape.circle, - border: Border.all( - color: color.withOpacity(0.3), - width: 1, - ), - ), - ); - } -} \ No newline at end of file diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..df8d2f7 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8c72a8c..3a8baed 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,14 +5,12 @@ import FlutterMacOS import Foundation -import path_provider_foundation -import shared_preferences_foundation +import connectivity_plus import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 2f71586..18a977d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,30 +49,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - cached_network_image: - dependency: "direct main" + build: + dependency: transitive description: - name: cached_network_image - sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "3.3.1" - cached_network_image_platform_interface: + version: "2.5.4" + build_config: dependency: transitive description: - name: cached_network_image_platform_interface - sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" url: "https://pub.dev" source: hosted - version: "4.0.0" - cached_network_image_web: + version: "1.1.2" + build_daemon: dependency: transitive description: - name: cached_network_image_web - sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7" + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" characters: dependency: transitive description: @@ -49,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -57,6 +137,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -65,22 +161,110 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" - cupertino_icons: + version: "3.0.7" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + dartz: + dependency: "direct main" + description: + name: dartz + sha256: e6acf34ad2e31b1eb00948692468c30ab48ac8250e0f0df661e29f12dd252168 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + equatable: dependency: "direct main" description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "2.0.8" fake_async: dependency: transitive description: @@ -93,10 +277,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -113,35 +297,35 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: d0f0d49112f2f4b192481c16d05b6418bd7820e021e265a3c22db98acf7ed7fb + url: "https://pub.dev" + source: hosted + version: "0.68.0" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_cache_manager: - dependency: transitive - description: - name: flutter_cache_manager - sha256: "8207f27539deb83732fdda03e259349046a39a4c767269285f449ade355d54ba" - url: "https://pub.dev" - source: hosted - version: "3.3.1" - flutter_lints: - dependency: "direct dev" + flutter_map: + dependency: "direct main" description: - name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + name: flutter_map + sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da" url: "https://pub.dev" source: hosted - version: "2.0.3" - flutter_spinkit: + version: "7.0.2" + flutter_riverpod: dependency: "direct main" description: - name: flutter_spinkit - sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter @@ -152,14 +336,102 @@ packages: description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" http: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "3.2.2" http_parser: dependency: transitive description: @@ -168,6 +440,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + injectable: + dependency: "direct main" + description: + name: injectable + sha256: "29559f7e3daebf0084597de86a825ae7f149d9e30264b7fbc71d1069ae82697d" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + injectable_generator: + dependency: "direct dev" + description: + name: injectable_generator + sha256: b04673a4c88b3a848c0c77bf58b8309f9b9e064d9fe1df5450c8ee1675eaea1a + url: "https://pub.dev" + source: hosted + version: "2.7.0" intl: dependency: "direct main" description: @@ -176,38 +464,118 @@ packages: url: "https://pub.dev" source: hosted version: "0.18.1" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.dev" + source: hosted + version: "6.9.5" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: - dependency: transitive + dependency: "direct dev" description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "5.1.1" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -228,10 +596,42 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "0.17.6" nested: dependency: transitive description: @@ -240,14 +640,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - octo_image: + nm: dependency: transitive description: - name: octo_image - sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -257,7 +673,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -268,18 +684,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -304,6 +720,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -320,6 +744,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + polylabel: + dependency: transitive + description: + name: polylabel + sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e + url: "https://pub.dev" + source: hosted + version: "2.1.0" provider: dependency: "direct main" description: @@ -328,115 +776,139 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5" - rxdart: + pub_semver: dependency: transitive description: - name: rxdart - sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted - version: "0.27.7" - shared_preferences: - dependency: "direct main" + version: "2.2.0" + pubspec_parse: + dependency: transitive description: - name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "2.5.3" - shared_preferences_android: + version: "1.5.0" + recase: dependency: transitive description: - name: shared_preferences_android - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 url: "https://pub.dev" source: hosted - version: "2.4.10" - shared_preferences_foundation: + version: "4.1.0" + record_use: dependency: transitive description: - name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" url: "https://pub.dev" source: hosted - version: "2.5.4" - shared_preferences_linux: + version: "0.6.0" + riverpod: dependency: transitive description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" url: "https://pub.dev" source: hosted - version: "2.4.1" - shared_preferences_platform_interface: + version: "2.6.1" + riverpod_analyzer_utils: dependency: transitive description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" url: "https://pub.dev" source: hosted - version: "2.4.1" - shared_preferences_web: + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.dev" + source: hosted + version: "2.6.4" + shelf: dependency: transitive description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 url: "https://pub.dev" source: hosted - version: "2.4.3" - shared_preferences_windows: + version: "1.4.2" + shelf_web_socket: dependency: transitive description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - source_span: + source_gen: dependency: transitive description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" url: "https://pub.dev" source: hosted - version: "1.10.1" - sprintf: + version: "2.0.0" + source_helper: dependency: transitive description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca url: "https://pub.dev" source: hosted - version: "7.0.0" - sqflite: + version: "1.3.7" + source_span: dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sqflite: + dependency: "direct main" description: name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + sha256: "564cfed0746fe53140c23b70b308e045c3b31f17778f2f326ccb7d804ea0250a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.2+1" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2+3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + sha256: f8a08a13fb8f0f8c590df89d745000bed44a673ed94bac846739e1a016875c21 url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.7" sqflite_darwin: dependency: transitive description: @@ -461,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -469,6 +949,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -481,10 +969,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.0" term_glyph: dependency: transitive description: @@ -497,10 +985,18 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.7" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -509,6 +1005,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" url_launcher: dependency: "direct main" description: @@ -577,18 +1081,18 @@ packages: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.5.3" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -597,6 +1101,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -605,6 +1117,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: @@ -613,6 +1149,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.7.0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index bd577fe..9390a11 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,37 +1,90 @@ name: db_cracker_tamaengs -description: Tool for accessing educational database systems. +description: "Platform Data Intelligence Pemerintah Indonesia — PDDIKTI, Pengadaan, Statistik BPS, Ekonomi, Bencana." publish_to: 'none' -version: 1.0.0+1 +version: 3.0.0+1 environment: - sdk: ">=2.19.0 <4.0.0" + sdk: ">=3.6.0 <4.0.0" dependencies: flutter: sdk: flutter - http: ^0.13.5 + + # State Management + flutter_riverpod: ^2.5.0 + riverpod_annotation: ^2.3.0 + + # Routing + go_router: ^14.0.0 + + # Network + dio: ^5.4.0 + connectivity_plus: ^6.0.0 + http: ^1.2.0 + + # Local Storage + sqflite: ^2.3.0 + path_provider: ^2.1.0 + hive_flutter: ^1.1.0 + + # DI + get_it: ^7.6.0 + injectable: ^2.3.0 + + # Maps + flutter_map: ^7.0.0 + latlong2: ^0.9.0 + + # Charts + fl_chart: ^0.68.0 + + # Functional + dartz: ^0.10.1 + equatable: ^2.0.5 + + # Annotations + freezed_annotation: ^2.4.0 + json_annotation: ^4.8.0 + + # Existing provider: ^6.0.5 - shared_preferences: ^2.2.0 - cached_network_image: ^3.2.3 - flutter_spinkit: ^5.1.0 url_launcher: ^6.1.11 intl: ^0.18.1 - cupertino_icons: ^1.0.5 # Update ke versi terbaru yang stabil dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^2.0.1 + lints: ^5.0.0 + build_runner: ^2.4.0 + freezed: ^2.4.0 + json_serializable: ^6.7.0 + riverpod_generator: ^2.3.0 + injectable_generator: ^2.4.0 + mocktail: ^1.0.0 flutter: uses-material-design: true assets: - assets/images/ - - # Tambahkan font Courier jika belum ada + fonts: - - family: Courier + - family: Inter + fonts: + - asset: assets/fonts/Inter/Inter-Regular.ttf + weight: 400 + - asset: assets/fonts/Inter/Inter-Medium.ttf + weight: 500 + - asset: assets/fonts/Inter/Inter-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Inter/Inter-Bold.ttf + weight: 700 + - family: JetBrainsMono fonts: - - asset: assets/fonts/CourierPrime-Regular.ttf - - asset: assets/fonts/CourierPrime-Bold.ttf - weight: 700 \ No newline at end of file + - asset: assets/fonts/JetBrainsMono/JetBrainsMono-Regular.ttf + weight: 400 + - asset: assets/fonts/JetBrainsMono/JetBrainsMono-Medium.ttf + weight: 500 + - asset: assets/fonts/JetBrainsMono/JetBrainsMono-SemiBold.ttf + weight: 600 + - asset: assets/fonts/JetBrainsMono/JetBrainsMono-Bold.ttf + weight: 700 diff --git a/test/api/api_factory_test.dart b/test/api/api_factory_test.dart new file mode 100644 index 0000000..ca9b58b --- /dev/null +++ b/test/api/api_factory_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/api_factory.dart'; + +void main() { + group('ApiFactory', () { + test('singleton pattern returns same instance', () { + final factory1 = ApiFactory(); + final factory2 = ApiFactory(); + expect(identical(factory1, factory2), true); + }); + + test('enableMockData sets mock mode', () { + final factory = ApiFactory(); + factory.enableMockData(); + // Verify it doesn't throw + expect(factory, isNotNull); + }); + + test('disableMockData unsets mock mode', () { + final factory = ApiFactory(); + factory.disableMockData(); + expect(factory, isNotNull); + }); + }); +} diff --git a/test/api/api_services_integration_test.dart b/test/api/api_services_integration_test.dart new file mode 100644 index 0000000..bd4e6c8 --- /dev/null +++ b/test/api/api_services_integration_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/api_services_integration.dart'; +import 'package:db_cracker_tamaengs/models/mahasiswa.dart'; +import 'package:db_cracker_tamaengs/models/dosen.dart'; + +void main() { + group('ApiServicesIntegration', () { + test('singleton pattern returns same instance', () { + final api1 = ApiServicesIntegration(); + final api2 = ApiServicesIntegration(); + expect(identical(api1, api2), true); + }); + + test('convertToMahasiswa with valid data', () { + final api = ApiServicesIntegration(); + final data = [ + { + 'id': 'mhs001', + 'nama': 'Budi', + 'nim': '19102001', + 'perguruan_tinggi': 'UI', + 'singkatan_pt': 'UI', + 'program_studi': 'Informatika', + }, + { + 'id': 'mhs002', + 'nama': 'Siti', + 'nim': '20102002', + 'perguruan_tinggi': 'ITB', + 'singkatan_pt': 'ITB', + 'program_studi': 'Teknik', + }, + ]; + final result = api.convertToMahasiswa(data); + expect(result.length, 2); + expect(result[0].nama, 'Budi'); + expect(result[1].nama, 'Siti'); + expect(result[0], isA()); + }); + + test('convertToMahasiswa filters out empty nama/nim', () { + final api = ApiServicesIntegration(); + final data = [ + {'id': '1', 'nama': 'Budi', 'nim': '123', 'perguruan_tinggi': '', 'singkatan_pt': '', 'program_studi': ''}, + {'id': '2', 'nama': '', 'nim': '456', 'perguruan_tinggi': '', 'singkatan_pt': '', 'program_studi': ''}, + {'id': '3', 'nama': 'Siti', 'nim': '', 'perguruan_tinggi': '', 'singkatan_pt': '', 'program_studi': ''}, + ]; + final result = api.convertToMahasiswa(data); + expect(result.length, 1); + expect(result[0].nama, 'Budi'); + }); + + test('convertToDosen with valid data', () { + final api = ApiServicesIntegration(); + final data = [ + { + 'id': 'dsn001', + 'nama': 'Dr. Bambang', + 'nidn': '0123456789', + 'perguruan_tinggi': 'UI', + 'singkatan_pt': 'UI', + 'program_studi': 'Informatika', + }, + ]; + final result = api.convertToDosen(data); + expect(result.length, 1); + expect(result[0].nama, 'Dr. Bambang'); + expect(result[0], isA()); + }); + + test('convertToDosen filters out empty nama/nidn', () { + final api = ApiServicesIntegration(); + final data = [ + {'id': '1', 'nama': 'Dr. A', 'nidn': '123', 'perguruan_tinggi': '', 'singkatan_pt': '', 'program_studi': ''}, + {'id': '2', 'nama': '', 'nidn': '456', 'perguruan_tinggi': '', 'singkatan_pt': '', 'program_studi': ''}, + ]; + final result = api.convertToDosen(data); + expect(result.length, 1); + }); + + test('convertToMahasiswa with alternative key names', () { + final api = ApiServicesIntegration(); + final data = [ + { + 'mahasiswa_id': 'alt001', + 'name': 'John', + 'nomor_induk': '999', + 'universitas': 'UGM', + 'kode_pt': 'UGM', + 'jurusan': 'CS', + }, + ]; + final result = api.convertToMahasiswa(data); + expect(result.length, 1); + expect(result[0].nama, 'John'); + expect(result[0].nim, '999'); + }); + }); +} diff --git a/test/api/core/data_result_test.dart b/test/api/core/data_result_test.dart new file mode 100644 index 0000000..26038c9 --- /dev/null +++ b/test/api/core/data_result_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/core/data_result.dart'; + +void main() { + group('DataResult', () { + test('DataResult.live sets correct sourceType and isStale=false', () { + final result = DataResult.live( + data: 'test', + providerId: 'pddikti_fastapicloud', + providerName: 'FastAPI Cloud', + ); + expect(result.sourceType, DataSourceType.live); + expect(result.isStale, false); + expect(result.providerId, 'pddikti_fastapicloud'); + }); + + test('DataResult.cached(isStale: false) returns memoryCache', () { + final result = DataResult.cached( + data: 'cached', + providerId: 'cache', + providerName: 'Cache', + isStale: false, + ); + expect(result.sourceType, DataSourceType.memoryCache); + expect(result.isStale, false); + expect(result.warning, isNull); + }); + + test('DataResult.cached(isStale: true) returns staleCache with warning', () { + final result = DataResult.cached( + data: 'stale', + providerId: 'cache', + providerName: 'Cache', + isStale: true, + ); + expect(result.sourceType, DataSourceType.staleCache); + expect(result.isStale, true); + expect(result.warning, isNotNull); + expect(result.warning, contains('tidak terbaru')); + }); + + test('sourceLabel returns correct string for each type', () { + expect( + DataResult.live(data: '', providerId: 'x', providerName: 'X').sourceLabel, + contains('live'), + ); + expect( + DataResult.cached(data: '', providerId: 'x', providerName: 'X', isStale: true).sourceLabel, + contains('lama'), + ); + }); + + test('all DataSourceType values have sourceLabel', () { + for (final type in DataSourceType.values) { + final result = DataResult( + data: '', + sourceType: type, + providerId: 'test', + providerName: 'Test', + isStale: false, + fetchedAt: DateTime.now(), + ); + expect(result.sourceLabel.isNotEmpty, true, reason: '$type should have label'); + } + }); + }); +} diff --git a/test/api/core/provider_registry_test.dart b/test/api/core/provider_registry_test.dart new file mode 100644 index 0000000..ab13bd2 --- /dev/null +++ b/test/api/core/provider_registry_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/core/provider_registry.dart'; + +void main() { + group('ProviderRegistry', () { + test('byKind returns correct providers filtered by kind', () { + final pddikti = ProviderRegistry.byKind(ProviderKind.pddikti); + expect(pddikti.isNotEmpty, true); + expect(pddikti.every((p) => p.kind == ProviderKind.pddikti), true); + }); + + test('byKind sorts by priority ascending', () { + final pddikti = ProviderRegistry.byKind(ProviderKind.pddikti); + for (int i = 1; i < pddikti.length; i++) { + expect(pddikti[i].priority, greaterThanOrEqualTo(pddikti[i - 1].priority)); + } + }); + + test('byKind excludes disabled providers', () { + final all = ProviderRegistry.byKind(ProviderKind.pddikti); + expect(all.every((p) => p.enabled), true); + }); + + test('byId returns correct provider for valid ID', () { + final provider = ProviderRegistry.byId('pddikti_fastapicloud'); + expect(provider, isNotNull); + expect(provider!.name, contains('FastAPI')); + }); + + test('byId returns null for non-existent ID', () { + final provider = ProviderRegistry.byId('nonexistent_provider'); + expect(provider, isNull); + }); + + test('coreProviders only returns authMode none', () { + final core = ProviderRegistry.coreProviders; + expect(core.every((p) => p.authMode == ProviderAuthMode.none), true); + }); + + test('externalLinkProviders returns GARUDA RAMA SINTA', () { + final links = ProviderRegistry.externalLinkProviders; + expect(links.length, 3); + expect(links.any((p) => p.id == 'garuda_link'), true); + expect(links.any((p) => p.id == 'rama_link'), true); + expect(links.any((p) => p.id == 'sinta_link'), true); + }); + + test('all providers have unique IDs', () { + final ids = ProviderRegistry.allProviders.map((p) => p.id).toSet(); + expect(ids.length, ProviderRegistry.allProviders.length); + }); + + test('all providers have valid HTTPS baseUrl', () { + for (final p in ProviderRegistry.allProviders) { + expect(p.baseUrl.startsWith('https://'), true, reason: '${p.id} baseUrl must be HTTPS'); + } + }); + }); +} diff --git a/test/api/multi_api_factory_test.dart b/test/api/multi_api_factory_test.dart new file mode 100644 index 0000000..452b468 --- /dev/null +++ b/test/api/multi_api_factory_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/multi_api_factory.dart'; + +void main() { + group('MultiApiFactory', () { + test('singleton pattern returns same instance', () { + final factory1 = MultiApiFactory(); + final factory2 = MultiApiFactory(); + expect(identical(factory1, factory2), true); + }); + + test('instance is not null', () { + final factory = MultiApiFactory(); + expect(factory, isNotNull); + }); + }); +} diff --git a/test/api/providers/provider_chain_test.dart b/test/api/providers/provider_chain_test.dart new file mode 100644 index 0000000..0250bb3 --- /dev/null +++ b/test/api/providers/provider_chain_test.dart @@ -0,0 +1,325 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:db_cracker_tamaengs/api/providers/api_provider.dart'; +import 'package:db_cracker_tamaengs/api/providers/provider_chain.dart'; +import 'package:db_cracker_tamaengs/api/cache/cache_entry.dart'; +import 'package:db_cracker_tamaengs/api/cache/cache_policy.dart'; +import 'package:db_cracker_tamaengs/api/cache/in_memory_cache_store.dart'; + +void main() { + late InMemoryCacheStore cacheStore; + late List providers; + + final testPolicy = const CachePolicy( + freshTtl: Duration(minutes: 5), + staleTtl: Duration(hours: 1), + ); + + setUp(() { + cacheStore = InMemoryCacheStore(maxEntries: 50); + providers = [ + const ApiProvider( + id: 'primary', + name: 'Primary', + baseUrl: 'https://primary.test/api', + priority: 1, + ), + const ApiProvider( + id: 'fallback', + name: 'Fallback', + baseUrl: 'https://fallback.test/api', + priority: 2, + ), + ]; + }); + + List decoder(dynamic json) { + if (json is List) return json.map((e) => e.toString()).toList(); + return []; + } + + group('ProviderChainService', () { + test('1. provider pertama sukses, provider kedua tidak dipanggil', () async { + final requestedUrls = []; + final client = MockClient((request) async { + requestedUrls.add(request.url.toString()); + return http.Response(json.encode(['data1', 'data2']), 200); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + final result = await service.request>( + path: '/search/mhs/test/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + expect(result.data, ['data1', 'data2']); + expect(result.providerId, 'primary'); + expect(result.fromCache, false); + expect(requestedUrls.length, 1); + expect(requestedUrls.first, contains('primary.test')); + }); + + test('2. provider pertama 503, provider kedua sukses', () async { + final requestedUrls = []; + final client = MockClient((request) async { + requestedUrls.add(request.url.host); + if (request.url.host == 'primary.test') { + return http.Response('Service Unavailable', 503); + } + return http.Response(json.encode(['fallback_data']), 200); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + final result = await service.request>( + path: '/search/mhs/test/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + expect(result.data, ['fallback_data']); + expect(result.providerId, 'fallback'); + expect(requestedUrls.length, 2); + }); + + test('3. provider pertama timeout, provider kedua sukses', () async { + final client = MockClient((request) async { + if (request.url.host == 'primary.test') { + await Future.delayed(const Duration(seconds: 15)); + return http.Response('', 200); + } + return http.Response(json.encode(['ok']), 200); + }); + + final shortTimeoutProviders = [ + const ApiProvider( + id: 'primary', + name: 'Primary', + baseUrl: 'https://primary.test/api', + priority: 1, + timeout: Duration(milliseconds: 100), // Very short for test + ), + const ApiProvider( + id: 'fallback', + name: 'Fallback', + baseUrl: 'https://fallback.test/api', + priority: 2, + timeout: Duration(seconds: 5), + ), + ]; + + final service = ProviderChainService( + providers: shortTimeoutProviders, + cacheStore: cacheStore, + httpClient: client, + ); + + final result = await service.request>( + path: '/search/mhs/test/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + expect(result.data, ['ok']); + expect(result.providerId, 'fallback'); + }); + + test('4. semua provider gagal, stale cache ada, return stale', () async { + // Pre-populate stale cache + await cacheStore.put(CacheEntry( + key: 'pddikti:/search/mhs/test/', + body: json.encode(['stale_data']), + createdAt: DateTime.now().subtract(const Duration(hours: 2)), + freshUntil: DateTime.now().subtract(const Duration(hours: 1)), // expired fresh + staleUntil: DateTime.now().add(const Duration(hours: 23)), // still stale-valid + source: 'old_provider', + )); + + final client = MockClient((request) async { + return http.Response('Error', 500); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + final result = await service.request>( + path: '/search/mhs/test/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + expect(result.data, ['stale_data']); + expect(result.fromCache, true); + expect(result.stale, true); + expect(result.providerId, contains('stale-cache')); + }); + + test('5. semua provider gagal dan stale cache tidak ada, throw AllProvidersFailedException', () async { + final client = MockClient((request) async { + return http.Response('Error', 500); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + expect( + () => service.request>( + path: '/search/mhs/notfound/', + cachePolicy: testPolicy, + decoder: decoder, + ), + throwsA(isA()), + ); + }); + + test('6. cache key membedakan endpoint dan query', () async { + final client = MockClient((request) async { + final keyword = request.url.pathSegments.last; + return http.Response(json.encode([keyword]), 200); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + await service.request>( + path: '/search/mhs/akbar/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + await service.request>( + path: '/search/mhs/budi/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + final stats = await cacheStore.stats(); + expect(stats.totalEntries, 2); + }); + + test('7. retryable status tidak disimpan sebagai cache sukses', () async { + final client = MockClient((request) async { + return http.Response('Service Unavailable', 503); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + try { + await service.request>( + path: '/search/mhs/fail/', + cachePolicy: testPolicy, + decoder: decoder, + ); + } catch (_) {} + + final cached = await cacheStore.get('pddikti:/search/mhs/fail/'); + expect(cached, isNull); + }); + + test('8. response JSON invalid menghasilkan error typed', () async { + final client = MockClient((request) async { + return http.Response('not json {{{', 200); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + expect( + () => service.request>( + path: '/search/mhs/badjson/', + cachePolicy: testPolicy, + decoder: decoder, + ), + throwsA(isA()), + ); + }); + + test('9. provider disabled tidak dipakai', () async { + final requestedUrls = []; + final client = MockClient((request) async { + requestedUrls.add(request.url.host); + return http.Response(json.encode(['ok']), 200); + }); + + final mixedProviders = [ + const ApiProvider( + id: 'disabled', + name: 'Disabled', + baseUrl: 'https://disabled.test/api', + priority: 1, + enabled: false, + ), + const ApiProvider( + id: 'active', + name: 'Active', + baseUrl: 'https://active.test/api', + priority: 2, + ), + ]; + + final service = ProviderChainService( + providers: mixedProviders, + cacheStore: cacheStore, + httpClient: client, + ); + + final result = await service.request>( + path: '/search/mhs/test/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + expect(result.providerId, 'active'); + expect(requestedUrls, isNot(contains('disabled.test'))); + }); + + test('10. latency tercatat untuk health report', () async { + final client = MockClient((request) async { + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response(json.encode(['ok']), 200); + }); + + final service = ProviderChainService( + providers: providers, + cacheStore: cacheStore, + httpClient: client, + ); + + final result = await service.request>( + path: '/search/mhs/test/', + cachePolicy: testPolicy, + decoder: decoder, + ); + + expect(result.latency.inMilliseconds, greaterThan(40)); + }); + }); +} diff --git a/test/cache/cache_store_test.dart b/test/cache/cache_store_test.dart new file mode 100644 index 0000000..e89341c --- /dev/null +++ b/test/cache/cache_store_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/cache/cache_entry.dart'; +import 'package:db_cracker_tamaengs/api/cache/in_memory_cache_store.dart'; + +void main() { + late InMemoryCacheStore store; + + setUp(() { + store = InMemoryCacheStore(maxEntries: 10); + }); + + CacheEntry _freshEntry(String key, {String body = '{"data":"test"}'}) { + return CacheEntry( + key: key, + body: body, + createdAt: DateTime.now(), + freshUntil: DateTime.now().add(const Duration(minutes: 5)), + staleUntil: DateTime.now().add(const Duration(hours: 1)), + source: 'test', + ); + } + + CacheEntry _staleEntry(String key) { + return CacheEntry( + key: key, + body: '{"data":"stale"}', + createdAt: DateTime.now().subtract(const Duration(hours: 2)), + freshUntil: DateTime.now().subtract(const Duration(hours: 1)), + staleUntil: DateTime.now().add(const Duration(hours: 23)), + source: 'test', + ); + } + + CacheEntry _expiredEntry(String key) { + return CacheEntry( + key: key, + body: '{"data":"expired"}', + createdAt: DateTime.now().subtract(const Duration(days: 2)), + freshUntil: DateTime.now().subtract(const Duration(days: 1)), + staleUntil: DateTime.now().subtract(const Duration(hours: 1)), + source: 'test', + ); + } + + group('InMemoryCacheStore', () { + test('1. fresh cache terbaca sebelum expired', () async { + await store.put(_freshEntry('key1')); + final result = await store.get('key1'); + expect(result, isNotNull); + expect(result!.isFresh, true); + expect(result.body, '{"data":"test"}'); + }); + + test('2. stale cache terbaca (untuk allowStaleOnFailure)', () async { + await store.put(_staleEntry('key2')); + final result = await store.get('key2'); + expect(result, isNotNull); + expect(result!.isFresh, false); + expect(result.isStale, true); + }); + + test('3. expired stale tidak dipakai (return null)', () async { + await store.put(_expiredEntry('key3')); + final result = await store.get('key3'); + expect(result, isNull); + }); + + test('4. clearByPrefix menghapus data sesuai prefix', () async { + await store.put(_freshEntry('pddikti:/search/mhs/a')); + await store.put(_freshEntry('pddikti:/search/mhs/b')); + await store.put(_freshEntry('wilayah:/provinces')); + + await store.clearByPrefix('pddikti:'); + + final stats = await store.stats(); + expect(stats.totalEntries, 1); + + final remaining = await store.get('wilayah:/provinces'); + expect(remaining, isNotNull); + }); + + test('5. stats menghitung entries fresh, stale, expired', () async { + await store.put(_freshEntry('fresh1')); + await store.put(_freshEntry('fresh2')); + await store.put(_staleEntry('stale1')); + + final stats = await store.stats(); + expect(stats.totalEntries, 3); + expect(stats.freshEntries, 2); + expect(stats.staleEntries, 1); + expect(stats.expiredEntries, 0); + }); + + test('6. cache key deterministic', () async { + await store.put(_freshEntry('same-key', body: '{"v":1}')); + await store.put(_freshEntry('same-key', body: '{"v":2}')); + + final stats = await store.stats(); + expect(stats.totalEntries, 1); // Overwritten, not duplicated + + final result = await store.get('same-key'); + expect(result!.body, '{"v":2}'); + }); + + test('7. eviction saat maxEntries tercapai', () async { + for (int i = 0; i < 12; i++) { + await store.put(_freshEntry('key_$i')); + } + + final stats = await store.stats(); + expect(stats.totalEntries, 10); // maxEntries = 10 + }); + + test('8. clearExpired aman saat cache kosong', () async { + await store.clearExpired(); + final stats = await store.stats(); + expect(stats.totalEntries, 0); + }); + + test('9. clearAll menghapus semua', () async { + await store.put(_freshEntry('a')); + await store.put(_freshEntry('b')); + await store.clearAll(); + + final stats = await store.stats(); + expect(stats.totalEntries, 0); + }); + + test('10. delete menghapus entry spesifik', () async { + await store.put(_freshEntry('target')); + await store.put(_freshEntry('keep')); + + await store.delete('target'); + + expect(await store.get('target'), isNull); + expect(await store.get('keep'), isNotNull); + }); + }); +} diff --git a/test/enrichment/external_links_test.dart b/test/enrichment/external_links_test.dart new file mode 100644 index 0000000..1e16c4d --- /dev/null +++ b/test/enrichment/external_links_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/api/enrichment/external_links.dart'; + +void main() { + group('ExternalEnrichmentLinks', () { + test('1. GARUDA link encode keyword dengan benar', () { + final link = GarudaLinkBuilder.searchByLecturer('Dr. Bambang Supriadi'); + expect(link.url.toString(), contains('Dr.%20Bambang%20Supriadi')); + expect(link.providerId, 'garuda_link'); + expect(link.title, contains('GARUDA')); + }); + + test('2. keyword kosong tidak membuat URL rusak', () { + final link = GarudaLinkBuilder.searchByKeyword(''); + expect(link.url.toString(), isNotEmpty); + expect(link.query, ''); + }); + + test('3. RAMA link terbentuk dari nama PT', () { + final link = RamaLinkBuilder.searchByInstitution('Universitas Indonesia'); + expect(link.url.toString(), contains('Universitas%20Indonesia')); + expect(link.providerId, 'rama_link'); + }); + + test('4. SINTA link terbentuk dari nama dosen', () { + final link = SintaLinkBuilder.searchLecturerProfile('Ichmi Yani'); + expect(link.url.toString(), contains('Ichmi%20Yani')); + expect(link.providerId, 'sinta_link'); + }); + + test('5. SINTA institution link terbentuk', () { + final link = SintaLinkBuilder.searchInstitution('ITB'); + expect(link.url.toString(), contains('affiliations')); + expect(link.url.toString(), contains('ITB')); + }); + + test('6. getDosenEnrichmentLinks returns multiple links', () { + final links = getDosenEnrichmentLinks( + dosenName: 'Dr. Test', + institutionName: 'Universitas Test', + ); + expect(links.length, 3); // GARUDA + SINTA + RAMA + expect(links.any((l) => l.providerId == 'garuda_link'), true); + expect(links.any((l) => l.providerId == 'sinta_link'), true); + expect(links.any((l) => l.providerId == 'rama_link'), true); + }); + + test('7. getPtEnrichmentLinks returns RAMA + SINTA', () { + final links = getPtEnrichmentLinks(ptName: 'UI'); + expect(links.length, 2); + }); + + test('8. semua link menggunakan https scheme', () { + final links = getDosenEnrichmentLinks(dosenName: 'Test', institutionName: 'UI'); + for (final link in links) { + expect(link.url.scheme, 'https'); + } + }); + }); +} diff --git a/test/features/disaster/data/datasources/bnpb_datasource_test.dart b/test/features/disaster/data/datasources/bnpb_datasource_test.dart new file mode 100644 index 0000000..6ba1484 --- /dev/null +++ b/test/features/disaster/data/datasources/bnpb_datasource_test.dart @@ -0,0 +1,418 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:db_cracker_tamaengs/core/error/exceptions.dart'; +import 'package:db_cracker_tamaengs/features/disaster/data/datasources/bnpb_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late BnpbRemoteDataSourceImpl dataSource; + + setUp(() { + mockDio = MockDio(); + dataSource = BnpbRemoteDataSourceImpl(dio: mockDio); + }); + + group('getRiskScore', () { + test('mengembalikan DisasterRiskModel pada response 200', () async { + final responseData = { + 'data': { + 'lat': -6.2088, + 'lon': 106.8456, + 'kabupaten': 'Kota Jakarta Selatan', + 'provinsi': 'DKI Jakarta', + 'risks': { + 'gempa_bumi': {'score': 24, 'risk_class': 'tinggi'}, + 'banjir': {'score': 20, 'risk_class': 'sedang'}, + 'longsor': {'score': 5, 'risk_class': 'rendah'}, + }, + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getRiskScore( + lat: -6.2088, + lon: 106.8456, + ); + + expect(result.lat, -6.2088); + expect(result.lon, 106.8456); + expect(result.kabupaten, 'Kota Jakarta Selatan'); + expect(result.provinsi, 'DKI Jakarta'); + expect(result.risks, hasLength(3)); + expect(result.risks['gempa_bumi']!.score, 24); + expect(result.risks['gempa_bumi']!.isHighRisk, isTrue); + expect(result.risks['banjir']!.isMediumRisk, isTrue); + }); + + test('melempar ServerException jika data null (koordinat invalid)', () async { + final responseData = {'data': null}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRiskScore(lat: 999.0, lon: 999.0), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada status non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {'error': 'Server Error'}, + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRiskScore(lat: -6.2, lon: 106.8), + throwsA(isA()), + ); + }); + + test('melempar TimeoutException pada connection timeout', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRiskScore(lat: -6.2, lon: 106.8), + throwsA(isA()), + ); + }); + + test('melempar NetworkException pada connection error', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionError, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRiskScore(lat: -6.2, lon: 106.8), + throwsA(isA()), + ); + }); + + test('melempar RateLimitException pada status 429', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 429, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRiskScore(lat: -6.2, lon: 106.8), + throwsA(isA()), + ); + }); + + test('parsing response dengan format data sebagai list', () async { + final responseData = { + 'data': [ + { + 'lat': -7.25, + 'lon': 112.75, + 'kabupaten': 'Kota Surabaya', + 'provinsi': 'Jawa Timur', + 'risks': { + 'banjir': {'score': 18, 'risk_class': 'sedang'}, + }, + }, + ], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getRiskScore( + lat: -7.25, + lon: 112.75, + ); + + expect(result.kabupaten, 'Kota Surabaya'); + expect(result.risks['banjir']!.score, 18); + }); + }); + + group('getIrbi', () { + test('mengembalikan list IrbiModel pada response 200', () async { + final responseData = { + 'data': [ + { + 'kode_wilayah': '3201', + 'nama_wilayah': 'Kabupaten Bogor', + 'provinsi': 'Jawa Barat', + 'skor_total': 185.5, + 'dominant_hazard': 'banjir', + 'hazard_scores': { + 'banjir': 35.5, + 'longsor': 28.0, + 'gempa_bumi': 25.0, + }, + }, + { + 'kode_wilayah': '3301', + 'nama_wilayah': 'Kabupaten Cilacap', + 'provinsi': 'Jawa Tengah', + 'skor_total': 170.0, + 'dominant_hazard': 'tsunami', + 'hazard_scores': { + 'tsunami': 40.0, + 'gempa_bumi': 30.0, + }, + }, + ], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getIrbi(tahun: 2023); + + expect(result, hasLength(2)); + expect(result[0].kodeWilayah, '3201'); + expect(result[0].namaWilayah, 'Kabupaten Bogor'); + expect(result[0].skorTotal, 185.5); + expect(result[0].riskCategory, 'Tinggi'); + expect(result[1].dominantHazard, 'tsunami'); + }); + + test('mengembalikan list kosong jika data kosong', () async { + final responseData = {'data': []}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getIrbi(tahun: 1990); + + expect(result, isEmpty); + }); + + test('melempar ServerException pada status non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {'error': 'Server Error'}, + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getIrbi(tahun: 2023), + throwsA(isA()), + ); + }); + + test('melempar TimeoutException pada receive timeout', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.receiveTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getIrbi(tahun: 2023), + throwsA(isA()), + ); + }); + + test('parsing response dengan format result.records', () async { + final responseData = { + 'result': { + 'records': [ + { + 'kode_wilayah': '5101', + 'nama_wilayah': 'Kabupaten Jembrana', + 'provinsi': 'Bali', + 'skor_total': 95.0, + 'dominant_hazard': 'gempa_bumi', + 'hazard_scores': {'gempa_bumi': 30.0}, + }, + ], + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getIrbi(tahun: 2023); + + expect(result, hasLength(1)); + expect(result[0].kodeWilayah, '5101'); + expect(result[0].riskCategory, 'Sedang'); + }); + }); + + group('getIrbiByProvinsi', () { + test('mengembalikan list IrbiModel untuk provinsi tertentu', () async { + final responseData = { + 'data': [ + { + 'kode_wilayah': '3201', + 'nama_wilayah': 'Kabupaten Bogor', + 'provinsi': 'Jawa Barat', + 'skor_total': 185.5, + 'dominant_hazard': 'banjir', + 'hazard_scores': {'banjir': 35.5}, + }, + { + 'kode_wilayah': '3202', + 'nama_wilayah': 'Kabupaten Sukabumi', + 'provinsi': 'Jawa Barat', + 'skor_total': 175.0, + 'dominant_hazard': 'longsor', + 'hazard_scores': {'longsor': 32.0}, + }, + ], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getIrbiByProvinsi( + tahun: 2023, + provinsi: 'Jawa Barat', + ); + + expect(result, hasLength(2)); + expect(result[0].provinsi, 'Jawa Barat'); + expect(result[1].provinsi, 'Jawa Barat'); + }); + + test('melempar ServerException pada status non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {}, + statusCode: 503, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getIrbiByProvinsi( + tahun: 2023, + provinsi: 'Test', + ), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada 404 DioException', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 404, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getIrbiByProvinsi( + tahun: 2023, + provinsi: 'Provinsi Tidak Ada', + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/disaster/data/models/disaster_models_test.dart b/test/features/disaster/data/models/disaster_models_test.dart new file mode 100644 index 0000000..64e5357 --- /dev/null +++ b/test/features/disaster/data/models/disaster_models_test.dart @@ -0,0 +1,622 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/features/disaster/data/models/disaster_models.dart'; + +void main() { + group('DisasterRiskModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'lat': -6.2088, + 'lon': 106.8456, + 'kabupaten': 'Kota Jakarta Selatan', + 'provinsi': 'DKI Jakarta', + 'risks': { + 'gempa_bumi': {'score': 28, 'risk_class': 'tinggi'}, + 'banjir': {'score': 20, 'risk_class': 'sedang'}, + 'longsor': {'score': 8, 'risk_class': 'rendah'}, + }, + }; + + final model = DisasterRiskModel.fromJson(json); + + expect(model.lat, -6.2088); + expect(model.lon, 106.8456); + expect(model.kabupaten, 'Kota Jakarta Selatan'); + expect(model.provinsi, 'DKI Jakarta'); + expect(model.risks, hasLength(3)); + expect(model.risks['gempa_bumi']!.score, 28); + expect(model.risks['banjir']!.riskClass, 'sedang'); + }); + + test('parsing dengan field alternatif latitude/longitude', () { + final json = { + 'latitude': -7.7956, + 'longitude': 110.3695, + 'kab_kota': 'Kota Yogyakarta', + 'provinsi': 'DI Yogyakarta', + 'risiko': { + 'gempa_bumi': {'skor': 30, 'kelas_risiko': 'tinggi'}, + }, + }; + + final model = DisasterRiskModel.fromJson(json); + + expect(model.lat, -7.7956); + expect(model.lon, 110.3695); + expect(model.kabupaten, 'Kota Yogyakarta'); + expect(model.risks['gempa_bumi']!.score, 30); + expect(model.risks['gempa_bumi']!.riskClass, 'tinggi'); + }); + + test('parsing dengan risks map kosong', () { + final json = { + 'lat': -6.0, + 'lon': 106.0, + 'kabupaten': 'Test', + 'provinsi': 'Test', + 'risks': {}, + }; + + final model = DisasterRiskModel.fromJson(json); + + expect(model.risks, isEmpty); + }); + + test('parsing tanpa field risks menghasilkan map kosong', () { + final json = { + 'lat': -6.0, + 'lon': 106.0, + 'kabupaten': 'Test', + 'provinsi': 'Test', + }; + + final model = DisasterRiskModel.fromJson(json); + + expect(model.risks, isEmpty); + }); + + test('parsing dengan field null menghasilkan default', () { + final json = {}; + + final model = DisasterRiskModel.fromJson(json); + + expect(model.lat, 0.0); + expect(model.lon, 0.0); + expect(model.kabupaten, ''); + expect(model.provinsi, ''); + expect(model.risks, isEmpty); + }); + }); + + group('dominantRisk', () { + test('mengembalikan hazard dengan score tertinggi', () { + final model = DisasterRiskModel( + lat: -6.0, + lon: 106.0, + kabupaten: 'Test', + provinsi: 'Test', + risks: { + 'banjir': const RiskDetailModel(score: 15, riskClass: 'sedang'), + 'gempa_bumi': const RiskDetailModel(score: 28, riskClass: 'tinggi'), + 'longsor': const RiskDetailModel(score: 5, riskClass: 'rendah'), + }, + ); + + final dominant = model.dominantRisk; + + expect(dominant, isNotNull); + expect(dominant!.key, 'gempa_bumi'); + expect(dominant.value.score, 28); + }); + + test('mengembalikan null jika risks kosong', () { + const model = DisasterRiskModel( + lat: -6.0, + lon: 106.0, + kabupaten: 'Test', + provinsi: 'Test', + risks: {}, + ); + + expect(model.dominantRisk, isNull); + }); + + test('mengembalikan entry pertama jika semua score sama', () { + final model = DisasterRiskModel( + lat: -6.0, + lon: 106.0, + kabupaten: 'Test', + provinsi: 'Test', + risks: { + 'banjir': const RiskDetailModel(score: 20, riskClass: 'sedang'), + 'longsor': const RiskDetailModel(score: 20, riskClass: 'sedang'), + }, + ); + + final dominant = model.dominantRisk; + + expect(dominant, isNotNull); + expect(dominant!.value.score, 20); + }); + }); + + group('activeHazardCount', () { + test('menghitung hazard dengan score > 0', () { + final model = DisasterRiskModel( + lat: -6.0, + lon: 106.0, + kabupaten: 'Test', + provinsi: 'Test', + risks: { + 'banjir': const RiskDetailModel(score: 15, riskClass: 'sedang'), + 'gempa_bumi': const RiskDetailModel(score: 0, riskClass: 'rendah'), + 'longsor': const RiskDetailModel(score: 5, riskClass: 'rendah'), + }, + ); + + expect(model.activeHazardCount, 2); + }); + + test('mengembalikan 0 jika semua score nol', () { + final model = DisasterRiskModel( + lat: -6.0, + lon: 106.0, + kabupaten: 'Test', + provinsi: 'Test', + risks: { + 'banjir': const RiskDetailModel(score: 0, riskClass: 'rendah'), + 'gempa_bumi': const RiskDetailModel(score: 0, riskClass: 'rendah'), + }, + ); + + expect(model.activeHazardCount, 0); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + final original = DisasterRiskModel( + lat: -6.2088, + lon: 106.8456, + kabupaten: 'Jakarta Selatan', + provinsi: 'DKI Jakarta', + risks: { + 'banjir': const RiskDetailModel(score: 20, riskClass: 'sedang'), + }, + ); + + final json = original.toJson(); + + expect(json['lat'], -6.2088); + expect(json['lon'], 106.8456); + expect(json['kabupaten'], 'Jakarta Selatan'); + expect(json['provinsi'], 'DKI Jakarta'); + expect((json['risks'] as Map)['banjir']['score'], 20); + }); + }); + + group('all hazard types', () { + test('model mendukung semua tipe hazard BNPB', () { + final hazardTypes = [ + 'gempa_bumi', + 'tsunami', + 'banjir', + 'longsor', + 'letusan_gunung_api', + 'kekeringan', + 'kebakaran_hutan', + 'angin_puting_beliung', + 'gelombang_ekstrim', + ]; + + final risks = {}; + for (final type in hazardTypes) { + risks[type] = const RiskDetailModel(score: 10, riskClass: 'sedang'); + } + + final model = DisasterRiskModel( + lat: -6.0, + lon: 106.0, + kabupaten: 'Test', + provinsi: 'Test', + risks: risks, + ); + + expect(model.risks.length, 9); + expect(model.activeHazardCount, 9); + for (final type in hazardTypes) { + expect(model.risks.containsKey(type), isTrue); + } + }); + }); + + group('equality', () { + test('model dengan lat/lon sama dianggap equal', () { + const a = DisasterRiskModel( + lat: -6.2088, + lon: 106.8456, + kabupaten: 'A', + provinsi: 'A', + risks: {}, + ); + const b = DisasterRiskModel( + lat: -6.2088, + lon: 106.8456, + kabupaten: 'B', + provinsi: 'B', + risks: {}, + ); + + expect(a, equals(b)); + }); + }); + }); + + group('RiskDetailModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'score': 24, + 'risk_class': 'tinggi', + }; + + final model = RiskDetailModel.fromJson(json); + + expect(model.score, 24); + expect(model.riskClass, 'tinggi'); + }); + + test('parsing dengan field alternatif bahasa Indonesia', () { + final json = { + 'skor': 15, + 'kelas_risiko': 'sedang', + }; + + final model = RiskDetailModel.fromJson(json); + + expect(model.score, 15); + expect(model.riskClass, 'sedang'); + }); + + test('parsing dengan field class sebagai alternatif', () { + final json = { + 'score': 8, + 'class': 'rendah', + }; + + final model = RiskDetailModel.fromJson(json); + + expect(model.riskClass, 'rendah'); + }); + + test('parsing tanpa field menghasilkan default', () { + final json = {}; + + final model = RiskDetailModel.fromJson(json); + + expect(model.score, 0); + expect(model.riskClass, 'rendah'); + }); + }); + + group('score/class mapping', () { + test('score >= 24 dianggap high risk', () { + const model = RiskDetailModel(score: 24, riskClass: 'tinggi'); + + expect(model.isHighRisk, isTrue); + expect(model.isMediumRisk, isFalse); + }); + + test('score 30 dianggap high risk', () { + const model = RiskDetailModel(score: 30, riskClass: 'tinggi'); + + expect(model.isHighRisk, isTrue); + }); + + test('score 12-23 dianggap medium risk', () { + const model = RiskDetailModel(score: 15, riskClass: 'sedang'); + + expect(model.isHighRisk, isFalse); + expect(model.isMediumRisk, isTrue); + }); + + test('score tepat 12 dianggap medium risk', () { + const model = RiskDetailModel(score: 12, riskClass: 'sedang'); + + expect(model.isMediumRisk, isTrue); + }); + + test('score < 12 bukan medium dan bukan high', () { + const model = RiskDetailModel(score: 8, riskClass: 'rendah'); + + expect(model.isHighRisk, isFalse); + expect(model.isMediumRisk, isFalse); + }); + + test('score 0 bukan medium dan bukan high', () { + const model = RiskDetailModel(score: 0, riskClass: 'rendah'); + + expect(model.isHighRisk, isFalse); + expect(model.isMediumRisk, isFalse); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = RiskDetailModel(score: 20, riskClass: 'sedang'); + + final json = original.toJson(); + final restored = RiskDetailModel.fromJson(json); + + expect(restored.score, original.score); + expect(restored.riskClass, original.riskClass); + }); + }); + + group('equality', () { + test('model dengan score dan riskClass sama dianggap equal', () { + const a = RiskDetailModel(score: 20, riskClass: 'sedang'); + const b = RiskDetailModel(score: 20, riskClass: 'sedang'); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('model dengan score berbeda tidak equal', () { + const a = RiskDetailModel(score: 20, riskClass: 'sedang'); + const b = RiskDetailModel(score: 25, riskClass: 'sedang'); + + expect(a, isNot(equals(b))); + }); + }); + }); + + group('IrbiModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'kode_wilayah': '3201', + 'nama_wilayah': 'Kabupaten Bogor', + 'provinsi': 'Jawa Barat', + 'skor_total': 185.5, + 'dominant_hazard': 'banjir', + 'hazard_scores': { + 'gempa_bumi': 25.0, + 'banjir': 35.5, + 'longsor': 28.0, + 'kekeringan': 15.0, + }, + }; + + final model = IrbiModel.fromJson(json); + + expect(model.kodeWilayah, '3201'); + expect(model.namaWilayah, 'Kabupaten Bogor'); + expect(model.provinsi, 'Jawa Barat'); + expect(model.skorTotal, 185.5); + expect(model.dominantHazard, 'banjir'); + expect(model.hazardScores, hasLength(4)); + expect(model.hazardScores['banjir'], 35.5); + }); + + test('parsing dengan field alternatif bahasa Indonesia', () { + final json = { + 'kode': '3301', + 'nama': 'Kabupaten Cilacap', + 'provinsi': 'Jawa Tengah', + 'total_score': 150.0, + 'ancaman_dominan': 'tsunami', + 'skor_ancaman': { + 'tsunami': 30.0, + 'gempa_bumi': 25.0, + }, + }; + + final model = IrbiModel.fromJson(json); + + expect(model.kodeWilayah, '3301'); + expect(model.namaWilayah, 'Kabupaten Cilacap'); + expect(model.skorTotal, 150.0); + expect(model.dominantHazard, 'tsunami'); + expect(model.hazardScores['tsunami'], 30.0); + }); + + test('parsing tanpa hazard_scores menghasilkan map kosong', () { + final json = { + 'kode_wilayah': '9999', + 'nama_wilayah': 'Test', + 'provinsi': 'Test', + 'skor_total': 0, + 'dominant_hazard': '', + }; + + final model = IrbiModel.fromJson(json); + + expect(model.hazardScores, isEmpty); + }); + + test('parsing dengan semua field null menghasilkan default', () { + final json = {}; + + final model = IrbiModel.fromJson(json); + + expect(model.kodeWilayah, ''); + expect(model.namaWilayah, ''); + expect(model.provinsi, ''); + expect(model.skorTotal, 0.0); + expect(model.dominantHazard, ''); + expect(model.hazardScores, isEmpty); + }); + }); + + group('dominant hazard detection', () { + test('dominant hazard sesuai dengan score tertinggi di hazardScores', () { + final model = IrbiModel( + kodeWilayah: '3201', + namaWilayah: 'Test', + provinsi: 'Test', + skorTotal: 100.0, + dominantHazard: 'banjir', + hazardScores: { + 'gempa_bumi': 20.0, + 'banjir': 40.0, + 'longsor': 15.0, + }, + ); + + expect(model.dominantHazard, 'banjir'); + final maxScore = model.hazardScores.values.reduce( + (a, b) => a > b ? a : b, + ); + expect(model.hazardScores[model.dominantHazard], maxScore); + }); + }); + + group('riskCategory', () { + test('skor >= 168 dikategorikan Tinggi', () { + const model = IrbiModel( + kodeWilayah: '1', + namaWilayah: 'A', + provinsi: 'A', + skorTotal: 200.0, + dominantHazard: 'banjir', + hazardScores: {}, + ); + + expect(model.riskCategory, 'Tinggi'); + }); + + test('skor tepat 168 dikategorikan Tinggi', () { + const model = IrbiModel( + kodeWilayah: '2', + namaWilayah: 'B', + provinsi: 'B', + skorTotal: 168.0, + dominantHazard: 'gempa', + hazardScores: {}, + ); + + expect(model.riskCategory, 'Tinggi'); + }); + + test('skor 84-167 dikategorikan Sedang', () { + const model = IrbiModel( + kodeWilayah: '3', + namaWilayah: 'C', + provinsi: 'C', + skorTotal: 120.0, + dominantHazard: 'longsor', + hazardScores: {}, + ); + + expect(model.riskCategory, 'Sedang'); + }); + + test('skor tepat 84 dikategorikan Sedang', () { + const model = IrbiModel( + kodeWilayah: '4', + namaWilayah: 'D', + provinsi: 'D', + skorTotal: 84.0, + dominantHazard: 'banjir', + hazardScores: {}, + ); + + expect(model.riskCategory, 'Sedang'); + }); + + test('skor < 84 dikategorikan Rendah', () { + const model = IrbiModel( + kodeWilayah: '5', + namaWilayah: 'E', + provinsi: 'E', + skorTotal: 50.0, + dominantHazard: 'kekeringan', + hazardScores: {}, + ); + + expect(model.riskCategory, 'Rendah'); + }); + + test('skor 0 dikategorikan Rendah', () { + const model = IrbiModel( + kodeWilayah: '6', + namaWilayah: 'F', + provinsi: 'F', + skorTotal: 0.0, + dominantHazard: '', + hazardScores: {}, + ); + + expect(model.riskCategory, 'Rendah'); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = IrbiModel( + kodeWilayah: '3201', + namaWilayah: 'Kabupaten Bogor', + provinsi: 'Jawa Barat', + skorTotal: 185.5, + dominantHazard: 'banjir', + hazardScores: {'banjir': 35.5, 'gempa_bumi': 25.0}, + ); + + final json = original.toJson(); + + expect(json['kode_wilayah'], '3201'); + expect(json['nama_wilayah'], 'Kabupaten Bogor'); + expect(json['provinsi'], 'Jawa Barat'); + expect(json['skor_total'], 185.5); + expect(json['dominant_hazard'], 'banjir'); + expect((json['hazard_scores'] as Map)['banjir'], 35.5); + }); + }); + + group('equality', () { + test('model dengan kodeWilayah sama dianggap equal', () { + const a = IrbiModel( + kodeWilayah: '3201', + namaWilayah: 'A', + provinsi: 'A', + skorTotal: 100, + dominantHazard: 'a', + hazardScores: {}, + ); + const b = IrbiModel( + kodeWilayah: '3201', + namaWilayah: 'B', + provinsi: 'B', + skorTotal: 200, + dominantHazard: 'b', + hazardScores: {}, + ); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('model dengan kodeWilayah berbeda tidak equal', () { + const a = IrbiModel( + kodeWilayah: '3201', + namaWilayah: 'A', + provinsi: 'A', + skorTotal: 100, + dominantHazard: 'a', + hazardScores: {}, + ); + const b = IrbiModel( + kodeWilayah: '3301', + namaWilayah: 'A', + provinsi: 'A', + skorTotal: 100, + dominantHazard: 'a', + hazardScores: {}, + ); + + expect(a, isNot(equals(b))); + }); + }); + }); +} diff --git a/test/features/economy/data/datasources/economy_datasource_test.dart b/test/features/economy/data/datasources/economy_datasource_test.dart new file mode 100644 index 0000000..2e2f7f0 --- /dev/null +++ b/test/features/economy/data/datasources/economy_datasource_test.dart @@ -0,0 +1,466 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:db_cracker_tamaengs/core/error/exceptions.dart'; +import 'package:db_cracker_tamaengs/features/economy/data/datasources/bi_remote_datasource.dart'; +import 'package:db_cracker_tamaengs/features/economy/data/datasources/kemnaker_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + group('BiRemoteDataSourceImpl', () { + late MockDio mockDio; + late BiRemoteDataSourceImpl dataSource; + + setUp(() { + mockDio = MockDio(); + dataSource = BiRemoteDataSourceImpl(dio: mockDio); + }); + + group('getExchangeRate', () { + test('mengembalikan ExchangeRateModel pada response 200', () async { + final responseData = { + 'data': [ + { + 'mata_uang': 'USD', + 'tanggal': '2024-03-15', + 'beli': 15750.0, + 'jual': 15850.0, + 'tengah': 15800.0, + }, + ], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-15', + ); + + expect(result.currency, 'USD'); + expect(result.date, '2024-03-15'); + expect(result.buy, 15750.0); + expect(result.sell, 15850.0); + expect(result.middle, 15800.0); + }); + + test('melempar ServerException jika data kosong (weekend)', () async { + final responseData = { + 'data': [], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-16', // Saturday + ), + throwsA(isA()), + ); + }); + + test('melempar TimeoutException pada timeout', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-15', + ), + throwsA(isA()), + ); + }); + + test('melempar NetworkException pada connection error', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionError, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-15', + ), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada status non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {'error': 'Server Error'}, + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-15', + ), + throwsA(isA()), + ); + }); + + test('melempar RateLimitException pada status 429', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 429, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-15', + ), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada 401 authentication failed', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 401, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getExchangeRate( + currency: 'USD', + date: '2024-03-15', + ), + throwsA(isA()), + ); + }); + }); + + group('getBiRate', () { + test('mengembalikan BiRateModel pada response 200', () async { + final responseData = { + 'data': [ + { + 'rate': 6.25, + 'effective_date': '2024-03-20', + 'description': 'BI-7 Day Reverse Repo Rate', + }, + ], + }; + + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getBiRate(); + + expect(result.rate, 6.25); + expect(result.effectiveDate, '2024-03-20'); + expect(result.description, 'BI-7 Day Reverse Repo Rate'); + }); + + test('melempar ServerException jika data null', () async { + final responseData = {'data': null}; + + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getBiRate(), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada status non-200', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: {}, + statusCode: 503, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getBiRate(), + throwsA(isA()), + ); + }); + }); + }); + + group('KemnakerRemoteDataSourceImpl', () { + late MockDio mockDio; + late KemnakerRemoteDataSourceImpl dataSource; + + setUp(() { + mockDio = MockDio(); + dataSource = KemnakerRemoteDataSourceImpl(dio: mockDio); + }); + + group('getUmp', () { + test('mengembalikan list MinimumWageModel pada response 200', () async { + final responseData = { + 'data': [ + {'provinsi': 'DKI Jakarta', 'ump': 5067381, 'tahun': 2024}, + {'provinsi': 'Jawa Barat', 'ump': 2057495, 'tahun': 2024}, + {'provinsi': 'Jawa Tengah', 'ump': 2032000, 'tahun': 2024}, + ], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getUmp(tahun: 2024); + + expect(result, hasLength(3)); + expect(result[0].provinsi, 'DKI Jakarta'); + expect(result[0].ump, 5067381); + expect(result[1].provinsi, 'Jawa Barat'); + }); + + test('mengembalikan list kosong jika data kosong', () async { + final responseData = {'data': []}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getUmp(tahun: 1990); + + expect(result, isEmpty); + }); + + test('melempar ServerException pada status non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {'error': 'Internal Server Error'}, + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getUmp(tahun: 2024), + throwsA(isA()), + ); + }); + + test('melempar TimeoutException pada send timeout', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.sendTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getUmp(tahun: 2024), + throwsA(isA()), + ); + }); + + test('melempar NetworkException pada connection error', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionError, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getUmp(tahun: 2024), + throwsA(isA()), + ); + }); + + test('parsing response dengan format result.records', () async { + final responseData = { + 'result': { + 'records': [ + {'provinsi': 'Bali', 'ump': 2813000, 'tahun': 2024}, + ], + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getUmp(tahun: 2024); + + expect(result, hasLength(1)); + expect(result[0].provinsi, 'Bali'); + }); + }); + + group('getUmpByProvinsi', () { + test('mengembalikan MinimumWageModel untuk provinsi tertentu', () async { + final responseData = { + 'data': [ + {'provinsi': 'DKI Jakarta', 'ump': 5067381, 'tahun': 2024}, + ], + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getUmpByProvinsi( + provinsi: 'DKI Jakarta', + tahun: 2024, + ); + + expect(result.provinsi, 'DKI Jakarta'); + expect(result.ump, 5067381); + expect(result.tahun, 2024); + }); + + test('melempar ServerException jika data tidak ditemukan', () async { + final responseData = {'data': []}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getUmpByProvinsi( + provinsi: 'Provinsi Tidak Ada', + tahun: 2024, + ), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada 404', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 404, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getUmpByProvinsi( + provinsi: 'Unknown', + tahun: 2024, + ), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/test/features/economy/data/models/economy_models_test.dart b/test/features/economy/data/models/economy_models_test.dart new file mode 100644 index 0000000..976a8ad --- /dev/null +++ b/test/features/economy/data/models/economy_models_test.dart @@ -0,0 +1,471 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/features/economy/data/models/economy_models.dart'; + +void main() { + group('ExchangeRateModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'currency': 'USD', + 'date': '2024-03-15', + 'buy': 15750.0, + 'sell': 15850.0, + 'middle': 15800.0, + }; + + final model = ExchangeRateModel.fromJson(json); + + expect(model.currency, 'USD'); + expect(model.date, '2024-03-15'); + expect(model.buy, 15750.0); + expect(model.sell, 15850.0); + expect(model.middle, 15800.0); + }); + + test('parsing dengan field null menghasilkan default values', () { + final json = {}; + + final model = ExchangeRateModel.fromJson(json); + + expect(model.currency, ''); + expect(model.date, ''); + expect(model.buy, 0.0); + expect(model.sell, 0.0); + expect(model.middle, 0.0); + }); + + test('parsing value integer dikonversi ke double', () { + final json = { + 'currency': 'EUR', + 'date': '2024-01-01', + 'buy': 17000, + 'sell': 17200, + 'middle': 17100, + }; + + final model = ExchangeRateModel.fromJson(json); + + expect(model.buy, 17000.0); + expect(model.sell, 17200.0); + expect(model.middle, 17100.0); + }); + }); + + group('fromBiResponse', () { + test('parsing format BI dengan field bahasa Indonesia', () { + final json = { + 'mata_uang': 'USD', + 'tanggal': '2024-03-15', + 'beli': 15750.0, + 'jual': 15850.0, + 'tengah': 15800.0, + }; + + final model = ExchangeRateModel.fromBiResponse(json); + + expect(model.currency, 'USD'); + expect(model.date, '2024-03-15'); + expect(model.buy, 15750.0); + expect(model.sell, 15850.0); + expect(model.middle, 15800.0); + }); + + test('middle dihitung otomatis jika tidak ada', () { + final json = { + 'mata_uang': 'JPY', + 'tanggal': '2024-03-15', + 'beli': 100.0, + 'jual': 110.0, + }; + + final model = ExchangeRateModel.fromBiResponse(json); + + expect(model.middle, 105.0); + }); + + test('parsing format nested jual_beli', () { + final json = { + 'mata_uang': 'SGD', + 'tanggal': '2024-03-15', + 'jual_beli': {'beli': 11500.0, 'jual': 11700.0}, + 'tengah': 11600.0, + }; + + final model = ExchangeRateModel.fromBiResponse(json); + + expect(model.buy, 11500.0); + expect(model.sell, 11700.0); + expect(model.middle, 11600.0); + }); + }); + + group('spread calculation', () { + test('spread dihitung dari selisih sell dan buy', () { + const model = ExchangeRateModel( + currency: 'USD', + date: '2024-01-01', + buy: 15700.0, + sell: 15900.0, + middle: 15800.0, + ); + + expect(model.spread, 200.0); + }); + + test('spread nol ketika buy sama dengan sell', () { + const model = ExchangeRateModel( + currency: 'USD', + date: '2024-01-01', + buy: 15800.0, + sell: 15800.0, + middle: 15800.0, + ); + + expect(model.spread, 0.0); + }); + + test('spread negatif jika buy lebih besar dari sell (anomali)', () { + const model = ExchangeRateModel( + currency: 'USD', + date: '2024-01-01', + buy: 16000.0, + sell: 15800.0, + middle: 15900.0, + ); + + expect(model.spread, -200.0); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = ExchangeRateModel( + currency: 'AUD', + date: '2024-06-01', + buy: 10500.0, + sell: 10700.0, + middle: 10600.0, + ); + + final json = original.toJson(); + final restored = ExchangeRateModel.fromJson(json); + + expect(restored.currency, original.currency); + expect(restored.date, original.date); + expect(restored.buy, original.buy); + expect(restored.sell, original.sell); + expect(restored.middle, original.middle); + }); + }); + + group('equality', () { + test('model dengan currency dan date sama dianggap equal', () { + const a = ExchangeRateModel( + currency: 'USD', + date: '2024-01-01', + buy: 15000, + sell: 15200, + middle: 15100, + ); + const b = ExchangeRateModel( + currency: 'USD', + date: '2024-01-01', + buy: 16000, + sell: 16200, + middle: 16100, + ); + + expect(a, equals(b)); + }); + }); + }); + + group('MinimumWageModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'provinsi': 'DKI Jakarta', + 'ump': 5067381, + 'tahun': 2024, + }; + + final model = MinimumWageModel.fromJson(json); + + expect(model.provinsi, 'DKI Jakarta'); + expect(model.ump, 5067381); + expect(model.tahun, 2024); + }); + + test('parsing dengan field alternatif upah_minimum', () { + final json = { + 'provinsi': 'Jawa Barat', + 'upah_minimum': 2057495, + 'tahun': 2024, + }; + + final model = MinimumWageModel.fromJson(json); + + expect(model.ump, 2057495); + }); + + test('parsing dengan field alternatif year', () { + final json = { + 'provinsi': 'Bali', + 'ump': 2813000, + 'year': 2024, + }; + + final model = MinimumWageModel.fromJson(json); + + expect(model.tahun, 2024); + }); + + test('parsing dengan field null menghasilkan default', () { + final json = {}; + + final model = MinimumWageModel.fromJson(json); + + expect(model.provinsi, ''); + expect(model.ump, 0); + expect(model.tahun, 0); + }); + }); + + group('formattedUmp', () { + test('format UMP jutaan dengan separator titik', () { + const model = MinimumWageModel( + provinsi: 'DKI Jakarta', + ump: 5067381, + tahun: 2024, + ); + + expect(model.formattedUmp, 'Rp 5.067.381'); + }); + + test('format UMP ratusan ribu', () { + const model = MinimumWageModel( + provinsi: 'Test', + ump: 500000, + tahun: 2024, + ); + + expect(model.formattedUmp, 'Rp 500.000'); + }); + + test('format UMP nol', () { + const model = MinimumWageModel( + provinsi: 'Test', + ump: 0, + tahun: 2024, + ); + + expect(model.formattedUmp, 'Rp 0'); + }); + + test('format UMP puluhan juta', () { + const model = MinimumWageModel( + provinsi: 'Test', + ump: 15000000, + tahun: 2024, + ); + + expect(model.formattedUmp, 'Rp 15.000.000'); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = MinimumWageModel( + provinsi: 'Jawa Tengah', + ump: 2032000, + tahun: 2024, + ); + + final json = original.toJson(); + final restored = MinimumWageModel.fromJson(json); + + expect(restored.provinsi, original.provinsi); + expect(restored.ump, original.ump); + expect(restored.tahun, original.tahun); + }); + }); + + group('equality', () { + test('model dengan provinsi dan tahun sama dianggap equal', () { + const a = MinimumWageModel( + provinsi: 'DKI Jakarta', + ump: 5000000, + tahun: 2024, + ); + const b = MinimumWageModel( + provinsi: 'DKI Jakarta', + ump: 6000000, + tahun: 2024, + ); + + expect(a, equals(b)); + }); + + test('model dengan tahun berbeda tidak equal', () { + const a = MinimumWageModel( + provinsi: 'DKI Jakarta', + ump: 5000000, + tahun: 2024, + ); + const b = MinimumWageModel( + provinsi: 'DKI Jakarta', + ump: 5000000, + tahun: 2023, + ); + + expect(a, isNot(equals(b))); + }); + }); + }); + + group('BiRateModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'rate': 6.25, + 'effective_date': '2024-03-20', + 'description': 'BI-7 Day Reverse Repo Rate', + }; + + final model = BiRateModel.fromJson(json); + + expect(model.rate, 6.25); + expect(model.effectiveDate, '2024-03-20'); + expect(model.description, 'BI-7 Day Reverse Repo Rate'); + }); + + test('parsing dengan field alternatif bahasa Indonesia', () { + final json = { + 'suku_bunga': 6.0, + 'tanggal_efektif': '2024-01-15', + 'keterangan': 'Suku bunga acuan', + }; + + final model = BiRateModel.fromJson(json); + + expect(model.rate, 6.0); + expect(model.effectiveDate, '2024-01-15'); + expect(model.description, 'Suku bunga acuan'); + }); + + test('parsing dengan field alternatif bi_rate', () { + final json = { + 'bi_rate': 5.75, + 'effective_date': '2023-12-01', + }; + + final model = BiRateModel.fromJson(json); + + expect(model.rate, 5.75); + expect(model.description, ''); + }); + + test('parsing dengan semua field null menghasilkan default', () { + final json = {}; + + final model = BiRateModel.fromJson(json); + + expect(model.rate, 0.0); + expect(model.effectiveDate, ''); + expect(model.description, ''); + }); + }); + + group('formattedRate', () { + test('format rate sebagai persentase dengan 2 desimal', () { + const model = BiRateModel( + rate: 6.25, + effectiveDate: '2024-03-20', + ); + + expect(model.formattedRate, '6.25%'); + }); + + test('format rate bulat tetap 2 desimal', () { + const model = BiRateModel( + rate: 6.0, + effectiveDate: '2024-01-01', + ); + + expect(model.formattedRate, '6.00%'); + }); + + test('format rate nol', () { + const model = BiRateModel( + rate: 0.0, + effectiveDate: '2024-01-01', + ); + + expect(model.formattedRate, '0.00%'); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = BiRateModel( + rate: 6.25, + effectiveDate: '2024-03-20', + description: 'BI Rate', + ); + + final json = original.toJson(); + final restored = BiRateModel.fromJson(json); + + expect(restored.rate, original.rate); + expect(restored.effectiveDate, original.effectiveDate); + expect(restored.description, original.description); + }); + }); + + group('equality', () { + test('model dengan rate dan effectiveDate sama dianggap equal', () { + const a = BiRateModel( + rate: 6.25, + effectiveDate: '2024-03-20', + description: 'A', + ); + const b = BiRateModel( + rate: 6.25, + effectiveDate: '2024-03-20', + description: 'B', + ); + + expect(a, equals(b)); + }); + + test('model dengan rate berbeda tidak equal', () { + const a = BiRateModel( + rate: 6.25, + effectiveDate: '2024-03-20', + ); + const b = BiRateModel( + rate: 6.00, + effectiveDate: '2024-03-20', + ); + + expect(a, isNot(equals(b))); + }); + }); + + group('copyWith', () { + test('copyWith mengubah field yang ditentukan saja', () { + const original = BiRateModel( + rate: 6.25, + effectiveDate: '2024-03-20', + description: 'Original', + ); + + final copied = original.copyWith(rate: 6.50); + + expect(copied.rate, 6.50); + expect(copied.effectiveDate, '2024-03-20'); + expect(copied.description, 'Original'); + }); + }); + }); +} diff --git a/test/features/procurement/data/datasources/nemesis_datasource_test.dart b/test/features/procurement/data/datasources/nemesis_datasource_test.dart new file mode 100644 index 0000000..0e4ed91 --- /dev/null +++ b/test/features/procurement/data/datasources/nemesis_datasource_test.dart @@ -0,0 +1,419 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:db_cracker_tamaengs/core/error/exceptions.dart'; +import 'package:db_cracker_tamaengs/features/procurement/data/datasources/nemesis_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late NemesisRemoteDataSourceImpl dataSource; + + setUp(() { + mockDio = MockDio(); + dataSource = NemesisRemoteDataSourceImpl(dio: mockDio); + }); + + group('getBootstrap', () { + test('returns BootstrapModel on 200 success', () async { + final responseData = { + 'summary': { + 'totalPackages': 3000, + 'totalPriorityPackages': 500, + 'totalPotentialWaste': 10000000000.0, + 'totalBudget': 80000000000, + 'unmappedPackages': 100, + 'multiLocationPackages': 25, + }, + 'regions': [ + { + 'regionKey': 'jawa-barat-kab-bandung', + 'regionName': 'Kabupaten Bandung', + 'totalPackages': 150, + }, + ], + }; + + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getBootstrap(); + + expect(result.summary, isNotNull); + expect(result.summary!.totalPackages, 3000); + expect(result.regions, hasLength(1)); + expect(result.regions[0].regionKey, 'jawa-barat-kab-bandung'); + verify(() => mockDio.get('https://nemesis.tams.codes/api/bootstrap')) + .called(1); + }); + + test('throws ServerException on 500 server error', () async { + when(() => mockDio.get(any())).thenThrow( + DioException( + response: Response( + data: {'error': 'Internal Server Error'}, + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + message: 'Server error', + ), + ); + + expect( + () => dataSource.getBootstrap(), + throwsA(isA()), + ); + }); + + test('throws RateLimitException on 429 response', () async { + when(() => mockDio.get(any())).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + data: {'error': 'Too Many Requests'}, + statusCode: 429, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + message: 'Rate limited', + ), + ); + + expect( + () => dataSource.getBootstrap(), + throwsA(isA()), + ); + }); + + test('throws ServerException on non-200 status code', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: {'error': 'Not Found'}, + statusCode: 404, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getBootstrap(), + throwsA(isA()), + ); + }); + + test('throws TimeoutException on connectionTimeout', () async { + when(() => mockDio.get(any())).thenThrow( + DioException( + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: ''), + message: 'Connection timeout', + ), + ); + + expect( + () => dataSource.getBootstrap(), + throwsA(isA()), + ); + }); + }); + + group('getRegionPackages', () { + test('returns PaginatedResponse on 200 success with pagination', () async { + final responseData = { + 'data': [ + { + 'id': 1, + 'sourceId': 'PKG-001', + 'packageName': 'Pengadaan Komputer', + 'ownerName': 'Dinas Pendidikan', + 'ownerType': 'Pemda', + 'budget': 500000000, + }, + { + 'id': 2, + 'sourceId': 'PKG-002', + 'packageName': 'Pembangunan Jalan', + 'ownerName': 'Dinas PU', + 'ownerType': 'Pemda', + 'budget': 2000000000, + }, + ], + 'pagination': { + 'page': 1, + 'pageSize': 25, + 'totalItems': 150, + 'totalPages': 6, + }, + }; + + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getRegionPackages( + regionKey: 'jawa-barat-kab-bandung', + page: 1, + pageSize: 25, + ); + + expect(result.data, hasLength(2)); + expect(result.data[0].packageName, 'Pengadaan Komputer'); + expect(result.data[1].packageName, 'Pembangunan Jalan'); + expect(result.pagination, isNotNull); + expect(result.pagination!.page, 1); + expect(result.pagination!.totalItems, 150); + expect(result.pagination!.totalPages, 6); + }); + + test('passes filter parameters correctly', () async { + final responseData = { + 'data': [ + { + 'id': 5, + 'sourceId': 'PKG-005', + 'packageName': 'Filtered Package', + 'ownerName': 'Kementerian', + 'ownerType': 'K/L', + }, + ], + 'pagination': { + 'page': 1, + 'pageSize': 10, + 'totalItems': 1, + 'totalPages': 1, + }, + }; + + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + await dataSource.getRegionPackages( + regionKey: 'test-region', + page: 2, + pageSize: 10, + search: 'laptop', + ownerType: 'K/L', + severity: 'high', + priorityOnly: true, + ); + + final captured = verify( + () => mockDio.get( + any(), + queryParameters: captureAny(named: 'queryParameters'), + ), + ).captured.last as Map; + + expect(captured['page'], 2); + expect(captured['pageSize'], 10); + expect(captured['search'], 'laptop'); + expect(captured['ownerType'], 'K/L'); + expect(captured['severity'], 'high'); + expect(captured['priorityOnly'], '1'); + }); + + test('omits empty search and null filters from query params', () async { + final responseData = { + 'data': [], + 'pagination': { + 'page': 1, + 'pageSize': 25, + 'totalItems': 0, + 'totalPages': 0, + }, + }; + + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + await dataSource.getRegionPackages( + regionKey: 'test-region', + search: '', + ownerType: null, + severity: null, + priorityOnly: false, + ); + + final captured = verify( + () => mockDio.get( + any(), + queryParameters: captureAny(named: 'queryParameters'), + ), + ).captured.last as Map; + + expect(captured.containsKey('search'), isFalse); + expect(captured.containsKey('ownerType'), isFalse); + expect(captured.containsKey('severity'), isFalse); + expect(captured.containsKey('priorityOnly'), isFalse); + }); + + test('throws RateLimitException on 429', () async { + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + data: {'error': 'Rate limited'}, + statusCode: 429, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRegionPackages(regionKey: 'test'), + throwsA(isA()), + ); + }); + + test('throws ServerException on non-200 response', () async { + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer( + (_) async => Response( + data: {'error': 'Forbidden'}, + statusCode: 403, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getRegionPackages(regionKey: 'forbidden-region'), + throwsA(isA()), + ); + }); + + test('handles empty data array gracefully', () async { + final responseData = { + 'data': [], + 'pagination': null, + }; + + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getRegionPackages(regionKey: 'empty'); + + expect(result.data, isEmpty); + expect(result.pagination, isNull); + }); + + test('calls correct URL with regionKey', () async { + final responseData = { + 'data': [], + 'pagination': null, + }; + + when(() => mockDio.get(any(), queryParameters: any(named: 'queryParameters'))) + .thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + await dataSource.getRegionPackages(regionKey: 'jawa-timur-kota-surabaya'); + + verify(() => mockDio.get( + 'https://nemesis.tams.codes/api/regions/jawa-timur-kota-surabaya/packages', + queryParameters: any(named: 'queryParameters'), + )).called(1); + }); + }); + + group('healthCheck', () { + test('returns true on 200 response', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: {'status': 'ok'}, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.healthCheck(); + + expect(result, isTrue); + verify(() => mockDio.get('https://nemesis.tams.codes/api/health')).called(1); + }); + + test('returns false on non-200 response', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: {'status': 'degraded'}, + statusCode: 503, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.healthCheck(); + + expect(result, isFalse); + }); + + test('returns false on DioException', () async { + when(() => mockDio.get(any())).thenThrow( + DioException( + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.healthCheck(); + + expect(result, isFalse); + }); + + test('returns false on generic exception', () async { + when(() => mockDio.get(any())).thenThrow(Exception('Unknown error')); + + final result = await dataSource.healthCheck(); + + expect(result, isFalse); + }); + + test('calls correct health endpoint URL', () async { + when(() => mockDio.get(any())).thenAnswer( + (_) async => Response( + data: {'status': 'ok'}, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + await dataSource.healthCheck(); + + verify(() => mockDio.get('https://nemesis.tams.codes/api/health')).called(1); + }); + }); +} diff --git a/test/features/procurement/data/models/bootstrap_model_test.dart b/test/features/procurement/data/models/bootstrap_model_test.dart new file mode 100644 index 0000000..8d26349 --- /dev/null +++ b/test/features/procurement/data/models/bootstrap_model_test.dart @@ -0,0 +1,355 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/features/procurement/data/models/bootstrap_model.dart'; +import 'package:db_cracker_tamaengs/features/procurement/data/models/region_model.dart'; + +void main() { + group('BootstrapModel', () { + group('fromJson', () { + test('parses full bootstrap payload correctly', () { + final json = { + 'summary': { + 'totalPackages': 5000, + 'totalPriorityPackages': 800, + 'totalPotentialWaste': 15000000000.0, + 'totalBudget': 100000000000, + 'unmappedPackages': 200, + 'multiLocationPackages': 50, + }, + 'regions': [ + { + 'regionKey': 'jawa-barat-kab-bandung', + 'regionName': 'Kabupaten Bandung', + 'totalPackages': 150, + }, + { + 'regionKey': 'jawa-timur-kota-surabaya', + 'regionName': 'Kota Surabaya', + 'totalPackages': 200, + }, + ], + 'legend': {'low': '#green', 'high': '#red'}, + 'geo': {'center': [-6.2, 106.8], 'zoom': 5}, + 'provinceView': {'totalProvinces': 34}, + }; + + final model = BootstrapModel.fromJson(json); + + expect(model.summary, isNotNull); + expect(model.summary!.totalPackages, 5000); + expect(model.summary!.totalPriorityPackages, 800); + expect(model.summary!.totalPotentialWaste, 15000000000.0); + expect(model.summary!.totalBudget, 100000000000); + expect(model.summary!.unmappedPackages, 200); + expect(model.summary!.multiLocationPackages, 50); + expect(model.regions, hasLength(2)); + expect(model.regions[0].regionKey, 'jawa-barat-kab-bandung'); + expect(model.regions[1].regionKey, 'jawa-timur-kota-surabaya'); + expect(model.legend, isNotNull); + expect(model.geo, isNotNull); + expect(model.provinceView, isNotNull); + }); + + test('parses with empty regions list', () { + final json = { + 'summary': { + 'totalPackages': 0, + 'totalBudget': 0, + }, + 'regions': [], + }; + + final model = BootstrapModel.fromJson(json); + + expect(model.regions, isEmpty); + expect(model.regionCount, 0); + expect(model.hasData, isFalse); + }); + + test('parses with null summary', () { + final json = { + 'summary': null, + 'regions': [ + {'regionKey': 'test-region', 'totalPackages': 10}, + ], + }; + + final model = BootstrapModel.fromJson(json); + + expect(model.summary, isNull); + expect(model.regions, hasLength(1)); + expect(model.hasData, isTrue); + }); + + test('handles completely empty json', () { + final json = {}; + + final model = BootstrapModel.fromJson(json); + + expect(model.summary, isNull); + expect(model.regions, isEmpty); + expect(model.legend, isNull); + expect(model.geo, isNull); + expect(model.provinceView, isNull); + expect(model.regionCount, 0); + expect(model.hasData, isFalse); + }); + + test('handles null regions field', () { + final json = { + 'regions': null, + 'summary': {'totalPackages': 100}, + }; + + final model = BootstrapModel.fromJson(json); + + expect(model.regions, isEmpty); + expect(model.summary, isNotNull); + expect(model.summary!.totalPackages, 100); + }); + + test('parses multiple regions with varying completeness', () { + final json = { + 'regions': [ + { + 'regionKey': 'full-region', + 'code': '1234', + 'provinceName': 'Jawa Barat', + 'regionName': 'Kab. Bogor', + 'totalPackages': 500, + 'totalBudget': 99000000000, + }, + { + 'regionKey': 'minimal-region', + }, + { + 'regionKey': 'partial-region', + 'totalPackages': 10, + }, + ], + }; + + final model = BootstrapModel.fromJson(json); + + expect(model.regions, hasLength(3)); + expect(model.regions[0].totalPackages, 500); + expect(model.regions[1].totalPackages, 0); + expect(model.regions[2].totalPackages, 10); + }); + }); + + group('toJson', () { + test('produces valid map with all fields', () { + final model = BootstrapModel( + summary: const BootstrapSummaryModel( + totalPackages: 100, + totalBudget: 5000000000, + ), + regions: const [ + RegionModel(regionKey: 'region-a', totalPackages: 50), + RegionModel(regionKey: 'region-b', totalPackages: 50), + ], + legend: {'low': 'green'}, + geo: {'zoom': 7}, + provinceView: {'count': 34}, + ); + + final json = model.toJson(); + + expect(json['summary'], isA>()); + expect(json['regions'], isA()); + expect((json['regions'] as List), hasLength(2)); + expect(json['legend'], isNotNull); + expect(json['geo'], isNotNull); + expect(json['provinceView'], isNotNull); + }); + + test('omits null optional fields', () { + const model = BootstrapModel( + regions: [RegionModel(regionKey: 'only-region')], + ); + + final json = model.toJson(); + + expect(json.containsKey('summary'), isFalse); + expect(json.containsKey('legend'), isFalse); + expect(json.containsKey('geo'), isFalse); + expect(json.containsKey('provinceView'), isFalse); + expect(json['regions'], hasLength(1)); + }); + }); + + group('computed properties', () { + test('regionCount returns correct count', () { + const model = BootstrapModel( + regions: [ + RegionModel(regionKey: 'a'), + RegionModel(regionKey: 'b'), + RegionModel(regionKey: 'c'), + ], + ); + expect(model.regionCount, 3); + }); + + test('hasData returns true when regions not empty', () { + const model = BootstrapModel( + regions: [RegionModel(regionKey: 'x')], + ); + expect(model.hasData, isTrue); + }); + + test('hasData returns false when regions empty', () { + const model = BootstrapModel(regions: []); + expect(model.hasData, isFalse); + }); + }); + }); + + group('BootstrapSummaryModel', () { + group('fromJson', () { + test('parses complete summary data', () { + final json = { + 'totalPackages': 10000, + 'totalPriorityPackages': 1500, + 'totalPotentialWaste': 25000000000.0, + 'totalBudget': 200000000000, + 'unmappedPackages': 500, + 'multiLocationPackages': 120, + }; + + final model = BootstrapSummaryModel.fromJson(json); + + expect(model.totalPackages, 10000); + expect(model.totalPriorityPackages, 1500); + expect(model.totalPotentialWaste, 25000000000.0); + expect(model.totalBudget, 200000000000); + expect(model.unmappedPackages, 500); + expect(model.multiLocationPackages, 120); + }); + + test('defaults all fields to 0 when json is empty', () { + final json = {}; + + final model = BootstrapSummaryModel.fromJson(json); + + expect(model.totalPackages, 0); + expect(model.totalPriorityPackages, 0); + expect(model.totalPotentialWaste, 0.0); + expect(model.totalBudget, 0); + expect(model.unmappedPackages, 0); + expect(model.multiLocationPackages, 0); + }); + + test('handles totalPotentialWaste as int', () { + final json = { + 'totalPotentialWaste': 5000000000, + 'totalBudget': 10000000000, + }; + + final model = BootstrapSummaryModel.fromJson(json); + expect(model.totalPotentialWaste, 5000000000.0); + expect(model.totalPotentialWaste, isA()); + }); + + test('handles partial data gracefully', () { + final json = { + 'totalPackages': 250, + 'totalBudget': 8000000000, + }; + + final model = BootstrapSummaryModel.fromJson(json); + + expect(model.totalPackages, 250); + expect(model.totalBudget, 8000000000); + expect(model.totalPriorityPackages, 0); + expect(model.unmappedPackages, 0); + expect(model.multiLocationPackages, 0); + }); + }); + + group('toJson', () { + test('produces complete map', () { + const model = BootstrapSummaryModel( + totalPackages: 500, + totalPriorityPackages: 75, + totalPotentialWaste: 3000000000.0, + totalBudget: 40000000000, + unmappedPackages: 30, + multiLocationPackages: 15, + ); + + final json = model.toJson(); + + expect(json['totalPackages'], 500); + expect(json['totalPriorityPackages'], 75); + expect(json['totalPotentialWaste'], 3000000000.0); + expect(json['totalBudget'], 40000000000); + expect(json['unmappedPackages'], 30); + expect(json['multiLocationPackages'], 15); + }); + + test('roundtrip preserves all values', () { + const original = BootstrapSummaryModel( + totalPackages: 999, + totalPriorityPackages: 111, + totalPotentialWaste: 7777777.77, + totalBudget: 88888888, + unmappedPackages: 22, + multiLocationPackages: 33, + ); + + final json = original.toJson(); + final restored = BootstrapSummaryModel.fromJson(json); + + expect(restored.totalPackages, original.totalPackages); + expect(restored.totalPriorityPackages, original.totalPriorityPackages); + expect(restored.totalPotentialWaste, original.totalPotentialWaste); + expect(restored.totalBudget, original.totalBudget); + expect(restored.unmappedPackages, original.unmappedPackages); + expect(restored.multiLocationPackages, original.multiLocationPackages); + }); + }); + + group('computed properties', () { + test('wastePercentage calculates correctly', () { + const model = BootstrapSummaryModel( + totalPotentialWaste: 25000000000, + totalBudget: 100000000000, + ); + expect(model.wastePercentage, 25.0); + }); + + test('wastePercentage returns 0 when totalBudget is 0', () { + const model = BootstrapSummaryModel( + totalPotentialWaste: 5000000, + totalBudget: 0, + ); + expect(model.wastePercentage, 0); + }); + + test('mappedPercentage calculates correctly', () { + const model = BootstrapSummaryModel( + totalPackages: 1000, + unmappedPackages: 200, + ); + // (1000 - 200) / 1000 * 100 = 80% + expect(model.mappedPercentage, 80.0); + }); + + test('mappedPercentage returns 0 when totalPackages is 0', () { + const model = BootstrapSummaryModel( + totalPackages: 0, + unmappedPackages: 0, + ); + expect(model.mappedPercentage, 0); + }); + + test('mappedPercentage handles all mapped (0 unmapped)', () { + const model = BootstrapSummaryModel( + totalPackages: 500, + unmappedPackages: 0, + ); + expect(model.mappedPercentage, 100.0); + }); + }); + }); +} diff --git a/test/features/procurement/data/models/package_model_test.dart b/test/features/procurement/data/models/package_model_test.dart new file mode 100644 index 0000000..8d946df --- /dev/null +++ b/test/features/procurement/data/models/package_model_test.dart @@ -0,0 +1,447 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/features/procurement/data/models/package_model.dart'; + +void main() { + group('ProcurementPackageModel', () { + group('fromJson', () { + test('parses complete valid data correctly', () { + final json = { + 'id': 42, + 'sourceId': 'SRC-001', + 'packageName': 'Pengadaan Laptop Dinas', + 'ownerName': 'Kementerian Pendidikan', + 'ownerType': 'K/L', + 'satker': 'Satker Jakarta Pusat', + 'locationRaw': 'DKI Jakarta', + 'budget': 5000000000, + 'fundingSource': 'APBN', + 'procurementType': 'Barang', + 'procurementMethod': 'Tender', + 'selectionDate': '2024-06-15', + 'audit': { + 'schemaVersion': '1.0', + 'severity': 'high', + 'potensiPemborosan': 0.85, + 'reason': 'Harga di atas rata-rata pasar', + 'flags': { + 'isMencurigakan': true, + 'isPemborosan': true, + }, + }, + 'meta': { + 'isPriority': true, + 'isFlagged': true, + 'riskScore': 0.92, + 'activeTagCount': 3, + 'mappedRegionCount': 2, + }, + }; + + final model = ProcurementPackageModel.fromJson(json); + + expect(model.id, 42); + expect(model.sourceId, 'SRC-001'); + expect(model.packageName, 'Pengadaan Laptop Dinas'); + expect(model.ownerName, 'Kementerian Pendidikan'); + expect(model.ownerType, 'K/L'); + expect(model.satker, 'Satker Jakarta Pusat'); + expect(model.locationRaw, 'DKI Jakarta'); + expect(model.budget, 5000000000); + expect(model.fundingSource, 'APBN'); + expect(model.procurementType, 'Barang'); + expect(model.procurementMethod, 'Tender'); + expect(model.selectionDate, '2024-06-15'); + expect(model.audit, isNotNull); + expect(model.meta, isNotNull); + }); + + test('parses null optional fields gracefully', () { + final json = { + 'id': 1, + 'sourceId': 'SRC-002', + 'packageName': 'Paket Minimal', + 'ownerName': 'Pemda', + 'ownerType': 'Pemda', + 'satker': null, + 'locationRaw': null, + 'budget': null, + 'fundingSource': null, + 'procurementType': null, + 'procurementMethod': null, + 'selectionDate': null, + 'audit': null, + 'meta': null, + }; + + final model = ProcurementPackageModel.fromJson(json); + + expect(model.id, 1); + expect(model.sourceId, 'SRC-002'); + expect(model.satker, isNull); + expect(model.locationRaw, isNull); + expect(model.budget, isNull); + expect(model.fundingSource, isNull); + expect(model.procurementType, isNull); + expect(model.procurementMethod, isNull); + expect(model.selectionDate, isNull); + expect(model.audit, isNull); + expect(model.meta, isNull); + }); + + test('handles missing fields with defensive defaults', () { + final json = {}; + + final model = ProcurementPackageModel.fromJson(json); + + expect(model.id, 0); + expect(model.sourceId, ''); + expect(model.packageName, ''); + expect(model.ownerName, ''); + expect(model.ownerType, ''); + expect(model.satker, isNull); + expect(model.budget, isNull); + expect(model.audit, isNull); + expect(model.meta, isNull); + }); + + test('handles budget edge cases: zero value', () { + final json = { + 'id': 10, + 'sourceId': 'SRC-ZERO', + 'packageName': 'Zero Budget', + 'ownerName': 'Test', + 'ownerType': 'Test', + 'budget': 0, + }; + + final model = ProcurementPackageModel.fromJson(json); + expect(model.budget, 0); + }); + + test('handles budget edge cases: very large number', () { + final json = { + 'id': 11, + 'sourceId': 'SRC-BIG', + 'packageName': 'Mega Project', + 'ownerName': 'Kementerian PUPR', + 'ownerType': 'K/L', + 'budget': 999999999999999, + }; + + final model = ProcurementPackageModel.fromJson(json); + expect(model.budget, 999999999999999); + }); + }); + + group('toJson', () { + test('produces valid map with all fields', () { + const model = ProcurementPackageModel( + id: 5, + sourceId: 'SRC-005', + packageName: 'Test Package', + ownerName: 'Owner', + ownerType: 'K/L', + satker: 'Satker A', + budget: 100000000, + fundingSource: 'APBD', + ); + + final json = model.toJson(); + + expect(json['id'], 5); + expect(json['sourceId'], 'SRC-005'); + expect(json['packageName'], 'Test Package'); + expect(json['ownerName'], 'Owner'); + expect(json['ownerType'], 'K/L'); + expect(json['satker'], 'Satker A'); + expect(json['budget'], 100000000); + expect(json['fundingSource'], 'APBD'); + }); + + test('omits null optional fields from output', () { + const model = ProcurementPackageModel( + id: 6, + sourceId: 'SRC-006', + packageName: 'Minimal', + ownerName: 'Owner', + ownerType: 'Pemda', + ); + + final json = model.toJson(); + + expect(json.containsKey('satker'), isFalse); + expect(json.containsKey('locationRaw'), isFalse); + expect(json.containsKey('budget'), isFalse); + expect(json.containsKey('fundingSource'), isFalse); + expect(json.containsKey('procurementType'), isFalse); + expect(json.containsKey('procurementMethod'), isFalse); + expect(json.containsKey('selectionDate'), isFalse); + expect(json.containsKey('audit'), isFalse); + expect(json.containsKey('meta'), isFalse); + }); + + test('roundtrip fromJson → toJson preserves data', () { + final originalJson = { + 'id': 99, + 'sourceId': 'ROUND-001', + 'packageName': 'Roundtrip Test', + 'ownerName': 'Tester', + 'ownerType': 'K/L', + 'satker': 'Satker X', + 'budget': 250000000, + 'audit': { + 'severity': 'med', + 'potensiPemborosan': 0.45, + }, + 'meta': { + 'isPriority': true, + 'isFlagged': false, + 'riskScore': 0.6, + 'activeTagCount': 2, + 'mappedRegionCount': 1, + }, + }; + + final model = ProcurementPackageModel.fromJson(originalJson); + final outputJson = model.toJson(); + + expect(outputJson['id'], originalJson['id']); + expect(outputJson['sourceId'], originalJson['sourceId']); + expect(outputJson['packageName'], originalJson['packageName']); + expect(outputJson['budget'], originalJson['budget']); + }); + }); + + group('equality', () { + test('two models with same id and sourceId are equal', () { + const a = ProcurementPackageModel( + id: 1, + sourceId: 'X', + packageName: 'A', + ownerName: 'O', + ownerType: 'T', + ); + const b = ProcurementPackageModel( + id: 1, + sourceId: 'X', + packageName: 'B', + ownerName: 'P', + ownerType: 'U', + ); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('two models with different id are not equal', () { + const a = ProcurementPackageModel( + id: 1, + sourceId: 'X', + packageName: 'A', + ownerName: 'O', + ownerType: 'T', + ); + const b = ProcurementPackageModel( + id: 2, + sourceId: 'X', + packageName: 'A', + ownerName: 'O', + ownerType: 'T', + ); + expect(a, isNot(equals(b))); + }); + }); + }); + + group('PackageAuditModel', () { + group('fromJson', () { + test('parses all severity values correctly', () { + for (final severity in ['low', 'med', 'high', 'absurd']) { + final json = { + 'severity': severity, + 'potensiPemborosan': 0.5, + }; + final model = PackageAuditModel.fromJson(json); + expect(model.severity, severity); + } + }); + + test('defaults severity to unknown when missing', () { + final json = {}; + final model = PackageAuditModel.fromJson(json); + expect(model.severity, 'unknown'); + expect(model.potensiPemborosan, 0); + }); + + test('parses flags correctly', () { + final json = { + 'severity': 'high', + 'potensiPemborosan': 0.9, + 'reason': 'Markup berlebihan', + 'flags': { + 'isMencurigakan': true, + 'isPemborosan': false, + }, + }; + + final model = PackageAuditModel.fromJson(json); + + expect(model.flags, isNotNull); + expect(model.flags!.isMencurigakan, isTrue); + expect(model.flags!.isPemborosan, isFalse); + expect(model.reason, 'Markup berlebihan'); + }); + + test('handles null flags gracefully', () { + final json = { + 'severity': 'low', + 'potensiPemborosan': 0.1, + 'flags': null, + }; + + final model = PackageAuditModel.fromJson(json); + expect(model.flags, isNull); + }); + + test('handles numeric potensiPemborosan as int', () { + final json = { + 'severity': 'med', + 'potensiPemborosan': 1, + }; + + final model = PackageAuditModel.fromJson(json); + expect(model.potensiPemborosan, 1.0); + expect(model.potensiPemborosan, isA()); + }); + }); + + group('toJson', () { + test('produces valid map', () { + const model = PackageAuditModel( + schemaVersion: '2.0', + severity: 'absurd', + potensiPemborosan: 0.99, + reason: 'Extreme waste', + flags: PackageFlagsModel( + isMencurigakan: true, + isPemborosan: true, + ), + ); + + final json = model.toJson(); + + expect(json['schemaVersion'], '2.0'); + expect(json['severity'], 'absurd'); + expect(json['potensiPemborosan'], 0.99); + expect(json['reason'], 'Extreme waste'); + expect(json['flags'], isA>()); + expect(json['flags']['isMencurigakan'], isTrue); + expect(json['flags']['isPemborosan'], isTrue); + }); + + test('omits null optional fields', () { + const model = PackageAuditModel(severity: 'low'); + + final json = model.toJson(); + + expect(json.containsKey('schemaVersion'), isFalse); + expect(json.containsKey('reason'), isFalse); + expect(json.containsKey('flags'), isFalse); + expect(json['severity'], 'low'); + expect(json['potensiPemborosan'], 0); + }); + }); + }); + + group('PackageMetaModel', () { + group('fromJson', () { + test('parses complete data', () { + final json = { + 'isPriority': true, + 'isFlagged': true, + 'riskScore': 0.88, + 'activeTagCount': 5, + 'mappedRegionCount': 3, + }; + + final model = PackageMetaModel.fromJson(json); + + expect(model.isPriority, isTrue); + expect(model.isFlagged, isTrue); + expect(model.riskScore, 0.88); + expect(model.activeTagCount, 5); + expect(model.mappedRegionCount, 3); + }); + + test('defaults all fields when json is empty', () { + final json = {}; + + final model = PackageMetaModel.fromJson(json); + + expect(model.isPriority, isFalse); + expect(model.isFlagged, isFalse); + expect(model.riskScore, 0); + expect(model.activeTagCount, 0); + expect(model.mappedRegionCount, 0); + }); + + test('handles riskScore as int from API', () { + final json = { + 'riskScore': 1, + 'activeTagCount': 0, + 'mappedRegionCount': 0, + }; + + final model = PackageMetaModel.fromJson(json); + expect(model.riskScore, 1.0); + expect(model.riskScore, isA()); + }); + }); + + group('toJson', () { + test('produces complete map', () { + const model = PackageMetaModel( + isPriority: true, + isFlagged: false, + riskScore: 0.75, + activeTagCount: 2, + mappedRegionCount: 4, + ); + + final json = model.toJson(); + + expect(json['isPriority'], isTrue); + expect(json['isFlagged'], isFalse); + expect(json['riskScore'], 0.75); + expect(json['activeTagCount'], 2); + expect(json['mappedRegionCount'], 4); + }); + }); + }); + + group('PackageFlagsModel', () { + test('fromJson with both true', () { + final json = {'isMencurigakan': true, 'isPemborosan': true}; + final model = PackageFlagsModel.fromJson(json); + expect(model.isMencurigakan, isTrue); + expect(model.isPemborosan, isTrue); + }); + + test('fromJson defaults to false when missing', () { + final json = {}; + final model = PackageFlagsModel.fromJson(json); + expect(model.isMencurigakan, isFalse); + expect(model.isPemborosan, isFalse); + }); + + test('toJson roundtrip', () { + const model = PackageFlagsModel( + isMencurigakan: true, + isPemborosan: false, + ); + final json = model.toJson(); + final restored = PackageFlagsModel.fromJson(json); + expect(restored.isMencurigakan, model.isMencurigakan); + expect(restored.isPemborosan, model.isPemborosan); + }); + }); +} diff --git a/test/features/procurement/data/models/region_model_test.dart b/test/features/procurement/data/models/region_model_test.dart new file mode 100644 index 0000000..7621395 --- /dev/null +++ b/test/features/procurement/data/models/region_model_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/features/procurement/data/models/region_model.dart'; + +void main() { + group('RegionModel', () { + group('fromJson', () { + test('parses complete data with all fields', () { + final json = { + 'regionKey': 'jawa-barat-kab-bandung', + 'code': '3204', + 'provinceName': 'Jawa Barat', + 'regionName': 'Kabupaten Bandung', + 'regionType': 'kabupaten', + 'displayName': 'Kab. Bandung', + 'totalPackages': 150, + 'totalPriorityPackages': 25, + 'totalFlaggedPackages': 10, + 'totalPotentialWaste': 2500000000.5, + 'totalBudget': 50000000000, + 'avgRiskScore': 0.45, + 'maxRiskScore': 0.92, + 'ownerMix': {'K/L': 30, 'Pemda': 120}, + 'severityCounts': {'low': 80, 'med': 50, 'high': 15, 'absurd': 5}, + 'dominantOwnerType': 'Pemda', + }; + + final model = RegionModel.fromJson(json); + + expect(model.regionKey, 'jawa-barat-kab-bandung'); + expect(model.code, '3204'); + expect(model.provinceName, 'Jawa Barat'); + expect(model.regionName, 'Kabupaten Bandung'); + expect(model.regionType, 'kabupaten'); + expect(model.displayName, 'Kab. Bandung'); + expect(model.totalPackages, 150); + expect(model.totalPriorityPackages, 25); + expect(model.totalFlaggedPackages, 10); + expect(model.totalPotentialWaste, 2500000000.5); + expect(model.totalBudget, 50000000000); + expect(model.avgRiskScore, 0.45); + expect(model.maxRiskScore, 0.92); + expect(model.ownerMix, isNotNull); + expect(model.ownerMix!['K/L'], 30); + expect(model.severityCounts, isNotNull); + expect(model.severityCounts!['high'], 15); + expect(model.dominantOwnerType, 'Pemda'); + }); + + test('parses minimal data with only regionKey', () { + final json = {'regionKey': 'minimal-region'}; + + final model = RegionModel.fromJson(json); + + expect(model.regionKey, 'minimal-region'); + expect(model.code, isNull); + expect(model.provinceName, isNull); + expect(model.regionName, isNull); + expect(model.regionType, isNull); + expect(model.displayName, isNull); + expect(model.totalPackages, 0); + expect(model.totalPriorityPackages, 0); + expect(model.totalFlaggedPackages, 0); + expect(model.totalPotentialWaste, 0); + expect(model.totalBudget, 0); + expect(model.avgRiskScore, 0); + expect(model.maxRiskScore, 0); + expect(model.ownerMix, isNull); + expect(model.severityCounts, isNull); + expect(model.dominantOwnerType, isNull); + }); + + test('defaults numeric fields to 0 when missing', () { + final json = { + 'regionKey': 'empty-numerics', + 'regionName': 'Test Region', + }; + + final model = RegionModel.fromJson(json); + + expect(model.totalPackages, 0); + expect(model.totalPriorityPackages, 0); + expect(model.totalFlaggedPackages, 0); + expect(model.totalPotentialWaste, 0.0); + expect(model.totalBudget, 0); + expect(model.avgRiskScore, 0.0); + expect(model.maxRiskScore, 0.0); + }); + + test('defaults regionKey to empty string when null', () { + final json = {'regionKey': null}; + + final model = RegionModel.fromJson(json); + expect(model.regionKey, ''); + }); + + test('handles totalPotentialWaste as int from API', () { + final json = { + 'regionKey': 'int-waste', + 'totalPotentialWaste': 5000000000, + }; + + final model = RegionModel.fromJson(json); + expect(model.totalPotentialWaste, 5000000000.0); + expect(model.totalPotentialWaste, isA()); + }); + + test('handles avgRiskScore and maxRiskScore as int', () { + final json = { + 'regionKey': 'int-scores', + 'avgRiskScore': 0, + 'maxRiskScore': 1, + }; + + final model = RegionModel.fromJson(json); + expect(model.avgRiskScore, 0.0); + expect(model.maxRiskScore, 1.0); + }); + }); + + group('toJson', () { + test('produces valid map with all fields', () { + final model = RegionModel( + regionKey: 'test-region', + code: '1234', + provinceName: 'Jawa Timur', + regionName: 'Kota Surabaya', + regionType: 'kota', + displayName: 'Surabaya', + totalPackages: 200, + totalPriorityPackages: 30, + totalFlaggedPackages: 5, + totalPotentialWaste: 1000000000.0, + totalBudget: 20000000000, + avgRiskScore: 0.35, + maxRiskScore: 0.8, + ownerMix: {'Pemda': 180, 'K/L': 20}, + severityCounts: {'low': 150, 'med': 40, 'high': 10}, + dominantOwnerType: 'Pemda', + ); + + final json = model.toJson(); + + expect(json['regionKey'], 'test-region'); + expect(json['code'], '1234'); + expect(json['provinceName'], 'Jawa Timur'); + expect(json['regionName'], 'Kota Surabaya'); + expect(json['regionType'], 'kota'); + expect(json['displayName'], 'Surabaya'); + expect(json['totalPackages'], 200); + expect(json['totalPriorityPackages'], 30); + expect(json['totalFlaggedPackages'], 5); + expect(json['totalPotentialWaste'], 1000000000.0); + expect(json['totalBudget'], 20000000000); + expect(json['avgRiskScore'], 0.35); + expect(json['maxRiskScore'], 0.8); + expect(json['ownerMix'], isNotNull); + expect(json['severityCounts'], isNotNull); + expect(json['dominantOwnerType'], 'Pemda'); + }); + + test('omits null optional fields', () { + const model = RegionModel(regionKey: 'minimal'); + + final json = model.toJson(); + + expect(json['regionKey'], 'minimal'); + expect(json.containsKey('code'), isFalse); + expect(json.containsKey('provinceName'), isFalse); + expect(json.containsKey('regionName'), isFalse); + expect(json.containsKey('regionType'), isFalse); + expect(json.containsKey('displayName'), isFalse); + expect(json.containsKey('ownerMix'), isFalse); + expect(json.containsKey('severityCounts'), isFalse); + expect(json.containsKey('dominantOwnerType'), isFalse); + // Numeric fields always present + expect(json['totalPackages'], 0); + expect(json['totalBudget'], 0); + }); + + test('roundtrip fromJson → toJson → fromJson preserves data', () { + final originalJson = { + 'regionKey': 'roundtrip-test', + 'code': '9999', + 'provinceName': 'Papua', + 'regionName': 'Kota Jayapura', + 'regionType': 'kota', + 'displayName': 'Jayapura', + 'totalPackages': 50, + 'totalPriorityPackages': 8, + 'totalFlaggedPackages': 3, + 'totalPotentialWaste': 750000000.0, + 'totalBudget': 5000000000, + 'avgRiskScore': 0.55, + 'maxRiskScore': 0.85, + 'dominantOwnerType': 'K/L', + }; + + final model1 = RegionModel.fromJson(originalJson); + final json = model1.toJson(); + final model2 = RegionModel.fromJson(json); + + expect(model2.regionKey, model1.regionKey); + expect(model2.code, model1.code); + expect(model2.provinceName, model1.provinceName); + expect(model2.totalPackages, model1.totalPackages); + expect(model2.totalPotentialWaste, model1.totalPotentialWaste); + expect(model2.avgRiskScore, model1.avgRiskScore); + expect(model2.maxRiskScore, model1.maxRiskScore); + }); + }); + + group('computed properties', () { + test('label returns displayName when available', () { + const model = RegionModel( + regionKey: 'key', + displayName: 'Display', + regionName: 'Region', + ); + expect(model.label, 'Display'); + }); + + test('label falls back to regionName when displayName is null', () { + const model = RegionModel( + regionKey: 'key', + regionName: 'Region Name', + ); + expect(model.label, 'Region Name'); + }); + + test('label falls back to regionKey when both are null', () { + const model = RegionModel(regionKey: 'fallback-key'); + expect(model.label, 'fallback-key'); + }); + + test('isHighRisk returns true when maxRiskScore >= 0.7', () { + const model = RegionModel( + regionKey: 'risky', + maxRiskScore: 0.75, + totalFlaggedPackages: 0, + ); + expect(model.isHighRisk, isTrue); + }); + + test('isHighRisk returns true when totalFlaggedPackages > 0', () { + const model = RegionModel( + regionKey: 'flagged', + maxRiskScore: 0.3, + totalFlaggedPackages: 1, + ); + expect(model.isHighRisk, isTrue); + }); + + test('isHighRisk returns false when both conditions unmet', () { + const model = RegionModel( + regionKey: 'safe', + maxRiskScore: 0.5, + totalFlaggedPackages: 0, + ); + expect(model.isHighRisk, isFalse); + }); + }); + + group('equality', () { + test('two models with same regionKey are equal', () { + const a = RegionModel(regionKey: 'same', totalPackages: 10); + const b = RegionModel(regionKey: 'same', totalPackages: 99); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('two models with different regionKey are not equal', () { + const a = RegionModel(regionKey: 'alpha'); + const b = RegionModel(regionKey: 'beta'); + expect(a, isNot(equals(b))); + }); + }); + }); +} diff --git a/test/features/statistics/data/datasources/ckan_datasource_test.dart b/test/features/statistics/data/datasources/ckan_datasource_test.dart new file mode 100644 index 0000000..f435824 --- /dev/null +++ b/test/features/statistics/data/datasources/ckan_datasource_test.dart @@ -0,0 +1,421 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:dio/dio.dart'; +import 'package:db_cracker_tamaengs/core/error/exceptions.dart'; +import 'package:db_cracker_tamaengs/features/statistics/data/datasources/ckan_remote_datasource.dart'; + +class MockDio extends Mock implements Dio {} + +void main() { + late MockDio mockDio; + late CkanRemoteDataSourceImpl dataSource; + + const baseUrl = 'https://data.go.id/api/3/action'; + + setUp(() { + mockDio = MockDio(); + dataSource = CkanRemoteDataSourceImpl(dio: mockDio); + }); + + group('searchPackages', () { + test('mengembalikan list CkanDatasetModel pada response 200', () async { + final responseData = { + 'result': { + 'results': [ + { + 'id': 'pkg-001', + 'name': 'kemiskinan-2024', + 'title': 'Data Kemiskinan 2024', + 'num_resources': 2, + }, + { + 'id': 'pkg-002', + 'name': 'inflasi-2024', + 'title': 'Data Inflasi 2024', + 'num_resources': 1, + }, + ], + 'count': 2, + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.searchPackages( + baseUrl: baseUrl, + query: 'kemiskinan', + rows: 10, + start: 0, + ); + + expect(result, hasLength(2)); + expect(result[0].id, 'pkg-001'); + expect(result[0].name, 'kemiskinan-2024'); + expect(result[1].id, 'pkg-002'); + }); + + test('mengembalikan list kosong jika results kosong', () async { + final responseData = { + 'result': { + 'results': [], + 'count': 0, + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.searchPackages( + baseUrl: baseUrl, + query: 'nonexistent', + ); + + expect(result, isEmpty); + }); + + test('mengembalikan list kosong jika result null', () async { + final responseData = {'result': null}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.searchPackages( + baseUrl: baseUrl, + query: 'test', + ); + + expect(result, isEmpty); + }); + + test('melempar ServerException pada status code non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {'error': 'Internal Server Error'}, + statusCode: 500, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.searchPackages(baseUrl: baseUrl, query: 'test'), + throwsA(isA()), + ); + }); + + test('melempar TimeoutException pada connection timeout', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.searchPackages(baseUrl: baseUrl, query: 'test'), + throwsA(isA()), + ); + }); + + test('melempar NetworkException pada connection error', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.connectionError, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.searchPackages(baseUrl: baseUrl, query: 'test'), + throwsA(isA()), + ); + }); + + test('melempar RateLimitException pada status 429', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 429, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.searchPackages(baseUrl: baseUrl, query: 'test'), + throwsA(isA()), + ); + }); + }); + + group('getPackage', () { + test('mengembalikan CkanDatasetModel pada response 200', () async { + final responseData = { + 'result': { + 'id': 'pkg-detail-001', + 'name': 'detail-dataset', + 'title': 'Detail Dataset', + 'notes': 'Deskripsi lengkap dataset', + 'resources': [ + { + 'id': 'res-1', + 'name': 'data.csv', + 'format': 'CSV', + 'url': 'https://data.go.id/res/data.csv', + 'datastore_active': true, + }, + ], + 'num_resources': 1, + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.getPackage( + baseUrl: baseUrl, + id: 'pkg-detail-001', + ); + + expect(result.id, 'pkg-detail-001'); + expect(result.name, 'detail-dataset'); + expect(result.notes, 'Deskripsi lengkap dataset'); + expect(result.resources, hasLength(1)); + }); + + test('melempar ServerException jika result null (not found)', () async { + final responseData = {'result': null}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getPackage(baseUrl: baseUrl, id: 'nonexistent'), + throwsA(isA()), + ); + }); + + test('melempar ServerException pada 404 DioException', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.badResponse, + response: Response( + statusCode: 404, + statusMessage: 'Not Found', + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.getPackage(baseUrl: baseUrl, id: 'missing'), + throwsA(isA()), + ); + }); + }); + + group('queryDatastore', () { + test('mengembalikan records dan metadata pada response 200', () async { + final responseData = { + 'result': { + 'records': [ + {'id': 1, 'provinsi': 'DKI Jakarta', 'nilai': 9.5}, + {'id': 2, 'provinsi': 'Jawa Barat', 'nilai': 7.8}, + ], + 'fields': [ + {'id': 'id', 'type': 'int'}, + {'id': 'provinsi', 'type': 'text'}, + {'id': 'nilai', 'type': 'float'}, + ], + 'total': 100, + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.queryDatastore( + baseUrl: baseUrl, + resourceId: 'res-001', + limit: 100, + offset: 0, + ); + + expect((result['records'] as List), hasLength(2)); + expect((result['fields'] as List), hasLength(3)); + expect(result['total'], 100); + }); + + test('mengembalikan default kosong jika result null', () async { + final responseData = {'result': null}; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.queryDatastore( + baseUrl: baseUrl, + resourceId: 'res-empty', + ); + + expect(result['records'], isEmpty); + expect(result['fields'], isEmpty); + expect(result['total'], 0); + }); + + test('menyertakan filters dalam query parameters', () async { + final responseData = { + 'result': { + 'records': [ + {'provinsi': 'DKI Jakarta', 'nilai': 9.5}, + ], + 'fields': [], + 'total': 1, + }, + }; + + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + ), + ); + + final result = await dataSource.queryDatastore( + baseUrl: baseUrl, + resourceId: 'res-001', + filters: '{"provinsi":"DKI Jakarta"}', + sort: 'nilai desc', + ); + + expect((result['records'] as List), hasLength(1)); + + // Verify correct query parameters were passed + final captured = verify(() => mockDio.get( + any(), + queryParameters: captureAny(named: 'queryParameters'), + )).captured.last as Map; + + expect(captured['filters'], '{"provinsi":"DKI Jakarta"}'); + expect(captured['sort'], 'nilai desc'); + }); + + test('melempar ServerException pada status code non-200', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenAnswer( + (_) async => Response( + data: {'error': 'Bad Request'}, + statusCode: 400, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.queryDatastore( + baseUrl: baseUrl, + resourceId: 'invalid', + ), + throwsA(isA()), + ); + }); + + test('melempar TimeoutException pada receive timeout', () async { + when(() => mockDio.get( + any(), + queryParameters: any(named: 'queryParameters'), + )).thenThrow( + DioException( + type: DioExceptionType.receiveTimeout, + requestOptions: RequestOptions(path: ''), + ), + ); + + expect( + () => dataSource.queryDatastore( + baseUrl: baseUrl, + resourceId: 'slow-resource', + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/statistics/data/models/statistics_models_test.dart b/test/features/statistics/data/models/statistics_models_test.dart new file mode 100644 index 0000000..a5c3344 --- /dev/null +++ b/test/features/statistics/data/models/statistics_models_test.dart @@ -0,0 +1,484 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/features/statistics/data/models/statistics_models.dart'; + +void main() { + group('CkanDatasetModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'id': 'dataset-001', + 'name': 'penduduk-miskin-2024', + 'title': 'Data Penduduk Miskin 2024', + 'notes': 'Dataset kemiskinan per provinsi', + 'organization': {'title': 'BPS'}, + 'resources': [ + { + 'id': 'res-001', + 'name': 'data.csv', + 'format': 'csv', + 'url': 'https://data.go.id/res/data.csv', + 'size': 1024, + 'datastore_active': true, + } + ], + 'tags': [ + {'display_name': 'kemiskinan'}, + {'display_name': 'bps'}, + ], + 'num_resources': 1, + 'metadata_modified': '2024-03-15T10:00:00', + }; + + final model = CkanDatasetModel.fromJson(json); + + expect(model.id, 'dataset-001'); + expect(model.name, 'penduduk-miskin-2024'); + expect(model.title, 'Data Penduduk Miskin 2024'); + expect(model.notes, 'Dataset kemiskinan per provinsi'); + expect(model.organization, 'BPS'); + expect(model.resources, hasLength(1)); + expect(model.tags, ['kemiskinan', 'bps']); + expect(model.numResources, 1); + expect(model.metadataModified, '2024-03-15T10:00:00'); + }); + + test('parsing dengan field null menghasilkan default values', () { + final json = {}; + + final model = CkanDatasetModel.fromJson(json); + + expect(model.id, ''); + expect(model.name, ''); + expect(model.title, ''); + expect(model.notes, isNull); + expect(model.organization, isNull); + expect(model.resources, isEmpty); + expect(model.tags, isEmpty); + expect(model.numResources, 0); + expect(model.metadataModified, isNull); + }); + + test('parsing organization sebagai string langsung', () { + final json = { + 'id': 'ds-1', + 'name': 'test', + 'title': 'Test', + 'organization': 'Kementerian Kesehatan', + }; + + final model = CkanDatasetModel.fromJson(json); + + expect(model.organization, 'Kementerian Kesehatan'); + }); + + test('parsing tags sebagai list of string', () { + final json = { + 'id': 'ds-2', + 'name': 'test', + 'title': 'Test', + 'tags': ['ekonomi', 'inflasi', 'bps'], + }; + + final model = CkanDatasetModel.fromJson(json); + + expect(model.tags, ['ekonomi', 'inflasi', 'bps']); + }); + + test('parsing tags dengan display_name kosong difilter', () { + final json = { + 'id': 'ds-3', + 'name': 'test', + 'title': 'Test', + 'tags': [ + {'display_name': 'valid'}, + {'display_name': ''}, + {'display_name': 'juga-valid'}, + ], + }; + + final model = CkanDatasetModel.fromJson(json); + + expect(model.tags, ['valid', 'juga-valid']); + }); + + test('parsing resources null menghasilkan list kosong', () { + final json = { + 'id': 'ds-4', + 'name': 'test', + 'title': 'Test', + 'resources': null, + }; + + final model = CkanDatasetModel.fromJson(json); + + expect(model.resources, isEmpty); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + final original = CkanDatasetModel( + id: 'roundtrip-001', + name: 'test-roundtrip', + title: 'Test Roundtrip', + notes: 'Catatan test', + organization: 'BPS', + resources: const [ + CkanResourceModel( + id: 'r-1', + name: 'file.csv', + format: 'CSV', + url: 'https://example.com/file.csv', + size: 2048, + datastoreActive: true, + ), + ], + tags: const ['tag1', 'tag2'], + numResources: 1, + metadataModified: '2024-01-01', + ); + + final json = original.toJson(); + + expect(json['id'], 'roundtrip-001'); + expect(json['name'], 'test-roundtrip'); + expect(json['title'], 'Test Roundtrip'); + expect(json['notes'], 'Catatan test'); + expect(json['organization'], 'BPS'); + expect(json['num_resources'], 1); + expect(json['metadata_modified'], '2024-01-01'); + expect(json['tags'], ['tag1', 'tag2']); + expect((json['resources'] as List), hasLength(1)); + }); + + test('toJson tidak menyertakan field null', () { + const model = CkanDatasetModel( + id: 'no-null', + name: 'test', + title: 'Test', + ); + + final json = model.toJson(); + + expect(json.containsKey('notes'), isFalse); + expect(json.containsKey('organization'), isFalse); + expect(json.containsKey('metadata_modified'), isFalse); + }); + }); + + group('equality', () { + test('dua model dengan id sama dianggap equal', () { + const a = CkanDatasetModel(id: 'same', name: 'a', title: 'A'); + const b = CkanDatasetModel(id: 'same', name: 'b', title: 'B'); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('dua model dengan id berbeda tidak equal', () { + const a = CkanDatasetModel(id: 'id-1', name: 'a', title: 'A'); + const b = CkanDatasetModel(id: 'id-2', name: 'a', title: 'A'); + + expect(a, isNot(equals(b))); + }); + }); + }); + + group('CkanResourceModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'id': 'res-abc', + 'name': 'data-kemiskinan.xlsx', + 'format': 'xlsx', + 'url': 'https://data.go.id/files/data-kemiskinan.xlsx', + 'size': 4096, + 'datastore_active': true, + }; + + final model = CkanResourceModel.fromJson(json); + + expect(model.id, 'res-abc'); + expect(model.name, 'data-kemiskinan.xlsx'); + expect(model.format, 'XLSX'); + expect(model.url, 'https://data.go.id/files/data-kemiskinan.xlsx'); + expect(model.size, 4096); + expect(model.datastoreActive, isTrue); + }); + + test('format dikonversi ke uppercase', () { + final json = { + 'id': 'r-1', + 'name': 'file', + 'format': 'json', + 'url': 'http://x.com/f', + }; + + final model = CkanResourceModel.fromJson(json); + + expect(model.format, 'JSON'); + }); + + test('format kosong tetap string kosong', () { + final json = { + 'id': 'r-2', + 'name': 'file', + 'url': 'http://x.com/f', + }; + + final model = CkanResourceModel.fromJson(json); + + expect(model.format, ''); + }); + + test('size null dihandle dengan benar', () { + final json = { + 'id': 'r-3', + 'name': 'file', + 'format': 'csv', + 'url': 'http://x.com/f', + 'size': null, + }; + + final model = CkanResourceModel.fromJson(json); + + expect(model.size, isNull); + }); + + test('datastore_active default false jika tidak ada', () { + final json = { + 'id': 'r-4', + 'name': 'file', + 'format': 'csv', + 'url': 'http://x.com/f', + }; + + final model = CkanResourceModel.fromJson(json); + + expect(model.datastoreActive, isFalse); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = CkanResourceModel( + id: 'res-rt', + name: 'roundtrip.csv', + format: 'CSV', + url: 'https://example.com/roundtrip.csv', + size: 8192, + datastoreActive: true, + ); + + final json = original.toJson(); + + expect(json['id'], 'res-rt'); + expect(json['name'], 'roundtrip.csv'); + expect(json['format'], 'CSV'); + expect(json['url'], 'https://example.com/roundtrip.csv'); + expect(json['size'], 8192); + expect(json['datastore_active'], isTrue); + }); + + test('toJson tidak menyertakan size jika null', () { + const model = CkanResourceModel( + id: 'no-size', + name: 'file', + format: 'PDF', + url: 'http://x.com/f', + ); + + final json = model.toJson(); + + expect(json.containsKey('size'), isFalse); + }); + }); + + group('format detection', () { + test('mendeteksi format CSV', () { + final model = CkanResourceModel.fromJson({ + 'id': 'f1', + 'name': 'data', + 'format': 'csv', + 'url': 'http://x.com/f', + }); + expect(model.format, 'CSV'); + }); + + test('mendeteksi format JSON', () { + final model = CkanResourceModel.fromJson({ + 'id': 'f2', + 'name': 'data', + 'format': 'JSON', + 'url': 'http://x.com/f', + }); + expect(model.format, 'JSON'); + }); + + test('mendeteksi format mixed case XLS', () { + final model = CkanResourceModel.fromJson({ + 'id': 'f3', + 'name': 'data', + 'format': 'Xls', + 'url': 'http://x.com/f', + }); + expect(model.format, 'XLS'); + }); + }); + }); + + group('StrategicIndicatorModel', () { + group('fromJson', () { + test('parsing data lengkap menghasilkan model yang benar', () { + final json = { + 'title': 'Tingkat Kemiskinan', + 'value': 9.54, + 'unit': 'persen', + 'period': 'Maret 2024', + 'domain': 'Kemiskinan', + 'source': 'BPS', + }; + + final model = StrategicIndicatorModel.fromJson(json); + + expect(model.title, 'Tingkat Kemiskinan'); + expect(model.value, 9.54); + expect(model.unit, 'persen'); + expect(model.period, 'Maret 2024'); + expect(model.domain, 'Kemiskinan'); + expect(model.source, 'BPS'); + }); + + test('parsing dengan semua field null menghasilkan default', () { + final json = {}; + + final model = StrategicIndicatorModel.fromJson(json); + + expect(model.title, ''); + expect(model.value, 0); + expect(model.unit, ''); + expect(model.period, ''); + expect(model.domain, ''); + expect(model.source, ''); + }); + + test('value integer dikonversi ke double', () { + final json = { + 'title': 'Jumlah Penduduk', + 'value': 275000000, + 'unit': 'jiwa', + 'period': '2024', + 'domain': 'Kependudukan', + 'source': 'BPS', + }; + + final model = StrategicIndicatorModel.fromJson(json); + + expect(model.value, 275000000.0); + expect(model.value, isA()); + }); + + test('value null menjadi 0', () { + final json = { + 'title': 'Test', + 'value': null, + 'unit': '', + 'period': '', + 'domain': '', + 'source': '', + }; + + final model = StrategicIndicatorModel.fromJson(json); + + expect(model.value, 0.0); + }); + }); + + group('toJson', () { + test('roundtrip serialization mempertahankan data', () { + const original = StrategicIndicatorModel( + title: 'Inflasi', + value: 3.05, + unit: 'persen', + period: 'April 2024', + domain: 'Harga', + source: 'BPS', + ); + + final json = original.toJson(); + final restored = StrategicIndicatorModel.fromJson(json); + + expect(restored.title, original.title); + expect(restored.value, original.value); + expect(restored.unit, original.unit); + expect(restored.period, original.period); + expect(restored.domain, original.domain); + expect(restored.source, original.source); + }); + }); + + group('equality', () { + test('model dengan title, period, domain sama dianggap equal', () { + const a = StrategicIndicatorModel( + title: 'Inflasi', + value: 3.0, + unit: 'persen', + period: '2024', + domain: 'Harga', + source: 'BPS', + ); + const b = StrategicIndicatorModel( + title: 'Inflasi', + value: 5.0, + unit: 'percent', + period: '2024', + domain: 'Harga', + source: 'BI', + ); + + expect(a, equals(b)); + }); + + test('model dengan title berbeda tidak equal', () { + const a = StrategicIndicatorModel( + title: 'Inflasi', + value: 3.0, + unit: 'persen', + period: '2024', + domain: 'Harga', + source: 'BPS', + ); + const b = StrategicIndicatorModel( + title: 'Deflasi', + value: 3.0, + unit: 'persen', + period: '2024', + domain: 'Harga', + source: 'BPS', + ); + + expect(a, isNot(equals(b))); + }); + }); + + group('copyWith', () { + test('copyWith mengubah field yang ditentukan saja', () { + const original = StrategicIndicatorModel( + title: 'Original', + value: 1.0, + unit: 'unit', + period: 'Jan', + domain: 'D', + source: 'S', + ); + + final copied = original.copyWith(value: 99.9, unit: 'baru'); + + expect(copied.title, 'Original'); + expect(copied.value, 99.9); + expect(copied.unit, 'baru'); + expect(copied.period, 'Jan'); + }); + }); + }); +} diff --git a/test/full_flow_test.dart b/test/full_flow_test.dart new file mode 100644 index 0000000..8789156 --- /dev/null +++ b/test/full_flow_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:db_cracker_tamaengs/main.dart'; +import 'package:db_cracker_tamaengs/api/api_factory.dart'; + +void main() { + setUp(() { + ApiFactory().enableMockData(); + }); + + tearDown(() { + ApiFactory().disableMockData(); + }); + + group('Full Flow — App Launch', () { + testWidgets('App launches without crash', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(const ProviderScope(child: DBCrackerApp())); + await tester.pumpAndSettle(); + + expect(find.byType(MaterialApp), findsOneWidget); + }); + + testWidgets('App shows navigation with 5 destinations', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(const ProviderScope(child: DBCrackerApp())); + await tester.pumpAndSettle(); + + // Navigation labels should exist somewhere in widget tree + expect(find.text('Beranda'), findsAtLeast(1)); + expect(find.text('Pengadaan'), findsAtLeast(1)); + }); + + testWidgets('Home screen shows DB Cracker branding', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(const ProviderScope(child: DBCrackerApp())); + await tester.pumpAndSettle(); + + expect(find.text('DB Cracker'), findsOneWidget); + }); + + testWidgets('App uses dark theme (not light)', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(const ProviderScope(child: DBCrackerApp())); + await tester.pumpAndSettle(); + + final materialApp = tester.widget(find.byType(MaterialApp).first); + expect(materialApp.theme?.brightness, Brightness.dark); + }); + + testWidgets('No Courier font anywhere in app', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await tester.pumpWidget(const ProviderScope(child: DBCrackerApp())); + await tester.pumpAndSettle(); + + final textWidgets = tester.widgetList(find.byType(Text)); + for (final text in textWidgets) { + if (text.style?.fontFamily != null) { + expect(text.style!.fontFamily, isNot('Courier')); + expect(text.style!.fontFamily, isNot('CourierPrime')); + } + } + }); + }); +} diff --git a/test/health/health_service_test.dart b/test/health/health_service_test.dart new file mode 100644 index 0000000..95d50ef --- /dev/null +++ b/test/health/health_service_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:db_cracker_tamaengs/api/health/health_service.dart'; +import 'package:db_cracker_tamaengs/api/core/provider_registry.dart'; +import 'package:db_cracker_tamaengs/api/cache/in_memory_cache_store.dart'; + +void main() { + late InMemoryCacheStore cacheStore; + + setUp(() { + cacheStore = InMemoryCacheStore(); + }); + + group('HealthService', () { + test('1. healthy provider returns status healthy', () async { + final client = MockClient((request) async { + return http.Response('{"status":"ok"}', 200); + }); + + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + final pddiktiResults = report.providers.where((p) => p.kind == ProviderKind.pddikti); + expect(pddiktiResults.any((p) => p.status == ProviderStatus.healthy), true); + }); + + test('2. 503 provider returns degraded', () async { + final client = MockClient((request) async { + if (request.url.host.contains('fastapicloud')) { + return http.Response('Service Unavailable', 503); + } + return http.Response('ok', 200); + }); + + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + final fastapicloud = report.providers.firstWhere((p) => p.providerId == 'pddikti_fastapicloud'); + expect(fastapicloud.status, ProviderStatus.degraded); + }); + + test('3. timeout provider returns timeout status', () async { + final client = MockClient((request) async { + if (request.url.host.contains('fastapicloud')) { + await Future.delayed(const Duration(seconds: 10)); + return http.Response('', 200); + } + return http.Response('ok', 200); + }); + + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + final fastapicloud = report.providers.firstWhere((p) => p.providerId == 'pddikti_fastapicloud'); + expect(fastapicloud.status, ProviderStatus.timeout); + }); + + test('4. external link providers always healthy', () async { + final client = MockClient((request) async => http.Response('ok', 200)); + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + final externalLinks = report.providers.where((p) => p.kind == ProviderKind.externalLink); + for (final link in externalLinks) { + expect(link.status, ProviderStatus.healthy); + } + }); + + test('5. report contains cache stats', () async { + final client = MockClient((request) async => http.Response('ok', 200)); + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + expect(report.cacheStats, isNotNull); + expect(report.cacheStats.totalEntries, 0); + }); + + test('6. report has all registered providers', () async { + final client = MockClient((request) async => http.Response('ok', 200)); + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + // Should have at least PDDIKTI + Wilayah + external links + expect(report.providers.length, greaterThanOrEqualTo(5)); + }); + + test('7. latency is recorded for network checks', () async { + final client = MockClient((request) async { + await Future.delayed(const Duration(milliseconds: 20)); + return http.Response('ok', 200); + }); + + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + final networkChecked = report.providers.where((p) => p.latency != null && p.kind != ProviderKind.externalLink); + expect(networkChecked.isNotEmpty, true); + for (final p in networkChecked) { + expect(p.latency!.inMilliseconds, greaterThanOrEqualTo(0)); + } + }); + + test('8. 429 returns rateLimited', () async { + final client = MockClient((request) async { + return http.Response('Too Many Requests', 429); + }); + + final service = HealthService(httpClient: client, cacheStore: cacheStore); + final report = await service.checkAll(); + + final pddikti = report.providers.where((p) => p.kind == ProviderKind.pddikti); + expect(pddikti.any((p) => p.status == ProviderStatus.rateLimited), true); + }); + }); +} diff --git a/test/models/dosen_test.dart b/test/models/dosen_test.dart new file mode 100644 index 0000000..b5ea502 --- /dev/null +++ b/test/models/dosen_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/models/dosen.dart'; + +void main() { + group('Dosen.fromJson', () { + test('parsing JSON valid dengan semua field', () { + final json = { + 'id': 'abc123', + 'nama': 'Dr. Bambang', + 'nidn': '0123456789', + 'nama_pt': 'Universitas Indonesia', + 'singkatan_pt': 'UI', + 'nama_prodi': 'Teknik Informatika', + }; + final dosen = Dosen.fromJson(json); + expect(dosen.id, 'abc123'); + expect(dosen.nama, 'Dr. Bambang'); + expect(dosen.nidn, '0123456789'); + expect(dosen.namaPt, 'Universitas Indonesia'); + expect(dosen.singkatanPt, 'UI'); + expect(dosen.namaProdi, 'Teknik Informatika'); + }); + + test('parsing JSON dengan null values return empty string', () { + final json = { + 'id': null, + 'nama': null, + 'nidn': null, + 'nama_pt': null, + 'singkatan_pt': null, + 'nama_prodi': null, + }; + final dosen = Dosen.fromJson(json); + expect(dosen.id, ''); + expect(dosen.nama, ''); + expect(dosen.nidn, ''); + }); + + test('parsing JSON kosong return fallback object', () { + final dosen = Dosen.fromJson({}); + expect(dosen.id, ''); + expect(dosen.nama, ''); + }); + + test('parsing JSON dengan tipe data int return string', () { + final json = {'id': 123, 'nama': 456, 'nidn': 789, 'nama_pt': true, 'singkatan_pt': 3.14, 'nama_prodi': null}; + final dosen = Dosen.fromJson(json); + expect(dosen.id, '123'); + expect(dosen.nama, '456'); + expect(dosen.nidn, '789'); + expect(dosen.namaPt, 'true'); + }); + }); + + group('DosenDetail.fromJson', () { + test('parsing JSON valid dengan field dasar', () { + final json = { + 'id_sdm': 'sdm001', + 'nama_dosen': 'Prof. Siti', + 'nidn': '9876543210', + 'nidk': 'NIDK001', + 'gelar_depan': 'Prof.', + 'gelar_belakang': 'M.Sc.', + 'jenis_kelamin': 'Perempuan', + 'tempat_lahir': 'Jakarta', + 'tanggal_lahir': '1980-01-01', + 'agama': 'Islam', + 'nama_pt': 'ITB', + 'nama_prodi': 'Informatika', + 'jabatan_akademik': 'Guru Besar', + 'pendidikan_tertinggi': 'S3', + 'status_ikatan_kerja': 'Tetap', + 'status_aktivitas': 'Aktif', + 'bidang_ilmu': 'Computer Science', + 'institusi_pendidikan': 'MIT', + 'tahun_lulus_tertinggi': '2010', + 'status_sertifikasi': 'Sudah', + 'tahun_sertifikasi': '2015', + 'nomor_sertifikat': 'CERT001', + 'bidang_sertifikasi': 'Informatika', + }; + final detail = DosenDetail.fromJson(json); + expect(detail.idSdm, 'sdm001'); + expect(detail.namaDosen, 'Prof. Siti'); + expect(detail.nidn, '9876543210'); + expect(detail.nidk, 'NIDK001'); + expect(detail.gelarDepan, 'Prof.'); + expect(detail.gelarBelakang, 'M.Sc.'); + expect(detail.tempatLahir, 'Jakarta'); + expect(detail.tanggalLahir, '1980-01-01'); + expect(detail.agama, 'Islam'); + expect(detail.bidangIlmu, 'Computer Science'); + expect(detail.statusSertifikasi, 'Sudah'); + expect(detail.tahunSertifikasi, '2015'); + }); + + test('parsing JSON kosong return fallback tanpa crash', () { + final detail = DosenDetail.fromJson({}); + expect(detail.idSdm, ''); + expect(detail.namaDosen, ''); + expect(detail.penelitian, isEmpty); + expect(detail.riwayatStudi, isEmpty); + }); + + test('error handling return fallback object bukan throw', () { + // Passing invalid data that might cause issues + final detail = DosenDetail.fromJson({'invalid': true}); + expect(detail.idSdm, ''); + }); + }); + + group('DosenPortofolio.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sdm': 'sdm001', + 'jenis_kegiatan': 'Penelitian', + 'judul_kegiatan': 'AI Research', + 'tahun_kegiatan': '2023', + 'detail_kegiatan': 'Deep learning', + 'status_kegiatan': 'Selesai', + }; + final porto = DosenPortofolio.fromJson(json); + expect(porto.idSdm, 'sdm001'); + expect(porto.judulKegiatan, 'AI Research'); + expect(porto.tahunKegiatan, '2023'); + }); + }); + + group('DosenRiwayatStudi.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sdm': 'sdm001', + 'jenjang': 'S3', + 'gelar': 'Ph.D.', + 'bidang_studi': 'Computer Science', + 'perguruan': 'MIT', + 'tahun_lulus': '2010', + }; + final riwayat = DosenRiwayatStudi.fromJson(json); + expect(riwayat.jenjang, 'S3'); + expect(riwayat.gelar, 'Ph.D.'); + expect(riwayat.perguruan, 'MIT'); + }); + }); + + group('DosenRiwayatMengajar.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sdm': 'sdm001', + 'nama_semester': '2023/2024 Ganjil', + 'kode_matkul': 'IF001', + 'nama_matkul': 'Algoritma', + 'nama_kelas': 'A', + 'nama_pt': 'ITB', + }; + final riwayat = DosenRiwayatMengajar.fromJson(json); + expect(riwayat.namaMatkul, 'Algoritma'); + expect(riwayat.namaKelas, 'A'); + }); + }); + + group('DosenPenugasan.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sdm': 'sdm001', + 'nama_pt': 'ITB', + 'nama_prodi': 'Informatika', + 'status_penugasan': 'Aktif', + 'tahun_mulai': '2015', + 'tahun_selesai': '', + 'keterangan': 'Dosen tetap', + }; + final penugasan = DosenPenugasan.fromJson(json); + expect(penugasan.statusPenugasan, 'Aktif'); + expect(penugasan.tahunMulai, '2015'); + }); + }); + + group('DosenJabatanFungsional.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sdm': 'sdm001', + 'jabatan': 'Guru Besar', + 'tanggal_sk': '2020-01-01', + 'nomor_sk': 'SK001', + 'tmt_jabatan': '2020-02-01', + 'status_jabatan': 'Aktif', + 'keterangan': '', + }; + final jabatan = DosenJabatanFungsional.fromJson(json); + expect(jabatan.jabatan, 'Guru Besar'); + expect(jabatan.nomorSk, 'SK001'); + }); + }); +} diff --git a/test/models/mahasiswa_test.dart b/test/models/mahasiswa_test.dart new file mode 100644 index 0000000..6131a50 --- /dev/null +++ b/test/models/mahasiswa_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/models/mahasiswa.dart'; + +void main() { + group('Mahasiswa.fromJson', () { + test('parsing JSON valid', () { + final json = { + 'id': 'mhs001', + 'nama': 'Budi Santoso', + 'nim': '19102001', + 'nama_pt': 'Universitas Indonesia', + 'singkatan_pt': 'UI', + 'nama_prodi': 'Teknik Informatika', + }; + final mhs = Mahasiswa.fromJson(json); + expect(mhs.id, 'mhs001'); + expect(mhs.nama, 'Budi Santoso'); + expect(mhs.nim, '19102001'); + expect(mhs.namaPt, 'Universitas Indonesia'); + }); + + test('parsing JSON dengan null values return empty string', () { + final json = {'id': null, 'nama': null, 'nim': null, 'nama_pt': null, 'singkatan_pt': null, 'nama_prodi': null}; + final mhs = Mahasiswa.fromJson(json); + expect(mhs.id, ''); + expect(mhs.nama, ''); + expect(mhs.nim, ''); + }); + + test('error handling return fallback object bukan throw', () { + // Setelah fix, fromJson harus return fallback bukan throw + final mhs = Mahasiswa.fromJson({}); + expect(mhs.id, ''); + expect(mhs.nama, ''); + }); + + test('parsing JSON dengan tipe data non-string', () { + final json = {'id': 123, 'nama': true, 'nim': 456.7, 'nama_pt': null, 'singkatan_pt': '', 'nama_prodi': 'IT'}; + final mhs = Mahasiswa.fromJson(json); + expect(mhs.id, '123'); + expect(mhs.nama, 'true'); + expect(mhs.nim, '456.7'); + }); + }); + + group('MahasiswaDetail.fromJson', () { + test('parsing JSON valid dengan semua field', () { + final json = { + 'id': 'mhs001', + 'nama': 'Budi', + 'nim': '19102001', + 'jenis_kelamin': 'Laki-laki', + 'status_saat_ini': 'Aktif', + 'nama_pt': 'UI', + 'kode_pt': '001', + 'prodi': 'Informatika', + 'kode_prodi': 'IF', + 'tahun_masuk': '2019', + 'semester_saat_ini': '8', + 'tempat_lahir': 'Jakarta', + 'tanggal_lahir': '2001-01-01', + 'agama': 'Islam', + 'alamat': 'Jl. Merdeka 1', + 'ipk': '3.85', + 'total_sks': '144', + 'judul_skripsi': 'Machine Learning untuk Prediksi', + }; + final detail = MahasiswaDetail.fromJson(json); + expect(detail.id, 'mhs001'); + expect(detail.nama, 'Budi'); + expect(detail.semesterSaatIni, '8'); + expect(detail.tempatLahir, 'Jakarta'); + expect(detail.agama, 'Islam'); + expect(detail.ipk, '3.85'); + expect(detail.totalSks, '144'); + expect(detail.judulSkripsi, 'Machine Learning untuk Prediksi'); + }); + + test('parsing JSON kosong return fallback tanpa crash', () { + final detail = MahasiswaDetail.fromJson({}); + expect(detail.id, ''); + expect(detail.nama, ''); + expect(detail.ipk, ''); + }); + + test('field alternatif key berfungsi', () { + final json = { + 'id_mahasiswa': 'alt001', + 'nama_mahasiswa': 'Siti', + 'nomor_induk': '20201001', + 'pt_nama': 'UGM', + 'angkatan': '2020', + 'sks_total': '120', + }; + final detail = MahasiswaDetail.fromJson(json); + expect(detail.id, 'alt001'); + expect(detail.nama, 'Siti'); + expect(detail.nim, '20201001'); + expect(detail.namaPt, 'UGM'); + expect(detail.tahunMasuk, '2020'); + expect(detail.totalSks, '120'); + }); + }); + + group('MahasiswaRiwayatSemester.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sms': 'sms001', + 'nama_semester': '2023/2024 Ganjil', + 'status_semester': 'Aktif', + 'ips': '3.75', + 'ipk': '3.80', + 'sks_total': '120', + 'sks_diambil': '21', + 'sks_lulus': '21', + }; + final semester = MahasiswaRiwayatSemester.fromJson(json); + expect(semester.namaSemester, '2023/2024 Ganjil'); + expect(semester.ips, '3.75'); + expect(semester.ipk, '3.80'); + }); + }); + + group('MahasiswaNilai.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sms': 'sms001', + 'kode_matkul': 'IF001', + 'nama_matkul': 'Algoritma', + 'sks': '3', + 'nilai_huruf': 'A', + 'nilai_angka': '4.0', + 'nama_semester': '2023/2024 Ganjil', + }; + final nilai = MahasiswaNilai.fromJson(json); + expect(nilai.namaMatkul, 'Algoritma'); + expect(nilai.nilaiHuruf, 'A'); + expect(nilai.nilaiAngka, '4.0'); + }); + }); + + group('MahasiswaKelas.fromJson', () { + test('parsing valid JSON', () { + final json = { + 'id_sms': 'sms001', + 'kode_matkul': 'IF001', + 'nama_matkul': 'Algoritma', + 'nama_kelas': 'A', + 'nama_dosen': 'Dr. Bambang', + 'nama_semester': '2023/2024 Ganjil', + }; + final kelas = MahasiswaKelas.fromJson(json); + expect(kelas.namaMatkul, 'Algoritma'); + expect(kelas.namaDosen, 'Dr. Bambang'); + }); + }); +} diff --git a/test/models/prodi_test.dart b/test/models/prodi_test.dart new file mode 100644 index 0000000..c0e3810 --- /dev/null +++ b/test/models/prodi_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/models/prodi.dart'; + +void main() { + group('Prodi.fromJson', () { + test('parsing JSON valid', () { + final json = {'id': 'p001', 'nama': 'Teknik Informatika', 'jenjang': 'S1', 'pt': 'ITB', 'pt_singkat': 'ITB'}; + final prodi = Prodi.fromJson(json); + expect(prodi.id, 'p001'); + expect(prodi.nama, 'Teknik Informatika'); + expect(prodi.jenjang, 'S1'); + }); + + test('parsing JSON kosong return fallback', () { + final prodi = Prodi.fromJson({}); + expect(prodi.id, ''); + expect(prodi.nama, ''); + }); + + test('null values return empty string', () { + final prodi = Prodi.fromJson({'id': null, 'nama': null, 'jenjang': null, 'pt': null, 'pt_singkat': null}); + expect(prodi.id, ''); + expect(prodi.jenjang, ''); + }); + }); + + group('Prodi.toJson', () { + test('toJson menghasilkan map yang benar', () { + final prodi = Prodi(id: 'p001', nama: 'TI', jenjang: 'S1', pt: 'ITB', ptSingkat: 'ITB'); + final json = prodi.toJson(); + expect(json['id'], 'p001'); + expect(json['nama'], 'TI'); + expect(json['jenjang'], 'S1'); + }); + }); + + group('Prodi.toString', () { + test('toString menghasilkan representasi yang readable', () { + final prodi = Prodi(id: 'p001', nama: 'TI', jenjang: 'S1', pt: 'ITB', ptSingkat: 'ITB'); + expect(prodi.toString(), contains('Prodi')); + expect(prodi.toString(), contains('TI')); + }); + }); + + group('ProdiDetail.fromJson', () { + test('parsing JSON valid', () { + final json = { + 'id_sp': 'sp001', 'id_sms': 'sms001', 'nama_pt': 'ITB', 'kode_pt': '001', + 'nama_prodi': 'Informatika', 'kode_prodi': 'IF', 'kel_bidang': 'Teknik', + 'jenj_didik': 'S1', 'tgl_berdiri': '2000-01-01', 'tgl_sk_selenggara': '2000-01-01', + 'sk_selenggara': 'SK001', 'no_tel': '021-123', 'no_fax': '021-456', + 'website': 'https://if.itb.ac.id', 'email': 'if@itb.ac.id', 'alamat': 'Bandung', + 'provinsi': 'Jawa Barat', 'kab_kota': 'Bandung', 'kecamatan': 'Coblong', + 'lintang': '-6.89', 'bujur': '107.61', 'status': 'Aktif', 'akreditasi': 'A', + 'akreditasi_internasional': 'ABET', 'status_akreditasi': 'Berlaku', + }; + final detail = ProdiDetail.fromJson(json); + expect(detail.namaProdi, 'Informatika'); + expect(detail.akreditasi, 'A'); + expect(detail.status, 'Aktif'); + }); + + test('parsing dengan descJson', () { + final json = {'id_sp': 'sp001', 'id_sms': 'sms001', 'nama_pt': 'ITB', 'kode_pt': '001', + 'nama_prodi': 'IF', 'kode_prodi': 'IF', 'kel_bidang': '', 'jenj_didik': 'S1', + 'tgl_berdiri': '', 'tgl_sk_selenggara': '', 'sk_selenggara': '', 'no_tel': '', + 'no_fax': '', 'website': '', 'email': '', 'alamat': '', 'provinsi': '', + 'kab_kota': '', 'kecamatan': '', 'lintang': '', 'bujur': '', 'status': '', + 'akreditasi': '', 'akreditasi_internasional': '', 'status_akreditasi': ''}; + final descJson = {'deskripsi_singkat': 'Program studi terbaik', 'visi': 'Menjadi yang terbaik', + 'misi': 'Mendidik', 'kompetensi': 'IT', 'capaian_belajar': 'Mampu', 'rata_masa_studi': '4.5'}; + final detail = ProdiDetail.fromJson(json, descJson); + expect(detail.deskripsiSingkat, 'Program studi terbaik'); + expect(detail.visi, 'Menjadi yang terbaik'); + expect(detail.rataMasaStudi, '4.5'); + }); + }); +} diff --git a/test/models/pt_test.dart b/test/models/pt_test.dart new file mode 100644 index 0000000..53013ed --- /dev/null +++ b/test/models/pt_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/models/pt.dart'; + +void main() { + group('PerguruanTinggi.fromJson', () { + test('parsing JSON valid', () { + final json = {'id': 'pt001', 'kode': '001', 'nama_singkat': 'UI', 'nama': 'Universitas Indonesia'}; + final pt = PerguruanTinggi.fromJson(json); + expect(pt.id, 'pt001'); + expect(pt.kode, '001'); + expect(pt.namaSingkat, 'UI'); + expect(pt.nama, 'Universitas Indonesia'); + }); + + test('parsing JSON kosong return fallback', () { + final pt = PerguruanTinggi.fromJson({}); + expect(pt.id, ''); + expect(pt.nama, ''); + }); + + test('null values return empty string', () { + final pt = PerguruanTinggi.fromJson({'id': null, 'kode': null, 'nama_singkat': null, 'nama': null}); + expect(pt.id, ''); + expect(pt.kode, ''); + }); + }); + + group('PerguruanTinggi.toJson', () { + test('toJson menghasilkan map yang benar', () { + final pt = PerguruanTinggi(id: 'pt001', kode: '001', namaSingkat: 'UI', nama: 'Universitas Indonesia'); + final json = pt.toJson(); + expect(json['id'], 'pt001'); + expect(json['nama'], 'Universitas Indonesia'); + }); + }); + + group('PerguruanTinggiDetail.fromJson', () { + test('parsing JSON valid', () { + final json = { + 'kelompok': 'PTN', 'pembina': 'Kemdikbud', 'id_sp': 'sp001', 'kode_pt': '001', + 'email': 'info@ui.ac.id', 'no_tel': '021-123', 'no_fax': '021-456', + 'website': 'https://ui.ac.id', 'alamat': 'Depok', 'nama_pt': 'Universitas Indonesia', + 'nm_singkat': 'UI', 'kode_pos': '16424', 'provinsi_pt': 'Jawa Barat', + 'kab_kota_pt': 'Depok', 'kecamatan_pt': 'Beji', 'lintang_pt': '-6.36', + 'bujur_pt': '106.83', 'tgl_berdiri_pt': '1950-02-02', 'tgl_sk_pendirian_sp': '1950-02-02', + 'sk_pendirian_sp': 'SK001', 'status_pt': 'Aktif', 'akreditasi_pt': 'A', + 'status_akreditasi': 'Berlaku', + }; + final detail = PerguruanTinggiDetail.fromJson(json); + expect(detail.namaPt, 'Universitas Indonesia'); + expect(detail.nmSingkat, 'UI'); + expect(detail.akreditasiPt, 'A'); + expect(detail.statusPt, 'Aktif'); + }); + + test('parsing JSON kosong return fallback', () { + final detail = PerguruanTinggiDetail.fromJson({}); + expect(detail.namaPt, ''); + expect(detail.kodePt, ''); + }); + }); + + group('ProdiPt.fromJson', () { + test('parsing JSON valid', () { + final json = { + 'id_sms': 'sms001', 'kode_prodi': 'IF', 'nama_prodi': 'Informatika', + 'akreditasi': 'A', 'jenjang_prodi': 'S1', 'status_prodi': 'Aktif', + 'jumlah_dosen_nidn': '50', 'jumlah_dosen_nidk': '10', 'jumlah_dosen': '60', + 'jumlah_dosen_ajar': '55', 'jumlah_mahasiswa': '500', 'rasio': '1:8', + 'indikator_kelengkapan_data': '95%', + }; + final prodiPt = ProdiPt.fromJson(json); + expect(prodiPt.namaProdi, 'Informatika'); + expect(prodiPt.akreditasi, 'A'); + expect(prodiPt.jumlahMahasiswa, '500'); + }); + + test('toJson roundtrip', () { + final json = { + 'id_sms': 'sms001', 'kode_prodi': 'IF', 'nama_prodi': 'Informatika', + 'akreditasi': 'A', 'jenjang_prodi': 'S1', 'status_prodi': 'Aktif', + 'jumlah_dosen_nidn': '50', 'jumlah_dosen_nidk': '10', 'jumlah_dosen': '60', + 'jumlah_dosen_ajar': '55', 'jumlah_mahasiswa': '500', 'rasio': '1:8', + 'indikator_kelengkapan_data': '95%', + }; + final prodiPt = ProdiPt.fromJson(json); + final output = prodiPt.toJson(); + expect(output['nama_prodi'], 'Informatika'); + expect(output['akreditasi'], 'A'); + }); + }); +} diff --git a/test/optional/wikipedia_kbbi_maganghub_test.dart b/test/optional/wikipedia_kbbi_maganghub_test.dart new file mode 100644 index 0000000..98dfeac --- /dev/null +++ b/test/optional/wikipedia_kbbi_maganghub_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:db_cracker_tamaengs/api/optional/wikipedia_service.dart'; +import 'package:db_cracker_tamaengs/api/optional/kbbi_service.dart'; +import 'package:db_cracker_tamaengs/api/optional/maganghub_service.dart'; +import 'package:db_cracker_tamaengs/api/cache/in_memory_cache_store.dart'; + +void main() { + late InMemoryCacheStore cacheStore; + + setUp(() { + cacheStore = InMemoryCacheStore(); + }); + + group('WikipediaService', () { + test('returns WikipediaSummary on valid response', () async { + final client = MockClient((request) async { + return http.Response(json.encode({ + 'title': 'Universitas Indonesia', + 'extract': 'UI adalah perguruan tinggi negeri.', + 'content_urls': {'desktop': {'page': 'https://id.wikipedia.org/wiki/UI'}}, + 'thumbnail': {'source': 'https://upload.wikimedia.org/thumb.png'}, + }), 200); + }); + + final service = WikipediaService(httpClient: client, cacheStore: cacheStore); + final result = await service.getSummary('Universitas Indonesia'); + + expect(result, isNotNull); + expect(result!.title, 'Universitas Indonesia'); + expect(result.extract, contains('perguruan tinggi')); + expect(result.pageUrl, contains('wikipedia')); + }); + + test('returns null for empty keyword', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = WikipediaService(httpClient: client, cacheStore: cacheStore); + final result = await service.getSummary(''); + expect(result, isNull); + }); + + test('returns null for keyword < 3 chars', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = WikipediaService(httpClient: client, cacheStore: cacheStore); + final result = await service.getSummary('ab'); + expect(result, isNull); + }); + + test('returns null on 404', () async { + final client = MockClient((request) async => http.Response('Not Found', 404)); + final service = WikipediaService(httpClient: client, cacheStore: cacheStore); + final result = await service.getSummary('xyznonexistent'); + expect(result, isNull); + }); + + test('uses cache on second call', () async { + int callCount = 0; + final client = MockClient((request) async { + callCount++; + return http.Response(json.encode({ + 'title': 'Test', 'extract': 'Cached content', + }), 200); + }); + + final service = WikipediaService(httpClient: client, cacheStore: cacheStore); + await service.getSummary('Test University'); + await service.getSummary('Test University'); + + expect(callCount, 1); // Second call uses cache + }); + }); + + group('KbbiService', () { + test('returns local fallback for known academic terms', () async { + final client = MockClient((request) async => http.Response('', 500)); + final service = KbbiService(httpClient: client, cacheStore: cacheStore); + + final result = await service.lookup('akreditasi'); + expect(result, isNotNull); + expect(result!.source, 'local_fallback'); + expect(result.definition, contains('kelayakan')); + }); + + test('returns null for empty term', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = KbbiService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookup(''); + expect(result, isNull); + }); + + test('local fallback has priority over API', () async { + int apiCalled = 0; + final client = MockClient((request) async { + apiCalled++; + return http.Response(json.encode([{'arti': 'from api'}]), 200); + }); + + final service = KbbiService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookup('sks'); // Known local term + + expect(result!.source, 'local_fallback'); + expect(apiCalled, 0); // API not called because local found first + }); + + test('getAllLocalTerms returns all entries', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = KbbiService(httpClient: client, cacheStore: cacheStore); + final terms = service.getAllLocalTerms(); + expect(terms.length, greaterThan(10)); + expect(terms.any((t) => t.word == 'ipk'), true); + }); + }); + + group('MagangHubService', () { + test('getInternships returns parsed list on 200', () async { + final client = MockClient((request) async { + return http.Response(json.encode([ + {'title': 'Frontend Dev', 'company': 'Tokopedia', 'location': 'Jakarta'}, + {'title': 'Backend Dev', 'company': 'Gojek', 'location': 'Bandung'}, + ]), 200); + }); + + final service = MagangHubService(httpClient: client, cacheStore: cacheStore); + final results = await service.getInternships(); + + expect(results.length, 2); + expect(results.first.title, 'Frontend Dev'); + expect(results.first.company, 'Tokopedia'); + }); + + test('getInternships returns empty on failure', () async { + final client = MockClient((request) async => http.Response('Error', 500)); + final service = MagangHubService(httpClient: client, cacheStore: cacheStore); + final results = await service.getInternships(); + expect(results, isEmpty); + }); + + test('getInternships filters empty titles', () async { + final client = MockClient((request) async { + return http.Response(json.encode([ + {'title': '', 'company': 'Empty'}, + {'title': 'Valid', 'company': 'Good'}, + ]), 200); + }); + + final service = MagangHubService(httpClient: client, cacheStore: cacheStore); + final results = await service.getInternships(); + expect(results.length, 1); + expect(results.first.title, 'Valid'); + }); + + test('getCompanies returns list', () async { + final client = MockClient((request) async { + return http.Response(json.encode([ + {'name': 'Tokopedia'}, + {'name': 'Gojek'}, + ]), 200); + }); + + final service = MagangHubService(httpClient: client, cacheStore: cacheStore); + final results = await service.getCompanies(); + expect(results.length, 2); + expect(results.first, 'Tokopedia'); + }); + }); +} diff --git a/test/sekolah/sekolah_test.dart b/test/sekolah/sekolah_test.dart new file mode 100644 index 0000000..a4a4437 --- /dev/null +++ b/test/sekolah/sekolah_test.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:db_cracker_tamaengs/api/sekolah/sekolah_models.dart'; +import 'package:db_cracker_tamaengs/api/sekolah/sekolah_service.dart'; +import 'package:db_cracker_tamaengs/api/cache/in_memory_cache_store.dart'; + +void main() { + late InMemoryCacheStore cacheStore; + + setUp(() { + cacheStore = InMemoryCacheStore(); + }); + + group('SekolahModels', () { + test('1. parse response map dengan field standar', () { + final json = { + 'npsn': '20100001', + 'nama': 'SMA Negeri 1 Bandung', + 'bentuk_pendidikan': 'SMA', + 'status_sekolah': 'Negeri', + 'alamat_jalan': 'Jl. Ir. H. Juanda No.93', + 'provinsi': 'Jawa Barat', + 'kabupaten_kota': 'Kota Bandung', + 'kecamatan': 'Coblong', + 'lintang': '-6.8934', + 'bujur': '107.6168', + }; + final sekolah = Sekolah.fromJson(json); + expect(sekolah.npsn, '20100001'); + expect(sekolah.nama, 'SMA Negeri 1 Bandung'); + expect(sekolah.bentukPendidikan, 'SMA'); + expect(sekolah.provinsi, 'Jawa Barat'); + expect(sekolah.lokasiLengkap, contains('Bandung')); + }); + + test('2. parse response dengan alternative keys', () { + final json = { + 'npsn': '20200002', + 'nama_sekolah': 'SMK Telkom', + 'jenjang': 'SMK', + 'status': 'Swasta', + 'alamat': 'Jl. Telekomunikasi', + 'propinsi': 'Jawa Barat', + 'kab_kota': 'Kota Bandung', + 'latitude': '-6.97', + 'longitude': '107.63', + }; + final sekolah = Sekolah.fromJson(json); + expect(sekolah.nama, 'SMK Telkom'); + expect(sekolah.bentukPendidikan, 'SMK'); + expect(sekolah.lintang, '-6.97'); + expect(sekolah.bujur, '107.63'); + }); + + test('3. field kosong tidak crash', () { + final sekolah = Sekolah.fromJson({}); + expect(sekolah.npsn, ''); + expect(sekolah.nama, ''); + expect(sekolah.lokasiLengkap, ''); + }); + }); + + group('SekolahService', () { + test('4. NPSN kosong ditolak', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = SekolahService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookupByNpsn(''); + expect(result, isNull); + }); + + test('5. NPSN non-numeric ditolak', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = SekolahService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookupByNpsn('abc123'); + expect(result, isNull); + }); + + test('6. NPSN terlalu pendek ditolak', () async { + final client = MockClient((request) async => http.Response('', 200)); + final service = SekolahService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookupByNpsn('123'); + expect(result, isNull); + }); + + test('7. lookup sukses dengan response nested data', () async { + final client = MockClient((request) async { + return http.Response(json.encode({ + 'data': { + 'npsn': '20100001', + 'nama': 'SMA Negeri 1', + 'provinsi': 'Jawa Barat', + } + }), 200); + }); + + final service = SekolahService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookupByNpsn('20100001'); + + expect(result, isNotNull); + expect(result!.npsn, '20100001'); + expect(result.nama, 'SMA Negeri 1'); + }); + + test('8. cache hit pada lookup kedua', () async { + int requestCount = 0; + final client = MockClient((request) async { + requestCount++; + return http.Response(json.encode({ + 'npsn': '20100001', 'nama': 'Test School' + }), 200); + }); + + final service = SekolahService(httpClient: client, cacheStore: cacheStore); + await service.lookupByNpsn('20100001'); + await service.lookupByNpsn('20100001'); + + expect(requestCount, 1); + }); + + test('9. provider unavailable return null', () async { + final client = MockClient((request) async { + return http.Response('Not Found', 404); + }); + + final service = SekolahService(httpClient: client, cacheStore: cacheStore); + final result = await service.lookupByNpsn('99999999'); + expect(result, isNull); + }); + }); +} diff --git a/test/theme/app_colors_test.dart b/test/theme/app_colors_test.dart new file mode 100644 index 0000000..cb07f06 --- /dev/null +++ b/test/theme/app_colors_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/theme/app_colors.dart'; +import 'package:db_cracker_tamaengs/theme/app_theme.dart'; + +void main() { + group('AppColors — Neo-Violet palette', () { + test('primary is violet #7C3AED', () { + expect(AppColors.primary, equals(const Color(0xFF7C3AED))); + }); + + test('background is deep navy #0F0F23, not pure black', () { + expect(AppColors.background, equals(const Color(0xFF0F0F23))); + expect(AppColors.background, isNot(equals(Colors.black))); + expect(AppColors.background, isNot(equals(const Color(0xFF000000)))); + }); + + test('colorScheme brightness is dark', () { + final scheme = AppColors.colorScheme; + expect(scheme.brightness, equals(Brightness.dark)); + }); + + test('textPrimary has luminance > 0.5 (readable on dark bg)', () { + final luminance = AppColors.textPrimary.computeLuminance(); + expect(luminance, greaterThan(0.5)); + }); + + test('background has luminance < 0.1 (dark surface)', () { + final luminance = AppColors.background.computeLuminance(); + expect(luminance, lessThan(0.1)); + }); + + test('semantic colors are distinct from each other', () { + final semanticColors = [ + AppColors.success, + AppColors.warning, + AppColors.error, + AppColors.info, + ]; + + // Every pair must be different + for (var i = 0; i < semanticColors.length; i++) { + for (var j = i + 1; j < semanticColors.length; j++) { + expect( + semanticColors[i], + isNot(equals(semanticColors[j])), + reason: + 'Semantic color at index $i should differ from index $j', + ); + } + } + }); + + test('semantic colors are distinct from primary', () { + expect(AppColors.success, isNot(equals(AppColors.primary))); + expect(AppColors.warning, isNot(equals(AppColors.primary))); + expect(AppColors.error, isNot(equals(AppColors.primary))); + expect(AppColors.info, isNot(equals(AppColors.primary))); + }); + }); + + group('AppTheme.darkTheme integration', () { + test('darkTheme uses AppColors.colorScheme', () { + final theme = AppTheme.darkTheme; + expect(theme.colorScheme.primary, equals(AppColors.primary)); + expect(theme.colorScheme.brightness, equals(Brightness.dark)); + }); + + test('scaffoldBackgroundColor matches AppColors.background', () { + final theme = AppTheme.darkTheme; + expect(theme.scaffoldBackgroundColor, equals(AppColors.background)); + }); + }); +} diff --git a/test/utils/constants_test.dart b/test/utils/constants_test.dart new file mode 100644 index 0000000..e1a6f1c --- /dev/null +++ b/test/utils/constants_test.dart @@ -0,0 +1,132 @@ +import 'dart:ui'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/utils/constants.dart'; +import 'package:db_cracker_tamaengs/theme/app_colors.dart'; +import 'package:db_cracker_tamaengs/theme/app_typography.dart'; +import 'package:db_cracker_tamaengs/theme/app_spacing.dart'; + +void main() { + group('AppColors (Neo-Violet)', () { + test('primary is violet', () { + expect(AppColors.primary, const Color(0xFF7C3AED)); + }); + + test('secondary is cyan', () { + expect(AppColors.secondary, const Color(0xFF06B6D4)); + }); + + test('background is deep navy not pure black', () { + expect(AppColors.background, const Color(0xFF0F0F23)); + expect(AppColors.background, isNot(const Color(0xFF000000))); + }); + + test('error is red', () { + expect(AppColors.error, const Color(0xFFEF4444)); + }); + + test('success is emerald', () { + expect(AppColors.success, const Color(0xFF10B981)); + }); + + test('text colors have proper hierarchy', () { + // Primary text should be lightest + expect(AppColors.textPrimary, const Color(0xFFE2E8F0)); + expect(AppColors.textSecondary, const Color(0xFF94A3B8)); + expect(AppColors.textTertiary, const Color(0xFF64748B)); + }); + }); + + group('CtOSColors (legacy compat)', () { + test('primary maps to violet', () { + expect(CtOSColors.primary, AppColors.primary); + }); + + test('background maps to deep navy', () { + expect(CtOSColors.background, AppColors.background); + }); + + test('textAccent matches primary', () { + expect(CtOSColors.textAccent, CtOSColors.primary); + }); + }); + + group('AppStrings', () { + test('appName is not empty', () { + expect(AppStrings.appName.isNotEmpty, true); + }); + + test('homeTitle is not empty', () { + expect(AppStrings.homeTitle.isNotEmpty, true); + }); + }); + + group('AnimationDurations', () { + test('fast < medium < slow < verySlow', () { + expect(AnimationDurations.fast < AnimationDurations.medium, true); + expect(AnimationDurations.medium < AnimationDurations.slow, true); + expect(AnimationDurations.slow < AnimationDurations.verySlow, true); + }); + }); + + group('AppDimensions', () { + test('spacing values are ordered', () { + expect(AppDimensions.xs < AppDimensions.sm, true); + expect(AppDimensions.sm < AppDimensions.md, true); + expect(AppDimensions.md < AppDimensions.lg, true); + expect(AppDimensions.lg < AppDimensions.xl, true); + expect(AppDimensions.xl < AppDimensions.xxl, true); + }); + + test('radius values are ordered', () { + expect(AppDimensions.radiusSm < AppDimensions.radiusMd, true); + expect(AppDimensions.radiusMd < AppDimensions.radiusLg, true); + expect(AppDimensions.radiusLg < AppDimensions.radiusXl, true); + }); + }); + + group('AppSpacing', () { + test('spacing scale is ordered', () { + expect(AppSpacing.xs < AppSpacing.sm, true); + expect(AppSpacing.sm < AppSpacing.md, true); + expect(AppSpacing.md < AppSpacing.lg, true); + expect(AppSpacing.lg < AppSpacing.xl, true); + }); + + test('border radius values are ordered', () { + expect(AppSpacing.radiusSm < AppSpacing.radiusMd, true); + expect(AppSpacing.radiusMd < AppSpacing.radiusLg, true); + expect(AppSpacing.radiusLg < AppSpacing.radiusXl, true); + }); + }); + + group('AppTypography', () { + test('fontBody is Inter', () { + expect(AppTypography.fontBody, 'Inter'); + }); + + test('fontDisplay is JetBrainsMono', () { + expect(AppTypography.fontDisplay, 'JetBrainsMono'); + }); + + test('display sizes are ordered large > medium > small', () { + expect(AppTypography.displayLarge.fontSize! > AppTypography.displayMedium.fontSize!, true); + expect(AppTypography.displayMedium.fontSize! > AppTypography.displaySmall.fontSize!, true); + }); + + test('body sizes are ordered large > medium > small', () { + expect(AppTypography.bodyLarge.fontSize! > AppTypography.bodyMedium.fontSize!, true); + expect(AppTypography.bodyMedium.fontSize! > AppTypography.bodySmall.fontSize!, true); + }); + }); + + group('ApiConstants', () { + test('pddiktiBaseUrl starts with https', () { + expect(ApiConstants.pddiktiBaseUrl.startsWith('https://'), true); + }); + + test('defaultTimeout is reasonable', () { + expect(ApiConstants.defaultTimeout.inSeconds, greaterThanOrEqualTo(10)); + expect(ApiConstants.defaultTimeout.inSeconds, lessThanOrEqualTo(60)); + }); + }); +} diff --git a/test/utils/json_utils_test.dart b/test/utils/json_utils_test.dart new file mode 100644 index 0000000..28ee6ed --- /dev/null +++ b/test/utils/json_utils_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/utils/json_utils.dart'; + +void main() { + group('JsonUtils.ensureString', () { + test('returns empty string for null', () { + expect(JsonUtils.ensureString(null), ''); + }); + + test('returns string for String input', () { + expect(JsonUtils.ensureString('hello'), 'hello'); + }); + + test('returns string for int input', () { + expect(JsonUtils.ensureString(42), '42'); + }); + + test('returns string for double input', () { + expect(JsonUtils.ensureString(3.14), '3.14'); + }); + + test('returns string for bool input', () { + expect(JsonUtils.ensureString(true), 'true'); + }); + + test('returns empty string for empty string', () { + expect(JsonUtils.ensureString(''), ''); + }); + }); + + group('JsonUtils.getStringValue', () { + test('returns value for existing key', () { + final json = {'name': 'John'}; + expect(JsonUtils.getStringValue(json, 'name'), 'John'); + }); + + test('returns empty string for missing key', () { + final json = {'name': 'John'}; + expect(JsonUtils.getStringValue(json, 'age'), ''); + }); + + test('returns empty string for null value', () { + final json = {'name': null}; + expect(JsonUtils.getStringValue(json, 'name'), ''); + }); + + test('converts int value to string', () { + final json = {'age': 25}; + expect(JsonUtils.getStringValue(json, 'age'), '25'); + }); + }); + + group('JsonUtils.getStringFromKeys', () { + test('returns first matching key value', () { + final json = {'nama': 'John', 'name': 'Jane'}; + expect(JsonUtils.getStringFromKeys(json, ['nama', 'name']), 'John'); + }); + + test('falls back to second key if first is null', () { + final json = {'name': 'Jane'}; + expect(JsonUtils.getStringFromKeys(json, ['nama', 'name']), 'Jane'); + }); + + test('returns empty string if no keys match', () { + final json = {'foo': 'bar'}; + expect(JsonUtils.getStringFromKeys(json, ['nama', 'name']), ''); + }); + + test('skips empty string values', () { + final json = {'nama': '', 'name': 'Jane'}; + expect(JsonUtils.getStringFromKeys(json, ['nama', 'name']), 'Jane'); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 9f7cb8c..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:db_cracker/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/test/widgets/error_boundary_test.dart b/test/widgets/error_boundary_test.dart new file mode 100644 index 0000000..dc0dc02 --- /dev/null +++ b/test/widgets/error_boundary_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/widgets/error_boundary.dart'; + +void main() { + group('CtOSErrorBoundary', () { + testWidgets('renders child when no error', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CtOSErrorBoundary( + child: Text('Normal Content'), + ), + ), + ), + ); + expect(find.text('Normal Content'), findsOneWidget); + }); + + testWidgets('shows error widget when errorMessage is set', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CtOSErrorBoundary( + errorMessage: 'Something went wrong', + child: Text('Normal Content'), + ), + ), + ), + ); + expect(find.text('Normal Content'), findsNothing); + expect(find.text('Something went wrong'), findsOneWidget); + }); + + testWidgets('shows retry button when onRetry provided', (tester) async { + bool retried = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CtOSErrorBoundary( + errorMessage: 'Error occurred', + onRetry: () => retried = true, + child: const Text('Content'), + ), + ), + ), + ); + expect(find.text('Coba Lagi'), findsOneWidget); + await tester.tap(find.text('Coba Lagi')); + expect(retried, true); + }); + + testWidgets('hides retry button when showRetryButton is false', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CtOSErrorBoundary( + errorMessage: 'Error', + showRetryButton: false, + onRetry: () {}, + child: const Text('Content'), + ), + ), + ), + ); + expect(find.text('COBA LAGI'), findsNothing); + }); + }); + + group('CtOSEmptyWidget', () { + testWidgets('renders title and message', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: CtOSEmptyWidget( + title: 'KOSONG', + message: 'Tidak ada data', + ), + ), + ), + ); + expect(find.text('KOSONG'), findsOneWidget); + expect(find.text('Tidak ada data'), findsOneWidget); + }); + + testWidgets('shows action button when provided', (tester) async { + bool actionCalled = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CtOSEmptyWidget( + title: 'KOSONG', + message: 'Tidak ada data', + actionText: 'REFRESH', + onAction: () => actionCalled = true, + ), + ), + ), + ); + expect(find.text('REFRESH'), findsOneWidget); + await tester.tap(find.text('REFRESH')); + expect(actionCalled, true); + }); + }); +} diff --git a/test/widgets/neo_card_test.dart b/test/widgets/neo_card_test.dart new file mode 100644 index 0000000..7820216 --- /dev/null +++ b/test/widgets/neo_card_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/widgets/core/neo_card.dart'; +import 'package:db_cracker_tamaengs/theme/app_theme.dart'; +import 'package:db_cracker_tamaengs/theme/app_spacing.dart'; + +void main() { + Widget buildApp({required Widget child}) { + return MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold(body: child), + ); + } + + group('NeoCard — rendering', () { + testWidgets('renders child content', (tester) async { + await tester.pumpWidget(buildApp( + child: const NeoCard( + child: Text('Hello Neo'), + ), + )); + + expect(find.text('Hello Neo'), findsOneWidget); + }); + + testWidgets('renders complex child widget tree', (tester) async { + await tester.pumpWidget(buildApp( + child: NeoCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.star), + Text('Star Card'), + ], + ), + ), + )); + + expect(find.byIcon(Icons.star), findsOneWidget); + expect(find.text('Star Card'), findsOneWidget); + }); + }); + + group('NeoCard — tap behavior', () { + testWidgets('responds to tap when onTap is provided', (tester) async { + var tapped = false; + + await tester.pumpWidget(buildApp( + child: NeoCard( + onTap: () => tapped = true, + child: const Text('Tap me'), + ), + )); + + await tester.tap(find.text('Tap me')); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('wraps in InkWell when onTap is provided', (tester) async { + await tester.pumpWidget(buildApp( + child: NeoCard( + onTap: () {}, + child: const Text('Tappable'), + ), + )); + + expect(find.byType(InkWell), findsOneWidget); + }); + + testWidgets('does NOT wrap in InkWell when onTap is null', (tester) async { + await tester.pumpWidget(buildApp( + child: const NeoCard( + child: Text('Static'), + ), + )); + + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets('does NOT respond to tap when onTap is null', (tester) async { + // Verify no Material/InkWell ancestor that could absorb taps + await tester.pumpWidget(buildApp( + child: const NeoCard( + child: Text('No tap'), + ), + )); + + // No InkWell means no tap handler — widget is purely visual + final inkWells = find.byType(InkWell); + expect(inkWells, findsNothing); + }); + }); + + group('NeoCard — border radius', () { + testWidgets('applies default border radius (radiusLg = 12)', + (tester) async { + await tester.pumpWidget(buildApp( + child: const NeoCard( + child: Text('Default radius'), + ), + )); + + // Find the Container that holds the BoxDecoration + final container = tester.widget( + find.descendant( + of: find.byType(NeoCard), + matching: find.byType(Container), + ), + ); + + final decoration = container.decoration as BoxDecoration; + expect( + decoration.borderRadius, + equals(BorderRadius.circular(AppSpacing.radiusLg)), + ); + }); + + testWidgets('applies custom border radius when specified', (tester) async { + const customRadius = 24.0; + + await tester.pumpWidget(buildApp( + child: const NeoCard( + borderRadius: customRadius, + child: Text('Custom radius'), + ), + )); + + final container = tester.widget( + find.descendant( + of: find.byType(NeoCard), + matching: find.byType(Container), + ), + ); + + final decoration = container.decoration as BoxDecoration; + expect( + decoration.borderRadius, + equals(BorderRadius.circular(customRadius)), + ); + }); + + testWidgets('InkWell borderRadius matches card radius when tappable', + (tester) async { + const customRadius = 20.0; + + await tester.pumpWidget(buildApp( + child: NeoCard( + borderRadius: customRadius, + onTap: () {}, + child: const Text('Tappable radius'), + ), + )); + + final inkWell = tester.widget(find.byType(InkWell)); + expect( + inkWell.borderRadius, + equals(BorderRadius.circular(customRadius)), + ); + }); + }); +} diff --git a/test/widgets/neo_search_bar_test.dart b/test/widgets/neo_search_bar_test.dart new file mode 100644 index 0000000..bc97255 --- /dev/null +++ b/test/widgets/neo_search_bar_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:db_cracker_tamaengs/widgets/search/neo_search_bar.dart'; +import 'package:db_cracker_tamaengs/theme/app_theme.dart'; + +void main() { + Widget buildApp({ + TextEditingController? controller, + String hintText = 'Search here...', + ValueChanged? onChanged, + ValueChanged? onSubmitted, + VoidCallback? onClear, + bool autofocus = false, + bool isLoading = false, + }) { + return MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: NeoSearchBar( + controller: controller, + hintText: hintText, + onChanged: onChanged, + onSubmitted: onSubmitted, + onClear: onClear, + autofocus: autofocus, + isLoading: isLoading, + ), + ), + ), + ); + } + + group('NeoSearchBar — hint text', () { + testWidgets('renders hint text', (tester) async { + await tester.pumpWidget(buildApp(hintText: 'Cari dosen...')); + + expect(find.text('Cari dosen...'), findsOneWidget); + }); + + testWidgets('renders default hint text when not specified', + (tester) async { + await tester.pumpWidget(MaterialApp( + theme: AppTheme.darkTheme, + home: const Scaffold( + body: Padding( + padding: EdgeInsets.all(16), + child: NeoSearchBar(), + ), + ), + )); + + expect( + find.text('Cari mahasiswa, dosen, atau prodi...'), + findsOneWidget, + ); + }); + }); + + group('NeoSearchBar — clear button', () { + testWidgets('clear button does NOT appear when text is empty', + (tester) async { + await tester.pumpWidget(buildApp()); + + // No close icon when empty + expect(find.byIcon(Icons.close_rounded), findsNothing); + }); + + testWidgets('clear button appears when text is entered', (tester) async { + final controller = TextEditingController(); + + await tester.pumpWidget(buildApp(controller: controller)); + + // Enter text + await tester.enterText(find.byType(TextField), 'flutter'); + await tester.pump(); + + // Clear button should now be visible + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + }); + + testWidgets('tapping clear button clears the text', (tester) async { + final controller = TextEditingController(); + var clearCalled = false; + + await tester.pumpWidget(buildApp( + controller: controller, + onClear: () => clearCalled = true, + )); + + // Enter text + await tester.enterText(find.byType(TextField), 'test query'); + await tester.pump(); + + // Tap clear + await tester.tap(find.byIcon(Icons.close_rounded)); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(clearCalled, isTrue); + }); + }); + + group('NeoSearchBar — onSubmitted callback', () { + testWidgets('onSubmitted fires on submit action', (tester) async { + String? submittedValue; + + await tester.pumpWidget(buildApp( + onSubmitted: (value) => submittedValue = value, + )); + + await tester.enterText(find.byType(TextField), 'search term'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(submittedValue, equals('search term')); + }); + + testWidgets('onSubmitted fires with empty string when submitted without typing', + (tester) async { + String? submittedValue; + + await tester.pumpWidget(buildApp( + onSubmitted: (value) => submittedValue = value, + autofocus: true, + )); + await tester.pump(); + + // Submit with empty text — callback fires with '' + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(submittedValue, equals('')); + }); + }); + + group('NeoSearchBar — loading indicator', () { + testWidgets('loading indicator shows when isLoading=true', (tester) async { + await tester.pumpWidget(buildApp(isLoading: true)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('loading indicator hidden when isLoading=false', + (tester) async { + await tester.pumpWidget(buildApp(isLoading: false)); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('clear button hidden when isLoading=true even with text', + (tester) async { + final controller = TextEditingController(text: 'some text'); + + await tester.pumpWidget(buildApp( + controller: controller, + isLoading: true, + )); + await tester.pump(); + + // Loading indicator takes priority over clear button + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byIcon(Icons.close_rounded), findsNothing); + }); + }); + + group('NeoSearchBar — search icon', () { + testWidgets('search icon is always visible', (tester) async { + await tester.pumpWidget(buildApp()); + + expect(find.byIcon(Icons.search_rounded), findsOneWidget); + }); + }); +} diff --git a/test/wilayah/wilayah_test.dart b/test/wilayah/wilayah_test.dart new file mode 100644 index 0000000..fa61ebf --- /dev/null +++ b/test/wilayah/wilayah_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:db_cracker_tamaengs/api/wilayah/wilayah_models.dart'; +import 'package:db_cracker_tamaengs/api/wilayah/wilayah_service.dart'; +import 'package:db_cracker_tamaengs/api/cache/in_memory_cache_store.dart'; + +void main() { + late InMemoryCacheStore cacheStore; + + setUp(() { + cacheStore = InMemoryCacheStore(); + }); + + group('WilayahModels', () { + test('1. parse wilayah.id province schema', () { + final json = {'code': '32', 'name': 'Jawa Barat'}; + final province = Province.fromWilayahId(json, 'wilayah_id'); + expect(province.code, '32'); + expect(province.name, 'Jawa Barat'); + expect(province.providerId, 'wilayah_id'); + }); + + test('2. parse emsifa province schema (UPPERCASE)', () { + final json = {'id': '32', 'name': 'JAWA BARAT'}; + final province = Province.fromEmsifa(json, 'emsifa_wilayah'); + expect(province.code, '32'); + expect(province.name, 'Jawa Barat'); // Title case normalized + expect(province.providerId, 'emsifa_wilayah'); + }); + + test('3. parse regency wilayah.id', () { + final json = {'code': '32.01', 'name': 'Kab. Bogor'}; + final regency = Regency.fromWilayahId(json, '32', 'wilayah_id'); + expect(regency.code, '32.01'); + expect(regency.provinceCode, '32'); + expect(regency.name, 'Kab. Bogor'); + }); + + test('4. parse regency emsifa', () { + final json = {'id': '3201', 'province_id': '32', 'name': 'KABUPATEN BOGOR'}; + final regency = Regency.fromEmsifa(json, 'emsifa_wilayah'); + expect(regency.code, '3201'); + expect(regency.provinceCode, '32'); + expect(regency.name, 'Kabupaten Bogor'); + }); + }); + + group('WilayahService', () { + test('5. fallback provider saat primary gagal', () async { + final client = MockClient((request) async { + if (request.url.host == 'wilayah.id') { + return http.Response('Server Error', 500); + } + // Emsifa fallback + return http.Response(json.encode([ + {'id': '11', 'name': 'ACEH'}, + {'id': '12', 'name': 'SUMATERA UTARA'}, + ]), 200); + }); + + final service = WilayahService(httpClient: client, cacheStore: cacheStore); + final provinces = await service.getProvinces(); + + expect(provinces.length, 2); + expect(provinces.first.name, 'Aceh'); + expect(provinces.first.providerId, 'emsifa_wilayah'); + }); + + test('6. cache hit tidak memanggil network', () async { + int requestCount = 0; + final client = MockClient((request) async { + requestCount++; + return http.Response(json.encode({'data': [ + {'code': '11', 'name': 'Aceh'}, + ]}), 200); + }); + + final service = WilayahService(httpClient: client, cacheStore: cacheStore); + + await service.getProvinces(); // First call — network + await service.getProvinces(); // Second call — should be cache + + expect(requestCount, 1); // Only 1 network call + }); + + test('7. findProvinceByName case-insensitive', () async { + final client = MockClient((request) async { + return http.Response(json.encode({'data': [ + {'code': '32', 'name': 'Jawa Barat'}, + {'code': '33', 'name': 'Jawa Tengah'}, + ]}), 200); + }); + + final service = WilayahService(httpClient: client, cacheStore: cacheStore); + final result = await service.findProvinceByName('jawa barat'); + + expect(result, isNotNull); + expect(result!.code, '32'); + }); + + test('8. response kosong tidak crash', () async { + final client = MockClient((request) async { + return http.Response('[]', 200); + }); + + final service = WilayahService(httpClient: client, cacheStore: cacheStore); + final provinces = await service.getProvinces(); + + expect(provinces, isEmpty); + }); + + test('9. response schema berbeda tidak crash', () async { + final client = MockClient((request) async { + return http.Response(json.encode({'unexpected': 'format'}), 200); + }); + + final service = WilayahService(httpClient: client, cacheStore: cacheStore); + final provinces = await service.getProvinces(); + + expect(provinces, isEmpty); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4f78848..5777988 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 88b22e5..0b0bda0 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,10 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)