commit e7090af3da7abac0d9f5cea0db69f05e90b1262e Author: Andreas Malvino Date: Mon Jun 2 22:39:03 2025 +0700 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..147e576 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +SUPABASE_URL=https://jyrmkggbszzloxyibcwf.supabase.co +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imp5cm1rZ2dic3p6bG94eWliY3dmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDIzNjQzOTUsImV4cCI6MjA1Nzk0MDM5NX0.bAG51o1cou2HTtNt-lHpl722mBQ5-qv4uZbx6mJgfOw \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..d77a4e0 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: android + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: ios + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: linux + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: macos + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: web + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + - platform: windows + create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000..29a8fa8 --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# bumrent_app + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..e6322dd --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.bumrent_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.bumrent_app" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7fa42f4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/bumrent_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/bumrent_app/MainActivity.kt new file mode 100644 index 0000000..22ba111 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/bumrent_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.bumrent_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..afa1e8e --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a439442 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.0" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false +} + +include(":app") diff --git a/assets/images/Solusi Sewa dan langganan aset BUMDes (1).png b/assets/images/Solusi Sewa dan langganan aset BUMDes (1).png new file mode 100644 index 0000000..f2f6e29 Binary files /dev/null and b/assets/images/Solusi Sewa dan langganan aset BUMDes (1).png differ diff --git a/assets/images/air_bersih.jpg b/assets/images/air_bersih.jpg new file mode 100644 index 0000000..c24d13d Binary files /dev/null and b/assets/images/air_bersih.jpg differ diff --git a/assets/images/avatar.png b/assets/images/avatar.png new file mode 100644 index 0000000..dbb5330 Binary files /dev/null and b/assets/images/avatar.png differ diff --git a/assets/images/bukti_transfer.jpg b/assets/images/bukti_transfer.jpg new file mode 100644 index 0000000..4be923a Binary files /dev/null and b/assets/images/bukti_transfer.jpg differ diff --git a/assets/images/gambar_pendukung.jpg b/assets/images/gambar_pendukung.jpg new file mode 100644 index 0000000..dd2384d Binary files /dev/null and b/assets/images/gambar_pendukung.jpg differ diff --git a/assets/images/kerusakan.jpg b/assets/images/kerusakan.jpg new file mode 100644 index 0000000..460b92d Binary files /dev/null and b/assets/images/kerusakan.jpg differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..6b746ec Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/logo_lama.png b/assets/images/logo_lama.png new file mode 100644 index 0000000..90bab1d Binary files /dev/null and b/assets/images/logo_lama.png differ diff --git a/assets/images/meteran_air.jpg b/assets/images/meteran_air.jpg new file mode 100644 index 0000000..9bb58ab Binary files /dev/null and b/assets/images/meteran_air.jpg differ diff --git a/assets/images/profil.jpg b/assets/images/profil.jpg new file mode 100644 index 0000000..dfefb65 Binary files /dev/null and b/assets/images/profil.jpg differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..7c56964 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ade8714 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..15cada4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..0088eab --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Bumrent App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + bumrent_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/app/bindings/auth_binding.dart b/lib/app/bindings/auth_binding.dart new file mode 100644 index 0000000..c354ebb --- /dev/null +++ b/lib/app/bindings/auth_binding.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import '../data/providers/auth_provider.dart'; +import '../modules/auth/controllers/auth_controller.dart'; + +class AuthBinding extends Bindings { + @override + void dependencies() { + debugPrint('Initializing AuthBinding dependencies'); + + // Pastikan AuthProvider dibuat sekali dan bersifat permanen + if (!Get.isRegistered()) { + debugPrint('Registering AuthProvider in AuthBinding'); + Get.put(AuthProvider(), permanent: true); + } else { + debugPrint('AuthProvider already registered'); + } + + // Buat AuthController + debugPrint('Creating AuthController'); + Get.lazyPut(() => AuthController()); + + debugPrint('AuthBinding dependencies initialized'); + } +} diff --git a/lib/app/bindings/home_binding.dart b/lib/app/bindings/home_binding.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/bindings/home_binding.dart @@ -0,0 +1 @@ + diff --git a/lib/app/bindings/petugas_bumdes_binding.dart b/lib/app/bindings/petugas_bumdes_binding.dart new file mode 100644 index 0000000..c1fd3db --- /dev/null +++ b/lib/app/bindings/petugas_bumdes_binding.dart @@ -0,0 +1,30 @@ +import 'package:get/get.dart'; +import '../data/providers/auth_provider.dart'; +import '../modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart'; + +class PetugasBumdesBinding extends Bindings { + @override + void dependencies() { + // Pastikan AuthProvider teregistrasi + if (!Get.isRegistered()) { + Get.put(AuthProvider()); + } + + // Hapus terlebih dahulu untuk memastikan clean state + try { + if (Get.isRegistered()) { + Get.delete(force: true); + } + } catch (e) { + print('Error removing controller: $e'); + } + + // Gunakan put untuk memastikan controller selalu tersedia dan permanent + Get.put( + PetugasBumdesDashboardController(), + permanent: true, + ); + + print('✅ PetugasBumdesDashboardController registered successfully'); + } +} diff --git a/lib/app/bindings/petugas_mitra_binding.dart b/lib/app/bindings/petugas_mitra_binding.dart new file mode 100644 index 0000000..8a649ad --- /dev/null +++ b/lib/app/bindings/petugas_mitra_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; +import '../data/providers/auth_provider.dart'; +import '../modules/petugas_mitra/controllers/petugas_mitra_dashboard_controller.dart'; + +class PetugasMitraBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AuthProvider()); + Get.lazyPut( + () => PetugasMitraDashboardController(), + ); + } +} diff --git a/lib/app/bindings/profile_binding.dart b/lib/app/bindings/profile_binding.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/bindings/profile_binding.dart @@ -0,0 +1 @@ + diff --git a/lib/app/bindings/splash_binding.dart b/lib/app/bindings/splash_binding.dart new file mode 100644 index 0000000..c32f3a8 --- /dev/null +++ b/lib/app/bindings/splash_binding.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import '../data/providers/auth_provider.dart'; +import '../modules/splash/controllers/splash_controller.dart'; + +class SplashBinding extends Bindings { + @override + void dependencies() { + debugPrint('Initializing SplashBinding dependencies'); + + // Pastikan AuthProvider dibuat sekali dan bersifat permanen + if (!Get.isRegistered()) { + debugPrint('Registering AuthProvider in SplashBinding'); + Get.put(AuthProvider(), permanent: true); + } else { + debugPrint('AuthProvider already registered'); + } + + // Buat SplashController + debugPrint('Creating SplashController'); + Get.put(SplashController()); + + debugPrint('SplashBinding dependencies initialized'); + } +} diff --git a/lib/app/bindings/warga_binding.dart b/lib/app/bindings/warga_binding.dart new file mode 100644 index 0000000..4cbd4d9 --- /dev/null +++ b/lib/app/bindings/warga_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../data/providers/auth_provider.dart'; +import '../modules/warga/controllers/warga_dashboard_controller.dart'; + +class WargaBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => AuthProvider()); + Get.lazyPut(() => WargaDashboardController()); + } +} diff --git a/lib/app/core/theme/app_theme.dart b/lib/app/core/theme/app_theme.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/core/theme/app_theme.dart @@ -0,0 +1 @@ + diff --git a/lib/app/data/models/aset_model.dart b/lib/app/data/models/aset_model.dart new file mode 100644 index 0000000..ca9051d --- /dev/null +++ b/lib/app/data/models/aset_model.dart @@ -0,0 +1,84 @@ +import 'package:get/get.dart'; + +class AsetModel { + final String id; + final String nama; + final String deskripsi; + final String kategori; + final int harga; + final int? denda; + final String status; + final DateTime? createdAt; + final DateTime? updatedAt; + final int? kuantitas; + final int? kuantitasTerpakai; + final String? satuanUkur; + + // Untuk menampung URL gambar pertama dari tabel foto_aset + String? imageUrl; + + // Menggunakan RxList untuk membuatnya mutable dan reaktif + RxList> satuanWaktuSewa = >[].obs; + + AsetModel({ + required this.id, + required this.nama, + required this.deskripsi, + required this.kategori, + required this.harga, + this.denda, + required this.status, + this.createdAt, + this.updatedAt, + this.kuantitas, + this.kuantitasTerpakai, + this.satuanUkur, + this.imageUrl, + List>? initialSatuanWaktuSewa, + }) { + // Inisialisasi satuanWaktuSewa jika ada data awal + if (initialSatuanWaktuSewa != null) { + satuanWaktuSewa.addAll(initialSatuanWaktuSewa); + } + } + + factory AsetModel.fromJson(Map json) { + return AsetModel( + id: json['id'] ?? '', + nama: json['nama'] ?? '', + deskripsi: json['deskripsi'] ?? '', + kategori: json['kategori'] ?? '', + harga: json['harga'] ?? 0, + denda: json['denda'], + status: json['status'] ?? '', + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + kuantitas: json['kuantitas'], + kuantitasTerpakai: json['kuantitas_terpakai'], + satuanUkur: json['satuan_ukur'], + ); + } + + Map toJson() { + return { + 'id': id, + 'nama': nama, + 'deskripsi': deskripsi, + 'kategori': kategori, + 'harga': harga, + 'denda': denda, + 'status': status, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'kuantitas': kuantitas, + 'kuantitas_terpakai': kuantitasTerpakai, + 'satuan_ukur': satuanUkur, + }; + } +} diff --git a/lib/app/data/models/foto_aset_model.dart b/lib/app/data/models/foto_aset_model.dart new file mode 100644 index 0000000..1554d65 --- /dev/null +++ b/lib/app/data/models/foto_aset_model.dart @@ -0,0 +1,41 @@ +class FotoAsetModel { + final String id; + final String fotoAset; // URL foto + final DateTime? createdAt; + final DateTime? updatedAt; + final String idAset; + + FotoAsetModel({ + required this.id, + required this.fotoAset, + this.createdAt, + this.updatedAt, + required this.idAset, + }); + + factory FotoAsetModel.fromJson(Map json) { + return FotoAsetModel( + id: json['id'] ?? '', + fotoAset: json['foto_aset'] ?? '', + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + idAset: json['id_aset'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'foto_aset': fotoAset, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + 'id_aset': idAset, + }; + } +} diff --git a/lib/app/data/models/paket_model.dart b/lib/app/data/models/paket_model.dart new file mode 100644 index 0000000..74c324b --- /dev/null +++ b/lib/app/data/models/paket_model.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +class PaketModel { + final String? id; + final String? nama; + final String? deskripsi; + final int? harga; + final int? kuantitas; + final String? foto_paket; + final List? satuanWaktuSewa; + + PaketModel({ + this.id, + this.nama, + this.deskripsi, + this.harga, + this.kuantitas, + this.foto_paket, + this.satuanWaktuSewa, + }); + + Map toMap() { + return { + 'id': id, + 'nama': nama, + 'deskripsi': deskripsi, + 'harga': harga, + 'kuantitas': kuantitas, + 'foto_paket': foto_paket, + 'satuanWaktuSewa': satuanWaktuSewa, + }; + } + + factory PaketModel.fromMap(Map map) { + return PaketModel( + id: map['id'], + nama: map['nama'], + deskripsi: map['deskripsi'], + harga: map['harga']?.toInt(), + kuantitas: map['kuantitas']?.toInt(), + foto_paket: map['foto_paket'], + satuanWaktuSewa: map['satuanWaktuSewa'], + ); + } + + String toJson() => json.encode(toMap()); + + factory PaketModel.fromJson(String source) => PaketModel.fromMap(json.decode(source)); + + @override + String toString() { + return 'PaketModel(id: $id, nama: $nama, deskripsi: $deskripsi, harga: $harga, kuantitas: $kuantitas, foto_paket: $foto_paket, satuanWaktuSewa: $satuanWaktuSewa)'; + } +} diff --git a/lib/app/data/models/pesanan_model.dart b/lib/app/data/models/pesanan_model.dart new file mode 100644 index 0000000..700d34a --- /dev/null +++ b/lib/app/data/models/pesanan_model.dart @@ -0,0 +1,79 @@ +class PesananModel { + final String id; + final String asetId; + final String satuanWaktuId; + final String userId; + final String status; + final DateTime tanggalPemesanan; + final String jamPemesanan; + final int durasi; + final int totalHarga; + final DateTime? createdAt; + final DateTime? updatedAt; + + // Optional fields for joined data from other tables + String? namaSatuanWaktu; + String? namaAset; + String? namaUser; + + PesananModel({ + required this.id, + required this.asetId, + required this.satuanWaktuId, + required this.userId, + required this.status, + required this.tanggalPemesanan, + required this.jamPemesanan, + required this.durasi, + required this.totalHarga, + this.createdAt, + this.updatedAt, + this.namaSatuanWaktu, + this.namaAset, + this.namaUser, + }); + + factory PesananModel.fromJson(Map json) { + return PesananModel( + id: json['id'] ?? '', + asetId: json['aset_id'] ?? '', + satuanWaktuId: json['satuan_waktu_id'] ?? '', + userId: json['user_id'] ?? '', + status: json['status'] ?? 'pending', + tanggalPemesanan: + json['tanggal_pemesanan'] != null + ? DateTime.parse(json['tanggal_pemesanan']) + : DateTime.now(), + jamPemesanan: json['jam_pemesanan'] ?? '00:00', + durasi: json['durasi'] ?? 1, + totalHarga: json['total_harga'] ?? 0, + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + namaSatuanWaktu: json['nama_satuan_waktu'], + namaAset: json['nama_aset'], + namaUser: json['nama_user'], + ); + } + + Map toJson() { + return { + 'id': id, + 'aset_id': asetId, + 'satuan_waktu_id': satuanWaktuId, + 'user_id': userId, + 'status': status, + 'tanggal_pemesanan': tanggalPemesanan.toIso8601String().split('T')[0], + 'jam_pemesanan': jamPemesanan, + 'durasi': durasi, + 'total_harga': totalHarga, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } +} diff --git a/lib/app/data/models/rental_booking_model.dart b/lib/app/data/models/rental_booking_model.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/data/models/rental_booking_model.dart @@ -0,0 +1 @@ + diff --git a/lib/app/data/models/rental_item_model.dart b/lib/app/data/models/rental_item_model.dart new file mode 100644 index 0000000..3551276 --- /dev/null +++ b/lib/app/data/models/rental_item_model.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +class RentalItem { + final String id; + final String title; + final String description; + final double pricePerDay; + final String? imageUrl; + final String ownerId; + final String category; + final List? features; + final bool isAvailable; + final String? location; + final DateTime createdAt; + final DateTime? updatedAt; + + RentalItem({ + required this.id, + required this.title, + required this.description, + required this.pricePerDay, + this.imageUrl, + required this.ownerId, + required this.category, + this.features, + required this.isAvailable, + this.location, + required this.createdAt, + this.updatedAt, + }); + + Map toMap() { + return { + 'id': id, + 'title': title, + 'description': description, + 'price_per_day': pricePerDay, + 'image_url': imageUrl, + 'owner_id': ownerId, + 'category': category, + 'features': features, + 'is_available': isAvailable, + 'location': location, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + factory RentalItem.fromMap(Map map) { + return RentalItem( + id: map['id'] ?? '', + title: map['title'] ?? '', + description: map['description'] ?? '', + pricePerDay: map['price_per_day']?.toDouble() ?? 0.0, + imageUrl: map['image_url'], + ownerId: map['owner_id'] ?? '', + category: map['category'] ?? '', + features: + map['features'] != null ? List.from(map['features']) : null, + isAvailable: map['is_available'] ?? true, + location: map['location'], + createdAt: DateTime.parse(map['created_at']), + updatedAt: + map['updated_at'] != null ? DateTime.parse(map['updated_at']) : null, + ); + } + + String toJson() => json.encode(toMap()); + + factory RentalItem.fromJson(String source) => + RentalItem.fromMap(json.decode(source)); + + RentalItem copyWith({ + String? id, + String? title, + String? description, + double? pricePerDay, + String? imageUrl, + String? ownerId, + String? category, + List? features, + bool? isAvailable, + String? location, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return RentalItem( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + pricePerDay: pricePerDay ?? this.pricePerDay, + imageUrl: imageUrl ?? this.imageUrl, + ownerId: ownerId ?? this.ownerId, + category: category ?? this.category, + features: features ?? this.features, + isAvailable: isAvailable ?? this.isAvailable, + location: location ?? this.location, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/app/data/models/satuan_waktu_model.dart b/lib/app/data/models/satuan_waktu_model.dart new file mode 100644 index 0000000..527959c --- /dev/null +++ b/lib/app/data/models/satuan_waktu_model.dart @@ -0,0 +1,37 @@ +class SatuanWaktuModel { + final String id; + final String namaSatuanWaktu; + final DateTime? createdAt; + final DateTime? updatedAt; + + SatuanWaktuModel({ + required this.id, + required this.namaSatuanWaktu, + this.createdAt, + this.updatedAt, + }); + + factory SatuanWaktuModel.fromJson(Map json) { + return SatuanWaktuModel( + id: json['id'] ?? '', + namaSatuanWaktu: json['nama_satuan_waktu'] ?? '', + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'nama_satuan_waktu': namaSatuanWaktu, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } +} diff --git a/lib/app/data/models/satuan_waktu_sewa_model.dart b/lib/app/data/models/satuan_waktu_sewa_model.dart new file mode 100644 index 0000000..faa9381 --- /dev/null +++ b/lib/app/data/models/satuan_waktu_sewa_model.dart @@ -0,0 +1,53 @@ +class SatuanWaktuSewaModel { + final String id; + final String asetId; + final String satuanWaktuId; + final int harga; + final int? denda; + final DateTime? createdAt; + final DateTime? updatedAt; + + // Untuk menyimpan nama satuan waktu (jam/hari) dari tabel satuan_waktu + String? namaSatuanWaktu; + + SatuanWaktuSewaModel({ + required this.id, + required this.asetId, + required this.satuanWaktuId, + required this.harga, + this.denda, + this.createdAt, + this.updatedAt, + this.namaSatuanWaktu, + }); + + factory SatuanWaktuSewaModel.fromJson(Map json) { + return SatuanWaktuSewaModel( + id: json['id'] ?? '', + asetId: json['aset_id'] ?? '', + satuanWaktuId: json['satuan_waktu_id'] ?? '', + harga: json['harga'] ?? 0, + denda: json['denda'], + createdAt: + json['created_at'] != null + ? DateTime.parse(json['created_at']) + : null, + updatedAt: + json['updated_at'] != null + ? DateTime.parse(json['updated_at']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'aset_id': asetId, + 'satuan_waktu_id': satuanWaktuId, + 'harga': harga, + 'denda': denda, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } +} diff --git a/lib/app/data/models/user_model.dart b/lib/app/data/models/user_model.dart new file mode 100644 index 0000000..d3dea4e --- /dev/null +++ b/lib/app/data/models/user_model.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +class User { + final String id; + final String email; + final String? name; + final String? avatarUrl; + final String? phoneNumber; + final DateTime? createdAt; + final DateTime? updatedAt; + + User({ + required this.id, + required this.email, + this.name, + this.avatarUrl, + this.phoneNumber, + this.createdAt, + this.updatedAt, + }); + + Map toMap() { + return { + 'id': id, + 'email': email, + 'name': name, + 'avatar_url': avatarUrl, + 'phone_number': phoneNumber, + 'created_at': createdAt?.toIso8601String(), + 'updated_at': updatedAt?.toIso8601String(), + }; + } + + factory User.fromMap(Map map) { + return User( + id: map['id'] ?? '', + email: map['email'] ?? '', + name: map['name'], + avatarUrl: map['avatar_url'], + phoneNumber: map['phone_number'], + createdAt: + map['created_at'] != null ? DateTime.parse(map['created_at']) : null, + updatedAt: + map['updated_at'] != null ? DateTime.parse(map['updated_at']) : null, + ); + } + + String toJson() => json.encode(toMap()); + + factory User.fromJson(String source) => User.fromMap(json.decode(source)); + + User copyWith({ + String? id, + String? email, + String? name, + String? avatarUrl, + String? phoneNumber, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return User( + id: id ?? this.id, + email: email ?? this.email, + name: name ?? this.name, + avatarUrl: avatarUrl ?? this.avatarUrl, + phoneNumber: phoneNumber ?? this.phoneNumber, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/app/data/providers/aset_provider.dart b/lib/app/data/providers/aset_provider.dart new file mode 100644 index 0000000..5262fc6 --- /dev/null +++ b/lib/app/data/providers/aset_provider.dart @@ -0,0 +1,1099 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../models/aset_model.dart'; +import '../models/foto_aset_model.dart'; +import '../models/satuan_waktu_model.dart'; +import '../models/satuan_waktu_sewa_model.dart'; +import 'package:intl/intl.dart'; + +class AsetProvider extends GetxService { + late final SupabaseClient client; + + AsetProvider() { + client = Supabase.instance.client; + } + + // Mendapatkan semua aset dengan kategori "sewa" + Future> getSewaAsets() async { + try { + debugPrint('Fetching aset with kategori: sewa'); + + // Query untuk mendapatkan semua aset dengan kategori "sewa" + final response = await client + .from('aset') + .select('*') + .eq('kategori', 'sewa') + .eq('status', 'tersedia') // Hanya yang tersedia + .order('nama', ascending: true); // Urutan berdasarkan nama + + debugPrint('Fetched ${response.length} aset'); + + // Konversi response ke list AsetModel + List asets = + response.map((item) => AsetModel.fromJson(item)).toList(); + + // Untuk setiap aset, ambil foto pertama dan satuan waktu sewa + for (var aset in asets) { + await _attachFirstPhoto(aset); + await attachSatuanWaktuSewa(aset); + } + + return asets; + } catch (e) { + debugPrint('Error fetching aset: $e'); + return []; + } + } + + // Mendapatkan semua aset dengan kategori "langganan" + Future> getLanggananAsets() async { + try { + debugPrint('Fetching aset with kategori: langganan'); + + // Query untuk mendapatkan semua aset dengan kategori "langganan" + final response = await client + .from('aset') + .select('*') + .eq('kategori', 'langganan') + .eq('status', 'tersedia') // Hanya yang tersedia + .order('nama', ascending: true); // Urutan berdasarkan nama + + debugPrint('Fetched ${response.length} langganan aset'); + + // Konversi response ke list AsetModel + List asets = + response.map((item) => AsetModel.fromJson(item)).toList(); + + // Untuk setiap aset, ambil foto pertama dan satuan waktu sewa + for (var aset in asets) { + await _attachFirstPhoto(aset); + await attachSatuanWaktuSewa(aset); + } + + return asets; + } catch (e) { + debugPrint('Error fetching langganan asets: $e'); + return []; + } + } + + // Mendapatkan aset berdasarkan ID + Future getAsetById(String asetId) async { + try { + debugPrint('📂 Fetching aset with ID: $asetId'); + + // Query untuk mendapatkan aset dengan ID tertentu + final response = + await client.from('aset').select('*').eq('id', asetId).maybeSingle(); + + debugPrint('📂 Raw response type: ${response.runtimeType}'); + debugPrint('📂 Raw response: $response'); + + if (response == null) { + debugPrint('❌ Aset dengan ID $asetId tidak ditemukan'); + return null; + } + + debugPrint( + '✅ Successfully fetched aset with ID: $asetId, name: ${response['nama']}', + ); + + // Konversi response ke AsetModel + AsetModel aset = AsetModel.fromJson(response); + debugPrint('✅ AsetModel created: ${aset.id} - ${aset.nama}'); + + // Ambil foto dan satuan waktu sewa untuk aset ini + await _attachFirstPhoto(aset); + await attachSatuanWaktuSewa(aset); + await loadAssetPhotos(aset); + + return aset; + } catch (e, stackTrace) { + debugPrint('❌ Error fetching aset by ID: $e'); + debugPrint('❌ StackTrace: $stackTrace'); + return null; + } + } + + // Load all photos for an asset + Future loadAssetPhotos(AsetModel aset) async { + try { + final photos = await getAsetPhotos(aset.id); + if (photos.isNotEmpty && + (aset.imageUrl == null || aset.imageUrl!.isEmpty)) { + aset.imageUrl = photos.first.fotoAset; + } + } catch (e) { + debugPrint('Error loading asset photos for ID ${aset.id}: $e'); + } + } + + // Fungsi untuk mengambil foto pertama dari aset + Future _attachFirstPhoto(AsetModel aset) async { + try { + final responsePhoto = + await client + .from('foto_aset') + .select('*') + .eq('id_aset', aset.id) + .limit(1) + .maybeSingle(); + + if (responsePhoto != null) { + final fotoAset = FotoAsetModel.fromJson(responsePhoto); + aset.imageUrl = fotoAset.fotoAset; + } + } catch (e) { + debugPrint('Error fetching photo for aset ${aset.id}: $e'); + } + } + + // Fungsi untuk mendapatkan semua foto aset berdasarkan ID aset + Future> getAsetPhotos(String asetId) async { + try { + debugPrint('Fetching photos for aset ID: $asetId'); + + final response = await client + .from('foto_aset') + .select('*') + .eq('id_aset', asetId) + .order('created_at'); + + debugPrint('Fetched ${response.length} photos for aset ID: $asetId'); + + // Konversi response ke list FotoAsetModel + return (response as List) + .map((item) => FotoAsetModel.fromJson(item)) + .toList(); + } catch (e) { + debugPrint('Error fetching photos for aset ID $asetId: $e'); + return []; + } + } + + // Retrieve bookings for a specific asset on a specific date + Future>> getAsetBookings( + String asetId, + String date, + ) async { + try { + // Convert the date to DateTime for comparison + final targetDate = DateTime.parse(date); + debugPrint('🔍 Fetching bookings for asset $asetId on date $date'); + + // Query booked_detail table (previously was sewa_aset table) for bookings related to this asset + final response = await client + .from('booked_detail') + .select('id, waktu_mulai, waktu_selesai, sewa_aset_id, kuantitas') + .eq('aset_id', asetId) + .order('waktu_mulai', ascending: true); + + // Filter bookings to only include those that overlap with our target date + final bookingsForDate = + response.where((booking) { + if (booking['waktu_mulai'] == null || + booking['waktu_selesai'] == null) { + debugPrint('⚠️ Booking has null timestamp: $booking'); + return false; + } + + // Parse the timestamps + final DateTime waktuMulai = DateTime.parse(booking['waktu_mulai']); + final DateTime waktuSelesai = DateTime.parse( + booking['waktu_selesai'], + ); + + // Check if booking overlaps with our target date + final bookingStartDate = DateTime( + waktuMulai.year, + waktuMulai.month, + waktuMulai.day, + ); + final bookingEndDate = DateTime( + waktuSelesai.year, + waktuSelesai.month, + waktuSelesai.day, + ); + + final targetDateOnly = DateTime( + targetDate.year, + targetDate.month, + targetDate.day, + ); + + // The booking overlaps with our target date if: + // 1. The booking starts on or before our target date AND + // 2. The booking ends on or after our target date + return !bookingStartDate.isAfter(targetDateOnly) && + !bookingEndDate.isBefore(targetDateOnly); + }).toList(); + + debugPrint( + '📅 Found ${bookingsForDate.length} bookings for date $date from booked_detail table', + ); + + // Return the complete booking information with original timestamps + return bookingsForDate.map((booking) { + // Parse the timestamps for debugging + final DateTime waktuMulai = DateTime.parse(booking['waktu_mulai']); + final DateTime waktuSelesai = DateTime.parse(booking['waktu_selesai']); + + // Return the full booking data with formatted display times + return { + 'id': + booking['sewa_aset_id'] ?? + booking['id'], // Use sewa_aset_id as id if available + 'waktu_mulai': booking['waktu_mulai'], // Keep original ISO timestamp + 'waktu_selesai': + booking['waktu_selesai'], // Keep original ISO timestamp + 'jam_mulai': DateFormat('HH:mm').format(waktuMulai), // For display + 'jam_selesai': DateFormat( + 'HH:mm', + ).format(waktuSelesai), // For display + 'tanggal_mulai': DateFormat( + 'yyyy-MM-dd', + ).format(waktuMulai), // For calculations + 'tanggal_selesai': DateFormat( + 'yyyy-MM-dd', + ).format(waktuSelesai), // For calculations + 'kuantitas': + booking['kuantitas'] ?? 1, // Default to 1 if not specified + }; + }).toList(); + } catch (e) { + debugPrint('❌ Error getting asset bookings: $e'); + return []; + } + } + + // Fungsi untuk membuat pesanan sewa aset + Future createSewaAsetOrder(Map orderData) async { + try { + debugPrint('🔄 Creating sewa_aset order with data:'); + orderData.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + final response = + await client.from('sewa_aset').insert(orderData).select().single(); + + debugPrint('✅ Order created successfully: ${response['id']}'); + return true; + } catch (e) { + debugPrint('❌ Error creating sewa_aset order: $e'); + debugPrint('❌ Stack trace: ${StackTrace.current}'); + + // Check for specific error types + if (e.toString().contains('duplicate key')) { + debugPrint('❌ This appears to be a duplicate key error'); + } else if (e.toString().contains('violates foreign key constraint')) { + debugPrint('❌ This appears to be a foreign key constraint violation'); + } else if (e.toString().contains('violates not-null constraint')) { + debugPrint('❌ This appears to be a null value in a required field'); + } + + return false; + } + } + + // Fungsi untuk membuat tagihan sewa + Future createTagihanSewa(Map tagihanData) async { + try { + debugPrint('🔄 Creating tagihan_sewa with data:'); + tagihanData.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Ensure we don't try to insert a nama_aset field that no longer exists + if (tagihanData.containsKey('nama_aset')) { + debugPrint( + '⚠️ Removing nama_aset field from tagihan_sewa data as it does not exist in the table', + ); + tagihanData.remove('nama_aset'); + } + + final response = + await client + .from('tagihan_sewa') + .insert(tagihanData) + .select() + .single(); + + debugPrint('✅ Tagihan created successfully: ${response['id']}'); + return true; + } catch (e) { + debugPrint('❌ Error creating tagihan_sewa: $e'); + debugPrint('❌ Stack trace: ${StackTrace.current}'); + + // Check for specific error types + if (e.toString().contains('duplicate key')) { + debugPrint('❌ This appears to be a duplicate key error'); + } else if (e.toString().contains('violates foreign key constraint')) { + debugPrint('❌ This appears to be a foreign key constraint violation'); + } else if (e.toString().contains('violates not-null constraint')) { + debugPrint('❌ This appears to be a null value in a required field'); + } else if (e.toString().contains('Could not find the')) { + debugPrint( + '❌ This appears to be a column mismatch error - check field names', + ); + // Print the field names from the data to help debug + debugPrint('❌ Fields in provided data: ${tagihanData.keys.toList()}'); + } + + return false; + } + } + + // Fungsi untuk membuat booked detail + Future createBookedDetail(Map bookedDetailData) async { + try { + debugPrint('🔄 Creating booked_detail with data:'); + bookedDetailData.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Ensure we don't try to insert a status field that no longer exists + if (bookedDetailData.containsKey('status')) { + debugPrint( + '⚠️ Removing status field from booked_detail data as it does not exist in the table', + ); + bookedDetailData.remove('status'); + } + + final response = + await client + .from('booked_detail') + .insert(bookedDetailData) + .select() + .single(); + + debugPrint('✅ Booked detail created successfully: ${response['id']}'); + return true; + } catch (e) { + debugPrint('❌ Error creating booked_detail: $e'); + debugPrint('❌ Stack trace: ${StackTrace.current}'); + + // Check for specific error types + if (e.toString().contains('duplicate key')) { + debugPrint('❌ This appears to be a duplicate key error'); + } else if (e.toString().contains('violates foreign key constraint')) { + debugPrint('❌ This appears to be a foreign key constraint violation'); + } else if (e.toString().contains('violates not-null constraint')) { + debugPrint('❌ This appears to be a null value in a required field'); + } else if (e.toString().contains('Could not find the')) { + debugPrint( + '❌ This appears to be a column mismatch error - check field names', + ); + // Print the field names from the data to help debug + debugPrint( + '❌ Fields in provided data: ${bookedDetailData.keys.toList()}', + ); + } + + return false; + } + } + + // Fungsi untuk membuat pesanan lengkap (sewa_aset, booked_detail, dan tagihan_sewa) dalam satu operasi + Future createCompleteOrder({ + required Map sewaAsetData, + required Map bookedDetailData, + required Map tagihanSewaData, + }) async { + try { + debugPrint('🔄 Creating complete order with transaction'); + debugPrint('📦 sewa_aset data:'); + sewaAsetData.forEach((key, value) => debugPrint(' $key: $value')); + + debugPrint('📦 booked_detail data:'); + bookedDetailData.forEach((key, value) => debugPrint(' $key: $value')); + + // Ensure we don't try to insert a status field that no longer exists + if (bookedDetailData.containsKey('status')) { + debugPrint( + '⚠️ Removing status field from booked_detail data as it does not exist in the table', + ); + bookedDetailData.remove('status'); + } + + debugPrint('📦 tagihan_sewa data:'); + tagihanSewaData.forEach((key, value) => debugPrint(' $key: $value')); + + // Ensure we don't try to insert a nama_aset field that no longer exists + if (tagihanSewaData.containsKey('nama_aset')) { + debugPrint( + '⚠️ Removing nama_aset field from tagihan_sewa data as it does not exist in the table', + ); + tagihanSewaData.remove('nama_aset'); + } + + // Insert all three records + final sewaAsetResult = + await client.from('sewa_aset').insert(sewaAsetData).select().single(); + debugPrint('✅ sewa_aset created: ${sewaAsetResult['id']}'); + + final bookedDetailResult = + await client + .from('booked_detail') + .insert(bookedDetailData) + .select() + .single(); + debugPrint('✅ booked_detail created: ${bookedDetailResult['id']}'); + + final tagihanSewaResult = + await client + .from('tagihan_sewa') + .insert(tagihanSewaData) + .select() + .single(); + debugPrint('✅ tagihan_sewa created: ${tagihanSewaResult['id']}'); + + debugPrint('✅ Complete order created successfully!'); + return true; + } catch (e) { + debugPrint('❌ Error creating complete order: $e'); + debugPrint('❌ Stack trace: ${StackTrace.current}'); + + // Check for specific error types + if (e.toString().contains('duplicate key')) { + debugPrint('❌ This appears to be a duplicate key error'); + } else if (e.toString().contains('violates foreign key constraint')) { + debugPrint('❌ This appears to be a foreign key constraint violation'); + } else if (e.toString().contains('violates not-null constraint')) { + debugPrint('❌ This appears to be a null value in a required field'); + } else if (e.toString().contains('Could not find the')) { + debugPrint( + '❌ This appears to be a column mismatch error - check field names', + ); + // Print the field names from each data object to help debug + debugPrint('❌ Fields in sewa_aset data: ${sewaAsetData.keys.toList()}'); + debugPrint( + '❌ Fields in booked_detail data: ${bookedDetailData.keys.toList()}', + ); + debugPrint( + '❌ Fields in tagihan_sewa data: ${tagihanSewaData.keys.toList()}', + ); + } + + return false; + } + } + + // Fungsi untuk mendapatkan data satuan waktu berdasarkan ID dari tabel `satuan_waktu` + Future getSatuanWaktuById(String id) async { + try { + // Asumsikan client adalah instance Supabase (atau library serupa) + final response = + await client + .from('satuan_waktu') + .select('*') + .eq('id', id) + .maybeSingle(); + + if (response == null) { + debugPrint('Tidak ditemukan data satuan waktu untuk id: $id'); + return null; + } + + return SatuanWaktuModel.fromJson(response); + } catch (e) { + debugPrint('Error fetching satuan waktu by id: $e'); + return null; + } + } + + // Fungsi untuk mendapatkan semua data satuan waktu dari tabel `satuan_waktu` + // Biasanya digunakan untuk menampilkan pilihan pada form atau filter + Future> getAllSatuanWaktu() async { + try { + final response = await client + .from('satuan_waktu') + .select('*') + .order('nama_satuan_waktu', ascending: true); + + // Pastikan response berupa list + return (response as List) + .map((item) => SatuanWaktuModel.fromJson(item)) + .toList(); + } catch (e) { + debugPrint('Error fetching all satuan waktu: $e'); + return []; + } + } + + // Fungsi untuk mendapatkan data satuan waktu sewa untuk suatu aset tertentu + // Data diambil dari tabel `satuan_waktu_sewa` dan langsung melakukan join ke tabel `satuan_waktu` + Future>> getSatuanWaktuSewa(String asetId) async { + try { + debugPrint('Fetching satuan waktu sewa for aset $asetId with join...'); + + // Query untuk mendapatkan data dari satuan_waktu_sewa dengan join ke satuan_waktu + final response = await client + .from('satuan_waktu_sewa') + .select(''' + id, + aset_id, + satuan_waktu_id, + harga, + denda, + maksimal_waktu, + satuan_waktu:satuan_waktu_id(id, nama_satuan_waktu) + ''') + .eq('aset_id', asetId); + + debugPrint('Join query raw response type: ${response.runtimeType}'); + debugPrint('Join query raw response: $response'); + + List> result = []; + + debugPrint('Response is List with ${response.length} items'); + for (var item in response) { + try { + debugPrint('Processing item: $item'); + + // Pengecekan null dan tipe data yang lebih aman + var satuanWaktu = item['satuan_waktu']; + String namaSatuanWaktu = ''; + + if (satuanWaktu != null) { + if (satuanWaktu is Map) { + // Jika satuan_waktu adalah Map + namaSatuanWaktu = + satuanWaktu['nama_satuan_waktu']?.toString() ?? ''; + } else if (satuanWaktu is List && satuanWaktu.isNotEmpty) { + // Jika satuan_waktu adalah List + var firstItem = satuanWaktu.first; + if (firstItem is Map) { + namaSatuanWaktu = + firstItem['nama_satuan_waktu']?.toString() ?? ''; + } + } + } + + final resultItem = { + 'id': item['id']?.toString() ?? '', + 'aset_id': item['aset_id']?.toString() ?? '', + 'satuan_waktu_id': item['satuan_waktu_id']?.toString() ?? '', + 'harga': item['harga'] ?? 0, + 'denda': item['denda'] ?? 0, + 'maksimal_waktu': item['maksimal_waktu'] ?? 0, + 'nama_satuan_waktu': namaSatuanWaktu, + }; + + debugPrint('Successfully processed item: $resultItem'); + result.add(resultItem); + } catch (e) { + debugPrint('Error processing item: $e'); + debugPrint('Item data: $item'); + } + } + + debugPrint( + 'Processed ${result.length} satuan waktu sewa records for aset $asetId', + ); + return result; + } catch (e) { + debugPrint('Error fetching satuan waktu sewa for aset $asetId: $e'); + debugPrint('Stack trace: ${StackTrace.current}'); + return []; + } + } + + // Fungsi untuk melampirkan data satuan waktu sewa ke model aset secara langsung + // Fungsi ini akan dipanggil misalnya saat Anda memuat detail aset atau list aset + Future attachSatuanWaktuSewa(AsetModel aset) async { + try { + debugPrint( + 'Attaching satuan waktu sewa to aset ${aset.id} (${aset.nama})', + ); + + // Ambil semua data satuan waktu sewa untuk aset tersebut + final satuanWaktuSewaList = await getSatuanWaktuSewa(aset.id); + + // Urutkan data satuan waktu sewa, Jam dulu, kemudian Hari, kemudian lainnya + satuanWaktuSewaList.sort((a, b) { + final namaA = (a['nama_satuan_waktu'] ?? '').toString().toLowerCase(); + final namaB = (b['nama_satuan_waktu'] ?? '').toString().toLowerCase(); + + // Jika ada 'jam', tempatkan di urutan pertama + if (namaA.contains('jam') && !namaB.contains('jam')) { + return -1; + } + // Jika ada 'hari', tempatkan di urutan kedua + else if (!namaA.contains('jam') && + namaA.contains('hari') && + !namaB.contains('jam') && + !namaB.contains('hari')) { + return -1; + } + // Jika keduanya 'jam' atau keduanya 'hari' atau keduanya lainnya, pertahankan urutan asli + else if ((namaA.contains('jam') && namaB.contains('jam')) || + (namaA.contains('hari') && namaB.contains('hari'))) { + return 0; + } + // Jika b adalah 'jam', tempatkan b lebih dulu + else if (!namaA.contains('jam') && namaB.contains('jam')) { + return 1; + } + // Jika b adalah 'hari' dan a bukan 'jam', tempatkan b lebih dulu + else if (!namaA.contains('jam') && + !namaA.contains('hari') && + !namaB.contains('jam') && + namaB.contains('hari')) { + return 1; + } + // Default, pertahankan urutan + return 0; + }); + + debugPrint('Sorted satuan waktu sewa list: $satuanWaktuSewaList'); + + // Bersihkan data lama dan masukkan data baru + aset.satuanWaktuSewa.clear(); + aset.satuanWaktuSewa.addAll(satuanWaktuSewaList); + + // Debug: tampilkan data satuan waktu sewa yang berhasil dilampirkan + if (satuanWaktuSewaList.isNotEmpty) { + debugPrint( + 'Attached ${satuanWaktuSewaList.length} satuan waktu sewa to aset ${aset.id}:', + ); + for (var sws in satuanWaktuSewaList) { + debugPrint( + ' - ID: ${sws['id']}, Harga: ${sws['harga']}, Satuan Waktu: ${sws['nama_satuan_waktu']} (${sws['satuan_waktu_id']})', + ); + } + } else { + debugPrint('No satuan waktu sewa found for aset ${aset.id}'); + } + } catch (e) { + debugPrint('Error attaching satuan waktu sewa: $e'); + } + } + + // Fungsi untuk memformat harga ke format rupiah (contoh: Rp 3.000) + String formatPrice(int price) { + // RegExp untuk menambahkan titik sebagai pemisah ribuan + return 'Rp ${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; + } + + String _formatNumber(dynamic number) { + if (number == null) return '0'; + + // Pastikan angka dikonversi ke string + var numStr = number.toString(); + + // Tangani kasus ketika number bukan angka + try { + return numStr.replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match match) => '${match[1]}.', + ); + } catch (e) { + return numStr; + } + } + + // Method untuk pemesanan aset + Future orderAset({ + required String userId, + required String asetId, + required String satuanWaktuSewaId, + required int durasi, + required int totalHarga, + }) async { + try { + debugPrint('Creating order for aset $asetId by user $userId'); + + // Dapatkan tanggal hari ini + final tanggalPemesanan = DateTime.now().toIso8601String(); + + // Buat pesanan baru + final response = + await client + .from('pesanan') + .insert({ + 'user_id': userId, + 'aset_id': asetId, + 'satuan_waktu_sewa_id': satuanWaktuSewaId, + 'tanggal_pemesanan': tanggalPemesanan, + 'durasi': durasi, + 'total_harga': totalHarga, + 'status': 'pending', // Status awal pesanan + }) + .select('id') + .single(); + + // Periksa apakah pesanan berhasil dibuat + if (response['id'] != null) { + debugPrint('Order created successfully with ID: ${response['id']}'); + return true; + } else { + debugPrint('Failed to create order: Response is null or missing ID'); + return false; + } + } catch (e) { + debugPrint('Error creating order: $e'); + return false; + } + } + + // Get daily bookings for an asset for a date range + Future>> getAsetDailyBookings( + String asetId, + String startDate, + String endDate, + ) async { + try { + debugPrint( + '🔍 Fetching daily bookings for asset $asetId from $startDate to $endDate from booked_detail table', + ); + + // Parse dates for comparison + final startDateTime = DateTime.parse(startDate); + final endDateTime = DateTime.parse(endDate); + + // Query booked_detail table (previously was sewa_aset table) for daily bookings related to this asset + final response = await client + .from('booked_detail') + .select('id, waktu_mulai, waktu_selesai, sewa_aset_id, kuantitas') + .eq('aset_id', asetId) + .order('waktu_mulai', ascending: true); + + // Filter bookings that overlap with the requested date range + final List> bookingsInRange = + response.where((booking) { + if (booking['waktu_mulai'] == null || + booking['waktu_selesai'] == null) { + debugPrint('⚠️ Booking has null dates: $booking'); + return false; + } + + // Parse the dates + final DateTime bookingStartDate = DateTime.parse( + booking['waktu_mulai'], + ); + final DateTime bookingEndDate = DateTime.parse( + booking['waktu_selesai'], + ); + + // A booking overlaps with our date range if: + // 1. The booking ends after or on our start date AND + // 2. The booking starts before or on our end date + return !bookingEndDate.isBefore(startDateTime) && + !bookingStartDate.isAfter(endDateTime); + }).toList(); + + debugPrint( + '📅 Found ${bookingsInRange.length} bookings in the specified range from booked_detail table', + ); + + // Debug the booking details + if (bookingsInRange.isNotEmpty) { + for (var booking in bookingsInRange) { + debugPrint( + '📋 Booking ID: ${booking['sewa_aset_id'] ?? booking['id']}', + ); + debugPrint(' - Start: ${booking['waktu_mulai']}'); + debugPrint(' - End: ${booking['waktu_selesai']}'); + debugPrint(' - Quantity: ${booking['kuantitas']}'); + } + } + + return bookingsInRange.map((booking) { + final Map result = Map.from(booking); + // Use sewa_aset_id as the id if available + if (booking['sewa_aset_id'] != null) { + result['id'] = booking['sewa_aset_id']; + } + return result; + }).toList(); + } catch (e) { + debugPrint('❌ Error getting daily bookings: $e'); + return []; + } + } + + bool _isBeforeToday(DateTime date) { + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + final checkDate = DateTime(date.year, date.month, date.day); + // Return true if date is today or before today (meaning it should be disabled) + return !checkDate.isAfter(todayDate); + } + + // Get tagihan sewa by sewa_aset_id + Future?> getTagihanSewa(String sewaAsetId) async { + try { + debugPrint('🔍 Fetching tagihan sewa for sewa_aset_id: $sewaAsetId'); + + final response = + await client + .from('tagihan_sewa') + .select('*') + .eq('sewa_aset_id', sewaAsetId) + .maybeSingle(); + + if (response == null) { + debugPrint('⚠️ No tagihan sewa found for sewa_aset_id: $sewaAsetId'); + return null; + } + + debugPrint('✅ Tagihan sewa found: ${response['id']}'); + return response; + } catch (e) { + debugPrint('❌ Error fetching tagihan sewa: $e'); + return null; + } + } + + // Get bank accounts from akun_bank table + Future>> getBankAccounts() async { + try { + debugPrint('🔍 Fetching bank accounts from akun_bank table'); + + final response = await client + .from('akun_bank') + .select('*') + .order('nama_bank', ascending: true); + + debugPrint('✅ Fetched ${response.length} bank accounts'); + + // Convert response to List> + List> accounts = List>.from(response); + + return accounts; + } catch (e) { + debugPrint('❌ Error fetching bank accounts: $e'); + return []; + } + } + + // Get sewa_aset details with aset data + Future?> getSewaAsetWithAsetData( + String sewaAsetId, + ) async { + try { + debugPrint('🔍 Fetching sewa_aset with aset data for id: $sewaAsetId'); + + // First get the sewa_aset record + debugPrint('📊 Executing query: FROM sewa_aset WHERE id = $sewaAsetId'); + final sewaResponse = + await client + .from('sewa_aset') + .select('*') + .eq('id', sewaAsetId) + .maybeSingle(); + + if (sewaResponse == null) { + debugPrint('⚠️ No sewa_aset found with id: $sewaAsetId'); + return null; + } + + debugPrint('📋 Raw sewa_aset response:'); + sewaResponse.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Get the aset_id from the sewa_aset record + final asetId = sewaResponse['aset_id']; + if (asetId == null) { + debugPrint('⚠️ sewa_aset record has no aset_id'); + return sewaResponse; + } + + debugPrint('🔍 Found aset_id: $asetId, now fetching aset details'); + + // Get the aset details + final asetResponse = + await client.from('aset').select('*').eq('id', asetId).maybeSingle(); + + if (asetResponse == null) { + debugPrint('⚠️ No aset found with id: $asetId'); + return sewaResponse; + } + + // Combine the data + final result = Map.from(sewaResponse); + result['aset_detail'] = asetResponse; + + debugPrint('✅ Combined sewa_aset and aset data successfully'); + debugPrint('📋 Final combined data:'); + result.forEach((key, value) { + if (key != 'aset_detail') { + // Skip the nested object for clearer output + debugPrint(' $key: $value'); + } + }); + + // Specifically check waktu_mulai and waktu_selesai + debugPrint('⏰ CRITICAL TIME FIELDS CHECK:'); + debugPrint(' waktu_mulai exists: ${result.containsKey('waktu_mulai')}'); + debugPrint(' waktu_mulai value: ${result['waktu_mulai']}'); + debugPrint( + ' waktu_selesai exists: ${result.containsKey('waktu_selesai')}', + ); + debugPrint(' waktu_selesai value: ${result['waktu_selesai']}'); + + return result; + } catch (e) { + debugPrint('❌ Error fetching sewa_aset with aset data: $e'); + debugPrint(' Stack trace: ${StackTrace.current}'); + return null; + } + } + + // Fungsi untuk mengambil foto pertama dari paket + Future _getFirstPaketPhoto(String paketId) async { + try { + debugPrint('Fetching first photo for paket ID: $paketId'); + final responsePhoto = + await client + .from('foto_aset') + .select('*') + .eq('id_paket', paketId) + .limit(1) + .maybeSingle(); + + if (responsePhoto != null) { + debugPrint( + 'Found photo for paket $paketId: ${responsePhoto['foto_aset']}', + ); + return responsePhoto['foto_aset']; + } + return null; + } catch (e) { + debugPrint('Error fetching photo for paket $paketId: $e'); + return null; + } + } + + // Get all photos for a paket using id_paket column + Future> getFotoPaket(String paketId) async { + try { + debugPrint('📷 Fetching all photos for paket ID: $paketId'); + + final response = await client + .from('foto_aset') + .select('*') + .eq('id_paket', paketId); + + if (response.isEmpty) { + debugPrint('⚠️ No photos found for paket $paketId'); + return []; + } + + debugPrint('✅ Found ${response.length} photos for paket $paketId'); + return response; + } catch (e) { + debugPrint('❌ Error fetching photos for paket $paketId: $e'); + return []; + } + } + + // Get paket data with their associated satuan waktu sewa data + Future> getPakets() async { + try { + final response = await client + .from('paket') + .select('*') + .order('created_at'); + final List pakets = response; + + // Fetch satuan waktu sewa data for each paket + for (var paket in pakets) { + // Fetch the first photo for this paket + final paketId = paket['id']; + final photoUrl = await _getFirstPaketPhoto(paketId); + if (photoUrl != null) { + paket['gambar_url'] = photoUrl; + } + + final swsResponse = await client + .from('satuan_waktu_sewa') + .select('*, satuan_waktu(id, nama_satuan_waktu)') + .eq('paket_id', paket['id']); + + // Transform the response to include nama_satuan_waktu + final List> formattedSWS = []; + for (var sws in swsResponse) { + final Map formattedItem = {...sws}; + if (sws['satuan_waktu'] != null) { + formattedItem['nama_satuan_waktu'] = + sws['satuan_waktu']['nama_satuan_waktu']; + } + formattedSWS.add(formattedItem); + } + + paket['satuanWaktuSewa'] = formattedSWS; + } + + return pakets; + } catch (e) { + debugPrint('Error getting pakets: $e'); + rethrow; + } + } + + // Order a paket + Future orderPaket({ + required String userId, + required String paketId, + required String satuanWaktuSewaId, + required int durasi, + required int totalHarga, + }) async { + try { + // Get satuan waktu sewa details to determine waktu_mulai and waktu_selesai + final swsResponse = + await client + .from('satuan_waktu_sewa') + .select('*, satuan_waktu(id, nama)') + .eq('id', satuanWaktuSewaId) + .single(); + + // Calculate waktu_mulai and waktu_selesai based on satuan waktu + final DateTime now = DateTime.now(); + final DateTime waktuMulai = now.add(Duration(days: 1)); // Start tomorrow + + // Default to hourly if not specified + String satuanWaktu = 'jam'; + if (swsResponse != null && + swsResponse['satuan_waktu'] != null && + swsResponse['satuan_waktu']['nama'] != null) { + satuanWaktu = swsResponse['satuan_waktu']['nama']; + } + + // Calculate waktu_selesai based on satuan waktu and durasi + DateTime waktuSelesai; + if (satuanWaktu.toLowerCase() == 'hari') { + waktuSelesai = waktuMulai.add(Duration(days: durasi)); + } else { + waktuSelesai = waktuMulai.add(Duration(hours: durasi)); + } + + // Create the order + final sewa = { + 'user_id': userId, + 'paket_id': paketId, + 'satuan_waktu_sewa_id': satuanWaktuSewaId, + 'kuantitas': 1, // Default to 1 for packages + 'durasi': durasi, + 'total_harga': totalHarga, + 'status': 'MENUNGGU_PEMBAYARAN', + 'waktu_mulai': waktuMulai.toIso8601String(), + 'waktu_selesai': waktuSelesai.toIso8601String(), + }; + + final response = await client.from('sewa_paket').insert(sewa).select(); + + if (response != null && response.isNotEmpty) { + return true; + } + return false; + } catch (e) { + debugPrint('Error ordering paket: $e'); + return false; + } + } +} diff --git a/lib/app/data/providers/auth_provider.dart b/lib/app/data/providers/auth_provider.dart new file mode 100644 index 0000000..23ad383 --- /dev/null +++ b/lib/app/data/providers/auth_provider.dart @@ -0,0 +1,535 @@ +import 'package:flutter/foundation.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:get/get.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class AuthProvider extends GetxService { + late final SupabaseClient client; + bool _isInitialized = false; + + Future init() async { + // Cek jika sudah diinisialisasi sebelumnya + if (_isInitialized) { + debugPrint('Supabase already initialized'); + return this; + } + + try { + // Cek jika dotenv sudah dimuat + if (dotenv.env['SUPABASE_URL'] == null || + dotenv.env['SUPABASE_ANON_KEY'] == null) { + await dotenv.load(); + } + + final supabaseUrl = dotenv.env['SUPABASE_URL']; + final supabaseKey = dotenv.env['SUPABASE_ANON_KEY']; + + if (supabaseUrl == null || supabaseKey == null) { + throw Exception('Supabase credentials not found in .env file'); + } + + debugPrint( + 'Initializing Supabase with URL: ${supabaseUrl.substring(0, 15)}...', + ); + + await Supabase.initialize( + url: supabaseUrl, + anonKey: supabaseKey, + debug: true, // Aktifkan debugging untuk membantu troubleshooting + ); + + client = Supabase.instance.client; + _isInitialized = true; + debugPrint('Supabase initialized successfully'); + return this; + } catch (e) { + debugPrint('Error initializing Supabase: $e'); + rethrow; + } + } + + // Authentication methods + Future signUp({ + required String email, + required String password, + Map? data, + }) async { + return await client.auth.signUp( + email: email, + password: password, + data: data, + ); + } + + Future signIn({ + required String email, + required String password, + }) async { + return await client.auth.signInWithPassword( + email: email, + password: password, + ); + } + + Future signOut() async { + await client.auth.signOut(); + } + + User? get currentUser => client.auth.currentUser; + + Stream get authChanges => client.auth.onAuthStateChange; + + String? getCurrentUserId() { + try { + final session = Supabase.instance.client.auth.currentSession; + return session?.user.id; + } catch (e) { + print('Error getting current user ID: $e'); + return null; + } + } + + // Metode untuk mendapatkan role_id dari raw_user_meta_data + Future getUserRoleId() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting role'); + return null; + } + + try { + debugPrint('Fetching role_id from user metadata for user ID: ${user.id}'); + + // Cek user metadata untuk role_id + final userMetadata = user.userMetadata; + debugPrint('User metadata: $userMetadata'); + + // Cek beberapa kemungkinan nama field untuk role_id + if (userMetadata != null) { + if (userMetadata.containsKey('role_id')) { + final roleId = userMetadata['role_id'].toString(); + debugPrint('Found role_id in metadata: $roleId'); + return roleId; + } + + if (userMetadata.containsKey('role')) { + final role = userMetadata['role'].toString(); + debugPrint('Found role in metadata: $role'); + + // Coba konversi nama role ke UUID (from hardcoded data) + if (role.toUpperCase() == 'WARGA') { + return 'bb5360d5-8fd0-404e-8f6f-71ec4d8ad0ae'; + } + if (role.toUpperCase() == 'PETUGAS_BUMDES') { + return '38a8a23c-1873-4033-b977-3293247903b'; + } + if (role.toUpperCase() == 'PETUGAS_MITRA') { + return '8b1af754-0866-4e12-a9d8-da8ed31bec15'; + } + } + } + + // Jika tidak ada di metadata, coba cari di tabel roles dengan user_id + debugPrint('Checking roles table for user ID: ${user.id}'); + + try { + // Mencoba mengambil roles berdasarkan id user di auth + final roleData = + await client + .from('roles') + .select('id') + .eq('user_id', user.id) + .maybeSingle(); + + debugPrint('Role data by user_id: $roleData'); + + if (roleData != null && roleData.containsKey('id')) { + final roleId = roleData['id'].toString(); + debugPrint('Found role ID in roles table: $roleId'); + return roleId; + } + } catch (e) { + debugPrint('Error querying roles by user_id: $e'); + } + + // Jika tidak ditemukan dengan user_id, coba lihat seluruh tabel roles + // untuk debugging + debugPrint('Getting all roles to debug matching issues'); + final allRoles = await client.from('roles').select('*').limit(10); + + debugPrint('All roles in table: $allRoles'); + + // Fallback - tampaknya user belum di-assign role + // Berikan hardcoded role berdasarkan email pattern + final email = user.email?.toLowerCase(); + if (email != null) { + if (email.contains('bumdes')) { + return '38a8a23c-1873-4033-b977-3293247903b'; // PETUGAS_BUMDES + } else if (email.contains('mitra')) { + return '8b1af754-0866-4e12-a9d8-da8ed31bec15'; // PETUGAS_MITRA + } + } + + // Default ke WARGA + return 'bb5360d5-8fd0-404e-8f6f-71ec4d8ad0ae'; // WARGA + } catch (e) { + debugPrint('Error fetching user role_id: $e'); + // Default ke WARGA sebagai fallback + return 'bb5360d5-8fd0-404e-8f6f-71ec4d8ad0ae'; + } + } + + // Metode untuk mendapatkan nama role dari tabel roles berdasarkan role_id + Future getRoleName(String roleId) async { + try { + debugPrint('Fetching role name for role_id: $roleId'); + + // Ambil nama role dari tabel roles + // ID di tabel roles adalah tipe UUID, pastikan format roleId sesuai + final roleData = + await client + .from('roles') + .select('nama_role, id') + .eq('id', roleId) + .maybeSingle(); + + debugPrint('Query result for roles table: $roleData'); + + if (roleData != null) { + // Cek berbagai kemungkinan nama kolom + String? roleName; + if (roleData.containsKey('nama_role')) { + roleName = roleData['nama_role'].toString(); + } else if (roleData.containsKey('nama_role')) { + roleName = roleData['nama_role'].toString(); + } else if (roleData.containsKey('role_name')) { + roleName = roleData['role_name'].toString(); + } + + if (roleName != null) { + debugPrint('Found role name in roles table: $roleName'); + return roleName; + } + + // Jika tidak ada nama kolom yang cocok, tampilkan kolom yang tersedia + debugPrint( + 'Available columns in roles table: ${roleData.keys.join(', ')}', + ); + } + + // Lihat data lengkap tabel untuk troubleshooting + debugPrint('Getting all roles data for troubleshooting'); + final allRoles = await client.from('roles').select('*').limit(5); + + debugPrint('All roles table data (up to 5 rows): $allRoles'); + + // Hardcoded fallback berdasarkan UUID roleId yang dilihat dari data + debugPrint('Using hardcoded fallback for role_id: $roleId'); + if (roleId == 'bb5360d5-8fd0-404e-8f6f-71ec4d8ad0ae') return 'WARGA'; + if (roleId == '38a8a23c-1873-4033-b977-3293247903b') { + return 'PETUGAS_BUMDES'; + } + if (roleId == '8b1af754-0866-4e12-a9d8-da8ed31bec15') { + return 'PETUGAS_MITRA'; + } + + // Default fallback jika role_id tidak dikenali + debugPrint('Unrecognized role_id: $roleId, defaulting to WARGA'); + return 'WARGA'; + } catch (e) { + debugPrint('Error fetching role name: $e'); + return 'WARGA'; // Default fallback + } + } + + // Metode untuk mendapatkan nama lengkap dari tabel warga_desa berdasarkan user_id + Future getUserFullName() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting full name'); + return null; + } + + try { + debugPrint('Fetching nama_lengkap for user_id: ${user.id}'); + + // Coba ambil nama lengkap dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('nama_lengkap') + .eq('user_id', user.id) + .maybeSingle(); + + debugPrint('User data from warga_desa table: $userData'); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('nama_lengkap')) { + final namaLengkap = userData['nama_lengkap']?.toString(); + if (namaLengkap != null && namaLengkap.isNotEmpty) { + debugPrint('Found nama_lengkap: $namaLengkap'); + return namaLengkap; + } + } + + // Jika tidak ada data di warga_desa, coba cek struktur tabel untuk troubleshooting + debugPrint('Checking warga_desa table structure'); + final tableData = + await client.from('warga_desa').select('*').limit(1).maybeSingle(); + + if (tableData != null) { + debugPrint( + 'Available columns in warga_desa table: ${tableData.keys.join(', ')}', + ); + } else { + debugPrint('No data found in warga_desa table'); + } + + // Fallback ke data dari Supabase Auth + final userMetadata = user.userMetadata; + if (userMetadata != null) { + if (userMetadata.containsKey('full_name')) { + return userMetadata['full_name']?.toString(); + } + if (userMetadata.containsKey('name')) { + return userMetadata['name']?.toString(); + } + } + + // Gunakan email jika nama tidak ditemukan + return user.email?.split('@').first ?? 'Pengguna Warga'; + } catch (e) { + debugPrint('Error fetching user full name: $e'); + return 'Pengguna Warga'; // Default fallback + } + } + + // Metode untuk mendapatkan avatar dari tabel warga_desa berdasarkan user_id + Future getUserAvatar() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting avatar'); + return null; + } + + try { + debugPrint('Fetching avatar for user_id: ${user.id}'); + + // Coba ambil avatar dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('avatar') + .eq('user_id', user.id) + .maybeSingle(); + + debugPrint('Avatar data from warga_desa table: $userData'); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('avatar')) { + final avatarUrl = userData['avatar']?.toString(); + if (avatarUrl != null && avatarUrl.isNotEmpty) { + debugPrint('Found avatar URL: $avatarUrl'); + return avatarUrl; + } + } + + // Fallback ke data dari Supabase Auth + final userMetadata = user.userMetadata; + if (userMetadata != null && userMetadata.containsKey('avatar_url')) { + return userMetadata['avatar_url']?.toString(); + } + + return null; // No avatar found + } catch (e) { + debugPrint('Error fetching user avatar: $e'); + return null; + } + } + + // Metode untuk mendapatkan email pengguna + Future getUserEmail() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting email'); + return null; + } + + // Email ada di data user Supabase Auth + return user.email; + } + + // Metode untuk mendapatkan NIK dari tabel warga_desa berdasarkan user_id + Future getUserNIK() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting NIK'); + return null; + } + + try { + debugPrint('Fetching NIK for user_id: ${user.id}'); + + // Coba ambil NIK dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('nik') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('nik')) { + final nik = userData['nik']?.toString(); + if (nik != null && nik.isNotEmpty) { + debugPrint('Found NIK: $nik'); + return nik; + } + } + + // Fallback ke data dari metadata + final userMetadata = user.userMetadata; + if (userMetadata != null && userMetadata.containsKey('nik')) { + return userMetadata['nik']?.toString(); + } + + return null; + } catch (e) { + debugPrint('Error fetching user NIK: $e'); + return null; + } + } + + // Metode untuk mendapatkan nomor telepon dari tabel warga_desa berdasarkan user_id + Future getUserPhone() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting phone'); + return null; + } + + try { + debugPrint('Fetching phone for user_id: ${user.id}'); + + // Coba ambil nomor telepon dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('nomor_telepon, no_telepon, phone') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data, cek beberapa kemungkinan nama kolom + if (userData != null) { + if (userData.containsKey('nomor_telepon')) { + final phone = userData['nomor_telepon']?.toString(); + if (phone != null && phone.isNotEmpty) return phone; + } + + if (userData.containsKey('no_telepon')) { + final phone = userData['no_telepon']?.toString(); + if (phone != null && phone.isNotEmpty) return phone; + } + + if (userData.containsKey('phone')) { + final phone = userData['phone']?.toString(); + if (phone != null && phone.isNotEmpty) return phone; + } + } + + // Fallback ke data dari Supabase Auth + final userMetadata = user.userMetadata; + if (userMetadata != null) { + if (userMetadata.containsKey('phone')) { + return userMetadata['phone']?.toString(); + } + if (userMetadata.containsKey('phone_number')) { + return userMetadata['phone_number']?.toString(); + } + } + + return null; + } catch (e) { + debugPrint('Error fetching user phone: $e'); + return null; + } + } + + // Metode untuk mendapatkan alamat dari tabel warga_desa berdasarkan user_id + Future getUserAddress() async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting address'); + return null; + } + + try { + debugPrint('Fetching address for user_id: ${user.id}'); + + // Coba ambil alamat dari tabel warga_desa + final userData = + await client + .from('warga_desa') + .select('alamat') + .eq('user_id', user.id) + .maybeSingle(); + + // Jika berhasil mendapatkan data + if (userData != null && userData.containsKey('alamat')) { + final address = userData['alamat']?.toString(); + if (address != null && address.isNotEmpty) { + debugPrint('Found address: $address'); + return address; + } + } + + // Fallback ke data dari Supabase Auth + final userMetadata = user.userMetadata; + if (userMetadata != null && userMetadata.containsKey('address')) { + return userMetadata['address']?.toString(); + } + + return null; + } catch (e) { + debugPrint('Error fetching user address: $e'); + return null; + } + } + + // Mendapatkan data sewa_aset berdasarkan status (misal: MENUNGGU PEMBAYARAN, PEMBAYARANAN DENDA) + Future>> getSewaAsetByStatus( + List statuses, + ) async { + final user = currentUser; + if (user == null) { + debugPrint('No current user found when getting sewa_aset by status'); + return []; + } + try { + debugPrint( + 'Fetching sewa_aset for user_id: \\${user.id} with statuses: \\${statuses.join(', ')}', + ); + // Supabase expects the IN filter as a comma-separated string in parentheses + final statusString = '(${statuses.map((s) => '"$s"').join(',')})'; + final response = await client + .from('sewa_aset') + .select('*') + .eq('user_id', user.id) + .filter('status', 'in', statusString); + debugPrint('Fetched sewa_aset count: \\${response.length}'); + // Pastikan response adalah List + if (response is List) { + return response + .map>( + (item) => Map.from(item), + ) + .toList(); + } else { + return []; + } + } catch (e) { + debugPrint('Error fetching sewa_aset by status: \\${e.toString()}'); + return []; + } + } +} diff --git a/lib/app/data/providers/pesanan_provider.dart b/lib/app/data/providers/pesanan_provider.dart new file mode 100644 index 0000000..5730665 --- /dev/null +++ b/lib/app/data/providers/pesanan_provider.dart @@ -0,0 +1,187 @@ +import 'package:bumrent_app/app/data/models/aset_model.dart'; +import 'package:bumrent_app/app/data/models/pesanan_model.dart'; +import 'package:bumrent_app/app/data/models/satuan_waktu_model.dart'; +import 'package:bumrent_app/app/data/providers/auth_provider.dart'; +import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class PesananProvider { + final SupabaseClient _supabase = Supabase.instance.client; + final _tableName = 'pesanan'; + + Future> getPesananByUserId(String userId) async { + try { + final response = await _supabase + .from(_tableName) + .select('*, aset(nama)') + .eq('user_id', userId) + .order('created_at', ascending: false); + + final List pesananList = []; + for (final item in response) { + final pesanan = PesananModel.fromJson(item); + + // Attach the asset name + if (item['aset'] != null) { + pesanan.namaAset = item['aset']['nama']; + } + + // Get and attach satuan waktu name + final satuanWaktu = await getSatuanWaktuById(pesanan.satuanWaktuId); + if (satuanWaktu != null) { + pesanan.namaSatuanWaktu = satuanWaktu.namaSatuanWaktu; + } + + pesananList.add(pesanan); + } + + return pesananList; + } catch (e) { + print('Error getting pesanan by user ID: $e'); + return []; + } + } + + Future> getAllPesanan() async { + try { + final response = await _supabase + .from(_tableName) + .select('*, aset(nama), auth_users(full_name)') + .order('created_at', ascending: false); + + final List pesananList = []; + for (final item in response) { + final pesanan = PesananModel.fromJson(item); + + // Attach the asset name + if (item['aset'] != null) { + pesanan.namaAset = item['aset']['nama']; + } + + // Attach the user name + if (item['auth_users'] != null) { + pesanan.namaUser = item['auth_users']['full_name']; + } + + // Get and attach satuan waktu name + final satuanWaktu = await getSatuanWaktuById(pesanan.satuanWaktuId); + if (satuanWaktu != null) { + pesanan.namaSatuanWaktu = satuanWaktu.namaSatuanWaktu; + } + + pesananList.add(pesanan); + } + + return pesananList; + } catch (e) { + print('Error getting all pesanan: $e'); + return []; + } + } + + Future getPesananById(String id) async { + try { + final response = + await _supabase + .from(_tableName) + .select('*, aset(nama), auth_users(full_name)') + .eq('id', id) + .single(); + + final pesanan = PesananModel.fromJson(response); + + // Attach the asset name + if (response['aset'] != null) { + pesanan.namaAset = response['aset']['nama']; + } + + // Attach the user name + if (response['auth_users'] != null) { + pesanan.namaUser = response['auth_users']['full_name']; + } + + // Get and attach satuan waktu name + final satuanWaktu = await getSatuanWaktuById(pesanan.satuanWaktuId); + if (satuanWaktu != null) { + pesanan.namaSatuanWaktu = satuanWaktu.namaSatuanWaktu; + } + + return pesanan; + } catch (e) { + print('Error getting pesanan by ID: $e'); + return null; + } + } + + Future createPesanan({ + required String asetId, + required String satuanWaktuId, + required String userId, + required DateTime tanggalPemesanan, + required String jamPemesanan, + required int durasi, + required int totalHarga, + }) async { + try { + final response = + await _supabase + .from(_tableName) + .insert({ + 'aset_id': asetId, + 'satuan_waktu_id': satuanWaktuId, + 'user_id': userId, + 'status': 'pending', + 'tanggal_pemesanan': + tanggalPemesanan.toIso8601String().split('T')[0], + 'jam_pemesanan': jamPemesanan, + 'durasi': durasi, + 'total_harga': totalHarga, + }) + .select('id') + .single(); + + return response['id']; + } catch (e) { + print('Error creating pesanan: $e'); + return null; + } + } + + Future updatePesananStatus(String id, String status) async { + try { + await _supabase + .from(_tableName) + .update({ + 'status': status, + 'updated_at': DateTime.now().toIso8601String(), + }) + .eq('id', id); + + return true; + } catch (e) { + print('Error updating pesanan status: $e'); + return false; + } + } + + Future deletePesanan(String id) async { + try { + await _supabase.from(_tableName).delete().eq('id', id); + return true; + } catch (e) { + print('Error deleting pesanan: $e'); + return false; + } + } + + Future getSatuanWaktuById(String id) async { + try { + final response = + await _supabase.from('satuan_waktu').select().eq('id', id).single(); + return SatuanWaktuModel.fromJson(response); + } catch (e) { + print('Error getting satuan waktu by ID: $e'); + return null; + } + } +} diff --git a/lib/app/data/providers/supabase_provider.dart b/lib/app/data/providers/supabase_provider.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/data/providers/supabase_provider.dart @@ -0,0 +1 @@ + diff --git a/lib/app/data/repositories/rental_booking_repository.dart b/lib/app/data/repositories/rental_booking_repository.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/data/repositories/rental_booking_repository.dart @@ -0,0 +1 @@ + diff --git a/lib/app/data/repositories/rental_item_repository.dart b/lib/app/data/repositories/rental_item_repository.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/data/repositories/rental_item_repository.dart @@ -0,0 +1 @@ + diff --git a/lib/app/data/repositories/user_repository.dart b/lib/app/data/repositories/user_repository.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/data/repositories/user_repository.dart @@ -0,0 +1 @@ + diff --git a/lib/app/modules/auth/controllers/auth_controller.dart b/lib/app/modules/auth/controllers/auth_controller.dart new file mode 100644 index 0000000..7838917 --- /dev/null +++ b/lib/app/modules/auth/controllers/auth_controller.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../routes/app_routes.dart'; + +class AuthController extends GetxController { + final AuthProvider _authProvider = Get.find(); + + final emailController = TextEditingController(); + final passwordController = TextEditingController(); + + // Form fields for registration + final RxString email = ''.obs; + final RxString password = ''.obs; + final RxString nik = ''.obs; + final RxString phoneNumber = ''.obs; + final RxString selectedRole = 'WARGA'.obs; // Default role + + // Form status + final RxBool isLoading = false.obs; + final RxBool isPasswordVisible = false.obs; + final RxString errorMessage = ''.obs; + + // Role options + final List roleOptions = ['WARGA', 'PETUGAS_MITRA']; + + void togglePasswordVisibility() { + isPasswordVisible.value = !isPasswordVisible.value; + } + + // Change role selection + void setRole(String? role) { + if (role != null) { + selectedRole.value = role; + } + } + + void login() async { + // Clear previous error messages + errorMessage.value = ''; + + // Basic validation + if (emailController.text.isEmpty || passwordController.text.isEmpty) { + errorMessage.value = 'Email dan password tidak boleh kosong'; + return; + } + + if (!GetUtils.isEmail(emailController.text.trim())) { + errorMessage.value = 'Format email tidak valid'; + return; + } + + try { + isLoading.value = true; + + // Use the actual Supabase authentication + final response = await _authProvider.signIn( + email: emailController.text.trim(), + password: passwordController.text, + ); + + // Check if login was successful + if (response.user != null) { + await _checkRoleAndNavigate(); + } else { + errorMessage.value = 'Login gagal. Periksa email dan password Anda.'; + } + } catch (e) { + errorMessage.value = 'Terjadi kesalahan: ${e.toString()}'; + } finally { + isLoading.value = false; + } + } + + Future _checkRoleAndNavigate() async { + try { + // Get the user's role ID from the auth provider + final roleId = await _authProvider.getUserRoleId(); + + if (roleId == null) { + errorMessage.value = 'Tidak dapat memperoleh peran pengguna'; + return; + } + + // Get role name based on role ID + final roleName = await _authProvider.getRoleName(roleId); + + // Navigate based on role name + if (roleName == null) { + _navigateToWargaDashboard(); // Default to warga if role name not found + return; + } + + switch (roleName.toUpperCase()) { + case 'PETUGAS_BUMDES': + _navigateToPetugasBumdesDashboard(); + break; + case 'WARGA': + default: + _navigateToWargaDashboard(); + break; + } + } catch (e) { + errorMessage.value = 'Gagal navigasi: ${e.toString()}'; + } + } + + void _navigateToPetugasBumdesDashboard() { + Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD); + } + + void _navigateToWargaDashboard() { + Get.offAllNamed(Routes.WARGA_DASHBOARD); + } + + void forgotPassword() async { + // Clear previous error messages + errorMessage.value = ''; + + // Basic validation + if (emailController.text.isEmpty) { + errorMessage.value = 'Email tidak boleh kosong'; + return; + } + + if (!GetUtils.isEmail(emailController.text.trim())) { + errorMessage.value = 'Format email tidak valid'; + return; + } + + try { + isLoading.value = true; + + // Call Supabase to send password reset email + await _authProvider.client.auth.resetPasswordForEmail( + emailController.text.trim(), + ); + + // Show success message + Get.snackbar( + 'Berhasil', + 'Link reset password telah dikirim ke email Anda', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green[100], + colorText: Colors.green[800], + icon: const Icon(Icons.check_circle, color: Colors.green), + ); + + // Return to login page after a short delay + await Future.delayed(const Duration(seconds: 2)); + Get.back(); + } catch (e) { + errorMessage.value = 'Terjadi kesalahan: ${e.toString()}'; + } finally { + isLoading.value = false; + } + } + + void goToSignUp() { + // Clear error message when navigating away + errorMessage.value = ''; + Get.toNamed(Routes.REGISTER); + } + + void goToForgotPassword() { + // Clear error message when navigating away + errorMessage.value = ''; + Get.toNamed(Routes.FORGOT_PASSWORD); + } + + @override + void onClose() { + emailController.dispose(); + passwordController.dispose(); + super.onClose(); + } + + // Register user implementation + Future registerUser() async { + // Validate all required fields + if (email.value.isEmpty || + password.value.isEmpty || + nik.value.isEmpty || + phoneNumber.value.isEmpty) { + errorMessage.value = 'Semua field harus diisi'; + return; + } + + // Basic validation for email + if (!GetUtils.isEmail(email.value.trim())) { + errorMessage.value = 'Format email tidak valid'; + return; + } + + // Basic validation for password + if (password.value.length < 6) { + errorMessage.value = 'Password minimal 6 karakter'; + return; + } + + // Basic validation for NIK + if (nik.value.length != 16) { + errorMessage.value = 'NIK harus 16 digit'; + return; + } + + // Basic validation for phone number + if (!phoneNumber.value.startsWith('08') || phoneNumber.value.length < 10) { + errorMessage.value = + 'Nomor HP tidak valid (harus diawali 08 dan minimal 10 digit)'; + return; + } + + try { + isLoading.value = true; + errorMessage.value = ''; + + // Create user with Supabase + final response = await _authProvider.signUp( + email: email.value.trim(), + password: password.value, + data: { + 'nik': nik.value.trim(), + 'phone_number': phoneNumber.value.trim(), + 'role': selectedRole.value, + }, + ); + + if (response.user != null) { + // Registration successful + Get.offNamed(Routes.REGISTRATION_SUCCESS); + } else { + errorMessage.value = 'Gagal mendaftar. Silakan coba lagi.'; + } + } catch (e) { + errorMessage.value = 'Terjadi kesalahan: ${e.toString()}'; + print('Registration error: ${e.toString()}'); + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/app/modules/auth/views/forgot_password_view.dart b/lib/app/modules/auth/views/forgot_password_view.dart new file mode 100644 index 0000000..cc535a5 --- /dev/null +++ b/lib/app/modules/auth/views/forgot_password_view.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/auth_controller.dart'; +import '../../../theme/app_colors.dart'; + +class ForgotPasswordView extends GetView { + const ForgotPasswordView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + // Background gradient + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.primarySoft, AppColors.background], + ), + ), + ), + + // Background pattern + Opacity( + opacity: 0.03, + child: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/pattern.png'), + repeat: ImageRepeat.repeat, + scale: 4.0, + ), + ), + ), + ), + + // Accent circle + Positioned( + top: -100, + right: -80, + child: Container( + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + + // Main content + SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Back button + Padding( + padding: const EdgeInsets.all(16.0), + child: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: AppColors.primary, + onPressed: () => Get.back(), + ), + ), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + _buildHeader(), + const SizedBox(height: 40), + _buildEmailField(), + const SizedBox(height: 32), + _buildResetButton(), + const SizedBox(height: 40), + _buildImportantInfo(), + const SizedBox(height: 24), + _buildBackToLoginLink(), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + // Floating lock icon with animation effect + Container( + width: 110, + height: 110, + decoration: BoxDecoration( + color: AppColors.surface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.2), + blurRadius: 20, + spreadRadius: 5, + offset: const Offset(0, 10), + ), + ], + ), + child: Center( + child: Icon( + Icons.lock_open_rounded, + size: 50, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 32), + Text( + 'Lupa Password?', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + letterSpacing: 0.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Masukkan email Anda di bawah ini dan kami akan mengirimkan link untuk reset password.', + style: TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildEmailField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4.0, bottom: 8.0), + child: Text( + 'Email', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontSize: 15, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: TextField( + controller: controller.emailController, + keyboardType: TextInputType.emailAddress, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Masukkan email Anda', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon( + Icons.email_outlined, + color: AppColors.iconGrey, + size: 22, + ), + filled: true, + fillColor: AppColors.surface, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ), + + // Error message + Obx( + () => + controller.errorMessage.value.isNotEmpty + ? Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + controller.errorMessage.value, + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + + Widget _buildResetButton() { + return Obx( + () => SizedBox( + height: 56, + child: ElevatedButton( + onPressed: + controller.isLoading.value ? null : controller.forgotPassword, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 2, + shadowColor: AppColors.primary.withOpacity(0.4), + disabledBackgroundColor: AppColors.primary.withOpacity(0.6), + ), + child: + controller.isLoading.value + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Kirim Link Reset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.send_rounded, size: 18), + ], + ), + ), + ), + ); + } + + Widget _buildImportantInfo() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.green.shade100), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.shade100, + shape: BoxShape.circle, + ), + child: Icon( + Icons.info_outline, + size: 20, + color: Colors.green.shade700, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Informasi Penting', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.green.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Petunjuk reset password akan dikirim ke email Anda. Silakan periksa kotak masuk atau folder spam setelah permintaan reset password.', + style: TextStyle( + fontSize: 13, + color: Colors.green.shade900, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBackToLoginLink() { + return Center( + child: TextButton.icon( + onPressed: () => Get.back(), + icon: Icon( + Icons.arrow_back_rounded, + size: 16, + color: AppColors.primary, + ), + label: Text( + 'Kembali ke Login', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/auth/views/login_view.dart b/lib/app/modules/auth/views/login_view.dart new file mode 100644 index 0000000..84f06d3 --- /dev/null +++ b/lib/app/modules/auth/views/login_view.dart @@ -0,0 +1,368 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/auth_controller.dart'; +import '../../../theme/app_colors.dart'; + +class LoginView extends GetView { + const LoginView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + // Background gradient + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [ + AppColors.primaryLight.withOpacity(0.1), + AppColors.background, + AppColors.accentLight.withOpacity(0.1), + ], + ), + ), + ), + + // Pattern overlay + Opacity( + opacity: 0.03, + child: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/pattern.png'), + repeat: ImageRepeat.repeat, + scale: 4.0, + ), + ), + ), + ), + + // Accent circles + Positioned( + top: -40, + right: -20, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + Positioned( + bottom: -50, + left: -30, + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.accent.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + + // Main content + SafeArea( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 50), + _buildHeader(), + const SizedBox(height: 40), + _buildLoginCard(), + const SizedBox(height: 24), + _buildRegisterLink(), + const SizedBox(height: 30), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHeader() { + return Center( + child: Hero( + tag: 'logo', + child: Image.asset( + 'assets/images/logo.png', + width: 220, + height: 220, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.apartment_rounded, + size: 180, + color: AppColors.primary, + ); + }, + ), + ), + ); + } + + Widget _buildLoginCard() { + return Card( + elevation: 4, + shadowColor: AppColors.shadow, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.all(28.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Welcome text + Text( + 'Selamat Datang', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Masuk untuk melanjutkan ke akun Anda', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 32), + + // Email field + _buildInputLabel('Email'), + const SizedBox(height: 8), + _buildTextField( + controller: controller.emailController, + hintText: 'Masukkan email Anda', + prefixIcon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 24), + + // Password field + _buildInputLabel('Password'), + const SizedBox(height: 8), + Obx( + () => _buildTextField( + controller: controller.passwordController, + hintText: 'Masukkan password Anda', + prefixIcon: Icons.lock_outline, + obscureText: !controller.isPasswordVisible.value, + suffixIcon: IconButton( + icon: Icon( + controller.isPasswordVisible.value + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.iconGrey, + ), + onPressed: controller.togglePasswordVisibility, + ), + ), + ), + + // Forgot password + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => controller.goToForgotPassword(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 0, + vertical: 8, + ), + ), + child: Text( + 'Lupa sandi?', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + const SizedBox(height: 32), + + // Login button + Obx( + () => SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: + controller.isLoading.value ? null : controller.login, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.buttonText, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: controller.isLoading.value ? 0 : 2, + shadowColor: AppColors.primary.withOpacity(0.4), + ), + child: + controller.isLoading.value + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Masuk', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, size: 18), + ], + ), + ), + ), + ), + + // Error message + Obx( + () => + controller.errorMessage.value.isNotEmpty + ? Container( + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + controller.errorMessage.value, + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } + + Widget _buildInputLabel(String label) { + return Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontSize: 15, + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String hintText, + required IconData prefixIcon, + TextInputType keyboardType = TextInputType.text, + bool obscureText = false, + Widget? suffixIcon, + }) { + return TextField( + controller: controller, + keyboardType: keyboardType, + obscureText: obscureText, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon(prefixIcon, color: AppColors.iconGrey, size: 22), + suffixIcon: suffixIcon, + filled: true, + fillColor: AppColors.inputBackground, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ); + } + + Widget _buildRegisterLink() { + return Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Belum punya akun?", + style: TextStyle(color: AppColors.textSecondary), + ), + TextButton( + onPressed: controller.goToSignUp, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: Text( + 'Daftar', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/auth/views/registration_success_view.dart b/lib/app/modules/auth/views/registration_success_view.dart new file mode 100644 index 0000000..123a4a9 --- /dev/null +++ b/lib/app/modules/auth/views/registration_success_view.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../theme/app_colors.dart'; + +class RegistrationSuccessView extends StatefulWidget { + const RegistrationSuccessView({Key? key}) : super(key: key); + + @override + State createState() => + _RegistrationSuccessViewState(); +} + +class _RegistrationSuccessViewState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _scaleAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + ); + + _scaleAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.elasticOut), + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.4, 1.0, curve: Curves.easeInOut), + ), + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Stack( + children: [ + // Background elements + Positioned( + top: -120, + left: -120, + child: Container( + width: 300, + height: 300, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.successLight, + ), + ), + ), + Positioned( + right: -80, + bottom: 100, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.primaryLight.withOpacity(0.2), + ), + ), + ), + + // Confetti particles + Positioned.fill(child: _buildConfettiParticles()), + + // Main content + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildSuccessAnimation(), + const SizedBox(height: 40), + _buildSuccessMessage(), + const SizedBox(height: 40), + _buildBackToLoginButton(), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildConfettiParticles() { + return Stack( + children: List.generate(20, (index) { + final left = (index * 20) % MediaQuery.of(context).size.width; + final top = (index * 30) % MediaQuery.of(context).size.height; + final size = 8.0 + (index % 5) * 2; + + final colors = [ + AppColors.success, + AppColors.primary, + AppColors.accent, + AppColors.primaryLight, + ]; + + return Positioned( + left: left.toDouble(), + top: top.toDouble(), + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final delay = index * 0.1; + final startTime = delay; + final endTime = startTime + 0.8; + + double opacity = 0.0; + if (_animationController.value >= startTime) { + opacity = + (_animationController.value - startTime) / + (endTime - startTime); + if (opacity > 1.0) opacity = 1.0; + } + + return Opacity( + opacity: opacity, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: colors[index % colors.length], + shape: + index % 2 == 0 ? BoxShape.circle : BoxShape.rectangle, + borderRadius: + index % 2 == 0 ? null : BorderRadius.circular(2), + ), + ), + ); + }, + ), + ); + }), + ); + } + + Widget _buildSuccessAnimation() { + return Center( + child: Column( + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Hero( + tag: 'success', + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.success.withOpacity(0.3), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: const Icon( + Icons.check, + size: 70, + color: Colors.white, + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildSuccessMessage() { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: Column( + children: [ + Text( + 'Pendaftaran Berhasil!', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + 'Akun Anda telah berhasil terdaftar. Silakan masuk dengan email dan password yang telah Anda daftarkan.', + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildBackToLoginButton() { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: ElevatedButton( + onPressed: () { + // Navigate back to login page + Get.offAllNamed('/login'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.buttonText, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + elevation: 0, + ), + child: const Text( + 'Masuk Sekarang', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/app/modules/auth/views/registration_view.dart b/lib/app/modules/auth/views/registration_view.dart new file mode 100644 index 0000000..458f2b1 --- /dev/null +++ b/lib/app/modules/auth/views/registration_view.dart @@ -0,0 +1,549 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/auth_controller.dart'; +import '../../../theme/app_colors.dart'; + +class RegistrationView extends GetView { + const RegistrationView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: Stack( + children: [ + // Background gradient + Positioned( + top: -100, + right: -100, + child: Container( + width: 300, + height: 300, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primaryLight.withOpacity(0.2), + AppColors.background.withOpacity(0), + ], + stops: const [0.0, 1.0], + ), + ), + ), + ), + Positioned( + bottom: -80, + left: -80, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.accent.withOpacity(0.15), + AppColors.background.withOpacity(0), + ], + stops: const [0.0, 1.0], + ), + ), + ), + ), + // Content + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildBackButton(), + const SizedBox(height: 20), + _buildHeader(), + const SizedBox(height: 24), + _buildRegistrationForm(), + const SizedBox(height: 32), + _buildRegisterButton(), + const SizedBox(height: 24), + _buildImportantInfo(), + const SizedBox(height: 24), + _buildLoginLink(), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBackButton() { + return Align( + alignment: Alignment.topLeft, + child: InkWell( + onTap: () => Get.back(), + borderRadius: BorderRadius.circular(50), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.surface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.arrow_back, + size: 20, + color: AppColors.primary, + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Hero( + tag: 'logo', + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + Icons.apartment_rounded, + size: 40, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 24), + Text( + 'Daftar Akun', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Text( + 'Lengkapi data berikut untuk mendaftar', + style: TextStyle(fontSize: 16, color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildImportantInfo() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.warningLight, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.warning.withOpacity(0.3)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.warning.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon(Icons.info_outline, size: 20, color: AppColors.warning), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Informasi Penting', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.warning, + ), + ), + const SizedBox(height: 4), + Text( + 'Pendaftaran hanya dapat dilakukan oleh warga dan mitra yang sudah terverivikasi. Silahkan hubungi petugas atau kunjungi kantor untuk informasi lebih lanjut.', + style: TextStyle( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRegistrationForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Email Input + _buildInputLabel('Email'), + const SizedBox(height: 8), + _buildEmailField(), + const SizedBox(height: 20), + + // Password Input + _buildInputLabel('Password'), + const SizedBox(height: 8), + _buildPasswordField(), + const SizedBox(height: 20), + + // NIK Input + _buildInputLabel('NIK'), + const SizedBox(height: 8), + _buildNikField(), + const SizedBox(height: 20), + + // Phone Number Input + _buildInputLabel('No. Hp'), + const SizedBox(height: 8), + _buildPhoneField(), + const SizedBox(height: 20), + + // Role Selection Dropdown + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Daftar Sebagai', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!, width: 1), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Obx( + () => DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: controller.selectedRole.value, + hint: const Text('Pilih Peran'), + items: [ + DropdownMenuItem( + value: 'WARGA', + child: const Text('Warga'), + ), + DropdownMenuItem( + value: 'PETUGAS_MITRA', + child: const Text('Mitra'), + ), + ], + onChanged: (value) { + controller.setRole(value); + }, + icon: const Icon(Icons.arrow_drop_down), + style: const TextStyle( + color: Colors.black87, + fontSize: 14, + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 20), + + // Error message + Obx( + () => + controller.errorMessage.value.isNotEmpty + ? Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: AppColors.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + controller.errorMessage.value, + style: TextStyle( + color: AppColors.error, + fontSize: 13, + ), + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ); + } + + Widget _buildInputLabel(String label) { + return Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ); + } + + Widget _buildEmailField() { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + onChanged: (value) => controller.email.value = value, + keyboardType: TextInputType.emailAddress, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Masukkan email anda', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon(Icons.email_outlined, color: AppColors.primary), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildPasswordField() { + return Obx( + () => Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + onChanged: (value) => controller.password.value = value, + obscureText: !controller.isPasswordVisible.value, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Masukkan password anda', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon(Icons.lock_outlined, color: AppColors.primary), + suffixIcon: IconButton( + icon: Icon( + controller.isPasswordVisible.value + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.iconGrey, + ), + onPressed: controller.togglePasswordVisibility, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ), + ); + } + + Widget _buildNikField() { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + onChanged: (value) => controller.nik.value = value, + keyboardType: TextInputType.number, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Masukkan NIK anda', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon( + Icons.credit_card_outlined, + color: AppColors.primary, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildPhoneField() { + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + onChanged: (value) => controller.phoneNumber.value = value, + keyboardType: TextInputType.phone, + style: TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Masukkan nomor HP anda', + hintStyle: TextStyle(color: AppColors.textLight), + prefixIcon: Icon(Icons.phone_outlined, color: AppColors.primary), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ); + } + + Widget _buildRegisterButton() { + return Obx( + () => ElevatedButton( + onPressed: controller.isLoading.value ? null : controller.registerUser, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.buttonText, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + disabledBackgroundColor: AppColors.primary.withOpacity(0.6), + ), + child: + controller.isLoading.value + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text( + 'Daftar', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ); + } + + Widget _buildLoginLink() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Sudah punya akun? ', + style: TextStyle(color: AppColors.textSecondary, fontSize: 14), + ), + GestureDetector( + onTap: () { + Get.back(); // Back to login page + }, + child: Text( + 'Masuk', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/list_pelanggan_aktif_binding.dart b/lib/app/modules/petugas_bumdes/bindings/list_pelanggan_aktif_binding.dart new file mode 100644 index 0000000..a810101 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/list_pelanggan_aktif_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/list_pelanggan_aktif_controller.dart'; + +class ListPelangganAktifBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => ListPelangganAktifController(), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/list_petugas_mitra_binding.dart b/lib/app/modules/petugas_bumdes/bindings/list_petugas_mitra_binding.dart new file mode 100644 index 0000000..d5eb221 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/list_petugas_mitra_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/list_petugas_mitra_controller.dart'; + +class ListPetugasMitraBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => ListPetugasMitraController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/list_tagihan_periode_binding.dart b/lib/app/modules/petugas_bumdes/bindings/list_tagihan_periode_binding.dart new file mode 100644 index 0000000..31ad0da --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/list_tagihan_periode_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/list_tagihan_periode_controller.dart'; + +class ListTagihanPeriodeBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => ListTagihanPeriodeController(), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart new file mode 100644 index 0000000..62f48f9 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_aset_binding.dart @@ -0,0 +1,15 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_aset_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; + +class PetugasAsetBinding extends Bindings { + @override + void dependencies() { + // Ensure dashboard controller is registered + if (!Get.isRegistered()) { + Get.put(PetugasBumdesDashboardController(), permanent: true); + } + + Get.lazyPut(() => PetugasAsetController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_bumdes_cbp_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_bumdes_cbp_binding.dart new file mode 100644 index 0000000..e128590 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_bumdes_cbp_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_bumdes_cbp_controller.dart'; + +class PetugasBumdesCbpBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => PetugasBumdesCbpController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart new file mode 100644 index 0000000..1b6f5af --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_detail_sewa_binding.dart @@ -0,0 +1,13 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_sewa_controller.dart'; + +class PetugasDetailSewaBinding extends Bindings { + @override + void dependencies() { + // Memastikan controller sudah tersedia + Get.lazyPut( + () => PetugasSewaController(), + fenix: true, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_manajemen_bumdes_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_manajemen_bumdes_binding.dart new file mode 100644 index 0000000..4f25a24 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_manajemen_bumdes_binding.dart @@ -0,0 +1,27 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_manajemen_bumdes_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../data/providers/auth_provider.dart'; + +class PetugasManajemenBumdesBinding extends Bindings { + @override + void dependencies() { + // Make sure AuthProvider is registered + if (!Get.isRegistered()) { + Get.put(AuthProvider()); + } + + // Register the dashboard controller if not already registered + if (!Get.isRegistered()) { + Get.put( + PetugasBumdesDashboardController(), + permanent: true, + ); + } + + // Register the manajemen bumdes controller + Get.lazyPut( + () => PetugasManajemenBumdesController(), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart new file mode 100644 index 0000000..90d94e3 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_paket_binding.dart @@ -0,0 +1,15 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_paket_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; + +class PetugasPaketBinding extends Bindings { + @override + void dependencies() { + // Ensure dashboard controller is registered + if (!Get.isRegistered()) { + Get.put(PetugasBumdesDashboardController(), permanent: true); + } + + Get.lazyPut(() => PetugasPaketController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart new file mode 100644 index 0000000..eff7112 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_sewa_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_sewa_controller.dart'; + +class PetugasSewaBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => PetugasSewaController()); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_tambah_aset_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_tambah_aset_binding.dart new file mode 100644 index 0000000..c65f913 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_tambah_aset_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_tambah_aset_controller.dart'; + +class PetugasTambahAsetBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => PetugasTambahAsetController(), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/bindings/petugas_tambah_paket_binding.dart b/lib/app/modules/petugas_bumdes/bindings/petugas_tambah_paket_binding.dart new file mode 100644 index 0000000..2115624 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/bindings/petugas_tambah_paket_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import '../controllers/petugas_tambah_paket_controller.dart'; + +class PetugasTambahPaketBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => PetugasTambahPaketController(), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/list_pelanggan_aktif_controller.dart b/lib/app/modules/petugas_bumdes/controllers/list_pelanggan_aktif_controller.dart new file mode 100644 index 0000000..b79bd63 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/list_pelanggan_aktif_controller.dart @@ -0,0 +1,94 @@ +import 'package:get/get.dart'; + +class ListPelangganAktifController extends GetxController { + // Reactive variables + final isLoading = true.obs; + final pelangganList = >[].obs; + final searchQuery = ''.obs; + final serviceName = ''.obs; + + @override + void onInit() { + super.onInit(); + + // Get the service name passed from previous page + if (Get.arguments != null && Get.arguments['serviceName'] != null) { + serviceName.value = Get.arguments['serviceName']; + } + + // Load the pelanggan data + loadPelangganData(); + } + + // Load sample pelanggan data (would be replaced with API call in production) + Future loadPelangganData() async { + isLoading.value = true; + + try { + // Simulate API call delay + await Future.delayed(const Duration(milliseconds: 800)); + + // For now, we only have Malih as an active subscriber + final sampleData = [ + { + 'id': '1', + 'nama': 'Malih', + 'alamat': 'Jl. Desa Sejahtera No. 15, RT 03/RW 02', + 'status': 'Aktif', + 'tanggal_mulai': '01/05/2023', + 'tanggal_berakhir': '01/05/2024', + 'pembayaran_terakhir': '01/04/2024', + 'tagihan': 'Rp 20.000', + 'telepon': '081234567890', + 'email': 'malih@example.com', + 'catatan': 'Pelanggan setia sejak 2023', + }, + ]; + + pelangganList.assignAll(sampleData); + } catch (e) { + print('Error loading pelanggan data: $e'); + } finally { + isLoading.value = false; + } + } + + // Filter the list based on search query + List> get filteredPelangganList { + if (searchQuery.value.isEmpty) { + return pelangganList; + } + + final query = searchQuery.value.toLowerCase(); + return pelangganList.where((pelanggan) { + final nama = pelanggan['nama'].toString().toLowerCase(); + final alamat = pelanggan['alamat'].toString().toLowerCase(); + final status = pelanggan['status'].toString().toLowerCase(); + + return nama.contains(query) || + alamat.contains(query) || + status.contains(query); + }).toList(); + } + + // Update search query + void updateSearchQuery(String query) { + searchQuery.value = query; + } + + // Get status color based on status value + getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'aktif': + return 0xFF4CAF50; // Green + case 'tertunda': + return 0xFFFFA000; // Amber + case 'berakhir': + return 0xFF9E9E9E; // Grey + case 'dibatalkan': + return 0xFFE53935; // Red + default: + return 0xFF2196F3; // Blue + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/list_petugas_mitra_controller.dart b/lib/app/modules/petugas_bumdes/controllers/list_petugas_mitra_controller.dart new file mode 100644 index 0000000..cfcca82 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/list_petugas_mitra_controller.dart @@ -0,0 +1,93 @@ +import 'package:get/get.dart'; + +class ListPetugasMitraController extends GetxController { + // Observable list of partners/mitra + final partners = + >[ + { + 'id': '1', + 'name': 'Malih', + 'contact': '081234567890', + 'address': 'Jl. Desa No. 123, Kecamatan Bumdes, Kabupaten Desa', + 'is_active': true, + 'role': 'Petugas Lapangan', + 'join_date': '10 Januari 2023', + }, + ].obs; + + // Loading state + final isLoading = false.obs; + + // Search functionality + final searchQuery = ''.obs; + + // Filtered list based on search + List> get filteredPartners { + if (searchQuery.value.isEmpty) { + return partners; + } + return partners + .where( + (partner) => + partner['name'].toString().toLowerCase().contains( + searchQuery.value.toLowerCase(), + ) || + partner['contact'].toString().toLowerCase().contains( + searchQuery.value.toLowerCase(), + ) || + partner['role'].toString().toLowerCase().contains( + searchQuery.value.toLowerCase(), + ), + ) + .toList(); + } + + // Add a new partner + void addPartner(Map partner) { + partners.add(partner); + Get.back(); + Get.snackbar( + 'Sukses', + 'Petugas mitra berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Edit an existing partner + void editPartner(String id, Map updatedPartner) { + final index = partners.indexWhere((partner) => partner['id'] == id); + if (index != -1) { + partners[index] = updatedPartner; + Get.back(); + Get.snackbar( + 'Sukses', + 'Data petugas mitra berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + // Delete a partner + void deletePartner(String id) { + partners.removeWhere((partner) => partner['id'] == id); + Get.snackbar( + 'Sukses', + 'Petugas mitra berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Toggle partner active status + void togglePartnerStatus(String id) { + final index = partners.indexWhere((partner) => partner['id'] == id); + if (index != -1) { + final currentStatus = partners[index]['is_active'] as bool; + partners[index]['is_active'] = !currentStatus; + Get.snackbar( + 'Status Diperbarui', + 'Status petugas mitra diubah menjadi ${!currentStatus ? 'Aktif' : 'Nonaktif'}', + snackPosition: SnackPosition.BOTTOM, + ); + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/list_tagihan_periode_controller.dart b/lib/app/modules/petugas_bumdes/controllers/list_tagihan_periode_controller.dart new file mode 100644 index 0000000..9fe9964 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/list_tagihan_periode_controller.dart @@ -0,0 +1,106 @@ +import 'package:get/get.dart'; + +class ListTagihanPeriodeController extends GetxController { + // Reactive variables + final isLoading = true.obs; + final periodeList = >[].obs; + final searchQuery = ''.obs; + + // Customer data + final pelangganData = Rx>({}); + final serviceName = ''.obs; + + @override + void onInit() { + super.onInit(); + + // Get the customer data and service name passed from previous page + if (Get.arguments != null) { + if (Get.arguments['pelanggan'] != null) { + pelangganData.value = Map.from( + Get.arguments['pelanggan'], + ); + } + + if (Get.arguments['serviceName'] != null) { + serviceName.value = Get.arguments['serviceName']; + } + } + + // Load periode data + loadPeriodeData(); + } + + // Load sample periode data (would be replaced with API call in production) + Future loadPeriodeData() async { + isLoading.value = true; + + try { + // Simulate API call delay + await Future.delayed(const Duration(milliseconds: 800)); + + // Sample data for periods + final sampleData = [ + { + 'id': '1', + 'bulan': 'Maret', + 'tahun': '2025', + 'nominal': 'Rp 20.000', + 'status_pembayaran': 'Lunas', + 'tanggal_pembayaran': '05/03/2025', + 'metode_pembayaran': 'Transfer Bank', + 'keterangan': 'Pembayaran tepat waktu', + 'is_current': true, + }, + ]; + + periodeList.assignAll(sampleData); + } catch (e) { + print('Error loading periode data: $e'); + } finally { + isLoading.value = false; + } + } + + // Filter the list based on search query + List> get filteredPeriodeList { + if (searchQuery.value.isEmpty) { + return periodeList; + } + + final query = searchQuery.value.toLowerCase(); + return periodeList.where((periode) { + final bulan = periode['bulan'].toString().toLowerCase(); + final tahun = periode['tahun'].toString().toLowerCase(); + final status = periode['status_pembayaran'].toString().toLowerCase(); + + return bulan.contains(query) || + tahun.contains(query) || + status.contains(query); + }).toList(); + } + + // Update search query + void updateSearchQuery(String query) { + searchQuery.value = query; + } + + // Get status color based on payment status + getStatusColor(String status) { + switch (status.toLowerCase()) { + case 'lunas': + return 0xFF4CAF50; // Green + case 'belum lunas': + return 0xFFFFA000; // Amber + case 'terlambat': + return 0xFFE53935; // Red + default: + return 0xFF2196F3; // Blue + } + } + + // Get formatted month-year string + String getPeriodeString(Map periode) { + return '${periode['bulan']} ${periode['tahun']}'; + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart new file mode 100644 index 0000000..2000970 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_aset_controller.dart @@ -0,0 +1,217 @@ +import 'package:get/get.dart'; + +class PetugasAsetController extends GetxController { + // Observable lists for asset data + final asetList = >[].obs; + final filteredAsetList = >[].obs; + final isLoading = true.obs; + final searchQuery = ''.obs; + + // Tab selection (0 for Sewa, 1 for Langganan) + final selectedTabIndex = 0.obs; + + // Sort options + final sortBy = 'Nama (A-Z)'.obs; + final sortOptions = + [ + 'Nama (A-Z)', + 'Nama (Z-A)', + 'Harga (Rendah-Tinggi)', + 'Harga (Tinggi-Rendah)', + ].obs; + + @override + void onInit() { + super.onInit(); + // Load sample data when the controller is initialized + loadAsetData(); + } + + // Load sample asset data (would be replaced with API call in production) + Future loadAsetData() async { + isLoading.value = true; + + try { + // Simulate API call with a delay + await Future.delayed(const Duration(seconds: 1)); + + // Sample assets data + final sampleData = [ + { + 'id': '1', + 'nama': 'Meja Rapat', + 'kategori': 'Furniture', + 'jenis': 'Sewa', // Added jenis field + 'harga': 50000, + 'satuan': 'per hari', + 'stok': 10, + 'deskripsi': + 'Meja rapat kayu jati ukuran besar untuk acara pertemuan', + 'gambar': 'https://example.com/meja.jpg', + 'tersedia': true, + }, + { + 'id': '2', + 'nama': 'Kursi Taman', + 'kategori': 'Furniture', + 'jenis': 'Sewa', // Added jenis field + 'harga': 10000, + 'satuan': 'per hari', + 'stok': 50, + 'deskripsi': 'Kursi taman plastik yang nyaman untuk acara outdoor', + 'gambar': 'https://example.com/kursi.jpg', + 'tersedia': true, + }, + { + 'id': '3', + 'nama': 'Proyektor', + 'kategori': 'Elektronik', + 'jenis': 'Sewa', // Added jenis field + 'harga': 100000, + 'satuan': 'per hari', + 'stok': 5, + 'deskripsi': 'Proyektor HD dengan brightness tinggi', + 'gambar': 'https://example.com/proyektor.jpg', + 'tersedia': true, + }, + { + 'id': '4', + 'nama': 'Sound System', + 'kategori': 'Elektronik', + 'jenis': 'Langganan', // Added jenis field + 'harga': 200000, + 'satuan': 'per bulan', + 'stok': 3, + 'deskripsi': 'Sound system lengkap dengan speaker dan mixer', + 'gambar': 'https://example.com/sound.jpg', + 'tersedia': false, + }, + { + 'id': '5', + 'nama': 'Mobil Pick Up', + 'kategori': 'Kendaraan', + 'jenis': 'Langganan', // Added jenis field + 'harga': 250000, + 'satuan': 'per bulan', + 'stok': 2, + 'deskripsi': 'Mobil pick up untuk mengangkut barang', + 'gambar': 'https://example.com/pickup.jpg', + 'tersedia': true, + }, + { + 'id': '6', + 'nama': 'Internet Fiber', + 'kategori': 'Elektronik', + 'jenis': 'Langganan', // Added jenis field + 'harga': 350000, + 'satuan': 'per bulan', + 'stok': 15, + 'deskripsi': 'Paket internet fiber 100Mbps untuk kantor', + 'gambar': 'https://example.com/internet.jpg', + 'tersedia': true, + }, + ]; + + asetList.assignAll(sampleData); + applyFilters(); // Apply default filters + } catch (e) { + print('Error loading asset data: $e'); + } finally { + isLoading.value = false; + } + } + + // Apply filters and sorting to asset list + void applyFilters() { + // Start with all assets + var filtered = List>.from(asetList); + + // Filter by tab selection (Sewa or Langganan) + String jenisFilter = selectedTabIndex.value == 0 ? 'Sewa' : 'Langganan'; + filtered = filtered.where((aset) => aset['jenis'] == jenisFilter).toList(); + + // Apply search query + if (searchQuery.value.isNotEmpty) { + final query = searchQuery.value.toLowerCase(); + filtered = + filtered.where((aset) { + final nama = aset['nama'].toString().toLowerCase(); + final deskripsi = aset['deskripsi'].toString().toLowerCase(); + final kategori = aset['kategori'].toString().toLowerCase(); + + return nama.contains(query) || + deskripsi.contains(query) || + kategori.contains(query); + }).toList(); + } + + // Apply sorting + switch (sortBy.value) { + case 'Nama (A-Z)': + filtered.sort( + (a, b) => a['nama'].toString().compareTo(b['nama'].toString()), + ); + break; + case 'Nama (Z-A)': + filtered.sort( + (a, b) => b['nama'].toString().compareTo(a['nama'].toString()), + ); + break; + case 'Harga (Rendah-Tinggi)': + filtered.sort((a, b) => a['harga'].compareTo(b['harga'])); + break; + case 'Harga (Tinggi-Rendah)': + filtered.sort((a, b) => b['harga'].compareTo(a['harga'])); + break; + } + + // Update filtered list + filteredAsetList.assignAll(filtered); + } + + // Change tab (Sewa or Langganan) + void changeTab(int index) { + selectedTabIndex.value = index; + applyFilters(); + } + + // Set search query + void setSearchQuery(String query) { + searchQuery.value = query; + applyFilters(); + } + + // Set sort option + void setSortBy(String option) { + sortBy.value = option; + applyFilters(); + } + + // Format price to Indonesian Rupiah + String formatPrice(int price) { + return 'Rp${price.toString().replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; + } + + // Add a new asset + void addAset(Map newAset) { + // In a real app, this would be an API call + // For demo, we'll just add to the list + asetList.add(newAset); + applyFilters(); + } + + // Update an existing asset + void updateAset(String id, Map updatedData) { + final index = asetList.indexWhere((aset) => aset['id'] == id); + if (index != -1) { + asetList[index] = updatedData; + applyFilters(); + } + } + + // Delete an asset + void deleteAset(String id) { + asetList.removeWhere((aset) => aset['id'] == id); + applyFilters(); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_cbp_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_cbp_controller.dart new file mode 100644 index 0000000..0be092c --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_cbp_controller.dart @@ -0,0 +1,217 @@ +import 'package:get/get.dart'; + +class PetugasBumdesCbpController extends GetxController { + // Observable variables + final isLoading = true.obs; + + // Bank account data + final bankAccounts = + >[ + { + 'id': '1', + 'bank_name': 'Bank BRI', + 'account_number': '1234-5678-9101', + 'account_holder': 'BUMDes CBP Sukamaju', + 'is_primary': true, + }, + { + 'id': '2', + 'bank_name': 'Bank BNI', + 'account_number': '9876-5432-1098', + 'account_holder': 'BUMDes CBP Sukamaju', + 'is_primary': false, + }, + ].obs; + + // Partners data + final partners = + >[ + { + 'id': '1', + 'name': 'UD Maju Jaya', + 'contact': '081234567890', + 'address': 'Jl. Raya Sukamaju No. 123', + 'is_active': true, + }, + { + 'id': '2', + 'name': 'CV Tani Mandiri', + 'contact': '087654321098', + 'address': 'Jl. Kelapa Dua No. 45', + 'is_active': true, + }, + { + 'id': '3', + 'name': 'PT Karya Sejahtera', + 'contact': '089876543210', + 'address': 'Jl. Industri Blok C No. 7', + 'is_active': false, + }, + ].obs; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + Future loadData() async { + try { + isLoading.value = true; + // Simulate API delay + await Future.delayed(const Duration(seconds: 1)); + // Data is already loaded in the initialized lists + } catch (e) { + print('Error loading data: $e'); + Get.snackbar( + 'Error', + 'Gagal memuat data. Silakan coba lagi.', + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isLoading.value = false; + } + } + + // Bank Account Methods + void setPrimaryBankAccount(String id) { + final index = bankAccounts.indexWhere((account) => account['id'] == id); + if (index != -1) { + // First, set all accounts to non-primary + for (int i = 0; i < bankAccounts.length; i++) { + final account = Map.from(bankAccounts[i]); + account['is_primary'] = false; + bankAccounts[i] = account; + } + + // Then set the selected account as primary + final account = Map.from(bankAccounts[index]); + account['is_primary'] = true; + bankAccounts[index] = account; + + Get.snackbar( + 'Rekening Utama', + 'Rekening ${account['bank_name']} telah dijadikan rekening utama', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + void addBankAccount(Map account) { + // Generate a new ID (in a real app, this would be from the backend) + account['id'] = (bankAccounts.length + 1).toString(); + + // By default, new accounts are not primary + account['is_primary'] = false; + + bankAccounts.add(account); + Get.back(); + Get.snackbar( + 'Rekening Ditambahkan', + 'Rekening bank baru telah berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void updateBankAccount(String id, Map updatedAccount) { + final index = bankAccounts.indexWhere((account) => account['id'] == id); + if (index != -1) { + // Preserve the ID and primary status + updatedAccount['id'] = id; + updatedAccount['is_primary'] = bankAccounts[index]['is_primary']; + + bankAccounts[index] = updatedAccount; + Get.back(); + Get.snackbar( + 'Rekening Diperbarui', + 'Informasi rekening bank telah berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + void deleteBankAccount(String id) { + final index = bankAccounts.indexWhere((account) => account['id'] == id); + if (index != -1) { + // Check if trying to delete the primary account + if (bankAccounts[index]['is_primary'] == true) { + Get.snackbar( + 'Tidak Dapat Menghapus', + 'Rekening utama tidak dapat dihapus. Silakan atur rekening lain sebagai utama terlebih dahulu.', + snackPosition: SnackPosition.BOTTOM, + ); + return; + } + + bankAccounts.removeAt(index); + Get.back(); + Get.snackbar( + 'Rekening Dihapus', + 'Rekening bank telah berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + // Partner Methods + void togglePartnerStatus(String id) { + final index = partners.indexWhere((partner) => partner['id'] == id); + if (index != -1) { + final partner = Map.from(partners[index]); + partner['is_active'] = !partner['is_active']; + partners[index] = partner; + + Get.snackbar( + 'Status Diperbarui', + 'Status mitra telah diubah menjadi ${partner['is_active'] ? 'Aktif' : 'Tidak Aktif'}', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + void addPartner(Map partner) { + // Generate a new ID (in a real app, this would be from the backend) + partner['id'] = (partners.length + 1).toString(); + + // By default, new partners are active + partner['is_active'] = true; + + partners.add(partner); + Get.back(); + Get.snackbar( + 'Mitra Ditambahkan', + 'Mitra baru telah berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void updatePartner(String id, Map updatedPartner) { + final index = partners.indexWhere((partner) => partner['id'] == id); + if (index != -1) { + // Preserve the ID and active status + updatedPartner['id'] = id; + updatedPartner['is_active'] = partners[index]['is_active']; + + partners[index] = updatedPartner; + Get.back(); + Get.snackbar( + 'Mitra Diperbarui', + 'Informasi mitra telah berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + void deletePartner(String id) { + final index = partners.indexWhere((partner) => partner['id'] == id); + if (index != -1) { + partners.removeAt(index); + Get.back(); + Get.snackbar( + 'Mitra Dihapus', + 'Mitra telah berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + ); + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart new file mode 100644 index 0000000..41f55c5 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart @@ -0,0 +1,147 @@ +import 'package:get/get.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../routes/app_routes.dart'; + +class PetugasBumdesDashboardController extends GetxController { + AuthProvider? _authProvider; + + // Reactive variables + final userEmail = ''.obs; + final currentTabIndex = 0.obs; + + // Revenue Statistics + final totalPendapatanBulanIni = 'Rp 8.500.000'.obs; + final totalPendapatanBulanLalu = 'Rp 7.200.000'.obs; + final persentaseKenaikan = '18%'.obs; + final isKenaikanPositif = true.obs; + + // Revenue by Category + final pendapatanSewa = 'Rp 5.200.000'.obs; + final persentaseSewa = 100.obs; + + // Revenue Trends (last 6 months) + final trendPendapatan = [4.2, 5.1, 4.8, 6.2, 7.2, 8.5].obs; // in millions + + // Status Counters for Sewa Aset + final terlaksanaCount = 5.obs; + final dijadwalkanCount = 1.obs; + final aktifCount = 1.obs; + final dibatalkanCount = 3.obs; + + // Additional Sewa Aset Status Counters + final menungguPembayaranCount = 2.obs; + final periksaPembayaranCount = 1.obs; + final diterimaCount = 3.obs; + final pembayaranDendaCount = 1.obs; + final periksaPembayaranDendaCount = 0.obs; + final selesaiCount = 4.obs; + + // Status counts for Sewa + final pengajuanSewaCount = 5.obs; + final pemasanganCountSewa = 3.obs; + final sewaAktifCount = 10.obs; + final tagihanAktifCountSewa = 7.obs; + final periksaPembayaranCountSewa = 2.obs; + + @override + void onInit() { + super.onInit(); + try { + _authProvider = Get.find(); + userEmail.value = _authProvider?.currentUser?.email ?? 'Tidak ada email'; + } catch (e) { + print('Error finding AuthProvider: $e'); + userEmail.value = 'Tidak ada email'; + } + + // In a real app, these counts would be fetched from backend + // loadStatusCounts(); + print('✅ PetugasBumdesDashboardController initialized successfully'); + } + + // Method to load status counts from backend + // Future loadStatusCounts() async { + // try { + // final response = await _asetProvider.getSewaStatusCounts(); + // if (response != null) { + // terlaksanaCount.value = response['terlaksana'] ?? 0; + // dijadwalkanCount.value = response['dijadwalkan'] ?? 0; + // aktifCount.value = response['aktif'] ?? 0; + // dibatalkanCount.value = response['dibatalkan'] ?? 0; + // menungguPembayaranCount.value = response['menunggu_pembayaran'] ?? 0; + // periksaPembayaranCount.value = response['periksa_pembayaran'] ?? 0; + // diterimaCount.value = response['diterima'] ?? 0; + // pembayaranDendaCount.value = response['pembayaran_denda'] ?? 0; + // periksaPembayaranDendaCount.value = response['periksa_pembayaran_denda'] ?? 0; + // selesaiCount.value = response['selesai'] ?? 0; + // } + // } catch (e) { + // print('Error loading status counts: $e'); + // } + // } + + void changeTab(int index) { + try { + currentTabIndex.value = index; + + // Navigate to the appropriate page based on the tab index + switch (index) { + case 0: + // Navigate to Dashboard + Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD); + break; + case 1: + // Navigate to Aset page + navigateToAset(); + break; + case 2: + // Navigate to Paket page + navigateToPaket(); + break; + case 3: + // Navigate to Sewa page + navigateToSewa(); + break; + } + } catch (e) { + print('Error changing tab: $e'); + } + } + + void navigateToAset() { + try { + Get.offAllNamed(Routes.PETUGAS_ASET); + } catch (e) { + print('Error navigating to Aset: $e'); + } + } + + void navigateToPaket() { + try { + Get.offAllNamed(Routes.PETUGAS_PAKET); + } catch (e) { + print('Error navigating to Paket: $e'); + } + } + + void navigateToSewa() { + try { + Get.offAllNamed(Routes.PETUGAS_SEWA); + } catch (e) { + print('Error navigating to Sewa: $e'); + } + } + + void logout() async { + try { + if (_authProvider != null) { + await _authProvider!.signOut(); + } + Get.offAllNamed(Routes.LOGIN); + } catch (e) { + print('Error during logout: $e'); + // Still try to navigate to login even if sign out fails + Get.offAllNamed(Routes.LOGIN); + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_manajemen_bumdes_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_manajemen_bumdes_controller.dart new file mode 100644 index 0000000..33655ab --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_manajemen_bumdes_controller.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class PetugasManajemenBumdesController extends GetxController { + // Reactive variables + final RxInt selectedTabIndex = 0.obs; + final RxBool isLoading = false.obs; + + // Tab options + final List tabOptions = ['Akun Bank', 'Mitra']; + + // Sample data for Bank Accounts + final RxList> bankAccounts = + >[ + { + 'bankName': 'Bank BRI', + 'accountName': 'BUMDes Sejahtera', + 'accountNumber': '123456789', + 'isPrimary': true, + }, + { + 'bankName': 'Bank BNI', + 'accountName': 'BUMDes Sejahtera', + 'accountNumber': '987654321', + 'isPrimary': false, + }, + ].obs; + + // Sample data for Partners + final RxList> partners = + >[ + { + 'name': 'CV Maju Jaya', + 'email': 'majujaya@example.com', + 'phone': '081234567890', + 'address': 'Jl. Maju No. 123, Kecamatan Berkah', + 'isActive': true, + }, + { + 'name': 'PT Sentosa', + 'email': 'sentosa@example.com', + 'phone': '089876543210', + 'address': 'Jl. Sentosa No. 456, Kecamatan Damai', + 'isActive': false, + }, + ].obs; + + @override + void onInit() { + super.onInit(); + loadData(); + } + + void loadData() { + isLoading.value = true; + // Simulate loading data from API + Future.delayed(const Duration(milliseconds: 500), () { + // Data already loaded with sample data + isLoading.value = false; + }); + } + + void changeTab(int index) { + selectedTabIndex.value = index; + } + + void setPrimaryBankAccount(int index) { + // Set all accounts to non-primary first + for (var i = 0; i < bankAccounts.length; i++) { + bankAccounts[i]['isPrimary'] = false; + } + + // Set the selected account as primary + bankAccounts[index]['isPrimary'] = true; + + // Force UI refresh + bankAccounts.refresh(); + + Get.snackbar( + 'Sukses', + 'Rekening utama berhasil diubah', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void togglePartnerStatus(int index) { + // Toggle the active status + partners[index]['isActive'] = !partners[index]['isActive']; + + // Force UI refresh + partners.refresh(); + + Get.snackbar( + 'Sukses', + 'Status mitra berhasil diubah', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void addBankAccount(Map account) { + // Set as primary if it's the first account + if (bankAccounts.isEmpty) { + account['isPrimary'] = true; + } else { + account['isPrimary'] = false; + } + + bankAccounts.add(account); + + Get.snackbar( + 'Sukses', + 'Rekening bank berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void updateBankAccount(int index, Map updatedAccount) { + // Preserve the primary status + updatedAccount['isPrimary'] = bankAccounts[index]['isPrimary']; + + bankAccounts[index] = updatedAccount; + bankAccounts.refresh(); + + Get.snackbar( + 'Sukses', + 'Rekening bank berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void deleteBankAccount(int index) { + // Check if the account to be deleted is primary + final isPrimary = bankAccounts[index]['isPrimary']; + + // Remove the account + bankAccounts.removeAt(index); + + // If the deleted account was primary and there are other accounts, set the first one as primary + if (isPrimary && bankAccounts.isNotEmpty) { + bankAccounts[0]['isPrimary'] = true; + } + + bankAccounts.refresh(); + + Get.snackbar( + 'Sukses', + 'Rekening bank berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void addPartner(Map partner) { + partners.add(partner); + + Get.snackbar( + 'Sukses', + 'Mitra berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void updatePartner(int index, Map updatedPartner) { + partners[index] = updatedPartner; + partners.refresh(); + + Get.snackbar( + 'Sukses', + 'Mitra berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } + + void deletePartner(int index) { + partners.removeAt(index); + partners.refresh(); + + Get.snackbar( + 'Sukses', + 'Mitra berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart new file mode 100644 index 0000000..d5a25c2 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_paket_controller.dart @@ -0,0 +1,253 @@ +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +class PetugasPaketController extends GetxController { + final isLoading = false.obs; + final searchQuery = ''.obs; + final selectedCategory = 'Semua'.obs; + final sortBy = 'Terbaru'.obs; + + // Kategori untuk filter + final categories = [ + 'Semua', + 'Pesta', + 'Rapat', + 'Olahraga', + 'Pernikahan', + 'Lainnya', + ]; + + // Opsi pengurutan + final sortOptions = [ + 'Terbaru', + 'Terlama', + 'Harga Tertinggi', + 'Harga Terendah', + 'Nama A-Z', + 'Nama Z-A', + ]; + + // Data dummy paket + final paketList = >[].obs; + final filteredPaketList = >[].obs; + + @override + void onInit() { + super.onInit(); + loadPaketData(); + } + + // Format harga ke Rupiah + String formatPrice(int price) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + return formatter.format(price); + } + + // Load data paket dummy + Future loadPaketData() async { + isLoading.value = true; + await Future.delayed(const Duration(milliseconds: 800)); // Simulasi loading + + paketList.value = [ + { + 'id': '1', + 'nama': 'Paket Pesta Ulang Tahun', + 'kategori': 'Pesta', + 'harga': 500000, + 'deskripsi': + 'Paket lengkap untuk acara ulang tahun. Termasuk 5 meja, 20 kursi, backdrop, dan sound system.', + 'tersedia': true, + 'created_at': '2023-08-10', + 'items': [ + {'nama': 'Meja Panjang', 'jumlah': 5}, + {'nama': 'Kursi Plastik', 'jumlah': 20}, + {'nama': 'Sound System', 'jumlah': 1}, + {'nama': 'Backdrop', 'jumlah': 1}, + ], + 'gambar': 'https://example.com/images/paket_ultah.jpg', + }, + { + 'id': '2', + 'nama': 'Paket Rapat Sedang', + 'kategori': 'Rapat', + 'harga': 300000, + 'deskripsi': + 'Paket untuk rapat sedang. Termasuk 1 meja rapat besar, 10 kursi, proyektor, dan screen.', + 'tersedia': true, + 'created_at': '2023-09-05', + 'items': [ + {'nama': 'Meja Rapat', 'jumlah': 1}, + {'nama': 'Kursi Kantor', 'jumlah': 10}, + {'nama': 'Proyektor', 'jumlah': 1}, + {'nama': 'Screen', 'jumlah': 1}, + ], + 'gambar': 'https://example.com/images/paket_rapat.jpg', + }, + { + 'id': '3', + 'nama': 'Paket Pesta Pernikahan', + 'kategori': 'Pernikahan', + 'harga': 1500000, + 'deskripsi': + 'Paket lengkap untuk acara pernikahan. Termasuk 20 meja, 100 kursi, sound system, dekorasi, dan tenda.', + 'tersedia': true, + 'created_at': '2023-10-12', + 'items': [ + {'nama': 'Meja Bundar', 'jumlah': 20}, + {'nama': 'Kursi Tamu', 'jumlah': 100}, + {'nama': 'Sound System Besar', 'jumlah': 1}, + {'nama': 'Tenda 10x10', 'jumlah': 2}, + {'nama': 'Set Dekorasi Pengantin', 'jumlah': 1}, + ], + 'gambar': 'https://example.com/images/paket_nikah.jpg', + }, + { + 'id': '4', + 'nama': 'Paket Olahraga Voli', + 'kategori': 'Olahraga', + 'harga': 200000, + 'deskripsi': + 'Paket perlengkapan untuk turnamen voli. Termasuk net, bola, dan tiang voli.', + 'tersedia': false, + 'created_at': '2023-07-22', + 'items': [ + {'nama': 'Net Voli', 'jumlah': 1}, + {'nama': 'Bola Voli', 'jumlah': 3}, + {'nama': 'Tiang Voli', 'jumlah': 2}, + ], + 'gambar': 'https://example.com/images/paket_voli.jpg', + }, + { + 'id': '5', + 'nama': 'Paket Pesta Anak', + 'kategori': 'Pesta', + 'harga': 350000, + 'deskripsi': + 'Paket untuk pesta ulang tahun anak-anak. Termasuk 3 meja, 15 kursi, dekorasi tema, dan sound system kecil.', + 'tersedia': true, + 'created_at': '2023-11-01', + 'items': [ + {'nama': 'Meja Anak', 'jumlah': 3}, + {'nama': 'Kursi Anak', 'jumlah': 15}, + {'nama': 'Set Dekorasi Tema', 'jumlah': 1}, + {'nama': 'Sound System Kecil', 'jumlah': 1}, + ], + 'gambar': 'https://example.com/images/paket_anak.jpg', + }, + ]; + + filterPaket(); + isLoading.value = false; + } + + // Filter paket berdasarkan search query dan kategori + void filterPaket() { + filteredPaketList.value = + paketList.where((paket) { + final matchesQuery = + paket['nama'].toString().toLowerCase().contains( + searchQuery.value.toLowerCase(), + ) || + paket['deskripsi'].toString().toLowerCase().contains( + searchQuery.value.toLowerCase(), + ); + + final matchesCategory = + selectedCategory.value == 'Semua' || + paket['kategori'] == selectedCategory.value; + + return matchesQuery && matchesCategory; + }).toList(); + + // Sort the filtered list + sortFilteredList(); + } + + // Sort the filtered list + void sortFilteredList() { + switch (sortBy.value) { + case 'Terbaru': + filteredPaketList.sort( + (a, b) => b['created_at'].compareTo(a['created_at']), + ); + break; + case 'Terlama': + filteredPaketList.sort( + (a, b) => a['created_at'].compareTo(b['created_at']), + ); + break; + case 'Harga Tertinggi': + filteredPaketList.sort((a, b) => b['harga'].compareTo(a['harga'])); + break; + case 'Harga Terendah': + filteredPaketList.sort((a, b) => a['harga'].compareTo(b['harga'])); + break; + case 'Nama A-Z': + filteredPaketList.sort((a, b) => a['nama'].compareTo(b['nama'])); + break; + case 'Nama Z-A': + filteredPaketList.sort((a, b) => b['nama'].compareTo(a['nama'])); + break; + } + } + + // Set search query dan filter paket + void setSearchQuery(String query) { + searchQuery.value = query; + filterPaket(); + } + + // Set kategori dan filter paket + void setCategory(String category) { + selectedCategory.value = category; + filterPaket(); + } + + // Set opsi pengurutan dan filter paket + void setSortBy(String option) { + sortBy.value = option; + sortFilteredList(); + } + + // Tambah paket baru + void addPaket(Map paket) { + paketList.add(paket); + filterPaket(); + Get.back(); + Get.snackbar( + 'Sukses', + 'Paket baru berhasil ditambahkan', + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Edit paket + void editPaket(String id, Map updatedPaket) { + final index = paketList.indexWhere((element) => element['id'] == id); + if (index >= 0) { + paketList[index] = updatedPaket; + filterPaket(); + Get.back(); + Get.snackbar( + 'Sukses', + 'Paket berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + ); + } + } + + // Hapus paket + void deletePaket(String id) { + paketList.removeWhere((element) => element['id'] == id); + filterPaket(); + Get.snackbar( + 'Sukses', + 'Paket berhasil dihapus', + snackPosition: SnackPosition.BOTTOM, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart new file mode 100644 index 0000000..ed22801 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_sewa_controller.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class PetugasSewaController extends GetxController { + // Reactive variables + final isLoading = true.obs; + final searchQuery = ''.obs; + final orderIdQuery = ''.obs; + final selectedStatusFilter = 'Semua'.obs; + final filteredSewaList = >[].obs; + + // Filter options + final List statusFilters = [ + 'Semua', + 'Menunggu Pembayaran', + 'Periksa Pembayaran', + 'Diterima', + 'Dikembalikan', + 'Selesai', + 'Dibatalkan', + ]; + + // Mock data for sewa list + final RxList> sewaList = >[].obs; + + @override + void onInit() { + super.onInit(); + + // Add listeners to update filtered list when any filter changes + ever(searchQuery, (_) => _updateFilteredList()); + ever(orderIdQuery, (_) => _updateFilteredList()); + ever(selectedStatusFilter, (_) => _updateFilteredList()); + ever(sewaList, (_) => _updateFilteredList()); + + // Load initial data + loadSewaData(); + } + + // Update filtered list based on current filters + void _updateFilteredList() { + filteredSewaList.value = + sewaList.where((sewa) { + // Apply search filter + final matchesSearch = sewa['nama_warga'] + .toString() + .toLowerCase() + .contains(searchQuery.value.toLowerCase()); + + // Apply order ID filter if provided + final matchesOrderId = + orderIdQuery.value.isEmpty || + sewa['order_id'].toString().toLowerCase().contains( + orderIdQuery.value.toLowerCase(), + ); + + // Apply status filter if not 'Semua' + final matchesStatus = + selectedStatusFilter.value == 'Semua' || + sewa['status'] == selectedStatusFilter.value; + + return matchesSearch && matchesOrderId && matchesStatus; + }).toList(); + } + + // Load sewa data (mock data for now) + Future loadSewaData() async { + isLoading.value = true; + + try { + // Simulate API call delay + await Future.delayed(const Duration(milliseconds: 800)); + + // Populate with mock data + sewaList.assignAll([ + { + 'id': '1', + 'order_id': 'SWA-001', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-02-05', + 'tanggal_selesai': '2025-02-10', + 'total_biaya': 45000, + 'status': 'Diterima', + 'photo_url': 'https://example.com/photo1.jpg', + }, + { + 'id': '2', + 'order_id': 'SWA-002', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-02-15', + 'tanggal_selesai': '2025-02-20', + 'total_biaya': 30000, + 'status': 'Selesai', + 'photo_url': 'https://example.com/photo2.jpg', + }, + { + 'id': '3', + 'order_id': 'SWA-003', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-02-25', + 'tanggal_selesai': '2025-03-01', + 'total_biaya': 35000, + 'status': 'Menunggu Pembayaran', + 'photo_url': 'https://example.com/photo3.jpg', + }, + { + 'id': '4', + 'order_id': 'SWA-004', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-03-05', + 'tanggal_selesai': '2025-03-08', + 'total_biaya': 20000, + 'status': 'Periksa Pembayaran', + 'photo_url': 'https://example.com/photo4.jpg', + }, + { + 'id': '5', + 'order_id': 'SWA-005', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-03-12', + 'tanggal_selesai': '2025-03-14', + 'total_biaya': 15000, + 'status': 'Dibatalkan', + 'photo_url': 'https://example.com/photo5.jpg', + }, + { + 'id': '6', + 'order_id': 'SWA-006', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-03-18', + 'tanggal_selesai': '2025-03-20', + 'total_biaya': 25000, + 'status': 'Pembayaran Denda', + 'photo_url': 'https://example.com/photo6.jpg', + }, + { + 'id': '7', + 'order_id': 'SWA-007', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-03-25', + 'tanggal_selesai': '2025-03-28', + 'total_biaya': 40000, + 'status': 'Periksa Denda', + 'photo_url': 'https://example.com/photo7.jpg', + }, + { + 'id': '8', + 'order_id': 'SWA-008', + 'nama_warga': 'Sukimin', + 'nama_aset': 'Mobil Pickup', + 'tanggal_mulai': '2025-04-02', + 'tanggal_selesai': '2025-04-05', + 'total_biaya': 10000, + 'status': 'Dikembalikan', + 'photo_url': 'https://example.com/photo8.jpg', + }, + ]); + } catch (e) { + print('Error loading sewa data: $e'); + } finally { + isLoading.value = false; + } + } + + // Update search query + void setSearchQuery(String query) { + searchQuery.value = query; + } + + // Update order ID query + void setOrderIdQuery(String query) { + orderIdQuery.value = query; + } + + // Update status filter + void setStatusFilter(String status) { + selectedStatusFilter.value = status; + applyFilters(); + } + + void resetFilters() { + selectedStatusFilter.value = 'Semua'; + searchQuery.value = ''; + filteredSewaList.value = sewaList; + } + + void applyFilters() { + filteredSewaList.value = + sewaList.where((sewa) { + bool matchesStatus = + selectedStatusFilter.value == 'Semua' || + sewa['status'] == selectedStatusFilter.value; + bool matchesSearch = + searchQuery.value.isEmpty || + sewa['nama_warga'].toLowerCase().contains( + searchQuery.value.toLowerCase(), + ); + return matchesStatus && matchesSearch; + }).toList(); + } + + // Format price to rupiah + String formatPrice(num price) { + return 'Rp ${price.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; + } + + // Get color based on status + Color getStatusColor(String status) { + switch (status) { + case 'Menunggu Pembayaran': + return Colors.orange; + case 'Periksa Pembayaran': + return Colors.amber.shade700; + case 'Diterima': + return Colors.blue; + case 'Pembayaran Denda': + return Colors.deepOrange; + case 'Periksa Denda': + return Colors.red.shade600; + case 'Dikembalikan': + return Colors.teal; + case 'Sedang Disewa': + return Colors.green; + case 'Selesai': + return Colors.purple; + case 'Dibatalkan': + return Colors.red; + default: + return Colors.grey; + } + } + + // Handle sewa approval (from "Periksa Pembayaran" to "Diterima") + void approveSewa(String id) { + final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + if (index != -1) { + final sewa = Map.from(sewaList[index]); + final currentStatus = sewa['status']; + + if (currentStatus == 'Periksa Pembayaran') { + sewa['status'] = 'Diterima'; + } else if (currentStatus == 'Periksa Denda') { + sewa['status'] = 'Selesai'; + } else if (currentStatus == 'Menunggu Pembayaran') { + sewa['status'] = 'Periksa Pembayaran'; + } + + sewaList[index] = sewa; + sewaList.refresh(); + } + } + + // Handle sewa rejection or cancellation + void rejectSewa(String id) { + final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + if (index != -1) { + final sewa = Map.from(sewaList[index]); + sewa['status'] = 'Dibatalkan'; + sewaList[index] = sewa; + sewaList.refresh(); + } + } + + // Request payment for penalty + void requestPenaltyPayment(String id) { + final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + if (index != -1) { + final sewa = Map.from(sewaList[index]); + sewa['status'] = 'Pembayaran Denda'; + sewaList[index] = sewa; + sewaList.refresh(); + } + } + + // Mark penalty payment as requiring inspection + void markPenaltyForInspection(String id) { + final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + if (index != -1) { + final sewa = Map.from(sewaList[index]); + sewa['status'] = 'Periksa Denda'; + sewaList[index] = sewa; + sewaList.refresh(); + } + } + + // Handle sewa completion + void completeSewa(String id) { + final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + if (index != -1) { + final sewa = Map.from(sewaList[index]); + sewa['status'] = 'Selesai'; + sewaList[index] = sewa; + sewaList.refresh(); + } + } + + // Mark rental as returned + void markAsReturned(String id) { + final index = sewaList.indexWhere((sewa) => sewa['id'] == id); + if (index != -1) { + final sewa = Map.from(sewaList[index]); + sewa['status'] = 'Dikembalikan'; + sewaList[index] = sewa; + sewaList.refresh(); + } + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart new file mode 100644 index 0000000..ee4a79e --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_aset_controller.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class PetugasTambahAsetController extends GetxController { + // Form controllers + final nameController = TextEditingController(); + final descriptionController = TextEditingController(); + final quantityController = TextEditingController(); + final unitOfMeasureController = TextEditingController(); + final pricePerHourController = TextEditingController(); + final maxHourController = TextEditingController(); + final pricePerDayController = TextEditingController(); + final maxDayController = TextEditingController(); + + // Dropdown and toggle values + final selectedCategory = 'Sewa'.obs; + final selectedStatus = 'Tersedia'.obs; + + // Replace single selection with multiple selections + final timeOptions = {'Per Jam': true.obs, 'Per Hari': false.obs}; + + // Category options + final categoryOptions = ['Sewa', 'Langganan']; + final statusOptions = ['Tersedia', 'Pemeliharaan']; + + // Images + final selectedImages = [].obs; + + // Form validation + final isFormValid = false.obs; + final isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + // Set default values + quantityController.text = '1'; + unitOfMeasureController.text = 'Unit'; + + // Listen to field changes for validation + nameController.addListener(validateForm); + descriptionController.addListener(validateForm); + quantityController.addListener(validateForm); + pricePerHourController.addListener(validateForm); + pricePerDayController.addListener(validateForm); + } + + @override + void onClose() { + // Dispose controllers + nameController.dispose(); + descriptionController.dispose(); + quantityController.dispose(); + unitOfMeasureController.dispose(); + pricePerHourController.dispose(); + maxHourController.dispose(); + pricePerDayController.dispose(); + maxDayController.dispose(); + super.onClose(); + } + + // Change selected category + void setCategory(String category) { + selectedCategory.value = category; + validateForm(); + } + + // Change selected status + void setStatus(String status) { + selectedStatus.value = status; + validateForm(); + } + + // Toggle time option + void toggleTimeOption(String option) { + timeOptions[option]?.value = !(timeOptions[option]?.value ?? false); + + // Ensure at least one option is selected + bool anySelected = false; + timeOptions.forEach((key, value) { + if (value.value) anySelected = true; + }); + + // If none selected, force this one to remain selected + if (!anySelected) { + timeOptions[option]?.value = true; + } + + validateForm(); + } + + // Add image to the list (in a real app, this would handle file upload) + void addImage(String imagePath) { + selectedImages.add(imagePath); + validateForm(); + } + + // Remove image from the list + void removeImage(int index) { + if (index >= 0 && index < selectedImages.length) { + selectedImages.removeAt(index); + validateForm(); + } + } + + // Validate form fields + void validateForm() { + // Basic validation + bool basicValid = + nameController.text.isNotEmpty && + descriptionController.text.isNotEmpty && + quantityController.text.isNotEmpty && + int.tryParse(quantityController.text) != null; + + // Time option validation + bool perHourValid = + !timeOptions['Per Jam']!.value || + (pricePerHourController.text.isNotEmpty && + int.tryParse(pricePerHourController.text) != null); + + bool perDayValid = + !timeOptions['Per Hari']!.value || + (pricePerDayController.text.isNotEmpty && + int.tryParse(pricePerDayController.text) != null); + + // At least one time option must be selected + bool anyTimeOptionSelected = false; + timeOptions.forEach((key, value) { + if (value.value) anyTimeOptionSelected = true; + }); + + isFormValid.value = + basicValid && perHourValid && perDayValid && anyTimeOptionSelected; + } + + // Submit form and save asset + Future saveAsset() async { + if (!isFormValid.value) return; + + isSubmitting.value = true; + + try { + // In a real app, this would make an API call to save the asset + await Future.delayed(const Duration(seconds: 1)); // Mock API call + + // Prepare asset data + final assetData = { + 'nama': nameController.text, + 'deskripsi': descriptionController.text, + 'kategori': selectedCategory.value, + 'status': selectedStatus.value, + 'kuantitas': int.parse(quantityController.text), + 'satuan_ukur': unitOfMeasureController.text, + 'opsi_waktu_sewa': + timeOptions.entries + .where((entry) => entry.value.value) + .map((entry) => entry.key) + .toList(), + 'harga_per_jam': + timeOptions['Per Jam']!.value + ? int.parse(pricePerHourController.text) + : null, + 'max_jam': + timeOptions['Per Jam']!.value && maxHourController.text.isNotEmpty + ? int.parse(maxHourController.text) + : null, + 'harga_per_hari': + timeOptions['Per Hari']!.value + ? int.parse(pricePerDayController.text) + : null, + 'max_hari': + timeOptions['Per Hari']!.value && maxDayController.text.isNotEmpty + ? int.parse(maxDayController.text) + : null, + 'gambar': selectedImages, + }; + + // Log the data (in a real app, this would be sent to an API) + print('Asset data: $assetData'); + + // Return to the asset list page + Get.back(); + + // Show success message + Get.snackbar( + 'Berhasil', + 'Aset berhasil ditambahkan', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e) { + // Show error message + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan: ${e.toString()}', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isSubmitting.value = false; + } + } + + // For demonstration purposes: add sample image + void addSampleImage() { + addImage('assets/images/sample_asset_${selectedImages.length + 1}.jpg'); + } +} diff --git a/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart new file mode 100644 index 0000000..25c79bf --- /dev/null +++ b/lib/app/modules/petugas_bumdes/controllers/petugas_tambah_paket_controller.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class PetugasTambahPaketController extends GetxController { + // Form controllers + final nameController = TextEditingController(); + final descriptionController = TextEditingController(); + final priceController = TextEditingController(); + final itemQuantityController = TextEditingController(); + + // Dropdown and toggle values + final selectedCategory = 'Bulanan'.obs; + final selectedStatus = 'Aktif'.obs; + + // Category options + final categoryOptions = ['Bulanan', 'Tahunan', 'Premium', 'Bisnis']; + final statusOptions = ['Aktif', 'Nonaktif']; + + // Images + final selectedImages = [].obs; + + // For package name and description + final packageNameController = TextEditingController(); + final packageDescriptionController = TextEditingController(); + final packagePriceController = TextEditingController(); + + // For items/assets in the package + final RxList> packageItems = + >[].obs; + + // For asset selection + final RxList> availableAssets = + >[].obs; + final Rx selectedAsset = Rx(null); + final RxBool isLoadingAssets = false.obs; + + // Form validation + final isFormValid = false.obs; + final isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + + // Listen to field changes for validation + nameController.addListener(validateForm); + descriptionController.addListener(validateForm); + priceController.addListener(validateForm); + + // Load available assets when the controller initializes + fetchAvailableAssets(); + } + + @override + void onClose() { + // Dispose controllers + nameController.dispose(); + descriptionController.dispose(); + priceController.dispose(); + itemQuantityController.dispose(); + packageNameController.dispose(); + packageDescriptionController.dispose(); + packagePriceController.dispose(); + super.onClose(); + } + + // Change selected category + void setCategory(String category) { + selectedCategory.value = category; + validateForm(); + } + + // Change selected status + void setStatus(String status) { + selectedStatus.value = status; + validateForm(); + } + + // Add image to the list (in a real app, this would handle file upload) + void addImage(String imagePath) { + selectedImages.add(imagePath); + validateForm(); + } + + // Remove image from the list + void removeImage(int index) { + if (index >= 0 && index < selectedImages.length) { + selectedImages.removeAt(index); + validateForm(); + } + } + + // Fetch available assets from the API or local data + void fetchAvailableAssets() { + isLoadingAssets.value = true; + + // This is a mock implementation - replace with actual API call + Future.delayed(const Duration(seconds: 1), () { + availableAssets.value = [ + {'id': 1, 'nama': 'Laptop Dell XPS', 'stok': 5}, + {'id': 2, 'nama': 'Proyektor Epson', 'stok': 3}, + {'id': 3, 'nama': 'Meja Kantor', 'stok': 10}, + {'id': 4, 'nama': 'Kursi Ergonomis', 'stok': 15}, + {'id': 5, 'nama': 'Printer HP LaserJet', 'stok': 2}, + {'id': 6, 'nama': 'AC Panasonic 1PK', 'stok': 8}, + ]; + isLoadingAssets.value = false; + }); + } + + // Set the selected asset + void setSelectedAsset(int? assetId) { + selectedAsset.value = assetId; + } + + // Get remaining stock for an asset (considering current selections) + int getRemainingStock(int assetId) { + // Find the asset in available assets + final asset = availableAssets.firstWhere( + (item) => item['id'] == assetId, + orElse: () => {}, + ); + + if (asset.isEmpty) return 0; + + // Get total stock + final totalStock = asset['stok'] as int; + + // Calculate how many of this asset are already in the package + int alreadySelected = 0; + for (var item in packageItems) { + if (item['asetId'] == assetId) { + alreadySelected += item['jumlah'] as int; + } + } + + // Return the remaining available stock + return totalStock - alreadySelected; + } + + // Add an asset to the package + void addAssetToPackage() { + if (selectedAsset.value == null || itemQuantityController.text.isEmpty) { + Get.snackbar( + 'Error', + 'Pilih aset dan masukkan jumlah', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Find the selected asset + final asset = availableAssets.firstWhere( + (item) => item['id'] == selectedAsset.value, + orElse: () => {}, + ); + + if (asset.isEmpty) return; + + // Convert quantity to int + final quantity = int.tryParse(itemQuantityController.text) ?? 0; + if (quantity <= 0) { + Get.snackbar( + 'Error', + 'Jumlah harus lebih dari 0', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Check if quantity is within limits + final remainingStock = getRemainingStock(selectedAsset.value!); + if (quantity > remainingStock) { + Get.snackbar( + 'Error', + 'Jumlah melebihi stok yang tersedia', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Add the item to package + packageItems.add({ + 'asetId': selectedAsset.value, + 'nama': asset['nama'], + 'jumlah': quantity, + 'stok': asset['stok'], + }); + + // Clear selection + selectedAsset.value = null; + itemQuantityController.clear(); + + Get.snackbar( + 'Sukses', + 'Item berhasil ditambahkan ke paket', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } + + // Update an existing package item + void updatePackageItem(int index) { + if (selectedAsset.value == null || itemQuantityController.text.isEmpty) { + Get.snackbar( + 'Error', + 'Pilih aset dan masukkan jumlah', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Find the selected asset + final asset = availableAssets.firstWhere( + (item) => item['id'] == selectedAsset.value, + orElse: () => {}, + ); + + if (asset.isEmpty) return; + + // Convert quantity to int + final quantity = int.tryParse(itemQuantityController.text) ?? 0; + if (quantity <= 0) { + Get.snackbar( + 'Error', + 'Jumlah harus lebih dari 0', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // If updating the same asset, check remaining stock + current quantity + final currentItem = packageItems[index]; + int availableQuantity = asset['stok'] as int; + + // If editing the same asset, we need to consider its current quantity + if (currentItem['asetId'] == selectedAsset.value) { + // For the same asset, we can reuse its current quantity + final alreadyUsed = packageItems + .where( + (item) => + item['asetId'] == selectedAsset.value && + packageItems.indexOf(item) != index, + ) + .fold(0, (sum, item) => sum + (item['jumlah'] as int)); + + availableQuantity -= alreadyUsed; + + if (quantity > availableQuantity) { + Get.snackbar( + 'Error', + 'Jumlah melebihi stok yang tersedia', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + } else { + // If changing to a different asset, check the new asset's remaining stock + final remainingStock = getRemainingStock(selectedAsset.value!); + if (quantity > remainingStock) { + Get.snackbar( + 'Error', + 'Jumlah melebihi stok yang tersedia', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + } + + // Update the item + packageItems[index] = { + 'asetId': selectedAsset.value, + 'nama': asset['nama'], + 'jumlah': quantity, + 'stok': asset['stok'], + }; + + // Clear selection + selectedAsset.value = null; + itemQuantityController.clear(); + + Get.snackbar( + 'Sukses', + 'Item berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } + + // Remove an item from the package + void removeItem(int index) { + packageItems.removeAt(index); + Get.snackbar( + 'Dihapus', + 'Item berhasil dihapus dari paket', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + } + + // Validate form fields + void validateForm() { + // Basic validation + bool basicValid = + nameController.text.isNotEmpty && + descriptionController.text.isNotEmpty && + priceController.text.isNotEmpty && + int.tryParse(priceController.text) != null; + + // Package should have at least one item + bool hasItems = packageItems.isNotEmpty; + + isFormValid.value = basicValid && hasItems; + } + + // Submit form and save package + Future savePaket() async { + if (!isFormValid.value) return; + + isSubmitting.value = true; + + try { + // In a real app, this would make an API call to save the package + await Future.delayed(const Duration(seconds: 1)); // Mock API call + + // Prepare package data + final paketData = { + 'nama': nameController.text, + 'deskripsi': descriptionController.text, + 'kategori': selectedCategory.value, + 'status': selectedStatus.value == 'Aktif', + 'harga': int.parse(priceController.text), + 'gambar': selectedImages, + 'items': packageItems, + }; + + // Log the data (in a real app, this would be sent to an API) + print('Package data: $paketData'); + + // Return to the package list page + Get.back(); + + // Show success message + Get.snackbar( + 'Berhasil', + 'Paket berhasil ditambahkan', + backgroundColor: Colors.green, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e) { + // Show error message + Get.snackbar( + 'Gagal', + 'Terjadi kesalahan: ${e.toString()}', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + isSubmitting.value = false; + } + } + + // Old sample method (will be replaced) + void addSampleItem() { + packageItems.add({'nama': 'Laptop Dell XPS', 'jumlah': 1}); + } + + // Method untuk menambahkan gambar sample + void addSampleImage() { + // Menambahkan URL gambar dummy untuk keperluan pengembangan + selectedImages.add('https://example.com/sample_image.jpg'); + validateForm(); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/list_pelanggan_aktif_view.dart b/lib/app/modules/petugas_bumdes/views/list_pelanggan_aktif_view.dart new file mode 100644 index 0000000..d920968 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/list_pelanggan_aktif_view.dart @@ -0,0 +1,581 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/list_pelanggan_aktif_controller.dart'; +import '../../../theme/app_colors.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../routes/app_routes.dart'; + +class ListPelangganAktifView extends GetView { + const ListPelangganAktifView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // Get dashboard controller for navigation + final dashboardController = Get.find(); + + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: Obx( + () => Text( + 'Pelanggan ${controller.serviceName.value}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + actions: [ + // Actions removed + ], + ), + drawer: PetugasSideNavbar(controller: dashboardController), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + _buildSearchBar(), + Expanded(child: _buildSubscribersList()), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, 2), + blurRadius: 5, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx( + () => Text( + 'Pelanggan Aktif ${controller.serviceName.value}', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + const SizedBox(height: 8), + Obx( + () => Text( + 'Daftar warga yang berlangganan ${controller.serviceName.value.toLowerCase()}', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(30), + ), + child: Row( + children: [ + const Icon( + Icons.people_alt_rounded, + size: 16, + color: Colors.green, + ), + const SizedBox(width: 6), + Obx( + () => Text( + '${controller.pelangganList.length} Pelanggan Aktif', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.green, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: TextField( + onChanged: controller.updateSearchQuery, + decoration: InputDecoration( + hintText: 'Cari pelanggan...', + prefixIcon: const Icon(Icons.search, color: Colors.grey), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + ), + ), + ); + } + + Widget _buildSubscribersList() { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.filteredPelangganList.isEmpty) { + return _buildEmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.filteredPelangganList.length, + itemBuilder: (context, index) { + final pelanggan = controller.filteredPelangganList[index]; + return _buildPelangganCard(pelanggan); + }, + ); + }); + } + + Widget _buildPelangganCard(Map pelanggan) { + final statusColor = Color(controller.getStatusColor(pelanggan['status'])); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: InkWell( + onTap: + () => Get.toNamed( + Routes.LIST_TAGIHAN_PERIODE, + arguments: { + 'pelanggan': pelanggan, + 'serviceName': controller.serviceName.value, + }, + ), + borderRadius: BorderRadius.circular(16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 25, + backgroundColor: AppColorsPetugas.babyBlueBright, + child: Text( + pelanggan['nama'].substring(0, 1).toUpperCase(), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pelanggan['nama'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + pelanggan['alamat'], + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon(Icons.check_circle, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + pelanggan['status'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildInfoItem( + icon: Icons.calendar_month, + label: 'Mulai', + value: pelanggan['tanggal_mulai'], + ), + _buildInfoItem( + icon: Icons.payment, + label: 'Tagihan', + value: pelanggan['tagihan'], + ), + _buildInfoItem( + icon: Icons.phone, + label: 'Telepon', + value: pelanggan['telepon'], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoItem({ + required IconData icon, + required String label, + required String value, + }) { + return Column( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 2), + Text( + value, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + ], + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_alt_outlined, + size: 60, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Tidak ada pelanggan aktif', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + Text( + 'Belum ada warga yang berlangganan layanan ini', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + void _showPelangganDetails(Map pelanggan) { + final statusColor = Color(controller.getStatusColor(pelanggan['status'])); + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColorsPetugas.navyBlue, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + children: [ + CircleAvatar( + radius: 25, + backgroundColor: Colors.white, + child: Text( + pelanggan['nama'].substring(0, 1).toUpperCase(), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pelanggan['nama'], + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + pelanggan['alamat'], + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.9), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + Icons.check_circle, + size: 14, + color: statusColor, + ), + const SizedBox(width: 4), + Text( + pelanggan['status'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + const Text( + 'Detail Pelanggan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildDetailRow( + icon: Icons.calendar_month, + label: 'Tanggal Mulai', + value: pelanggan['tanggal_mulai'], + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.event_busy, + label: 'Tanggal Berakhir', + value: pelanggan['tanggal_berakhir'], + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.payment, + label: 'Pembayaran Terakhir', + value: pelanggan['pembayaran_terakhir'], + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.receipt_long, + label: 'Tagihan', + value: pelanggan['tagihan'], + ), + const SizedBox(height: 20), + const Text( + 'Kontak', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildDetailRow( + icon: Icons.phone, + label: 'Telepon', + value: pelanggan['telepon'], + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.email, + label: 'Email', + value: pelanggan['email'], + ), + const SizedBox(height: 20), + if (pelanggan['catatan'] != null) ...[ + const Text( + 'Catatan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + pelanggan['catatan'], + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade800, + ), + ), + ), + const SizedBox(height: 20), + ], + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close), + label: const Text('Tutup'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: AppColorsPetugas.navyBlue), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/list_petugas_mitra_view.dart b/lib/app/modules/petugas_bumdes/views/list_petugas_mitra_view.dart new file mode 100644 index 0000000..38a982d --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/list_petugas_mitra_view.dart @@ -0,0 +1,720 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/list_petugas_mitra_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; + +class ListPetugasMitraView extends GetView { + const ListPetugasMitraView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: const Text( + 'Petugas Mitra', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.help_outline), + onPressed: () { + _showHelpDialog(context); + }, + ), + ], + ), + body: SafeArea( + child: Column( + children: [ + // Search Bar + _buildSearchBar(), + + // List of Partners + Expanded( + child: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.filteredPartners.isEmpty) { + return _buildEmptyState(); + } + + return _buildPartnersList(); + }), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + _showAddPartnerDialog(context); + }, + backgroundColor: AppColorsPetugas.blueGrotto, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.all(16), + color: Colors.white, + child: TextField( + onChanged: (value) { + controller.searchQuery.value = value; + }, + decoration: InputDecoration( + hintText: 'Cari petugas mitra...', + prefixIcon: const Icon(Icons.search, color: Colors.grey), + suffixIcon: Obx(() { + return controller.searchQuery.value.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + controller.searchQuery.value = ''; + }, + ) + : const SizedBox.shrink(); + }), + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: const EdgeInsets.symmetric(vertical: 0), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.people_outline, size: 80, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Belum ada petugas mitra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + Text( + 'Tambahkan petugas mitra dengan menekan tombol "+" di bawah', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildPartnersList() { + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: controller.filteredPartners.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final partner = controller.filteredPartners[index]; + return _buildPartnerCard(partner); + }, + ); + } + + Widget _buildPartnerCard(Map partner) { + final isActive = partner['is_active'] as bool; + + return Card( + elevation: 2, + shadowColor: Colors.black.withOpacity(0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: + isActive ? Colors.green.shade100 : Colors.red.shade100, + child: Icon( + Icons.person, + color: + isActive ? Colors.green.shade700 : Colors.red.shade700, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + partner['name'], + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + partner['role'], + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: isActive ? Colors.green.shade50 : Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isActive ? 'Aktif' : 'Nonaktif', + style: TextStyle( + color: + isActive + ? Colors.green.shade700 + : Colors.red.shade700, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + _handleMenuAction(value, partner); + }, + itemBuilder: + (BuildContext context) => [ + PopupMenuItem( + value: 'toggle_status', + child: Row( + children: [ + Icon( + isActive ? Icons.toggle_off : Icons.toggle_on, + color: + isActive + ? Colors.red.shade700 + : Colors.green.shade700, + size: 18, + ), + const SizedBox(width: 8), + Text(isActive ? 'Nonaktifkan' : 'Aktifkan'), + ], + ), + ), + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.blue, size: 18), + SizedBox(width: 8), + Text('Edit'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red, size: 18), + SizedBox(width: 8), + Text('Hapus'), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 8), + _buildInfoRow(Icons.phone, 'Kontak', partner['contact']), + const SizedBox(height: 12), + _buildInfoRow(Icons.location_on, 'Alamat', partner['address']), + const SizedBox(height: 12), + _buildInfoRow( + Icons.calendar_today, + 'Tanggal Bergabung', + partner['join_date'], + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 8), + Text( + '$label:', + style: TextStyle(fontSize: 13, color: Colors.grey.shade700), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + value, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + ), + ], + ); + } + + void _handleMenuAction(String action, Map partner) { + switch (action) { + case 'toggle_status': + controller.togglePartnerStatus(partner['id']); + break; + case 'edit': + _showEditPartnerDialog(Get.context!, partner); + break; + case 'delete': + _showDeleteConfirmationDialog(Get.context!, partner); + break; + } + } + + void _showHelpDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Bantuan Petugas Mitra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 16), + _buildHelpItem( + Icons.add_circle_outline, + 'Tambah Mitra', + 'Tekan tombol + untuk menambah petugas mitra baru', + ), + const SizedBox(height: 12), + _buildHelpItem( + Icons.toggle_on, + 'Aktif/Nonaktif', + 'Ubah status aktif petugas mitra melalui menu opsi', + ), + const SizedBox(height: 12), + _buildHelpItem( + Icons.edit, + 'Edit Data', + 'Ubah informasi petugas mitra melalui menu opsi', + ), + const SizedBox(height: 12), + _buildHelpItem( + Icons.delete, + 'Hapus', + 'Hapus petugas mitra melalui menu opsi', + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Mengerti'), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildHelpItem(IconData icon, String title, String description) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: AppColorsPetugas.blueGrotto, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ), + ], + ); + } + + void _showAddPartnerDialog(BuildContext context) { + final nameController = TextEditingController(); + final contactController = TextEditingController(); + final addressController = TextEditingController(); + final roleController = TextEditingController(); + + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tambah Petugas Mitra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 20), + _buildTextField(nameController, 'Nama Lengkap', Icons.person), + const SizedBox(height: 12), + _buildTextField( + contactController, + 'Nomor Kontak', + Icons.phone, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + _buildTextField( + addressController, + 'Alamat', + Icons.location_on, + maxLines: 2, + ), + const SizedBox(height: 12), + _buildTextField(roleController, 'Jabatan', Icons.work), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColorsPetugas.navyBlue), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Batal', + style: TextStyle(color: AppColorsPetugas.navyBlue), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + if (nameController.text.isEmpty || + contactController.text.isEmpty || + addressController.text.isEmpty || + roleController.text.isEmpty) { + Get.snackbar( + 'Error', + 'Harap isi semua data', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + final newPartner = { + 'id': + DateTime.now().millisecondsSinceEpoch + .toString(), + 'name': nameController.text, + 'contact': contactController.text, + 'address': addressController.text, + 'role': roleController.text, + 'is_active': true, + 'join_date': + '${DateTime.now().day} ${_getMonthName(DateTime.now().month)} ${DateTime.now().year}', + }; + + controller.addPartner(newPartner); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Simpan'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _showEditPartnerDialog( + BuildContext context, + Map partner, + ) { + final nameController = TextEditingController(text: partner['name']); + final contactController = TextEditingController(text: partner['contact']); + final addressController = TextEditingController(text: partner['address']); + final roleController = TextEditingController(text: partner['role']); + + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Petugas Mitra', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 20), + _buildTextField(nameController, 'Nama Lengkap', Icons.person), + const SizedBox(height: 12), + _buildTextField( + contactController, + 'Nomor Kontak', + Icons.phone, + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + _buildTextField( + addressController, + 'Alamat', + Icons.location_on, + maxLines: 2, + ), + const SizedBox(height: 12), + _buildTextField(roleController, 'Jabatan', Icons.work), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColorsPetugas.navyBlue), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Batal', + style: TextStyle(color: AppColorsPetugas.navyBlue), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + if (nameController.text.isEmpty || + contactController.text.isEmpty || + addressController.text.isEmpty || + roleController.text.isEmpty) { + Get.snackbar( + 'Error', + 'Harap isi semua data', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + final updatedPartner = { + 'id': partner['id'], + 'name': nameController.text, + 'contact': contactController.text, + 'address': addressController.text, + 'role': roleController.text, + 'is_active': partner['is_active'], + 'join_date': partner['join_date'], + }; + + controller.editPartner( + partner['id'], + updatedPartner, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Simpan'), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + void _showDeleteConfirmationDialog( + BuildContext context, + Map partner, + ) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Konfirmasi Penghapusan'), + content: Text( + 'Apakah Anda yakin ingin menghapus petugas mitra "${partner['name']}"?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Batal'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + controller.deletePartner(partner['id']); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Hapus'), + ), + ], + ); + }, + ); + } + + Widget _buildTextField( + TextEditingController controller, + String label, + IconData icon, { + TextInputType? keyboardType, + int maxLines = 1, + }) { + return TextField( + controller: controller, + keyboardType: keyboardType, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ); + } + + String _getMonthName(int month) { + const months = [ + '', + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + return months[month]; + } +} diff --git a/lib/app/modules/petugas_bumdes/views/list_tagihan_periode_view.dart b/lib/app/modules/petugas_bumdes/views/list_tagihan_periode_view.dart new file mode 100644 index 0000000..7e06a49 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/list_tagihan_periode_view.dart @@ -0,0 +1,691 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/list_tagihan_periode_controller.dart'; +import '../../../theme/app_colors.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../routes/app_routes.dart'; + +class ListTagihanPeriodeView extends GetView { + const ListTagihanPeriodeView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // Get dashboard controller for navigation + final dashboardController = Get.find(); + + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: Text( + 'Riwayat Tagihan', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + ), + drawer: PetugasSideNavbar(controller: dashboardController), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_buildHeader(), Expanded(child: _buildPeriodeList())], + ), + ); + } + + Widget _buildHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, 2), + blurRadius: 5, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + final pelanggan = controller.pelangganData.value; + final nama = pelanggan['nama'] ?? 'Pelanggan'; + + return Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: AppColorsPetugas.babyBlueBright, + child: Text( + nama.substring(0, 1).toUpperCase(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + nama, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Obx( + () => Text( + 'Pelanggan ${controller.serviceName.value}', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + const Icon( + Icons.check_circle, + size: 14, + color: Colors.green, + ), + const SizedBox(width: 4), + Text( + 'Aktif', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.green, + ), + ), + ], + ), + ), + ], + ); + }), + const SizedBox(height: 16), + const Text( + 'Riwayat Tagihan Bulanan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + Text( + 'Daftar periode tagihan dan status pembayaran', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildPeriodeList() { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.filteredPeriodeList.isEmpty) { + return _buildEmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.filteredPeriodeList.length, + itemBuilder: (context, index) { + final periode = controller.filteredPeriodeList[index]; + return _buildPeriodeCard(periode); + }, + ); + }); + } + + Widget _buildPeriodeCard(Map periode) { + final statusColor = Color( + controller.getStatusColor(periode['status_pembayaran']), + ); + final isCurrent = periode['is_current'] ?? false; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + border: + isCurrent + ? Border.all(color: AppColorsPetugas.blueGrotto, width: 2) + : null, + ), + child: InkWell( + onTap: () { + Get.snackbar( + 'Informasi', + 'Detail tagihan untuk periode ini tidak tersedia', + backgroundColor: Colors.orange.withOpacity(0.1), + colorText: Colors.orange.shade800, + duration: const Duration(seconds: 3), + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(8), + ); + }, + borderRadius: BorderRadius.circular(16), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + isCurrent + ? AppColorsPetugas.babyBlueBright.withOpacity(0.3) + : Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + periode['bulan'].substring(0, 3), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + Text( + periode['tahun'], + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Periode ${controller.getPeriodeString(periode)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.calendar_today, + size: 14, + color: Colors.grey.shade600, + ), + const SizedBox(width: 4), + Text( + 'Jatuh tempo: 20 ${periode['bulan']} ${periode['tahun']}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + periode['status_pembayaran'].toLowerCase() == 'lunas' + ? Icons.check_circle + : Icons.pending, + size: 14, + color: statusColor, + ), + const SizedBox(width: 4), + Text( + periode['status_pembayaran'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Nominal', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + periode['nominal'], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + if (periode['status_pembayaran'].toLowerCase() == 'lunas') + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Tanggal Bayar', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + periode['tanggal_pembayaran'], + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ) + else + TextButton.icon( + onPressed: () {}, + icon: Icon( + Icons.payment, + size: 16, + color: AppColorsPetugas.blueGrotto, + ), + label: Text( + 'Bayar Sekarang', + style: TextStyle(color: AppColorsPetugas.blueGrotto), + ), + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: AppColorsPetugas.blueGrotto), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + ), + ), + ], + ), + ), + if (isCurrent) + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright.withOpacity(0.3), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Center( + child: Text( + 'Periode Berjalan', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.receipt_long, size: 60, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + 'Tidak ada riwayat tagihan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 8), + Text( + 'Pelanggan belum memiliki riwayat tagihan', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + void _showPeriodeDetails(Map periode) { + final statusColor = Color( + controller.getStatusColor(periode['status_pembayaran']), + ); + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColorsPetugas.navyBlue, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + periode['bulan'].substring(0, 3), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + Text( + periode['tahun'], + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Detail Tagihan', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Periode ${controller.getPeriodeString(periode)}', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.9), + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Icon( + periode['status_pembayaran'].toLowerCase() == + 'lunas' + ? Icons.check_circle + : Icons.pending, + size: 14, + color: statusColor, + ), + const SizedBox(width: 4), + Text( + periode['status_pembayaran'], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + const Text( + 'Informasi Tagihan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildDetailRow( + icon: Icons.person, + label: 'Pelanggan', + value: + controller.pelangganData.value['nama'] ?? 'Pelanggan', + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.calendar_today, + label: 'Periode', + value: controller.getPeriodeString(periode), + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.attach_money, + label: 'Nominal', + value: periode['nominal'], + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.event, + label: 'Jatuh Tempo', + value: '20 ${periode['bulan']} ${periode['tahun']}', + ), + if (periode['status_pembayaran'].toLowerCase() == + 'lunas') ...[ + const SizedBox(height: 20), + const Text( + 'Informasi Pembayaran', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildDetailRow( + icon: Icons.date_range, + label: 'Tanggal Pembayaran', + value: periode['tanggal_pembayaran'], + ), + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.payment, + label: 'Metode Pembayaran', + value: periode['metode_pembayaran'], + ), + if (periode['keterangan'] != null) ...[ + const SizedBox(height: 12), + _buildDetailRow( + icon: Icons.info_outline, + label: 'Keterangan', + value: periode['keterangan'], + ), + ], + ], + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.close), + label: const Text('Tutup'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: AppColorsPetugas.navyBlue), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart new file mode 100644 index 0000000..c6a0c35 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_aset_view.dart @@ -0,0 +1,1291 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_aset_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../routes/app_routes.dart'; + +class PetugasAsetView extends StatefulWidget { + const PetugasAsetView({Key? key}) : super(key: key); + + @override + State createState() => _PetugasAsetViewState(); +} + +class _PetugasAsetViewState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late PetugasAsetController controller; + + @override + void initState() { + super.initState(); + controller = Get.find(); + _tabController = TabController(length: 2, vsync: this); + + // Listen to tab changes and update controller + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + controller.changeTab(_tabController.index); + } + }); + + // Listen to controller tab changes and update TabController + ever(controller.selectedTabIndex, (index) { + if (_tabController.index != index) { + _tabController.animateTo(index); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Get dashboard controller for navigation + final dashboardController = Get.find(); + + return WillPopScope( + onWillPop: () async { + // Saat back button ditekan, kembali ke dashboard + dashboardController.changeTab(0); + return false; + }, + child: Scaffold( + appBar: AppBar( + title: const Text( + 'Manajemen Aset', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.sort, size: 22), + onPressed: () => _showSortingBottomSheet(context), + tooltip: 'Urutkan', + ), + const SizedBox(width: 8), + ], + ), + drawer: PetugasSideNavbar(controller: dashboardController), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + backgroundColor: AppColorsPetugas.babyBlueBright, + body: Column( + children: [ + _buildSearchBar(), + _buildTabBar(), + Expanded(child: _buildAssetList()), + ], + ), + bottomNavigationBar: Obx( + () => PetugasBumdesBottomNavbar( + selectedIndex: dashboardController.currentTabIndex.value, + onItemTapped: (index) => dashboardController.changeTab(index), + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_ASET), + backgroundColor: AppColorsPetugas.babyBlueBright, + icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto), + label: Text( + "Tambah Aset", + style: TextStyle( + color: AppColorsPetugas.blueGrotto, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.shadowColor, + blurRadius: 10, + offset: const Offset(0, 1), + ), + ], + ), + child: TextField( + onChanged: controller.setSearchQuery, + decoration: InputDecoration( + hintText: 'Cari aset...', + hintStyle: TextStyle(color: Colors.grey.shade400), + prefixIcon: Icon( + Icons.search, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + isDense: true, + ), + ), + ); + } + + Widget _buildTabBar() { + return Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + controller: _tabController, + labelColor: Colors.white, + unselectedLabelColor: AppColorsPetugas.textSecondary, + indicatorSize: TabBarIndicatorSize.tab, + indicator: BoxDecoration( + color: AppColorsPetugas.blueGrotto, + borderRadius: BorderRadius.circular(12), + ), + dividerColor: Colors.transparent, + tabs: const [ + Tab( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.shopping_cart, size: 18), + SizedBox(width: 8), + Text('Sewa', style: TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.subscriptions, size: 18), + SizedBox(width: 8), + Text( + 'Langganan', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildAssetList() { + return Obx(() { + if (controller.isLoading.value) { + return Center( + child: CircularProgressIndicator( + color: AppColorsPetugas.blueGrotto, + strokeWidth: 3, + ), + ); + } + + if (controller.filteredAsetList.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 80, + color: AppColorsPetugas.babyBlue, + ), + const SizedBox(height: 24), + Text( + 'Tidak ada aset ${controller.selectedTabIndex.value == 0 ? "sewa" : "langganan"} ditemukan', + style: TextStyle( + fontSize: 16, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _showAddEditAssetDialog(Get.context!), + icon: const Icon(Icons.add), + label: Text( + 'Tambah Aset ${controller.selectedTabIndex.value == 0 ? "Sewa" : "Langganan"}', + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.loadAsetData, + color: AppColorsPetugas.blueGrotto, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.filteredAsetList.length, + itemBuilder: (context, index) { + final aset = controller.filteredAsetList[index]; + return _buildAssetCard(context, aset); + }, + ), + ); + }); + } + + Widget _buildAssetCard(BuildContext context, Map aset) { + final isAvailable = aset['tersedia'] == true; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.shadowColor, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showAssetDetails(context, aset), + child: Row( + children: [ + // Asset image + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + child: Center( + child: Icon( + _getAssetIcon(aset['kategori']), + color: AppColorsPetugas.navyBlue, + size: 32, + ), + ), + ), + + // Asset info + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + aset['nama'], + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: AppColorsPetugas.navyBlue, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + '${controller.formatPrice(aset['harga'])} ${aset['satuan']}', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + + // Status badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: + isAvailable + ? AppColorsPetugas.successLight + : AppColorsPetugas.errorLight, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + width: 1, + ), + ), + child: Text( + isAvailable ? 'Tersedia' : 'Kosong', + style: TextStyle( + fontSize: 10, + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Action icons + const SizedBox(width: 12), + Row( + children: [ + // Edit icon + GestureDetector( + onTap: + () => _showAddEditAssetDialog( + context, + aset: aset, + ), + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: AppColorsPetugas.blueGrotto + .withOpacity(0.5), + ), + ), + child: Icon( + Icons.edit_outlined, + color: AppColorsPetugas.blueGrotto, + size: 16, + ), + ), + ), + + const SizedBox(width: 8), + + // Delete icon + GestureDetector( + onTap: + () => _showDeleteConfirmation(context, aset), + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: AppColorsPetugas.errorLight, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: AppColorsPetugas.error.withOpacity( + 0.5, + ), + ), + ), + child: Icon( + Icons.delete_outline, + color: AppColorsPetugas.error, + size: 16, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + IconData _getAssetIcon(String category) { + switch (category.toLowerCase()) { + case 'elektronik': + return Icons.devices; + case 'furniture': + return Icons.chair; + case 'kendaraan': + return Icons.directions_car; + default: + return Icons.inventory_2; + } + } + + void _showSortingBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.shadowColor, + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Urutkan Aset', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + IconButton( + icon: Icon( + Icons.close, + color: AppColorsPetugas.textSecondary, + ), + onPressed: () => Navigator.pop(context), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + Divider(height: 24, color: AppColorsPetugas.divider), + // Options + ...controller.sortOptions.map((option) { + return Obx(() { + final isSelected = option == controller.sortBy.value; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + controller.setSortBy(option); + Navigator.pop(context); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + decoration: BoxDecoration( + color: + isSelected + ? AppColorsPetugas.babyBlueBright + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + _getSortOptionIcon(option), + color: + isSelected + ? AppColorsPetugas.blueGrotto + : AppColorsPetugas.textSecondary, + size: 20, + ), + const SizedBox(width: 16), + Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: + isSelected + ? FontWeight.bold + : FontWeight.normal, + color: + isSelected + ? AppColorsPetugas.navyBlue + : AppColorsPetugas.textPrimary, + ), + ), + const Spacer(), + if (isSelected) + Icon( + Icons.check_circle, + color: AppColorsPetugas.blueGrotto, + size: 20, + ), + ], + ), + ), + ), + ); + }); + }).toList(), + ], + ), + ); + }, + ); + } + + IconData _getSortOptionIcon(String option) { + switch (option) { + case 'Nama (A-Z)': + return Icons.sort_by_alpha; + case 'Nama (Z-A)': + return Icons.sort_by_alpha; + case 'Harga (Rendah-Tinggi)': + return Icons.arrow_upward; + case 'Harga (Tinggi-Rendah)': + return Icons.arrow_downward; + default: + return Icons.sort; + } + } + + void _showAssetDetails(BuildContext context, Map aset) { + final isAvailable = aset['tersedia'] == true; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with gradient + Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColorsPetugas.blueGrotto, + AppColorsPetugas.navyBlue, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Close button and availability badge + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: + isAvailable + ? AppColorsPetugas.successLight + : AppColorsPetugas.errorLight, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isAvailable ? Icons.check_circle : Icons.cancel, + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + size: 16, + ), + const SizedBox(width: 4), + Text( + isAvailable ? 'Tersedia' : 'Tidak Tersedia', + style: TextStyle( + fontSize: 12, + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Category badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + aset['kategori'], + style: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + + const SizedBox(height: 12), + + // Asset name + Text( + aset['nama'], + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + + const SizedBox(height: 8), + + // Price + Row( + children: [ + const Icon( + Icons.monetization_on, + color: Colors.white, + size: 18, + ), + const SizedBox(width: 8), + Text( + '${controller.formatPrice(aset['harga'])} ${aset['satuan']}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ], + ), + ), + + // Asset details + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quick info cards + Row( + children: [ + _buildInfoCard( + Icons.inventory_2, + 'Stok', + '${aset['stok']} unit', + flex: 1, + ), + const SizedBox(width: 16), + _buildInfoCard( + Icons.category, + 'Jenis', + aset['jenis'], + flex: 1, + ), + ], + ), + + const SizedBox(height: 24), + + // Description section + Text( + 'Deskripsi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 8), + Text( + aset['deskripsi'], + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textPrimary, + height: 1.5, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + + // Action buttons + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.shadowColor, + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.pop(context); + _showAddEditAssetDialog(context, aset: aset); + }, + icon: const Icon(Icons.edit), + label: const Text('Edit'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColorsPetugas.blueGrotto, + side: BorderSide(color: AppColorsPetugas.blueGrotto), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + _showDeleteConfirmation(context, aset); + }, + icon: const Icon(Icons.delete), + label: const Text('Hapus'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.error, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildInfoCard( + IconData icon, + String label, + String value, { + int flex = 1, + }) { + return Expanded( + flex: flex, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColorsPetugas.babyBlue), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: AppColorsPetugas.blueGrotto), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + ), + ); + } + + Widget _buildDetailItem(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), + ); + } + + void _showAddEditAssetDialog( + BuildContext context, { + Map? aset, + }) { + final isEditing = aset != null; + final jenisOptions = ['Sewa', 'Langganan']; + final typeOptions = ['Elektronik', 'Furniture', 'Kendaraan', 'Lainnya']; + + // In a real app, this would have proper form handling with controllers + showDialog( + context: context, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlue, + shape: BoxShape.circle, + ), + child: Icon( + isEditing ? Icons.edit : Icons.add, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isEditing ? 'Edit Aset' : 'Tambah Aset Baru', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 4), + Text( + 'Silakan lengkapi form di bawah ini', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Mock form - In a real app this would have actual form fields + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColorsPetugas.babyBlue), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Form pengelolaan aset akan ditampilkan di sini dengan field untuk:', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 16), + _buildMockFormField('Nama Aset', 'Contoh: Meja Rapat'), + _buildMockFormField('Kategori', 'Pilih kategori aset'), + _buildMockFormField( + 'Harga', + 'Masukkan harga per unit/periode', + ), + _buildMockFormField( + 'Satuan', + 'Contoh: per hari, per bulan', + ), + _buildMockFormField('Stok', 'Jumlah unit tersedia'), + _buildMockFormField( + 'Deskripsi', + 'Keterangan lengkap aset', + ), + _buildMockToggle( + 'Status Ketersediaan', + isEditing && aset?['tersedia'] == true, + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: TextStyle( + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + // In a real app, we would save the form data + Get.snackbar( + isEditing ? 'Aset Diperbarui' : 'Aset Ditambahkan', + isEditing + ? 'Aset berhasil diperbarui' + : 'Aset baru berhasil ditambahkan', + backgroundColor: AppColorsPetugas.success, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 10, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(isEditing ? 'Simpan' : 'Tambah'), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildMockFormField(String label, String hint) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColorsPetugas.babyBlue), + ), + child: Row( + children: [ + Expanded( + child: Text( + hint, + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textLight, + ), + ), + ), + Icon( + Icons.keyboard_arrow_down, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMockToggle(String label, bool value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + Switch( + value: value, + onChanged: (_) {}, + activeColor: AppColorsPetugas.blueGrotto, + ), + ], + ), + ); + } + + void _showDeleteConfirmation( + BuildContext context, + Map aset, + ) { + showDialog( + context: context, + builder: (context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Warning icon + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.errorLight, + shape: BoxShape.circle, + ), + child: Icon( + Icons.delete_forever, + color: AppColorsPetugas.error, + size: 32, + ), + ), + + const SizedBox(height: 24), + + // Title and message + Text( + 'Konfirmasi Hapus', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 12), + + Text( + 'Apakah Anda yakin ingin menghapus aset "${aset['nama']}"? Tindakan ini tidak dapat dibatalkan.', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textPrimary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 24), + + // Action buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + foregroundColor: AppColorsPetugas.textPrimary, + side: BorderSide(color: AppColorsPetugas.divider), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Batal'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + controller.deleteAset(aset['id']); + Get.snackbar( + 'Aset Dihapus', + 'Aset berhasil dihapus dari sistem', + backgroundColor: AppColorsPetugas.error, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + margin: const EdgeInsets.all(16), + borderRadius: 10, + icon: const Icon( + Icons.check_circle, + color: Colors.white, + ), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('Hapus'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart new file mode 100644 index 0000000..81264c2 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart @@ -0,0 +1,518 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_bumdes_cbp_controller.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../../../routes/app_routes.dart'; + +class PetugasBumdesCbpView extends GetView { + const PetugasBumdesCbpView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: const Text( + 'BUMDes CBP', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + ), + drawer: _buildDrawer(), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Text + const Text( + 'Pengelolaan BUMDes CBP', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 8), + Text( + 'Kelola informasi akun bank dan petugas mitra BUMDes', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + ), + const SizedBox(height: 24), + + // Main Content + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + children: [ + // Bank Account Card + _buildInfoCard( + title: 'Rekening Bank', + icon: Icons.account_balance_outlined, + primaryInfo: + '${controller.bankAccounts.length} Rekening Terdaftar', + secondaryInfo: + controller.bankAccounts.isNotEmpty + ? 'Rekening Utama: ${controller.bankAccounts.firstWhere((acc) => acc['is_primary'] == true, orElse: () => {'bank_name': 'Tidak ada'})['bank_name']}' + : 'Belum ada rekening utama', + gradient: const LinearGradient( + colors: [Color(0xFF0072B5), Color(0xFF0088CC)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + onTap: _showBankAccountsPage, + ), + + const SizedBox(height: 16), + + // Partners Card + _buildInfoCard( + title: 'Petugas Mitra', + icon: Icons.people_outline_rounded, + primaryInfo: '${controller.partners.length} Mitra', + secondaryInfo: + '${controller.partners.where((p) => p['is_active'] == true).length} Mitra Aktif', + gradient: const LinearGradient( + colors: [Color(0xFF00B4D8), Color(0xFF48CAE4)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + onTap: _showPartnersPage, + ), + ], + ), + ), + ), + ], + ), + ), + ), + bottomNavigationBar: _buildBottomNavigationBar(), + ); + } + + Widget _buildInfoCard({ + required String title, + required IconData icon, + required String primaryInfo, + required String secondaryInfo, + required Gradient gradient, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: gradient, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: gradient.colors.first.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: Colors.white, size: 30), + const SizedBox(width: 12), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + primaryInfo, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + secondaryInfo, + style: TextStyle( + color: Colors.white.withOpacity(0.85), + fontSize: 14, + ), + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.bottomRight, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Lihat Detail', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(width: 4), + Icon(Icons.arrow_forward, color: Colors.white, size: 12), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildBottomNavigationBar() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(0), + topRight: Radius.circular(0), + ), + child: BottomNavigationBar( + currentIndex: 5, // BUMDes tab + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + selectedItemColor: AppColorsPetugas.blueGrotto, + unselectedItemColor: Colors.grey, + selectedLabelStyle: const TextStyle(fontSize: 12), + unselectedLabelStyle: const TextStyle(fontSize: 12), + onTap: (index) { + // Use the dashboard controller to handle tab navigation + // This is typically provided by the parent Dashboard + final dashboardController = + Get.find(); + dashboardController.changeTab(index); + }, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.dashboard_outlined), + activeIcon: Icon(Icons.dashboard), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.inventory_2_outlined), + activeIcon: Icon(Icons.inventory_2), + label: 'Aset', + ), + BottomNavigationBarItem( + icon: Icon(Icons.archive_outlined), + activeIcon: Icon(Icons.archive), + label: 'Paket', + ), + BottomNavigationBarItem( + icon: Icon(Icons.assignment_outlined), + activeIcon: Icon(Icons.assignment), + label: 'Sewa', + ), + BottomNavigationBarItem( + icon: Icon(Icons.subscriptions_outlined), + activeIcon: Icon(Icons.subscriptions), + label: 'Langganan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.business_outlined), + activeIcon: Icon(Icons.business), + label: 'BUMDes', + ), + ], + ), + ), + ); + } + + Widget _buildDrawer() { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: BoxDecoration(color: AppColorsPetugas.navyBlue), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const CircleAvatar( + backgroundColor: Colors.white, + radius: 30, + child: Icon(Icons.person, size: 40, color: Colors.blueGrey), + ), + const SizedBox(height: 10), + const Text( + 'Admin BUMDes', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'admin@bumdes.desa.id', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 12, + ), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.dashboard_outlined), + title: const Text('Dashboard'), + onTap: () { + Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD); + }, + ), + ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: const Text('Kelola Aset'), + onTap: () { + Get.offAllNamed(Routes.PETUGAS_ASET); + }, + ), + ListTile( + leading: const Icon(Icons.feed_outlined), + title: const Text('Kelola Paket'), + onTap: () { + Get.offAllNamed(Routes.PETUGAS_PAKET); + }, + ), + ListTile( + leading: const Icon(Icons.assignment_outlined), + title: const Text('Kelola Permintaan Sewa'), + onTap: () { + Get.offAllNamed(Routes.PETUGAS_SEWA); + }, + ), + ListTile( + leading: const Icon(Icons.subscriptions_outlined), + title: const Text('Kelola Langganan'), + onTap: () { + Get.offAllNamed(Routes.PETUGAS_LANGGANAN); + }, + ), + ListTile( + leading: const Icon(Icons.business_outlined), + title: const Text('BUMDes CBP'), + tileColor: Colors.blue.shade50, + onTap: () { + Get.back(); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: const Text('Logout'), + onTap: () { + // Implement logout + Get.offAllNamed(Routes.LOGIN); + }, + ), + ], + ), + ); + } + + // Method to handle navigation to bank accounts management + void _showBankAccountsPage() { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + padding: const EdgeInsets.all(20), + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.account_balance, + color: AppColorsPetugas.blueGrotto, + size: 24, + ), + const SizedBox(width: 10), + const Text( + 'Rekening Bank', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 16), + Obx( + () => + controller.bankAccounts.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: Text( + 'Belum ada rekening yang terdaftar', + style: TextStyle(color: Colors.grey), + ), + ), + ) + : Column( + children: + controller.bankAccounts + .map( + (account) => _buildBankAccountItem(account), + ) + .toList(), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Get.back(); + // Show the full-screen bank accounts page + Get.snackbar( + 'Informasi', + 'Menuju halaman kelola rekening bank', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + ); + }, + icon: const Icon(Icons.arrow_forward), + label: const Text('Lihat Semua Rekening'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildBankAccountItem(Map account) { + final isPrimary = account['is_primary'] as bool; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isPrimary ? AppColorsPetugas.blueGrotto : Colors.grey.shade300, + width: isPrimary ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.credit_card, + color: AppColorsPetugas.blueGrotto, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + account['bank_name'], + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + if (isPrimary) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Utama', + style: TextStyle( + color: AppColorsPetugas.blueGrotto, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + account['account_number'], + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ], + ), + ), + ], + ), + ); + } + + // Method to handle navigation to partners management + void _showPartnersPage() { + // Navigate to the ListPetugasMitraView + Get.toNamed(Routes.LIST_PETUGAS_MITRA); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart new file mode 100644 index 0000000..fd2ef86 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart @@ -0,0 +1,1322 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'dart:math'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../../../theme/app_colors_petugas.dart'; + +class PetugasBumdesDashboardView + extends GetView { + const PetugasBumdesDashboardView({super.key}); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + // Mencegah navigasi kembali dengan tombol back + return false; + }, + child: Scaffold( + appBar: AppBar( + title: Obx(() => Text(_getTitle())), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () => _showLogoutConfirmation(context), + ), + ], + ), + drawer: PetugasSideNavbar(controller: controller), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + body: Obx(() => _getTabContent()), + bottomNavigationBar: Obx( + () => PetugasBumdesBottomNavbar( + selectedIndex: controller.currentTabIndex.value, + onItemTapped: (index) => controller.changeTab(index), + ), + ), + floatingActionButton: Obx(() { + // Show FAB only on specific tabs + if (controller.currentTabIndex.value == 1 || // Aset + controller.currentTabIndex.value == 2) { + // Paket + return FloatingActionButton( + onPressed: () => _showAddItemDialog(context), + backgroundColor: AppColorsPetugas.babyBlueBright, + child: Icon(Icons.add, color: AppColorsPetugas.blueGrotto), + ); + } + return const SizedBox.shrink(); + }), + ), + ); + } + + String _getTitle() { + switch (controller.currentTabIndex.value) { + case 0: + return 'Dashboard Petugas BUMDES'; + case 1: + return 'Manajemen Aset'; + case 2: + return 'Manajemen Paket'; + case 3: + return 'Permintaan Sewa'; + case 4: + return 'Profil BUMDes'; + default: + return 'Dashboard Petugas BUMDES'; + } + } + + Widget _getTabContent() { + switch (controller.currentTabIndex.value) { + case 0: + return _buildDashboardTab(); + case 1: + return _buildAsetTab(); + case 2: + return _buildPaketTab(); + case 3: + return _buildSewaTab(); + case 4: + return _buildBumdesTab(); + default: + return _buildDashboardTab(); + } + } + + Widget _buildDashboardTab() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildWelcomeCard(), + const SizedBox(height: 24), + + // Detail Status Sewa Aset section with improved header + _buildSectionHeader( + 'Detail Status Sewa Aset', + AppColorsPetugas.navyBlue, + Icons.shopping_cart_outlined, + ), + _buildDetailedStatusBreakdown(), + + const SizedBox(height: 24), + + // Revenue Statistics Section with improved header + _buildSectionHeader( + 'Statistik Pendapatan', + AppColorsPetugas.success, + Icons.account_balance_wallet_outlined, + ), + _buildRevenueStatistics(), + const SizedBox(height: 16), + _buildRevenueSources(), + const SizedBox(height: 16), + _buildRevenueTrend(), + + // Add some padding at the bottom for better scrolling + const SizedBox(height: 16), + ], + ), + ); + } + + Widget _buildWelcomeCard() { + return Card( + elevation: 2, + shadowColor: AppColorsPetugas.shadowColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: AppColorsPetugas.navyBlue.withOpacity(0.05)), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColorsPetugas.navyBlue.withOpacity(0.8), + AppColorsPetugas.blueGrotto, + ], + ), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: const Icon( + Icons.person, + color: Colors.white, + size: 30, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'Petugas BUMDES', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 8), + const Text( + 'Selamat Datang', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Obx( + () => Text( + controller.userEmail.value, + style: const TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.insert_chart_outlined_rounded, + color: Colors.white, + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Pantau sewa aset dengan mudah melalui dashboard Anda', + style: TextStyle( + fontSize: 13, + color: Colors.white.withOpacity(0.9), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSectionHeader(String title, Color color, IconData icon) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.15), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + letterSpacing: 0.2, + ), + ), + ], + ), + ); + } + + Widget _buildDetailedStatusBreakdown() { + return Card( + elevation: 2, + shadowColor: AppColorsPetugas.shadowColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Use a responsive grid layout for better display on different screen sizes + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 14, + mainAxisSpacing: 14, + childAspectRatio: 0.75, + children: [ + _buildSourceItem( + 'Menunggu Pembayaran', + controller.menungguPembayaranCount.value.toString(), + controller.menungguPembayaranCount.value, + AppColorsPetugas.warning, + ), + _buildSourceItem( + 'Periksa Pembayaran', + controller.periksaPembayaranCount.value.toString(), + controller.periksaPembayaranCount.value, + AppColorsPetugas.info, + ), + _buildSourceItem( + 'Diterima', + controller.diterimaCount.value.toString(), + controller.diterimaCount.value, + AppColorsPetugas.success, + ), + _buildSourceItem( + 'Pembayaran Denda', + controller.pembayaranDendaCount.value.toString(), + controller.pembayaranDendaCount.value, + AppColorsPetugas.error, + ), + _buildSourceItem( + 'Periksa Denda', + controller.periksaPembayaranDendaCount.value.toString(), + controller.periksaPembayaranDendaCount.value, + AppColorsPetugas.info, + ), + _buildSourceItem( + 'Selesai', + controller.selesaiCount.value.toString(), + controller.selesaiCount.value, + AppColorsPetugas.success, + ), + ], + ), + const SizedBox(height: 24), + // Modern visualization with improved progress bar + _buildDetailedStatusProgressBar(), + ], + ), + ), + ); + } + + Widget _buildSourceItem( + String title, + String value, + int percentage, + Color color, + ) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.15), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + border: Border.all(color: color.withOpacity(0.1), width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.insert_chart_outlined_rounded, + color: color, + size: 24, + ), + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + title, + style: TextStyle( + fontSize: 10, + height: 1.2, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildDetailedStatusProgressBar() { + // Calculate the total count for all detailed statuses + final total = + controller.menungguPembayaranCount.value + + controller.periksaPembayaranCount.value + + controller.diterimaCount.value + + controller.pembayaranDendaCount.value + + controller.periksaPembayaranDendaCount.value + + controller.selesaiCount.value; + + // Calculate percentages for each status (avoid division by zero) + final menungguPercent = + total > 0 ? controller.menungguPembayaranCount.value / total : 0.0; + final periksaPercent = + total > 0 ? controller.periksaPembayaranCount.value / total : 0.0; + final diterimaPercent = + total > 0 ? controller.diterimaCount.value / total : 0.0; + final dendaPercent = + total > 0 ? controller.pembayaranDendaCount.value / total : 0.0; + final periksaDendaPercent = + total > 0 ? controller.periksaPembayaranDendaCount.value / total : 0.0; + final selesaiPercent = + total > 0 ? controller.selesaiCount.value / total : 0.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Distribusi Status Sewa', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 16), + Stack( + children: [ + // Background for the progress bar + Container( + height: 12, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + ), + // Actual progress bar segments + Row( + children: [ + _buildModernProgressSegment( + menungguPercent, + AppColorsPetugas.warning, + isFirst: true, + ), + _buildModernProgressSegment( + periksaPercent, + AppColorsPetugas.info, + ), + _buildModernProgressSegment( + diterimaPercent, + AppColorsPetugas.success, + ), + _buildModernProgressSegment( + dendaPercent, + AppColorsPetugas.error, + ), + _buildModernProgressSegment( + periksaDendaPercent, + AppColorsPetugas.blueGrotto, + ), + _buildModernProgressSegment( + selesaiPercent, + AppColorsPetugas.blueGreen, + isLast: true, + ), + ], + ), + ], + ), + const SizedBox(height: 16), + // Use grid layout for legends + GridView.count( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 3.0, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildCompactStatusLegend( + 'Menunggu', + AppColorsPetugas.warning, + menungguPercent, + ), + _buildCompactStatusLegend( + 'Periksa', + AppColorsPetugas.info, + periksaPercent, + ), + _buildCompactStatusLegend( + 'Diterima', + AppColorsPetugas.success, + diterimaPercent, + ), + _buildCompactStatusLegend( + 'Denda', + AppColorsPetugas.error, + dendaPercent, + ), + _buildCompactStatusLegend( + 'Cek Denda', + AppColorsPetugas.blueGrotto, + periksaDendaPercent, + ), + _buildCompactStatusLegend( + 'Selesai', + AppColorsPetugas.blueGreen, + selesaiPercent, + ), + ], + ), + ], + ); + } + + Widget _buildModernProgressSegment( + double percentage, + Color color, { + bool isFirst = false, + bool isLast = false, + }) { + return Flexible( + flex: (percentage * 100).round(), + child: + percentage > 0 + ? Container( + height: 12, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.horizontal( + left: isFirst ? const Radius.circular(6) : Radius.zero, + right: isLast ? const Radius.circular(6) : Radius.zero, + ), + ), + ) + : const SizedBox(), // Empty container when percentage is 0 + ); + } + + Widget _buildCompactStatusLegend( + String text, + Color color, + double percentage, + ) { + final count = (percentage * 100).round(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '$text ${count > 0 ? '($count%)' : ''}', + style: TextStyle( + fontSize: 10, + color: Colors.black87, + fontWeight: count > 20 ? FontWeight.w500 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + Widget _buildRevenueStatistics() { + return Card( + elevation: 2, + shadowColor: AppColorsPetugas.shadowColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.white, AppColorsPetugas.success.withOpacity(0.05)], + ), + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pendapatan Bulan Ini', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + Obx( + () => Text( + controller.totalPendapatanBulanIni.value, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.success, + ), + ), + ), + const SizedBox(height: 6), + Obx( + () => Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: + controller.isKenaikanPositif.value + ? AppColorsPetugas.success.withOpacity( + 0.1, + ) + : AppColorsPetugas.error.withOpacity( + 0.1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + controller.isKenaikanPositif.value + ? Icons.arrow_upward + : Icons.arrow_downward, + size: 14, + color: + controller.isKenaikanPositif.value + ? AppColorsPetugas.success + : AppColorsPetugas.error, + ), + const SizedBox(width: 4), + Text( + controller.persentaseKenaikan.value, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: + controller.isKenaikanPositif.value + ? AppColorsPetugas.success + : AppColorsPetugas.error, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + 'dari bulan lalu', + style: TextStyle( + fontSize: 13, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.success.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColorsPetugas.success.withOpacity(0.3), + width: 1, + ), + ), + child: Icon( + Icons.account_balance_wallet_outlined, + color: AppColorsPetugas.success, + size: 32, + ), + ), + ], + ), + const SizedBox(height: 20), + const Divider(height: 1), + const SizedBox(height: 20), + _buildRevenueSummary(), + ], + ), + ), + ); + } + + Widget _buildRevenueSummary() { + return Row( + children: [ + Expanded( + child: _buildRevenueQuickInfo( + 'Pendapatan Sewa', + controller.pendapatanSewa.value, + AppColorsPetugas.navyBlue, + Icons.shopping_cart_outlined, + ), + ), + ], + ); + } + + Widget _buildRevenueQuickInfo( + String title, + String value, + Color color, + IconData icon, + ) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.1), width: 1), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 18), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRevenueSources() { + return Card( + elevation: 2, + shadowColor: AppColorsPetugas.shadowColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sumber Pendapatan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + // Revenue Donut Chart + Expanded( + flex: 2, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorsPetugas.navyBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text( + 'Sewa Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 8), + Obx( + () => Text( + controller.pendapatanSewa.value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + const SizedBox(height: 8), + Text( + '100% dari total pendapatan', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildRevenueTrend() { + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun']; + + return Card( + elevation: 2, + shadowColor: AppColorsPetugas.shadowColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tren Pendapatan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 220, + child: Obx(() { + // Get the trend data from controller + final List trendData = controller.trendPendapatan; + final double maxValue = trendData.reduce( + (curr, next) => curr > next ? curr : next, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Y-axis labels + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${maxValue.toStringAsFixed(1)}M', + style: TextStyle( + fontSize: 10, + color: AppColorsPetugas.textSecondary, + ), + ), + Text( + '${(maxValue * 0.75).toStringAsFixed(1)}M', + style: TextStyle( + fontSize: 10, + color: AppColorsPetugas.textSecondary, + ), + ), + Text( + '${(maxValue * 0.5).toStringAsFixed(1)}M', + style: TextStyle( + fontSize: 10, + color: AppColorsPetugas.textSecondary, + ), + ), + Text( + '${(maxValue * 0.25).toStringAsFixed(1)}M', + style: TextStyle( + fontSize: 10, + color: AppColorsPetugas.textSecondary, + ), + ), + Text( + '0', + style: TextStyle( + fontSize: 10, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + const SizedBox(width: 10), + // Chart bars + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.only(top: 5), + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Colors.grey.shade200, + width: 1, + ), + top: BorderSide( + color: Colors.grey.shade200, + width: 1, + ), + right: BorderSide( + color: Colors.grey.shade200, + width: 1, + ), + ), + color: Colors.grey.shade50.withOpacity(0.3), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: List.generate(trendData.length, ( + index, + ) { + final percentage = + trendData[index] / maxValue; + final isLastMonth = + index == trendData.length - 1; + + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + width: 35, + height: 170 * percentage, + decoration: BoxDecoration( + borderRadius: + const BorderRadius.vertical( + top: Radius.circular(6), + ), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: + isLastMonth + ? [ + AppColorsPetugas.success, + AppColorsPetugas.success + .withOpacity(0.7), + ] + : [ + AppColorsPetugas + .blueGrotto + .withOpacity(0.9), + AppColorsPetugas + .blueGrotto + .withOpacity(0.5), + ], + ), + boxShadow: [ + BoxShadow( + color: + isLastMonth + ? AppColorsPetugas.success + .withOpacity(0.3) + : AppColorsPetugas + .blueGrotto + .withOpacity(0.2), + blurRadius: 4, + spreadRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + ), + ], + ); + }), + ), + ), + ), + Container( + height: 30, + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Colors.grey.shade200, + width: 1, + ), + bottom: BorderSide( + color: Colors.grey.shade200, + width: 1, + ), + right: BorderSide( + color: Colors.grey.shade200, + width: 1, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(trendData.length, ( + index, + ) { + final isLastMonth = + index == trendData.length - 1; + + return Container( + width: 35, + padding: const EdgeInsets.only(top: 8), + child: Text( + months[index], + style: TextStyle( + fontSize: 12, + color: + isLastMonth + ? AppColorsPetugas.success + : AppColorsPetugas.textSecondary, + fontWeight: + isLastMonth + ? FontWeight.bold + : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ); + }), + ), + ), + ], + ), + ), + ], + ); + }), + ), + ], + ), + ), + ); + } + + void _showAddItemDialog(BuildContext context) { + final isAsetTab = controller.currentTabIndex.value == 1; + + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('Tambah ${isAsetTab ? 'Aset' : 'Paket'} Baru'), + content: Text( + 'Formulir untuk menambahkan ${isAsetTab ? 'aset' : 'paket'} baru akan ditampilkan di sini.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Batal'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + // TODO: Implement add item functionality + }, + style: TextButton.styleFrom( + foregroundColor: AppColorsPetugas.navyBlue, + ), + child: const Text('Tambah'), + ), + ], + ); + }, + ); + } + + // Fungsi untuk menampilkan dialog konfirmasi logout + void _showLogoutConfirmation(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Konfirmasi Logout'), + content: const Text('Apakah Anda yakin ingin keluar?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), // Tutup dialog + child: const Text('Batal'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Tutup dialog + controller.logout(); // Lakukan logout + }, + child: const Text('Logout', style: TextStyle(color: Colors.red)), + ), + ], + ); + }, + ); + } + + // Build individual tab methods + Widget _buildAsetTab() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 80, + color: AppColorsPetugas.blueGrotto.withOpacity(0.5), + ), + const SizedBox(height: 16), + const Text( + 'Manajemen Aset', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Kelola semua aset BUMDes', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildPaketTab() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.category_outlined, + size: 80, + color: AppColorsPetugas.navyBlue.withOpacity(0.5), + ), + const SizedBox(height: 16), + const Text( + 'Manajemen Paket', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Kelola paket aset untuk sewa', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildSewaTab() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.shopping_cart_outlined, + size: 80, + color: AppColorsPetugas.blueGrotto.withOpacity(0.5), + ), + const SizedBox(height: 16), + const Text( + 'Permintaan Sewa', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Kelola permintaan sewa dari warga', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildBumdesTab() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.business, + size: 80, + color: AppColorsPetugas.navyBlue.withOpacity(0.5), + ), + const SizedBox(height: 16), + const Text( + 'Profil BUMDes', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Kelola informasi dan data BUMDes', + style: TextStyle(fontSize: 16, color: Colors.grey.shade600), + ), + ], + ), + ); + } +} + +// Custom clipper for creating pie/donut chart segments +class _SweepClipper extends CustomClipper { + final double startAngle; + final double sweepAngle; + + _SweepClipper({required this.startAngle, required this.sweepAngle}); + + @override + Path getClip(Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Convert angles from degrees to radians + final startRad = startAngle * (3.14159 / 180); + final endRad = (startAngle + sweepAngle) * (3.14159 / 180); + + final path = Path(); + + // Move to center + path.moveTo(center.dx, center.dy); + + // Line to start point on the circle + path.lineTo( + center.dx + radius * cos(startRad), + center.dy + radius * sin(startRad), + ); + + // Arc to end point + path.arcTo( + Rect.fromCircle(center: center, radius: radius), + startRad, + sweepAngle * (3.14159 / 180), + false, + ); + + // Close path back to center + path.close(); + + return path; + } + + @override + bool shouldReclip(_SweepClipper oldClipper) { + return oldClipper.startAngle != startAngle || + oldClipper.sweepAngle != sweepAngle; + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart new file mode 100644 index 0000000..c7dc47d --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_detail_sewa_view.dart @@ -0,0 +1,2054 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_sewa_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; + +class PetugasDetailSewaView extends StatelessWidget { + final Map sewa; + final PetugasSewaController controller = Get.find(); + + PetugasDetailSewaView({Key? key, required this.sewa}) : super(key: key); + + @override + Widget build(BuildContext context) { + final statusColor = controller.getStatusColor(sewa['status']); + final status = sewa['status']; + + // Get appropriate icon for status + IconData statusIcon; + switch (status) { + case 'Menunggu Pembayaran': + statusIcon = Icons.payments_outlined; + break; + case 'Periksa Pembayaran': + statusIcon = Icons.fact_check_outlined; + break; + case 'Diterima': + statusIcon = Icons.check_circle_outlined; + break; + case 'Pembayaran Denda': + statusIcon = Icons.money_off_csred_outlined; + break; + case 'Periksa Denda': + statusIcon = Icons.assignment_late_outlined; + break; + case 'Selesai': + statusIcon = Icons.task_alt_outlined; + break; + case 'Dibatalkan': + statusIcon = Icons.cancel_outlined; + break; + default: + statusIcon = Icons.help_outline_rounded; + } + + return Scaffold( + backgroundColor: Colors.grey.shade50, + appBar: AppBar( + title: Text( + 'Detail Sewa #${sewa['order_id']}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.file_download_outlined), + onPressed: () => _showDownloadOptions(context), + tooltip: 'Unduh Bukti', + ), + _buildActionMenu(context), + ], + ), + body: CustomScrollView( + slivers: [ + // App Bar Extension with Status + SliverToBoxAdapter( + child: Container( + color: AppColorsPetugas.navyBlue, + child: Container( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 30), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + ), + child: Column( + children: [ + // Status Pill + Container( + margin: const EdgeInsets.only(top: 16, bottom: 16), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(30), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 18, color: statusColor), + const SizedBox(width: 8), + Text( + status, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + + // Price Tag + Text( + controller.formatPrice(sewa['total_biaya']), + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + + const SizedBox(height: 8), + + // Order ID and Date Range + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.calendar_today_rounded, + size: 14, + color: AppColorsPetugas.textSecondary, + ), + const SizedBox(width: 6), + Text( + '${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ), + ), + + // Content Sections + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Warga & Asset info card + _buildInfoCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Warga info + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: AppColorsPetugas.babyBlueLight, + child: Text( + sewa['nama_warga'] + .substring(0, 1) + .toUpperCase(), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sewa['nama_warga'], + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.phone_outlined, + size: 14, + color: AppColorsPetugas.textSecondary, + ), + const SizedBox(width: 4), + Text( + '0812-3456-7890', // Placeholder + style: TextStyle( + fontSize: 13, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ], + ), + + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Divider(), + ), + + // Asset info + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.inventory_2_outlined, + size: 20, + color: AppColorsPetugas.blueGrotto, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sewa['nama_aset'], + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '1 unit', + style: TextStyle( + fontSize: 13, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Rental Details Card + _buildInfoCard( + title: 'Detail Sewa', + titleIcon: Icons.receipt_long_rounded, + child: Column( + children: [ + _buildDetailRow( + icon: Icons.calendar_today_rounded, + label: 'Tanggal Mulai', + value: sewa['tanggal_mulai'], + ), + _buildDetailRow( + icon: Icons.event_rounded, + label: 'Tanggal Selesai', + value: sewa['tanggal_selesai'], + ), + _buildDetailRow( + icon: Icons.timer_rounded, + label: 'Durasi', + value: '7 hari', // Placeholder + ), + _buildDetailRow( + icon: Icons.schedule_rounded, + label: 'Status', + value: status, + valueColor: statusColor, + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Billing Details Card (replacing Payment Details) + _buildInfoCard( + title: 'Detail Tagihan', + titleIcon: Icons.receipt_long_rounded, + child: Column( + children: [ + _buildDetailRow( + icon: Icons.inventory_2_outlined, + label: 'Tagihan Sewa', + value: controller.formatPrice(sewa['total_biaya']), + ), + _buildDetailRow( + icon: Icons.warning_amber_rounded, + label: 'Denda', + value: controller.formatPrice(sewa['denda'] ?? 0), + ), + _buildDetailRow( + icon: Icons.payments_outlined, + label: 'Tagihan Dibayar', + value: controller.formatPrice(sewa['dibayar'] ?? 0), + valueColor: AppColorsPetugas.blueGrotto, + valueBold: true, + ), + // Add Total row when status is "Menunggu Pembayaran" + if (status == 'Menunggu Pembayaran') + _buildDetailRow( + icon: Icons.summarize_rounded, + label: 'Total', + value: controller.formatPrice( + (sewa['total_biaya'] ?? 0) + + (sewa['denda'] ?? 0) - + (sewa['dibayar'] ?? 0), + ), + valueColor: AppColorsPetugas.navyBlue, + valueBold: true, + ), + ], + ), + ), + + // Payment Options (only for Menunggu Pembayaran status) + if (status == 'Menunggu Pembayaran') ...[ + const SizedBox(height: 16), + _buildPaymentOptionsCard(), + ], + + // Payment Proof and Options (for Periksa Pembayaran status) + if (status == 'Periksa Pembayaran') ...[ + const SizedBox(height: 16), + _buildInfoCard( + title: 'Bukti Pembayaran', + titleIcon: Icons.receipt_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColorsPetugas.babyBlue, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/bukti_transfer.jpg', + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + onPressed: () { + // Open image in fullscreen or larger view + // Implement image viewer here + Get.snackbar( + 'Lihat Bukti Transfer', + 'Membuka bukti transfer dalam tampilan penuh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.zoom_in), + label: const Text('Lihat Bukti Transfer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlue + .withOpacity(0.8), + foregroundColor: AppColorsPetugas.navyBlue, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildPaymentOptionsCard(), + ], + + // Penalty Details and Payment Options (for Pembayaran Denda status) + if (status == 'Pembayaran Denda') ...[ + const SizedBox(height: 16), + _buildInfoCard( + title: 'Detail Denda', + titleIcon: Icons.warning_amber_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + icon: Icons.text_snippet_outlined, + label: 'Alasan Denda', + value: 'Kerusakan pada aset saat pengembalian', + ), + _buildDetailRow( + icon: Icons.calendar_today_rounded, + label: 'Tanggal Pelaporan', + value: '20 Maret 2025', + ), + _buildDetailRow( + icon: Icons.money_outlined, + label: 'Nominal Denda', + value: controller.formatPrice(25000), + valueColor: Colors.deepOrange, + valueBold: true, + ), + const SizedBox(height: 16), + Text( + 'Bukti Kerusakan', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColorsPetugas.babyBlue, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/kerusakan.jpg', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 150, + color: AppColorsPetugas.babyBlueBright, + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported_outlined, + size: 40, + color: Colors.grey, + ), + const SizedBox(height: 8), + Text( + 'Gambar tidak tersedia', + style: TextStyle( + color: + AppColorsPetugas + .textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + onPressed: () { + Get.snackbar( + 'Lihat Bukti Kerusakan', + 'Membuka gambar dalam tampilan penuh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.zoom_in), + label: const Text('Lihat Bukti Kerusakan'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlue + .withOpacity(0.8), + foregroundColor: AppColorsPetugas.navyBlue, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildPaymentOptionsCard(isPenalty: true), + ], + + // Penalty Details, Payment Proof, and Payment Options (for Periksa Denda status) + if (status == 'Periksa Denda') ...[ + const SizedBox(height: 16), + _buildInfoCard( + title: 'Detail Denda', + titleIcon: Icons.warning_amber_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow( + icon: Icons.text_snippet_outlined, + label: 'Alasan Denda', + value: 'Kerusakan pada aset saat pengembalian', + ), + _buildDetailRow( + icon: Icons.calendar_today_rounded, + label: 'Tanggal Pelaporan', + value: '20 Maret 2025', + ), + _buildDetailRow( + icon: Icons.money_outlined, + label: 'Nominal Denda', + value: controller.formatPrice(25000), + valueColor: Colors.deepOrange, + valueBold: true, + ), + _buildDetailRow( + icon: Icons.calendar_month_rounded, + label: 'Tanggal Pembayaran', + value: '22 Maret 2025', + ), + const SizedBox(height: 16), + Text( + 'Bukti Kerusakan', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColorsPetugas.babyBlue, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/kerusakan.jpg', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 150, + color: AppColorsPetugas.babyBlueBright, + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported_outlined, + size: 40, + color: Colors.grey, + ), + const SizedBox(height: 8), + Text( + 'Gambar tidak tersedia', + style: TextStyle( + color: + AppColorsPetugas + .textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + onPressed: () { + Get.snackbar( + 'Lihat Bukti Kerusakan', + 'Membuka gambar dalam tampilan penuh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.zoom_in), + label: const Text('Lihat Bukti Kerusakan'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlue + .withOpacity(0.8), + foregroundColor: AppColorsPetugas.navyBlue, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildInfoCard( + title: 'Bukti Pembayaran Denda', + titleIcon: Icons.receipt_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColorsPetugas.babyBlue, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/bukti_transfer.jpg', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 150, + color: AppColorsPetugas.babyBlueBright, + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported_outlined, + size: 40, + color: Colors.grey, + ), + const SizedBox(height: 8), + Text( + 'Gambar tidak tersedia', + style: TextStyle( + color: + AppColorsPetugas + .textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 16), + Center( + child: ElevatedButton.icon( + onPressed: () { + Get.snackbar( + 'Lihat Bukti Transfer', + 'Membuka bukti transfer dalam tampilan penuh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.zoom_in), + label: const Text('Lihat Bukti Transfer'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlue + .withOpacity(0.8), + foregroundColor: AppColorsPetugas.navyBlue, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _buildPaymentOptionsCard( + isPenalty: true, + isVerifying: true, + ), + ], + + const SizedBox(height: 100), // Space for bottom bar + ], + ), + ), + ), + ], + ), + bottomNavigationBar: _buildBottomActionBar(), + ); + } + + Widget _buildInfoCard({ + required Widget child, + String? title, + IconData? titleIcon, + }) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Row( + children: [ + if (titleIcon != null) ...[ + Icon( + titleIcon, + size: 18, + color: AppColorsPetugas.blueGrotto, + ), + const SizedBox(width: 8), + ], + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + const SizedBox(height: 16), + ], + child, + ], + ), + ), + ); + } + + Widget _buildDetailRow({ + required IconData icon, + required String label, + required String value, + Color? valueColor, + bool valueBold = false, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 16, color: AppColorsPetugas.textSecondary), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: Text( + label, + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + ), + Expanded( + flex: 4, + child: Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: valueBold ? FontWeight.bold : FontWeight.w500, + color: valueColor ?? AppColorsPetugas.textPrimary, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + } + + Widget _buildActionMenu(BuildContext context) { + final status = sewa['status']; + + // Determine available actions based on status + List> menuItems = []; + + if (status == 'Menunggu Pembayaran') { + menuItems.add( + PopupMenuItem( + value: 'check_payment', + child: _buildMenuItemContent( + icon: Icons.fact_check_outlined, + text: 'Periksa Pembayaran', + color: Colors.amber.shade700, + ), + ), + ); + } else if (status == 'Periksa Pembayaran') { + menuItems.add( + PopupMenuItem( + value: 'approve', + child: _buildMenuItemContent( + icon: Icons.check_circle_outline, + text: 'Terima Pengajuan', + color: Colors.green.shade600, + ), + ), + ); + } else if (status == 'Diterima') { + menuItems.add( + PopupMenuItem( + value: 'request_penalty', + child: _buildMenuItemContent( + icon: Icons.money_off_csred_outlined, + text: 'Minta Pembayaran Denda', + color: Colors.deepOrange, + ), + ), + ); + } else if (status == 'Pembayaran Denda') { + menuItems.add( + PopupMenuItem( + value: 'check_penalty', + child: _buildMenuItemContent( + icon: Icons.assignment_late_outlined, + text: 'Periksa Pembayaran Denda', + color: Colors.red.shade600, + ), + ), + ); + } else if (status == 'Periksa Denda') { + menuItems.add( + PopupMenuItem( + value: 'complete', + child: _buildMenuItemContent( + icon: Icons.task_alt_outlined, + text: 'Selesaikan Sewa', + color: Colors.purple, + ), + ), + ); + } else if (status == 'Dikembalikan') { + menuItems.add( + PopupMenuItem( + value: 'complete', + child: _buildMenuItemContent( + icon: Icons.task_alt_outlined, + text: 'Selesaikan Sewa', + color: Colors.purple, + ), + ), + ); + } + + // Always add cancel option if not already completed or canceled + if (status != 'Selesai' && status != 'Dibatalkan') { + menuItems.add( + PopupMenuItem( + value: 'cancel', + child: _buildMenuItemContent( + icon: Icons.cancel_outlined, + text: 'Batalkan Sewa', + color: Colors.red, + ), + ), + ); + } + + // If no actions available, return empty container + if (menuItems.isEmpty) { + return Container(); + } + + return PopupMenuButton( + icon: const Icon(Icons.more_vert), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + itemBuilder: (context) => menuItems, + onSelected: (value) => _handleMenuAction(value), + ); + } + + Widget _buildMenuItemContent({ + required IconData icon, + required String text, + required Color color, + }) { + return Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 10), + Text(text), + ], + ); + } + + void _handleMenuAction(String value) { + switch (value) { + case 'check_payment': + // Update status to "Periksa Pembayaran" + controller.approveSewa(sewa['id']); // Reusing existing method + Get.back(); + Get.snackbar( + 'Status Diubah', + 'Status pengajuan diubah menjadi Periksa Pembayaran', + backgroundColor: Colors.amber.shade700, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + break; + + case 'approve': + // Update status to "Diterima" + controller.approveSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Pengajuan Diterima', + 'Pengajuan sewa aset telah disetujui', + backgroundColor: Colors.green.shade600, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + break; + + case 'request_penalty': + // Update status to "Pembayaran Denda" + controller.requestPenaltyPayment(sewa['id']); + Get.back(); + Get.snackbar( + 'Permintaan Denda', + 'Permintaan pembayaran denda telah dikirim', + backgroundColor: Colors.deepOrange, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + break; + + case 'check_penalty': + // Update status to "Periksa Denda" + controller.markPenaltyForInspection(sewa['id']); + Get.back(); + Get.snackbar( + 'Status Diubah', + 'Status pengajuan diubah menjadi Periksa Denda', + backgroundColor: Colors.red.shade600, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + break; + + case 'complete': + // Update status to "Selesai" + controller.completeSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Sewa Selesai', + 'Aset telah dikembalikan dan sewa telah selesai', + backgroundColor: Colors.green.shade600, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + break; + + case 'cancel': + // Update status to "Dibatalkan" + controller.rejectSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Sewa Dibatalkan', + 'Sewa aset telah dibatalkan', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + break; + } + } + + Widget? _buildBottomActionBar() { + final status = sewa['status']; + + // Button text and actions based on status + String? buttonText; + VoidCallback? onPressed; + Color? buttonColor; + IconData? buttonIcon; + + if (status == 'Menunggu Pembayaran') { + // Remove the "Periksa Pembayaran" button for "Menunggu Pembayaran" status + return null; + } else if (status == 'Periksa Pembayaran') { + // Remove the "Terima Pengajuan Sewa" button for "Periksa Pembayaran" status + return null; + } else if (status == 'Diterima') { + buttonText = 'Konfirmasi Pengembalian'; + buttonIcon = Icons.assignment_return_outlined; + buttonColor = Colors.blue.shade700; + onPressed = () { + // Show confirmation dialog + Get.dialog( + AlertDialog( + title: const Text('Konfirmasi Pengembalian'), + content: const Text( + 'Apakah Anda yakin aset telah dikembalikan oleh penyewa?', + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text( + 'Batal', + style: TextStyle(color: AppColorsPetugas.textSecondary), + ), + ), + ElevatedButton( + onPressed: () { + // Close dialog + Get.back(); + + // Request penalty or complete the rental + showModalBottomSheet( + context: Get.context!, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + backgroundColor: Colors.white, + builder: (context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Status Pengembalian', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 16), + Text( + 'Pilih status pengembalian aset:', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 20), + + // Return Without Penalty Option + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_outline, + color: Colors.green.shade600, + ), + ), + title: const Text('Pengembalian Normal'), + subtitle: const Text( + 'Aset dikembalikan dalam kondisi baik', + ), + onTap: () { + Get.back(); + controller.markAsReturned(sewa['id']); + Get.back(); + Get.snackbar( + 'Aset Dikembalikan', + 'Status sewa diubah menjadi Dikembalikan', + backgroundColor: Colors.teal, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + ), + + const SizedBox(height: 12), + + // Return With Penalty Option + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade50, + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_amber_rounded, + color: Colors.deepOrange, + ), + ), + title: const Text('Pengembalian Dengan Denda'), + subtitle: const Text( + 'Aset rusak/telat/tidak sesuai ketentuan', + ), + onTap: () { + Get.back(); + controller.requestPenaltyPayment(sewa['id']); + Get.back(); + Get.snackbar( + 'Denda Diterapkan', + 'Permintaan pembayaran denda telah dikirim', + backgroundColor: Colors.deepOrange, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + ), + + const SizedBox(height: 20), + ], + ), + ); + }, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + ), + child: const Text('Konfirmasi'), + ), + ], + ), + ); + }; + } else if (status == 'Periksa Denda') { + buttonText = 'Selesaikan Sewa'; + buttonIcon = Icons.task_alt_outlined; + buttonColor = Colors.purple; + onPressed = () { + controller.completeSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Sewa Selesai', + 'Sewa aset telah selesai', + backgroundColor: Colors.purple, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }; + } else if (status == 'Dikembalikan') { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Row( + children: [ + // Add Penalty Button + Expanded( + child: ElevatedButton.icon( + onPressed: () { + _showAddPenaltyDialog(); + }, + icon: const Icon(Icons.warning_amber_rounded), + label: const Text('Tambah Denda'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + // Complete Rental Button + Expanded( + child: ElevatedButton.icon( + onPressed: () { + controller.completeSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Sewa Selesai', + 'Sewa aset telah selesai', + backgroundColor: Colors.purple, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.task_alt_outlined), + label: const Text('Selesaikan Sewa'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ); + } + + // No button for other statuses + if (buttonText == null) return null; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: ElevatedButton.icon( + onPressed: onPressed, + icon: Icon(buttonIcon), + label: Text(buttonText), + style: ElevatedButton.styleFrom( + backgroundColor: buttonColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); + } + + void _showDownloadOptions(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + backgroundColor: Colors.white, + builder: (context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Unduh Bukti Sewa', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + color: Colors.grey, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Pilih format dokumen yang ingin diunduh', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 24), + + // PDF option + _buildDownloadOption( + context: context, + icon: Icons.picture_as_pdf_outlined, + title: 'PDF', + subtitle: 'Dokumen lengkap bukti sewa', + onTap: () { + Navigator.pop(context); + Get.snackbar( + 'Mengunduh PDF', + 'Bukti sewa dalam format PDF sedang diunduh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + ), + + const SizedBox(height: 16), + + // Image option + _buildDownloadOption( + context: context, + icon: Icons.image_outlined, + title: 'Gambar (JPG)', + subtitle: 'Tampilan bukti sewa sebagai gambar', + onTap: () { + Navigator.pop(context); + Get.snackbar( + 'Mengunduh Gambar', + 'Bukti sewa dalam format JPG sedang diunduh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + ), + + const SizedBox(height: 16), + + // Excel option + _buildDownloadOption( + context: context, + icon: Icons.table_chart_outlined, + title: 'Excel (XLSX)', + subtitle: 'Data sewa dalam format spreadsheet', + onTap: () { + Navigator.pop(context); + Get.snackbar( + 'Mengunduh Excel', + 'Data sewa dalam format XLSX sedang diunduh', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + ), + + const SizedBox(height: 20), + ], + ), + ); + }, + ); + } + + Widget _buildDownloadOption({ + required BuildContext context, + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border.all(color: AppColorsPetugas.babyBlue), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + color: AppColorsPetugas.blueGrotto, + size: 16, + ), + ], + ), + ), + ); + } + + Widget _buildPaymentOptionsCard({ + bool isPenalty = false, + bool isVerifying = false, + }) { + final status = sewa['status']; + final isFullPayment = true.obs; + + // Set title based on context + String cardTitle = 'Opsi Pembayaran'; + if (isPenalty) { + cardTitle = + isVerifying ? 'Verifikasi Pembayaran Denda' : 'Opsi Pembayaran Denda'; + } else if (isVerifying) { + cardTitle = 'Verifikasi Pembayaran'; + } + + return _buildInfoCard( + title: cardTitle, + titleIcon: Icons.payment_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Full Payment Option (Radio Button) - Only show when not verifying + if (!isVerifying) + Obx( + () => Row( + children: [ + Radio( + value: true, + groupValue: isFullPayment.value, + onChanged: (value) { + isFullPayment.value = true; + // Set payment amount to full rental fee + }, + activeColor: AppColorsPetugas.blueGrotto, + ), + Text( + isPenalty ? 'Pembayaran Denda Penuh' : 'Pembayaran Penuh', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), + ), + + // Payment Method Selection - Modified when verifying + Text( + isVerifying ? 'Metode Pembayaran Diterima' : 'Metode Pembayaran', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 12), + + // Payment Method Options + Row( + children: [ + Expanded( + child: InkWell( + onTap: + !isVerifying + ? () { + // Handle cash payment selection + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + isVerifying && sewa['payment_method'] != 'cash' + ? Colors.white + : AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isVerifying && sewa['payment_method'] == 'cash' + ? AppColorsPetugas.blueGrotto + : AppColorsPetugas.babyBlue, + width: + isVerifying && sewa['payment_method'] == 'cash' + ? 2 + : 1, + ), + ), + child: Column( + children: [ + Icon( + Icons.payments_outlined, + color: AppColorsPetugas.blueGrotto, + size: 24, + ), + const SizedBox(height: 8), + Text( + 'Tunai', + style: TextStyle( + fontSize: 14, + fontWeight: + isVerifying && sewa['payment_method'] == 'cash' + ? FontWeight.w600 + : FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: InkWell( + onTap: + !isVerifying + ? () { + // Handle transfer payment selection + } + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: + isVerifying && sewa['payment_method'] != 'transfer' + ? Colors.white + : AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isVerifying && sewa['payment_method'] == 'transfer' + ? AppColorsPetugas.blueGrotto + : AppColorsPetugas.babyBlue, + width: + isVerifying && sewa['payment_method'] == 'transfer' + ? 2 + : 1, + ), + ), + child: Column( + children: [ + Icon( + Icons.account_balance_outlined, + color: AppColorsPetugas.blueGrotto, + size: 24, + ), + const SizedBox(height: 8), + Text( + 'Transfer', + style: TextStyle( + fontSize: 14, + fontWeight: + isVerifying && + sewa['payment_method'] == 'transfer' + ? FontWeight.w600 + : FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 20), + + // Amount Input Field + Text( + isPenalty + ? isVerifying + ? 'Nominal Denda Dibayarkan' + : 'Nominal Pembayaran Denda' + : isVerifying + ? 'Nominal Dibayarkan' + : 'Nominal Pembayaran', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + Obx( + () => TextFormField( + keyboardType: TextInputType.number, + initialValue: + isFullPayment.value + ? isPenalty + ? '25000' // Hardcoded penalty amount + : ((sewa['total_biaya'] ?? 0) + + (sewa['denda'] ?? 0) - + (sewa['dibayar'] ?? 0)) + .toString() + : isVerifying + ? (sewa['paid_amount'] ?? 0).toString() + : null, + enabled: !isVerifying, + decoration: InputDecoration( + hintText: + isPenalty + ? 'Masukkan nominal pembayaran denda' + : 'Masukkan nominal pembayaran', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, + ), + ), + ), + ), + + const SizedBox(height: 20), + + // Confirm Payment Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + // Handle payment confirmation or verification + if (isVerifying && isPenalty) { + // Verify penalty payment + controller.completeSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Pembayaran Denda Diverifikasi', + 'Status sewa diubah menjadi Selesai', + backgroundColor: Colors.purple, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } else if (isVerifying) { + // Verify regular payment + controller.approveSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Pembayaran Diverifikasi', + 'Pengajuan sewa aset telah disetujui', + backgroundColor: Colors.green.shade600, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } else if (isPenalty) { + // Handle penalty payment + controller.markPenaltyForInspection(sewa['id']); + Get.back(); + Get.snackbar( + 'Pembayaran Denda Dikonfirmasi', + 'Status diubah menjadi Periksa Denda', + backgroundColor: Colors.deepOrange, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } else { + // Confirm regular payment + controller.approveSewa(sewa['id']); + Get.back(); + Get.snackbar( + 'Pembayaran Dikonfirmasi', + 'Status pengajuan diubah menjadi Periksa Pembayaran', + backgroundColor: Colors.green.shade600, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } + }, + icon: Icon( + isVerifying + ? isPenalty + ? Icons.task_alt_outlined + : Icons.check_circle_outline + : isPenalty + ? Icons.warning_amber_rounded + : Icons.payments_outlined, + ), + label: Text( + isVerifying + ? isPenalty + ? 'Verifikasi Pembayaran Denda' + : 'Verifikasi Pembayaran' + : isPenalty + ? 'Konfirmasi Pembayaran Denda' + : 'Konfirmasi Pembayaran', + ), + style: ElevatedButton.styleFrom( + backgroundColor: + isVerifying && isPenalty + ? Colors.purple + : isVerifying + ? Colors.green.shade600 + : isPenalty + ? Colors.deepOrange + : AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ); + } + + void _showAddPenaltyDialog() { + // TextEditingControllers for form inputs + final penaltyAmountController = TextEditingController(); + final descriptionController = TextEditingController(); + // Image file value (to be updated when capturing an image) + final Rx hasImage = false.obs; + + Get.dialog( + Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.deepOrange, + size: 24, + ), + const SizedBox(width: 10), + Text( + 'Tambah Denda', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.textPrimary, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Get.back(), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + color: AppColorsPetugas.textSecondary, + ), + ], + ), + const SizedBox(height: 16), + + // Penalty Amount Field + Text( + 'Nominal Denda', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: penaltyAmountController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Masukkan nominal denda', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + contentPadding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, + ), + ), + ), + const SizedBox(height: 16), + + // Description Field + Text( + 'Keterangan', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: descriptionController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Masukkan keterangan denda', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColorsPetugas.babyBlue), + ), + contentPadding: const EdgeInsets.all(16), + ), + ), + const SizedBox(height: 16), + + // Image Upload + Text( + 'Bukti Kerusakan', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + Obx( + () => Container( + height: 150, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColorsPetugas.babyBlue), + color: + hasImage.value + ? Colors.transparent + : AppColorsPetugas.babyBlueBright, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () { + // This would open the camera in a real implementation + // Using a snackbar here as a placeholder + Get.snackbar( + 'Membuka Kamera', + 'Implementasi kamera akan dibuka di sini', + backgroundColor: AppColorsPetugas.blueGrotto, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + + // Simulate taking a photo for the UI + hasImage.value = true; + }, + borderRadius: BorderRadius.circular(8), + child: + hasImage.value + ? Stack( + alignment: Alignment.center, + children: [ + // This would be the actual image preview + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/damage_preview.jpg', + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) { + // Fallback if image not found + return Center( + child: Icon( + Icons.image, + size: 60, + color: + AppColorsPetugas.blueGrotto, + ), + ); + }, + ), + ), + ), + // Edit overlay + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.black.withOpacity(0.5), + ), + child: Center( + child: Icon( + Icons.camera_alt, + size: 40, + color: Colors.white, + ), + ), + ), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.camera_alt, + size: 40, + color: AppColorsPetugas.blueGrotto, + ), + const SizedBox(height: 12), + Text( + 'Klik untuk ambil foto bukti', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 24), + + // Submit Button + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + // Validate inputs + if (penaltyAmountController.text.isEmpty) { + Get.snackbar( + 'Peringatan', + 'Nominal denda harus diisi', + backgroundColor: Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + return; + } + + // Process the penalty + Get.back(); // Close the dialog + + // Update status to Pembayaran Denda + controller.requestPenaltyPayment(sewa['id']); + Get.back(); // Return from the detail page + + Get.snackbar( + 'Denda Diterapkan', + 'Permintaan pembayaran denda telah dikirim', + backgroundColor: Colors.deepOrange, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.check_circle), + label: const Text('Konfirmasi Denda'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepOrange, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_langganan_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_langganan_view.dart new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_langganan_view.dart @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/app/modules/petugas_bumdes/views/petugas_manajemen_bumdes_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_manajemen_bumdes_view.dart new file mode 100644 index 0000000..61403fb --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_manajemen_bumdes_view.dart @@ -0,0 +1,914 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_manajemen_bumdes_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; + +class PetugasManajemenBumdesView + extends GetView { + const PetugasManajemenBumdesView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final dashboardController = Get.find(); + + return Scaffold( + appBar: AppBar( + title: const Text('Manajemen BUMDes'), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + ), + body: Obx( + () => + controller.isLoading.value + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + _buildTabBar(), + Expanded( + child: Obx(() { + switch (controller.selectedTabIndex.value) { + case 0: + return _buildProfileTab(); + case 1: + return _buildBankAccountTab(); + case 2: + return _buildPartnerTab(); + default: + return _buildProfileTab(); + } + }), + ), + ], + ), + ), + bottomNavigationBar: _buildBottomNav(dashboardController), + ); + } + + Widget _buildTabBar() { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x29000000), + offset: Offset(0, 3), + blurRadius: 6, + ), + ], + ), + child: Row( + children: [ + _buildTab(0, 'Profile'), + _buildTab(1, 'Rekening Bank'), + _buildTab(2, 'Mitra'), + ], + ), + ); + } + + Widget _buildTab(int index, String title) { + final isSelected = controller.selectedTabIndex.value == index; + + return Expanded( + child: GestureDetector( + onTap: () => controller.changeTab(index), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: + isSelected ? AppColorsPetugas.navyBlue : Colors.transparent, + width: 3, + ), + ), + ), + child: Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: + isSelected ? AppColorsPetugas.navyBlue : Colors.grey.shade600, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ); + } + + Widget _buildProfileTab() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Profile BUMDes', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _buildProfileForm(), + ], + ), + ); + } + + Widget _buildProfileForm() { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + _buildProfileField('Nama BUMDes', 'BUMDes Sejahtera'), + _buildProfileField('Alamat', 'Jl. Desa No. 123, Kecamatan Makmur'), + _buildProfileField('Email', 'bumdes.sejahtera@gmail.com'), + _buildProfileField('Telepon', '081234567890'), + _buildProfileField( + 'Deskripsi', + 'BUMDes yang bergerak dalam bidang penyewaan aset dan paket untuk masyarakat desa.', + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + minimumSize: const Size(double.infinity, 45), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Edit Profile'), + ), + ], + ), + ), + ); + } + + Widget _buildProfileField(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 14)), + const Divider(), + ], + ), + ); + } + + Widget _buildBankAccountTab() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Rekening Bank', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ElevatedButton.icon( + onPressed: () => _showAddBankAccountDialog(), + icon: const Icon(Icons.add, size: 16), + label: const Text('Tambah'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: controller.bankAccounts.length, + itemBuilder: (context, index) { + final account = controller.bankAccounts[index]; + return _buildBankAccountCard(account, index); + }, + ), + ), + ], + ), + ); + } + + Widget _buildBankAccountCard(Map account, int index) { + final isPrimary = account['isPrimary'] as bool; + + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isPrimary ? AppColorsPetugas.navyBlue : Colors.transparent, + width: isPrimary ? 2 : 0, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + account['bankName'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + PopupMenuButton( + itemBuilder: + (context) => [ + if (!isPrimary) + PopupMenuItem( + value: 'primary', + child: const Text('Jadikan Utama'), + ), + const PopupMenuItem(value: 'edit', child: Text('Edit')), + const PopupMenuItem( + value: 'delete', + child: Text('Hapus'), + ), + ], + onSelected: (value) { + switch (value) { + case 'primary': + controller.setPrimaryBankAccount(index); + break; + case 'edit': + _showEditBankAccountDialog(account, index); + break; + case 'delete': + _showDeleteBankAccountDialog(index); + break; + } + }, + ), + ], + ), + const SizedBox(height: 8), + _buildBankAccountInfo('Nama Pemilik', account['accountName']), + _buildBankAccountInfo('Nomor Rekening', account['accountNumber']), + if (isPrimary) + Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColorsPetugas.navyBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Rekening Utama', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.navyBlue, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBankAccountInfo(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), + ], + ), + ); + } + + Widget _buildPartnerTab() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Mitra BUMDes', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ElevatedButton.icon( + onPressed: () => _showAddPartnerDialog(), + icon: const Icon(Icons.add, size: 16), + label: const Text('Tambah'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: controller.partners.length, + itemBuilder: (context, index) { + final partner = controller.partners[index]; + return _buildPartnerCard(partner, index); + }, + ), + ), + ], + ), + ); + } + + Widget _buildPartnerCard(Map partner, int index) { + final isActive = partner['isActive'] as bool; + + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + partner['name'], + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Row( + children: [ + Switch( + value: isActive, + onChanged: + (value) => controller.togglePartnerStatus(index), + activeColor: AppColorsPetugas.navyBlue, + ), + PopupMenuButton( + itemBuilder: + (context) => [ + const PopupMenuItem( + value: 'edit', + child: Text('Edit'), + ), + const PopupMenuItem( + value: 'delete', + child: Text('Hapus'), + ), + ], + onSelected: (value) { + switch (value) { + case 'edit': + _showEditPartnerDialog(partner, index); + break; + case 'delete': + _showDeletePartnerDialog(index); + break; + } + }, + ), + ], + ), + ], + ), + const SizedBox(height: 8), + _buildPartnerInfo('Email', partner['email']), + _buildPartnerInfo('Telepon', partner['phone']), + _buildPartnerInfo('Alamat', partner['address']), + Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: + isActive + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isActive ? 'Aktif' : 'Tidak Aktif', + style: TextStyle( + fontSize: 12, + color: isActive ? Colors.green : Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPartnerInfo(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + ), + Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), + ], + ), + ); + } + + Widget _buildBottomNav(PetugasBumdesDashboardController dashboardController) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavItem(Icons.dashboard, 'Dashboard', 0, dashboardController), + _buildNavItem(Icons.inventory, 'Aset', 1, dashboardController), + _buildNavItem(Icons.inventory_2, 'Paket', 2, dashboardController), + _buildNavItem(Icons.shopping_cart, 'Sewa', 3, dashboardController), + _buildNavItem( + Icons.subscriptions, + 'Langganan', + 4, + dashboardController, + ), + ], + ), + ), + ); + } + + Widget _buildNavItem( + IconData icon, + String label, + int index, + PetugasBumdesDashboardController dashboardController, + ) { + return InkWell( + onTap: () => dashboardController.changeTab(index), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColorsPetugas.blueGrotto, size: 24), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(fontSize: 12, color: AppColorsPetugas.blueGrotto), + ), + ], + ), + ); + } + + void _showAddBankAccountDialog() { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Tambah Rekening Bank', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const TextField( + decoration: InputDecoration( + labelText: 'Nama Bank', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration( + labelText: 'Nama Pemilik Rekening', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration( + labelText: 'Nomor Rekening', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Get.back(); + Get.snackbar( + 'Info', + 'Fitur tambah rekening bank sedang dalam pengembangan', + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + ), + child: const Text('Simpan'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showEditBankAccountDialog(Map account, int index) { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Edit Rekening Bank', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'Nama Bank', + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: account['bankName']), + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + labelText: 'Nama Pemilik Rekening', + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: account['accountName']), + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + labelText: 'Nomor Rekening', + border: OutlineInputBorder(), + ), + controller: TextEditingController( + text: account['accountNumber'], + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Get.back(); + Get.snackbar( + 'Info', + 'Fitur edit rekening bank sedang dalam pengembangan', + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + ), + child: const Text('Simpan'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showDeleteBankAccountDialog(int index) { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Hapus Rekening Bank', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const Text( + 'Apakah Anda yakin ingin menghapus rekening bank ini?', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Get.back(); + Get.snackbar( + 'Info', + 'Fitur hapus rekening bank sedang dalam pengembangan', + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Hapus'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showAddPartnerDialog() { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Tambah Mitra', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const TextField( + decoration: InputDecoration( + labelText: 'Nama Mitra', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration( + labelText: 'Telepon', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + const TextField( + decoration: InputDecoration( + labelText: 'Alamat', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Get.back(); + Get.snackbar( + 'Info', + 'Fitur tambah mitra sedang dalam pengembangan', + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + ), + child: const Text('Simpan'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showEditPartnerDialog(Map partner, int index) { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Edit Mitra', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + TextField( + decoration: const InputDecoration( + labelText: 'Nama Mitra', + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: partner['name']), + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: partner['email']), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + labelText: 'Telepon', + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: partner['phone']), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + labelText: 'Alamat', + border: OutlineInputBorder(), + ), + controller: TextEditingController(text: partner['address']), + maxLines: 2, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Get.back(); + Get.snackbar( + 'Info', + 'Fitur edit mitra sedang dalam pengembangan', + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.navyBlue, + ), + child: const Text('Simpan'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showDeletePartnerDialog(int index) { + Get.dialog( + Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Hapus Mitra', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const Text( + 'Apakah Anda yakin ingin menghapus mitra ini?', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + Get.back(); + Get.snackbar( + 'Info', + 'Fitur hapus mitra sedang dalam pengembangan', + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Hapus'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart new file mode 100644 index 0000000..cbd2353 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_paket_view.dart @@ -0,0 +1,700 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_paket_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import '../../../routes/app_routes.dart'; + +class PetugasPaketView extends GetView { + const PetugasPaketView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // Get dashboard controller for navigation + final dashboardController = Get.find(); + + return WillPopScope( + onWillPop: () async { + // Saat back button ditekan, kembali ke dashboard + dashboardController.changeTab(0); + return false; + }, + child: Scaffold( + appBar: AppBar( + title: const Text( + 'Manajemen Paket', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.sort, size: 22), + onPressed: () => _showSortingBottomSheet(context), + tooltip: 'Urutkan', + ), + const SizedBox(width: 8), + ], + ), + drawer: PetugasSideNavbar(controller: dashboardController), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + backgroundColor: AppColorsPetugas.babyBlueBright, + body: Column( + children: [_buildSearchBar(), Expanded(child: _buildPaketList())], + ), + bottomNavigationBar: Obx( + () => PetugasBumdesBottomNavbar( + selectedIndex: dashboardController.currentTabIndex.value, + onItemTapped: (index) => dashboardController.changeTab(index), + ), + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET), + label: Text( + 'Tambah Paket', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColorsPetugas.blueGrotto, + ), + ), + icon: Icon(Icons.add, color: AppColorsPetugas.blueGrotto), + backgroundColor: AppColorsPetugas.babyBlueBright, + ), + ), + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.shadowColor, + blurRadius: 10, + offset: const Offset(0, 1), + ), + ], + ), + child: TextField( + onChanged: controller.setSearchQuery, + decoration: InputDecoration( + hintText: 'Cari paket...', + hintStyle: TextStyle(color: Colors.grey.shade400), + prefixIcon: Icon( + Icons.search, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 0), + isDense: true, + ), + ), + ); + } + + Widget _buildPaketList() { + return Obx(() { + if (controller.isLoading.value) { + return Center( + child: CircularProgressIndicator( + color: AppColorsPetugas.blueGrotto, + strokeWidth: 3, + ), + ); + } + + if (controller.filteredPaketList.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.category_outlined, + size: 80, + color: AppColorsPetugas.babyBlue, + ), + const SizedBox(height: 24), + Text( + 'Tidak ada paket ditemukan', + style: TextStyle( + fontSize: 16, + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => Get.toNamed(Routes.PETUGAS_TAMBAH_PAKET), + icon: const Icon(Icons.add), + label: const Text('Tambah Paket'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.loadPaketData, + color: AppColorsPetugas.blueGrotto, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: controller.filteredPaketList.length, + itemBuilder: (context, index) { + final paket = controller.filteredPaketList[index]; + return _buildPaketCard(context, paket); + }, + ), + ); + }); + } + + Widget _buildPaketCard(BuildContext context, Map paket) { + final isAvailable = paket['tersedia'] == true; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColorsPetugas.shadowColor, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _showPaketDetails(context, paket), + child: Row( + children: [ + // Paket image or icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + child: Center( + child: Icon( + _getPaketIcon(paket['kategori']), + color: AppColorsPetugas.navyBlue, + size: 32, + ), + ), + ), + + // Paket info + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Name and price + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + paket['nama'], + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: AppColorsPetugas.navyBlue, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Rp ${_formatPrice(paket['harga'])}', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ), + + // Status badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: + isAvailable + ? AppColorsPetugas.successLight + : AppColorsPetugas.errorLight, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + width: 1, + ), + ), + child: Text( + isAvailable ? 'Aktif' : 'Nonaktif', + style: TextStyle( + fontSize: 10, + color: + isAvailable + ? AppColorsPetugas.success + : AppColorsPetugas.error, + fontWeight: FontWeight.w500, + ), + ), + ), + + // Action icons + const SizedBox(width: 12), + Row( + children: [ + // Edit icon + GestureDetector( + onTap: + () => _showAddEditPaketDialog( + context, + paket: paket, + ), + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: AppColorsPetugas.blueGrotto + .withOpacity(0.5), + ), + ), + child: Icon( + Icons.edit_outlined, + color: AppColorsPetugas.blueGrotto, + size: 16, + ), + ), + ), + + const SizedBox(width: 8), + + // Delete icon + GestureDetector( + onTap: + () => _showDeleteConfirmation(context, paket), + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: AppColorsPetugas.errorLight, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: AppColorsPetugas.error.withOpacity( + 0.5, + ), + ), + ), + child: Icon( + Icons.delete_outline, + color: AppColorsPetugas.error, + size: 16, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + String _formatPrice(dynamic price) { + if (price == null) return '0'; + + // Convert the price to string and handle formatting + String priceStr = price.toString(); + + // Add thousand separators + final RegExp reg = RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'); + String formatted = priceStr.replaceAllMapped(reg, (Match m) => '${m[1]}.'); + + return formatted; + } + + IconData _getPaketIcon(String? category) { + if (category == null) return Icons.category; + + switch (category.toLowerCase()) { + case 'bulanan': + return Icons.calendar_month; + case 'tahunan': + return Icons.calendar_today; + case 'premium': + return Icons.star; + case 'bisnis': + return Icons.business; + default: + return Icons.category; + } + } + + void _showSortingBottomSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Urutkan Paket', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 16), + ...controller.sortOptions.map((option) { + return Obx(() { + final isSelected = option == controller.sortBy.value; + return RadioListTile( + title: Text(option), + value: option, + groupValue: controller.sortBy.value, + activeColor: AppColorsPetugas.blueGrotto, + onChanged: (value) { + if (value != null) { + controller.setSortBy(value); + Navigator.pop(context); + } + }, + ); + }); + }).toList(), + ], + ), + ); + }, + ); + } + + void _showPaketDetails(BuildContext context, Map paket) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return Container( + padding: const EdgeInsets.all(16), + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + paket['nama'], + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ), + IconButton( + icon: Icon(Icons.close, color: AppColorsPetugas.blueGrotto), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView( + children: [ + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailItem('Kategori', paket['kategori']), + _buildDetailItem( + 'Harga', + controller.formatPrice(paket['harga']), + ), + _buildDetailItem( + 'Status', + paket['tersedia'] ? 'Tersedia' : 'Tidak Tersedia', + ), + _buildDetailItem('Deskripsi', paket['deskripsi']), + ], + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Item dalam Paket', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 8), + Card( + margin: EdgeInsets.zero, + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: paket['items'].length, + separatorBuilder: + (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = paket['items'][index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: AppColorsPetugas.babyBlue, + child: Icon( + Icons.inventory_2_outlined, + color: AppColorsPetugas.blueGrotto, + size: 16, + ), + ), + title: Text(item['nama']), + trailing: Text( + '${item['jumlah']} unit', + style: TextStyle( + color: AppColorsPetugas.blueGrotto, + fontWeight: FontWeight.bold, + ), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.pop(context); + Get.toNamed( + Routes.PETUGAS_TAMBAH_PAKET, + arguments: paket, + ); + }, + icon: const Icon(Icons.edit), + label: const Text('Edit'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColorsPetugas.blueGrotto, + side: BorderSide(color: AppColorsPetugas.blueGrotto), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + _showDeleteConfirmation(context, paket); + }, + icon: const Icon(Icons.delete), + label: const Text('Hapus'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.error, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + + Widget _buildDetailItem(String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle(fontSize: 14, color: AppColorsPetugas.blueGrotto), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + ); + } + + void _showAddEditPaketDialog( + BuildContext context, { + Map? paket, + }) { + final isEditing = paket != null; + + // This would be implemented with proper form validation in a real app + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + isEditing ? 'Edit Paket' : 'Tambah Paket Baru', + style: TextStyle(color: AppColorsPetugas.navyBlue), + ), + content: const Text( + 'Form pengelolaan paket akan ditampilkan di sini dengan field untuk nama, kategori, harga, deskripsi, status, dan item-item dalam paket.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: TextStyle(color: Colors.grey.shade600), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + // In a real app, we would save the form data + Get.snackbar( + isEditing ? 'Paket Diperbarui' : 'Paket Ditambahkan', + isEditing + ? 'Paket berhasil diperbarui' + : 'Paket baru berhasil ditambahkan', + backgroundColor: AppColorsPetugas.success, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + ), + child: Text(isEditing ? 'Simpan' : 'Tambah'), + ), + ], + ); + }, + ); + } + + void _showDeleteConfirmation( + BuildContext context, + Map paket, + ) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text( + 'Konfirmasi Hapus', + style: TextStyle(color: AppColorsPetugas.navyBlue), + ), + content: Text( + 'Apakah Anda yakin ingin menghapus paket "${paket['nama']}"?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: TextStyle(color: Colors.grey.shade600), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + controller.deletePaket(paket['id']); + Get.snackbar( + 'Paket Dihapus', + 'Paket berhasil dihapus dari sistem', + backgroundColor: AppColorsPetugas.error, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.error, + ), + child: const Text('Hapus'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart new file mode 100644 index 0000000..72ab0bb --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_sewa_view.dart @@ -0,0 +1,682 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/petugas_sewa_controller.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../widgets/petugas_bumdes_bottom_navbar.dart'; +import '../widgets/petugas_side_navbar.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; +import 'petugas_detail_sewa_view.dart'; + +class PetugasSewaView extends StatefulWidget { + const PetugasSewaView({Key? key}) : super(key: key); + + @override + State createState() => _PetugasSewaViewState(); +} + +class _PetugasSewaViewState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late PetugasSewaController controller; + late PetugasBumdesDashboardController dashboardController; + + @override + void initState() { + super.initState(); + controller = Get.find(); + dashboardController = Get.find(); + + _tabController = TabController( + length: controller.statusFilters.length, + vsync: this, + ); + + // Add listener to sync tab selection with controller's filter + _tabController.addListener(_onTabChanged); + + // Listen to controller's filter changes + ever(controller.selectedStatusFilter, _onFilterChanged); + } + + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + final selectedStatus = controller.statusFilters[_tabController.index]; + controller.setStatusFilter(selectedStatus); + } + } + + void _onFilterChanged(String status) { + final index = controller.statusFilters.indexOf(status); + if (index != -1 && index != _tabController.index) { + _tabController.animateTo(index); + } + } + + @override + void dispose() { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + dashboardController.changeTab(0); + return false; + }, + child: Scaffold( + appBar: AppBar( + title: const Text( + 'Manajemen Sewa', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + backgroundColor: AppColorsPetugas.navyBlue, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.filter_alt_outlined, size: 22), + onPressed: () => _showFilterBottomSheet(), + tooltip: 'Filter', + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + decoration: const BoxDecoration( + color: AppColorsPetugas.navyBlue, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: TabBar( + controller: _tabController, + isScrollable: true, + indicator: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(30), + ), + indicatorSize: TabBarIndicatorSize.tab, + indicatorPadding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 4, + ), + labelColor: Colors.white, + unselectedLabelColor: Colors.white.withOpacity(0.7), + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w400, + fontSize: 14, + ), + tabs: + controller.statusFilters + .map( + (status) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Tab(text: status), + ), + ) + .toList(), + dividerColor: Colors.transparent, + ), + ), + ), + ), + drawer: PetugasSideNavbar(controller: dashboardController), + drawerEdgeDragWidth: 60, + drawerScrimColor: Colors.black.withOpacity(0.6), + backgroundColor: Colors.grey.shade50, + body: Column( + children: [ + _buildSearchSection(), + Expanded( + child: TabBarView( + controller: _tabController, + children: + controller.statusFilters.map((status) { + return _buildSewaListForStatus(status); + }).toList(), + ), + ), + ], + ), + bottomNavigationBar: Obx( + () => PetugasBumdesBottomNavbar( + selectedIndex: dashboardController.currentTabIndex.value, + onItemTapped: (index) => dashboardController.changeTab(index), + ), + ), + ), + ); + } + + Widget _buildSearchSection() { + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + onChanged: (value) { + controller.setSearchQuery(value); + controller.setOrderIdQuery(value); + }, + decoration: InputDecoration( + hintText: 'Cari nama warga atau ID pesanan...', + hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14), + prefixIcon: Container( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.search_rounded, + color: AppColorsPetugas.blueGrotto, + size: 22, + ), + ), + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(50), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(50), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(50), + borderSide: BorderSide.none, + ), + contentPadding: EdgeInsets.zero, + isDense: true, + suffixIcon: Icon( + Icons.tune_rounded, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + ), + ), + ); + } + + Widget _buildSewaListForStatus(String status) { + return Obx(() { + if (controller.isLoading.value) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: AppColorsPetugas.blueGrotto, + strokeWidth: 3, + ), + const SizedBox(height: 16), + Text( + 'Memuat data...', + style: TextStyle( + color: AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + final filteredList = + status == 'Semua' + ? controller.filteredSewaList + : status == 'Periksa Pembayaran' + ? controller.sewaList + .where( + (sewa) => + sewa['status'] == 'Periksa Pembayaran' || + sewa['status'] == 'Pembayaran Denda' || + sewa['status'] == 'Periksa Denda', + ) + .toList() + : controller.sewaList + .where((sewa) => sewa['status'] == status) + .toList(); + + if (filteredList.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + shape: BoxShape.circle, + ), + child: Icon( + Icons.inventory_2_outlined, + size: 70, + color: AppColorsPetugas.blueGrotto, + ), + ), + const SizedBox(height: 24), + Text( + 'Tidak ada sewa ditemukan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 12), + Text( + status == 'Semua' + ? 'Belum ada data sewa untuk kriteria yang dipilih' + : status == 'Periksa Pembayaran' + ? 'Belum ada data sewa yang perlu pembayaran diverifikasi atau memiliki denda' + : 'Belum ada data sewa dengan status "$status"', + style: TextStyle( + fontSize: 14, + color: AppColorsPetugas.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.loadSewaData, + color: AppColorsPetugas.blueGrotto, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filteredList.length, + itemBuilder: (context, index) { + final sewa = filteredList[index]; + return _buildSewaCard(context, sewa); + }, + ), + ); + }); + } + + Widget _buildSewaCard(BuildContext context, Map sewa) { + final statusColor = controller.getStatusColor(sewa['status']); + final status = sewa['status']; + + // Get appropriate icon for status + IconData statusIcon; + switch (status) { + case 'Menunggu Pembayaran': + statusIcon = Icons.payments_outlined; + break; + case 'Periksa Pembayaran': + statusIcon = Icons.fact_check_outlined; + break; + case 'Diterima': + statusIcon = Icons.check_circle_outlined; + break; + case 'Pembayaran Denda': + statusIcon = Icons.money_off_csred_outlined; + break; + case 'Periksa Denda': + statusIcon = Icons.assignment_late_outlined; + break; + case 'Dikembalikan': + statusIcon = Icons.assignment_return_outlined; + break; + case 'Selesai': + statusIcon = Icons.task_alt_outlined; + break; + case 'Dibatalkan': + statusIcon = Icons.cancel_outlined; + break; + default: + statusIcon = Icons.help_outline_rounded; + } + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () => Get.to(() => PetugasDetailSewaView(sewa: sewa)), + borderRadius: BorderRadius.circular(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Row( + children: [ + // Customer Circle Avatar + CircleAvatar( + radius: 24, + backgroundColor: AppColorsPetugas.babyBlueLight, + child: Text( + sewa['nama_warga'].substring(0, 1).toUpperCase(), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ), + ), + const SizedBox(width: 16), + + // Customer details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sewa['nama_warga'], + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(30), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + statusIcon, + size: 12, + color: statusColor, + ), + const SizedBox(width: 4), + Text( + status, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + '#${sewa['order_id']}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ], + ), + ), + + // Price + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + controller.formatPrice(sewa['total_biaya']), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.blueGrotto, + ), + ), + ), + ], + ), + ), + + // Divider + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + child: Divider(height: 1, color: Colors.grey.shade200), + ), + + // Asset details + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Row( + children: [ + // Asset icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.inventory_2_outlined, + size: 20, + color: AppColorsPetugas.blueGrotto, + ), + ), + const SizedBox(width: 12), + + // Asset name and duration + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sewa['nama_aset'], + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Icon( + Icons.calendar_today_rounded, + size: 12, + color: AppColorsPetugas.textSecondary, + ), + const SizedBox(width: 4), + Text( + '${sewa['tanggal_mulai']} - ${sewa['tanggal_selesai']}', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + ), + ], + ), + ], + ), + ), + + // Chevron icon + Icon( + Icons.chevron_right_rounded, + color: AppColorsPetugas.textSecondary, + size: 20, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _showFilterBottomSheet() { + Get.bottomSheet( + Container( + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Filter', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + ), + ], + ), + const SizedBox(height: 20), + const Text( + 'Status', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + Obx(() { + return Wrap( + spacing: 8, + children: + controller.statusFilters.map((status) { + final isSelected = + status == controller.selectedStatusFilter.value; + return ChoiceChip( + label: Text(status), + selected: isSelected, + selectedColor: AppColorsPetugas.blueGrotto, + backgroundColor: Colors.white, + labelStyle: TextStyle( + color: + isSelected + ? Colors.white + : AppColorsPetugas.textSecondary, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: + isSelected + ? AppColorsPetugas.blueGrotto + : Colors.grey.shade300, + width: 1, + ), + ), + onSelected: (selected) { + if (selected) { + controller.setStatusFilter(status); + Get.back(); + } + }, + ); + }).toList(), + ); + }), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OutlinedButton( + onPressed: () { + controller.resetFilters(); + Get.back(); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColorsPetugas.blueGrotto), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + child: Text( + 'Reset', + style: TextStyle(color: AppColorsPetugas.blueGrotto), + ), + ), + ElevatedButton( + onPressed: () { + controller.applyFilters(); + Get.back(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + child: const Text( + 'Terapkan', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ], + ), + ), + isDismissible: true, + enableDrag: true, + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart new file mode 100644 index 0000000..4873099 --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_tambah_aset_view.dart @@ -0,0 +1,871 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../controllers/petugas_tambah_aset_controller.dart'; + +class PetugasTambahAsetView extends GetView { + const PetugasTambahAsetView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text( + 'Tambah Aset', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: AppColorsPetugas.navyBlue, + elevation: 0, + centerTitle: true, + ), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_buildHeaderSection(), _buildFormSection(context)], + ), + ), + ), + bottomNavigationBar: _buildBottomBar(), + ); + } + + Widget _buildHeaderSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.inventory_2_outlined, + color: Colors.white, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Informasi Aset Baru', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Isi data dengan lengkap untuk menambahkan aset', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.8), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildFormSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + + // Basic Information Section + _buildSectionHeader( + icon: Icons.info_outline, + title: 'Informasi Dasar', + ), + const SizedBox(height: 16), + _buildTextField( + label: 'Nama Aset', + hint: 'Masukkan nama aset', + controller: controller.nameController, + isRequired: true, + prefixIcon: Icons.title, + ), + const SizedBox(height: 16), + _buildTextField( + label: 'Deskripsi', + hint: 'Masukkan deskripsi aset', + controller: controller.descriptionController, + maxLines: 3, + isRequired: true, + prefixIcon: Icons.description, + ), + const SizedBox(height: 24), + + // Media Section + _buildSectionHeader( + icon: Icons.photo_library, + title: 'Media & Gambar', + ), + const SizedBox(height: 16), + _buildImageUploader(), + const SizedBox(height: 24), + + // Category Section + _buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'), + const SizedBox(height: 16), + + // Category and Status as cards + Row( + children: [ + Expanded( + child: _buildCategorySelect( + title: 'Kategori', + options: controller.categoryOptions, + selectedOption: controller.selectedCategory, + onChanged: controller.setCategory, + icon: Icons.inventory_2, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildCategorySelect( + title: 'Status', + options: controller.statusOptions, + selectedOption: controller.selectedStatus, + onChanged: controller.setStatus, + icon: Icons.check_circle, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Quantity Section + _buildSectionHeader( + icon: Icons.format_list_numbered, + title: 'Kuantitas & Pengukuran', + ), + const SizedBox(height: 16), + + // Quantity fields + Row( + children: [ + Expanded( + flex: 2, + child: _buildTextField( + label: 'Kuantitas', + hint: 'Jumlah aset', + controller: controller.quantityController, + isRequired: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + prefixIcon: Icons.numbers, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 3, + child: _buildTextField( + label: 'Satuan Ukur', + hint: 'contoh: Unit, Buah', + controller: controller.unitOfMeasureController, + prefixIcon: Icons.straighten, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Rental Options Section + _buildSectionHeader( + icon: Icons.schedule, + title: 'Opsi Waktu & Harga Sewa', + ), + const SizedBox(height: 16), + + // Time Options as cards + _buildTimeOptionsCards(), + const SizedBox(height: 16), + + // Rental price fields based on selection + Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Per Hour Option + if (controller.timeOptions['Per Jam']!.value) + _buildPriceCard( + title: 'Harga Per Jam', + icon: Icons.timer, + priceController: controller.pricePerHourController, + maxController: controller.maxHourController, + maxLabel: 'Maksimal Jam', + ), + + if (controller.timeOptions['Per Jam']!.value && + controller.timeOptions['Per Hari']!.value) + const SizedBox(height: 16), + + // Per Day Option + if (controller.timeOptions['Per Hari']!.value) + _buildPriceCard( + title: 'Harga Per Hari', + icon: Icons.calendar_today, + priceController: controller.pricePerDayController, + maxController: controller.maxDayController, + maxLabel: 'Maksimal Hari', + ), + ], + ), + ), + + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildTimeOptionsCards() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: + controller.timeOptions.entries.map((entry) { + final option = entry.key; + final isSelected = entry.value; + + return Obx( + () => Material( + color: Colors.transparent, + child: InkWell( + onTap: () => controller.toggleTimeOption(option), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: + isSelected.value + ? AppColorsPetugas.blueGrotto.withOpacity( + 0.1, + ) + : Colors.grey.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon( + option == 'Per Jam' + ? Icons.hourglass_bottom + : Icons.calendar_today, + color: + isSelected.value + ? AppColorsPetugas.blueGrotto + : Colors.grey, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + option, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: + isSelected.value + ? AppColorsPetugas.navyBlue + : Colors.grey.shade700, + ), + ), + const SizedBox(height: 2), + Text( + option == 'Per Jam' + ? 'Sewa aset dengan basis perhitungan per jam' + : 'Sewa aset dengan basis perhitungan per hari', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Checkbox( + value: isSelected.value, + onChanged: + (_) => controller.toggleTimeOption(option), + activeColor: AppColorsPetugas.blueGrotto, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildPriceCard({ + required String title, + required IconData icon, + required TextEditingController priceController, + required TextEditingController maxController, + required String maxLabel, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: AppColorsPetugas.blueGrotto), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Harga Sewa', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: priceController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: 'Masukkan harga', + hintStyle: TextStyle(color: AppColorsPetugas.textLight), + prefixText: 'Rp ', + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + maxLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: maxController, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + hintText: 'Opsional', + hintStyle: TextStyle(color: AppColorsPetugas.textLight), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildSectionHeader({required IconData icon, required String title}) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 20), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ); + } + + Widget _buildTextField({ + required String label, + required String hint, + required TextEditingController controller, + bool isRequired = false, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, + List? inputFormatters, + String? prefixText, + IconData? prefixIcon, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + if (isRequired) ...[ + const SizedBox(width: 4), + Text( + '*', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: AppColorsPetugas.textLight), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + prefixText: prefixText, + prefixIcon: + prefixIcon != null + ? Icon( + prefixIcon, + size: 20, + color: AppColorsPetugas.textSecondary, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: AppColorsPetugas.blueGrotto, + width: 1.5, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ], + ); + } + + Widget _buildCategorySelect({ + required String title, + required List options, + required RxString selectedOption, + required Function(String) onChanged, + required IconData icon, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Obx( + () => DropdownButtonFormField( + value: selectedOption.value, + decoration: InputDecoration( + prefixIcon: Icon( + icon, + color: AppColorsPetugas.blueGrotto, + size: 20, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + ), + items: + options.map((option) { + return DropdownMenuItem( + value: option, + child: Text( + option, + style: TextStyle( + color: AppColorsPetugas.textPrimary, + fontSize: 14, + ), + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) onChanged(value); + }, + icon: Icon( + Icons.keyboard_arrow_down_rounded, + color: AppColorsPetugas.blueGrotto, + ), + dropdownColor: Colors.white, + ), + ), + ), + ], + ); + } + + Widget _buildImageUploader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Unggah Foto Aset', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 4), + Text( + 'Tambahkan foto aset untuk informasi visual.', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 16), + Obx( + () => Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // Add button + GestureDetector( + onTap: () => controller.addSampleImage(), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColorsPetugas.babyBlue, + width: 1, + style: BorderStyle.solid, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate_outlined, + color: AppColorsPetugas.blueGrotto, + size: 32, + ), + const SizedBox(height: 8), + Text( + 'Tambah Foto', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.blueGrotto, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + + // Image previews + ...controller.selectedImages.asMap().entries.map((entry) { + final index = entry.key; + return Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 100, + height: 100, + color: AppColorsPetugas.babyBlueLight, + child: Center( + child: Icon( + Icons.image, + color: AppColorsPetugas.blueGrotto, + size: 40, + ), + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => controller.removeImage(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Icon( + Icons.close, + color: AppColorsPetugas.error, + size: 16, + ), + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBottomBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back), + label: const Text('Batal'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColorsPetugas.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + side: BorderSide(color: AppColorsPetugas.divider), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + final isValid = controller.isFormValid.value; + final isSubmitting = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isValid && !isSubmitting ? controller.saveAsset : null, + icon: + isSubmitting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.save), + label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Aset'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + disabledBackgroundColor: AppColorsPetugas.textLight, + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart b/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart new file mode 100644 index 0000000..acb279c --- /dev/null +++ b/lib/app/modules/petugas_bumdes/views/petugas_tambah_paket_view.dart @@ -0,0 +1,932 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../../theme/app_colors_petugas.dart'; +import '../controllers/petugas_tambah_paket_controller.dart'; + +class PetugasTambahPaketView extends GetView { + const PetugasTambahPaketView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text( + 'Tambah Paket', + style: TextStyle(fontWeight: FontWeight.w600), + ), + backgroundColor: AppColorsPetugas.navyBlue, + elevation: 0, + centerTitle: true, + ), + body: SafeArea( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_buildHeaderSection(), _buildFormSection(context)], + ), + ), + ), + bottomNavigationBar: _buildBottomBar(), + ); + } + + Widget _buildHeaderSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColorsPetugas.navyBlue, AppColorsPetugas.blueGrotto], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.category, + color: Colors.white, + size: 28, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Informasi Paket Baru', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Isi data dengan lengkap untuk menambahkan paket', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.8), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildFormSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + + // Basic Information Section + _buildSectionHeader( + icon: Icons.info_outline, + title: 'Informasi Dasar', + ), + const SizedBox(height: 16), + _buildTextField( + label: 'Nama Paket', + hint: 'Masukkan nama paket', + controller: controller.nameController, + isRequired: true, + prefixIcon: Icons.title, + ), + const SizedBox(height: 16), + _buildTextField( + label: 'Deskripsi', + hint: 'Masukkan deskripsi paket', + controller: controller.descriptionController, + maxLines: 3, + isRequired: true, + prefixIcon: Icons.description, + ), + const SizedBox(height: 24), + + // Media Section + _buildSectionHeader( + icon: Icons.photo_library, + title: 'Media & Gambar', + ), + const SizedBox(height: 16), + _buildImageUploader(), + const SizedBox(height: 24), + + // Category Section + _buildSectionHeader(icon: Icons.category, title: 'Kategori & Status'), + const SizedBox(height: 16), + + // Category and Status as cards + Row( + children: [ + Expanded( + child: _buildCategorySelect( + title: 'Kategori', + options: controller.categoryOptions, + selectedOption: controller.selectedCategory, + onChanged: controller.setCategory, + icon: Icons.category, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildCategorySelect( + title: 'Status', + options: controller.statusOptions, + selectedOption: controller.selectedStatus, + onChanged: controller.setStatus, + icon: Icons.check_circle, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Price Section + _buildSectionHeader( + icon: Icons.monetization_on, + title: 'Harga Paket', + ), + const SizedBox(height: 16), + _buildTextField( + label: 'Harga Paket', + hint: 'Masukkan harga paket', + controller: controller.priceController, + isRequired: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + prefixText: 'Rp ', + prefixIcon: Icons.payments, + ), + const SizedBox(height: 24), + + // Package Items Section + _buildSectionHeader( + icon: Icons.inventory_2, + title: 'Item dalam Paket', + ), + const SizedBox(height: 16), + _buildPackageItems(), + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildPackageItems() { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Item Paket', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ElevatedButton.icon( + onPressed: () => _showAddItemDialog(), + icon: const Icon(Icons.add), + label: const Text('Tambah Item'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlueLight, + foregroundColor: AppColorsPetugas.blueGrotto, + ), + ), + ], + ), + const SizedBox(height: 16), + Obx( + () => + controller.packageItems.isEmpty + ? const Center( + child: Text( + 'Belum ada item dalam paket', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.packageItems.length, + itemBuilder: (context, index) { + final item = controller.packageItems[index]; + return Card( + elevation: 1, + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(item['nama'] ?? 'Item Paket'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Jumlah: ${item['jumlah']}'), + if (item['stok'] != null) + Text('Stok tersedia: ${item['stok']}'), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon( + Icons.edit, + color: Colors.blue, + ), + onPressed: () => _showEditItemDialog(index), + ), + IconButton( + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + onPressed: + () => controller.removeItem(index), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _showAddItemDialog() { + // Reset controllers + controller.selectedAsset.value = null; + controller.itemQuantityController.clear(); + + // Fetch available assets + controller.fetchAvailableAssets(); + + Get.dialog( + Dialog( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoadingAssets.value) { + return const SizedBox( + height: 150, + child: Center(child: CircularProgressIndicator()), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Tambah Item ke Paket', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // Asset dropdown + DropdownButtonFormField( + value: controller.selectedAsset.value, + decoration: const InputDecoration( + labelText: 'Pilih Aset', + border: OutlineInputBorder(), + ), + hint: const Text('Pilih Aset'), + items: + controller.availableAssets.map((asset) { + return DropdownMenuItem( + value: asset['id'] as int, + child: Text( + '${asset['nama']} (Stok: ${asset['stok']})', + ), + ); + }).toList(), + onChanged: (value) { + controller.setSelectedAsset(value); + }, + ), + + const SizedBox(height: 16), + + // Quantity field + Obx(() { + // Calculate max quantity based on selected asset + String? helperText; + if (controller.selectedAsset.value != null) { + final remaining = controller.getRemainingStock( + controller.selectedAsset.value!, + ); + helperText = 'Maksimal: $remaining unit'; + } + + return TextFormField( + controller: controller.itemQuantityController, + decoration: InputDecoration( + labelText: 'Jumlah', + border: const OutlineInputBorder(), + helperText: helperText, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ); + }), + + const SizedBox(height: 24), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + controller.addAssetToPackage(); + Get.back(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlueLight, + foregroundColor: AppColorsPetugas.blueGrotto, + ), + child: const Text('Tambah'), + ), + ], + ), + ], + ); + }), + ), + ), + ); + } + + void _showEditItemDialog(int index) { + final item = controller.packageItems[index]; + + // Set controllers + controller.selectedAsset.value = item['asetId']; + controller.itemQuantityController.text = item['jumlah'].toString(); + + // Fetch available assets + controller.fetchAvailableAssets(); + + Get.dialog( + Dialog( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() { + if (controller.isLoadingAssets.value) { + return const SizedBox( + height: 150, + child: Center(child: CircularProgressIndicator()), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Edit Item Paket', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + + // Asset dropdown + DropdownButtonFormField( + value: controller.selectedAsset.value, + decoration: const InputDecoration( + labelText: 'Pilih Aset', + border: OutlineInputBorder(), + ), + hint: const Text('Pilih Aset'), + items: + controller.availableAssets.map((asset) { + return DropdownMenuItem( + value: asset['id'] as int, + child: Text( + '${asset['nama']} (Stok: ${asset['stok']})', + ), + ); + }).toList(), + onChanged: (value) { + controller.setSelectedAsset(value); + }, + ), + + const SizedBox(height: 16), + + // Quantity field + Obx(() { + // Calculate max quantity based on selected asset + String? helperText; + if (controller.selectedAsset.value != null) { + // Get the appropriate max quantity for editing + final currentItem = controller.packageItems[index]; + final isCurrentAsset = + currentItem['asetId'] == controller.selectedAsset.value; + + int maxQuantity; + if (isCurrentAsset) { + // For same asset, include current quantity in calculation + final asset = controller.availableAssets.firstWhere( + (a) => a['id'] == controller.selectedAsset.value, + orElse: () => {'stok': 0}, + ); + + final totalUsed = controller.packageItems + .where( + (item) => + item['asetId'] == + controller.selectedAsset.value && + controller.packageItems.indexOf(item) != index, + ) + .fold( + 0, + (sum, item) => sum + (item['jumlah'] as int), + ); + + maxQuantity = (asset['stok'] as int) - totalUsed; + } else { + // For different asset, use remaining stock + maxQuantity = controller.getRemainingStock( + controller.selectedAsset.value!, + ); + } + + helperText = 'Maksimal: $maxQuantity unit'; + } + + return TextFormField( + controller: controller.itemQuantityController, + decoration: InputDecoration( + labelText: 'Jumlah', + border: const OutlineInputBorder(), + helperText: helperText, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ); + }), + + const SizedBox(height: 24), + + // Action buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Batal'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () { + controller.updatePackageItem(index); + Get.back(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.babyBlueLight, + foregroundColor: AppColorsPetugas.blueGrotto, + ), + child: const Text('Simpan'), + ), + ], + ), + ], + ); + }), + ), + ), + ); + } + + Widget _buildSectionHeader({required IconData icon, required String title}) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColorsPetugas.blueGrotto.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppColorsPetugas.blueGrotto, size: 20), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorsPetugas.navyBlue, + ), + ), + ], + ); + } + + Widget _buildTextField({ + required String label, + required String hint, + required TextEditingController controller, + bool isRequired = false, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, + List? inputFormatters, + String? prefixText, + IconData? prefixIcon, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + if (isRequired) ...[ + const SizedBox(width: 4), + Text( + '*', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ], + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(color: AppColorsPetugas.textLight), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + prefixText: prefixText, + prefixIcon: + prefixIcon != null + ? Icon( + prefixIcon, + size: 20, + color: AppColorsPetugas.textSecondary, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: AppColorsPetugas.blueGrotto, + width: 1.5, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ], + ); + } + + Widget _buildCategorySelect({ + required String title, + required List options, + required RxString selectedOption, + required Function(String) onChanged, + required IconData icon, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColorsPetugas.textPrimary, + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Obx( + () => DropdownButtonFormField( + value: selectedOption.value, + decoration: InputDecoration( + prefixIcon: Icon( + icon, + color: AppColorsPetugas.blueGrotto, + size: 20, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + filled: true, + fillColor: AppColorsPetugas.babyBlueBright, + ), + items: + options.map((option) { + return DropdownMenuItem( + value: option, + child: Text( + option, + style: TextStyle( + color: AppColorsPetugas.textPrimary, + fontSize: 14, + ), + ), + ); + }).toList(), + onChanged: (value) { + if (value != null) onChanged(value); + }, + icon: Icon( + Icons.keyboard_arrow_down_rounded, + color: AppColorsPetugas.blueGrotto, + ), + dropdownColor: Colors.white, + ), + ), + ), + ], + ); + } + + Widget _buildImageUploader() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColorsPetugas.babyBlue.withOpacity(0.5)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Unggah Foto Paket', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorsPetugas.navyBlue, + ), + ), + const SizedBox(height: 4), + Text( + 'Tambahkan foto paket untuk informasi visual.', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.textSecondary, + ), + ), + const SizedBox(height: 16), + Obx( + () => Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // Add button + GestureDetector( + onTap: () => controller.addSampleImage(), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueBright, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColorsPetugas.babyBlue, + width: 1, + style: BorderStyle.solid, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate_outlined, + color: AppColorsPetugas.blueGrotto, + size: 32, + ), + const SizedBox(height: 8), + Text( + 'Tambah Foto', + style: TextStyle( + fontSize: 12, + color: AppColorsPetugas.blueGrotto, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + + // Image previews + ...controller.selectedImages.asMap().entries.map((entry) { + final index = entry.key; + return Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: AppColorsPetugas.babyBlueLight, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + width: 100, + height: 100, + color: AppColorsPetugas.babyBlueLight, + child: Center( + child: Icon( + Icons.image, + color: AppColorsPetugas.blueGrotto, + size: 40, + ), + ), + ), + ), + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => controller.removeImage(index), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Icon( + Icons.close, + color: AppColorsPetugas.error, + size: 16, + ), + ), + ), + ), + ], + ), + ); + }), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBottomBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -5), + ), + ], + ), + child: Row( + children: [ + OutlinedButton.icon( + onPressed: () => Get.back(), + icon: const Icon(Icons.arrow_back), + label: const Text('Batal'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColorsPetugas.textSecondary, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + side: BorderSide(color: AppColorsPetugas.divider), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Obx(() { + final isValid = controller.isFormValid.value; + final isSubmitting = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isValid && !isSubmitting ? controller.savePaket : null, + icon: + isSubmitting + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.save), + label: Text(isSubmitting ? 'Menyimpan...' : 'Simpan Paket'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColorsPetugas.blueGrotto, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + disabledBackgroundColor: AppColorsPetugas.textLight, + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart b/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart new file mode 100644 index 0000000..1ba74aa --- /dev/null +++ b/lib/app/modules/petugas_bumdes/widgets/petugas_bumdes_bottom_navbar.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../routes/app_routes.dart'; +import '../../../theme/app_colors.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; + +class PetugasBumdesBottomNavbar extends StatelessWidget { + final int selectedIndex; + final Function(int) onItemTapped; + + const PetugasBumdesBottomNavbar({ + super.key, + required this.selectedIndex, + required this.onItemTapped, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 76, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.07), + blurRadius: 14, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildNavItem( + context: context, + icon: Icons.dashboard_outlined, + activeIcon: Icons.dashboard, + label: 'Dashboard', + isSelected: selectedIndex == 0, + onTap: () => onItemTapped(0), + ), + _buildNavItem( + context: context, + icon: Icons.inventory_2_outlined, + activeIcon: Icons.inventory_2, + label: 'Aset', + isSelected: selectedIndex == 1, + onTap: () => onItemTapped(1), + ), + _buildNavItem( + context: context, + icon: Icons.category_outlined, + activeIcon: Icons.category, + label: 'Paket', + isSelected: selectedIndex == 2, + onTap: () => onItemTapped(2), + ), + _buildNavItem( + context: context, + icon: Icons.shopping_cart_outlined, + activeIcon: Icons.shopping_cart, + label: 'Sewa', + isSelected: selectedIndex == 3, + onTap: () => onItemTapped(3), + ), + ], + ), + ); + } + + // Modern navigation item for bottom bar + Widget _buildNavItem({ + required BuildContext context, + required IconData icon, + required IconData activeIcon, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + final primaryColor = AppColors.primary; + final tabWidth = MediaQuery.of(context).size.width / 4; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + splashColor: primaryColor.withOpacity(0.1), + highlightColor: primaryColor.withOpacity(0.05), + child: SizedBox( + width: tabWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Indicator line at top + AnimatedContainer( + duration: const Duration(milliseconds: 250), + height: 2, + width: tabWidth * 0.5, + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: isSelected ? primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + + // Icon with animated scale effect when selected + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.all(isSelected ? 8 : 0), + decoration: BoxDecoration( + color: + isSelected + ? primaryColor.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isSelected ? activeIcon : icon, + color: isSelected ? primaryColor : Colors.grey.shade400, + size: 22, + ), + ), + + const SizedBox(height: 4), + + // Label with animated opacity + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontSize: 10, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? primaryColor : Colors.grey.shade500, + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart b/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart new file mode 100644 index 0000000..055dd6f --- /dev/null +++ b/lib/app/modules/petugas_bumdes/widgets/petugas_side_navbar.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../theme/app_colors.dart'; +import '../controllers/petugas_bumdes_dashboard_controller.dart'; + +class PetugasSideNavbar extends StatelessWidget { + final PetugasBumdesDashboardController controller; + + const PetugasSideNavbar({super.key, required this.controller}); + + @override + Widget build(BuildContext context) { + return Drawer( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(0), + bottomRight: Radius.circular(0), + ), + ), + child: Column( + children: [ + _buildHeader(), + Expanded(child: _buildMenu()), + _buildFooter(context), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 60, 20, 20), + color: AppColors.primary, + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: CircleAvatar( + radius: 30, + backgroundColor: Colors.white, + child: Icon(Icons.person, color: AppColors.primary, size: 36), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Petugas BUMDes', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Obx( + () => Text( + controller.userEmail.value, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildMenu() { + return Obx( + () => ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + _buildSectionHeader('Menu Utama'), + _buildMenuItem( + icon: Icons.dashboard_outlined, + activeIcon: Icons.dashboard, + title: 'Dashboard', + subtitle: 'Ringkasan aktivitas', + isSelected: controller.currentTabIndex.value == 0, + onTap: () => controller.changeTab(0), + ), + _buildMenuItem( + icon: Icons.inventory_2_outlined, + activeIcon: Icons.inventory_2, + title: 'Aset', + subtitle: 'Kelola aset BUMDes', + isSelected: controller.currentTabIndex.value == 1, + onTap: () => controller.changeTab(1), + ), + _buildMenuItem( + icon: Icons.category_outlined, + activeIcon: Icons.category, + title: 'Paket', + subtitle: 'Kelola paket aset', + isSelected: controller.currentTabIndex.value == 2, + onTap: () => controller.changeTab(2), + ), + _buildMenuItem( + icon: Icons.shopping_cart_outlined, + activeIcon: Icons.shopping_cart, + title: 'Sewa', + subtitle: 'Kelola sewa aset', + isSelected: controller.currentTabIndex.value == 3, + onTap: () => controller.changeTab(3), + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + title, + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ); + } + + Widget _buildMenuItem({ + required IconData icon, + required IconData activeIcon, + required String title, + required String subtitle, + required bool isSelected, + required VoidCallback onTap, + }) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isSelected ? AppColors.primarySoft : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + isSelected + ? AppColors.primary.withOpacity(0.15) + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + isSelected ? activeIcon : icon, + color: isSelected ? AppColors.primary : Colors.grey.shade600, + size: 20, + ), + ), + title: Text( + title, + style: TextStyle( + color: isSelected ? AppColors.primary : Colors.black87, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + fontSize: 15, + ), + ), + subtitle: Text( + subtitle, + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + trailing: + isSelected + ? Container( + width: 4, + height: 36, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(10), + ), + ) + : null, + onTap: onTap, + ), + ); + } + + Widget _buildFooter(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.shade200, + blurRadius: 4, + offset: const Offset(0, -1), + ), + ], + ), + child: Column( + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.logout, color: Colors.red.shade400, size: 20), + ), + title: const Text( + 'Keluar', + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 15), + ), + subtitle: const Text( + 'Keluar dari aplikasi', + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + onTap: () => _showLogoutConfirmation(context), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '© 2025 BumRent App', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + 'assets/images/logo.png', + width: 24, + height: 24, + ), + ), + ], + ), + ], + ), + ); + } + + void _showLogoutConfirmation(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Konfirmasi Keluar'), + content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: Colors.grey.shade700, + ), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + controller.logout(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade400, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Keluar'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/app/modules/petugas_mitra/controllers/petugas_mitra_dashboard_controller.dart b/lib/app/modules/petugas_mitra/controllers/petugas_mitra_dashboard_controller.dart new file mode 100644 index 0000000..1eefd10 --- /dev/null +++ b/lib/app/modules/petugas_mitra/controllers/petugas_mitra_dashboard_controller.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../routes/app_routes.dart'; + +class PetugasMitraDashboardController extends GetxController { + final AuthProvider _authProvider = Get.find(); + + // Observable user data + final userEmail = ''.obs; + final currentTabIndex = 0.obs; + + @override + void onInit() { + super.onInit(); + _loadUserEmail(); + } + + // Load user email from auth provider + Future _loadUserEmail() async { + try { + final user = _authProvider.currentUser; + userEmail.value = user?.email ?? 'User'; + } catch (e) { + debugPrint('Error loading user email: $e'); + } + } + + // Change tab index + void changeTab(int index) { + currentTabIndex.value = index; + } + + // Logout function + void logout() async { + try { + await _authProvider.signOut(); + Get.offAllNamed(Routes.LOGIN); + } catch (e) { + debugPrint('Error signing out: $e'); + Get.snackbar( + 'Error', + 'Gagal keluar dari aplikasi', + snackPosition: SnackPosition.BOTTOM, + ); + } + } +} diff --git a/lib/app/modules/splash/controllers/splash_controller.dart b/lib/app/modules/splash/controllers/splash_controller.dart new file mode 100644 index 0000000..a1e1726 --- /dev/null +++ b/lib/app/modules/splash/controllers/splash_controller.dart @@ -0,0 +1,28 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import '../../../routes/app_routes.dart'; + +class SplashController extends GetxController { + late Timer _timer; + + @override + void onInit() { + super.onInit(); + debugPrint('SplashController onInit called'); + + // Menggunakan Timer alih-alih Future.delayed + _timer = Timer(const Duration(seconds: 3), () { + debugPrint('Timer completed, navigating to LOGIN'); + // Gunakan Get.offAll untuk menghapus semua rute sebelumnya + Get.offAllNamed(Routes.LOGIN); + }); + } + + @override + void onClose() { + // Pastikan timer dibatalkan saat controller ditutup + _timer.cancel(); + super.onClose(); + } +} diff --git a/lib/app/modules/splash/views/splash_view.dart b/lib/app/modules/splash/views/splash_view.dart new file mode 100644 index 0000000..551e048 --- /dev/null +++ b/lib/app/modules/splash/views/splash_view.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'dart:math' as math; +import 'dart:ui'; +import '../controllers/splash_controller.dart'; +import '../../../theme/app_colors.dart'; + +class SplashView extends GetView { + const SplashView({super.key}); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [ + AppColors.primaryLight.withOpacity(0.1), + AppColors.background, + AppColors.accentLight.withOpacity(0.1), + ], + ), + ), + child: Stack( + children: [ + // Pattern overlay + Opacity( + opacity: 0.03, + child: Container( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/pattern.png'), + repeat: ImageRepeat.repeat, + scale: 4.0, + ), + ), + ), + ), + + // Accent circles + Positioned( + top: -40, + right: -20, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.primary.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + + Positioned( + bottom: -50, + left: -30, + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + AppColors.accent.withOpacity(0.2), + Colors.transparent, + ], + ), + ), + ), + ), + + // Main content + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animated Logo + TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: const Duration(seconds: 1), + curve: Curves.easeOutBack, + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: Image.asset( + 'assets/images/logo.png', + width: 180, + height: 180, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.business, + size: 100, + color: AppColors.primary, + ); + }, + ), + ); + }, + ), + const SizedBox(height: 40), + + // Animated loading indicator + _DelayedAnimation( + delay: 400, + child: Column( + children: [ + SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + AppColors.primary, + ), + strokeWidth: 3, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// Animation helper class +class _DelayedAnimation extends StatefulWidget { + final Widget child; + final int delay; + + const _DelayedAnimation({required this.child, required this.delay}); + + @override + _DelayedAnimationState createState() => _DelayedAnimationState(); +} + +class _DelayedAnimationState extends State<_DelayedAnimation> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animOffset; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + final curve = CurvedAnimation( + parent: _controller, + curve: Curves.decelerate, + ); + + _animOffset = Tween( + begin: const Offset(0.0, 0.35), + end: Offset.zero, + ).animate(curve); + + Future.delayed(Duration(milliseconds: widget.delay), () { + if (mounted) { + _controller.forward(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _controller, + child: SlideTransition(position: _animOffset, child: widget.child), + ); + } +} diff --git a/lib/app/modules/warga/bindings/order_sewa_aset_binding.dart b/lib/app/modules/warga/bindings/order_sewa_aset_binding.dart new file mode 100644 index 0000000..ac0bc8c --- /dev/null +++ b/lib/app/modules/warga/bindings/order_sewa_aset_binding.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../controllers/order_sewa_aset_controller.dart'; + +class OrderSewaAsetBinding extends Bindings { + @override + void dependencies() { + debugPrint('⚡ OrderSewaAsetBinding: dependencies called'); + final box = GetStorage(); + + // Ensure providers are registered + if (!Get.isRegistered()) { + debugPrint('⚡ Registering AsetProvider'); + Get.put(AsetProvider(), permanent: true); + } + + if (!Get.isRegistered()) { + debugPrint('⚡ Registering AuthProvider'); + Get.put(AuthProvider(), permanent: true); + } + + // Check if we have the asetId in arguments + final args = Get.arguments; + debugPrint('⚡ Arguments received in binding: $args'); + String? asetId; + + if (args != null && args.containsKey('asetId') && args['asetId'] != null) { + asetId = args['asetId'].toString(); + if (asetId.isNotEmpty) { + debugPrint('✅ Valid asetId found in arguments: $asetId'); + // Simpan ID di storage untuk digunakan saat hot reload + box.write('current_aset_id', asetId); + debugPrint('💾 Saved asetId to GetStorage in binding: $asetId'); + } else { + debugPrint('⚠️ Warning: Empty asetId found in arguments'); + } + } else { + debugPrint( + '⚠️ Warning: No valid asetId found in arguments, checking storage', + ); + // Cek apakah ada ID tersimpan di storage + if (box.hasData('current_aset_id')) { + asetId = box.read('current_aset_id'); + debugPrint('📦 Found asetId in GetStorage: $asetId'); + } + } + + // Only delete the existing controller if we're not in a hot reload situation + if (Get.isRegistered()) { + // Check if we're going through a hot reload by looking at the controller's state + final existingController = Get.find(); + if (existingController.aset.value == null) { + // Controller exists but doesn't have data, likely a fresh navigation or reload + debugPrint('⚡ Removing old OrderSewaAsetController without data'); + Get.delete(force: true); + + // Use put instead of lazyPut to ensure controller is created immediately + debugPrint('⚡ Creating new OrderSewaAsetController'); + Get.put(OrderSewaAsetController()); + } else { + // Controller exists and has data, leave it alone during hot reload + debugPrint( + '🔥 Hot reload detected, preserving existing controller with data', + ); + } + } else { + // No controller exists, create a new one + debugPrint('⚡ Creating new OrderSewaAsetController (first time)'); + Get.put(OrderSewaAsetController()); + } + } +} diff --git a/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart b/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart new file mode 100644 index 0000000..23fad22 --- /dev/null +++ b/lib/app/modules/warga/bindings/order_sewa_paket_binding.dart @@ -0,0 +1,22 @@ +import 'package:get/get.dart'; +import '../controllers/order_sewa_paket_controller.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/providers/sewa_provider.dart'; + +class OrderSewaPaketBinding extends Bindings { + @override + void dependencies() { + // Ensure providers are registered + if (!Get.isRegistered()) { + Get.put(AsetProvider()); + } + + if (!Get.isRegistered()) { + Get.put(SewaProvider()); + } + + Get.lazyPut( + () => OrderSewaPaketController(), + ); + } +} diff --git a/lib/app/modules/warga/bindings/pembayaran_sewa_binding.dart b/lib/app/modules/warga/bindings/pembayaran_sewa_binding.dart new file mode 100644 index 0000000..a9eedb7 --- /dev/null +++ b/lib/app/modules/warga/bindings/pembayaran_sewa_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import '../controllers/pembayaran_sewa_controller.dart'; + +class PembayaranSewaBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => PembayaranSewaController()); + } +} diff --git a/lib/app/modules/warga/bindings/sewa_aset_binding.dart b/lib/app/modules/warga/bindings/sewa_aset_binding.dart new file mode 100644 index 0000000..74269b2 --- /dev/null +++ b/lib/app/modules/warga/bindings/sewa_aset_binding.dart @@ -0,0 +1,16 @@ +import 'package:get/get.dart'; +import '../controllers/sewa_aset_controller.dart'; +import '../../../data/providers/aset_provider.dart'; + +class SewaAsetBinding extends Bindings { + @override + void dependencies() { + // Register AsetProvider if not already registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } + + // Register SewaAsetController + Get.lazyPut(() => SewaAsetController()); + } +} diff --git a/lib/app/modules/warga/bindings/warga_sewa_binding.dart b/lib/app/modules/warga/bindings/warga_sewa_binding.dart new file mode 100644 index 0000000..e7bf650 --- /dev/null +++ b/lib/app/modules/warga/bindings/warga_sewa_binding.dart @@ -0,0 +1,34 @@ +import 'package:get/get.dart'; +import '../controllers/warga_sewa_controller.dart'; +import '../controllers/warga_dashboard_controller.dart'; +import '../../../services/navigation_service.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../data/providers/aset_provider.dart'; + +class WargaSewaBinding extends Bindings { + @override + void dependencies() { + // Ensure NavigationService is registered and set to Sewa tab + if (Get.isRegistered()) { + final navService = Get.find(); + navService.setNavIndex(1); // Set to Sewa tab + } + + // Ensure AuthProvider is registered + if (!Get.isRegistered()) { + Get.put(AuthProvider(), permanent: true); + } + + // Ensure AsetProvider is registered + if (!Get.isRegistered()) { + Get.put(AsetProvider(), permanent: true); + } + + // Register WargaDashboardController if not already registered + if (!Get.isRegistered()) { + Get.put(WargaDashboardController()); + } + + Get.lazyPut(() => WargaSewaController()); + } +} diff --git a/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart b/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart new file mode 100644 index 0000000..b72c40b --- /dev/null +++ b/lib/app/modules/warga/controllers/order_sewa_aset_controller.dart @@ -0,0 +1,2364 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:intl/intl.dart'; +import 'dart:math' as math; +import 'package:uuid/uuid.dart'; +import '../../../data/models/aset_model.dart'; +import '../../../data/models/foto_aset_model.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../services/navigation_service.dart'; +import '../../../services/service_manager.dart'; +import '../widgets/custom_date_range_picker.dart'; +import '../../../routes/app_pages.dart'; +import '../../../routes/app_routes.dart'; +import '../../../theme/app_colors.dart'; + +class OrderSewaAsetController extends GetxController { + // Dependency injection melalui Get.find + final AsetProvider asetProvider = Get.find(); + final AuthProvider authProvider = Get.find(); + + // Use Rx for NavigationService to ensure it's always available, even after hot reload + final Rx _navigationService = Rx( + null, + ); + + // Getter for navigation service with auto-recovery capability + NavigationService get navigationService { + // If navigation service is null, try to find it + if (_navigationService.value == null) { + try { + // Try to find existing instance + if (Get.isRegistered()) { + _navigationService.value = Get.find(); + debugPrint('✅ Found existing NavigationService instance'); + } else { + // Create a new instance if not found + _navigationService.value = Get.put(NavigationService()); + debugPrint('✅ Created new NavigationService instance'); + } + } catch (e) { + debugPrint('⚠️ Error accessing NavigationService: $e'); + // Create a temporary instance as fallback + _navigationService.value = NavigationService(); + } + } + return _navigationService.value!; + } + + final box = GetStorage(); + + // Asset data + final aset = Rx(null); + final isLoading = true.obs; + final hasError = false.obs; + final errorMessage = ''.obs; + + // Store the asset ID to retrieve it after hot reload + final asetId = RxString(''); + + // Asset photos data + final assetPhotos = [].obs; + final currentPhotoIndex = 0.obs; + final isPhotosLoading = false.obs; + + // Booking data + final selectedSatuanWaktu = Rx?>(null); + final duration = 1.obs; + final totalPrice = 0.obs; + + // Unit quantity data + final jumlahUnit = 1.obs; + final maxUnit = 1.obs; + + // Date and time selection + final selectedDate = ''.obs; + final startHour = RxInt(-1); + final endHour = RxInt(-1); + final formattedTimeRange = ''.obs; + final DateTime now = DateTime.now(); + + // Date range for daily rental + final startDate = Rx(null); + final endDate = Rx(null); + final formattedDateRange = ''.obs; + final bookedDates = [].obs; + final isLoadingBookedDates = false.obs; + final maxDayLimit = RxInt( + 0, + ); // Maximum allowed rental days from maksimal_waktu + + // Available hours and booked hours + final availableHours = [].obs; + final RxList> bookedHours = >[].obs; + final RxList selectedHours = [].obs; + final RxList bookedHoursList = [].obs; + final isLoadingBookings = false.obs; + + // New hourly inventory tracking + final Map> hourlyInventory = + >{}.obs; + final unavailableDatesForHourly = [].obs; + + // Static method for navigation (moved to NavigationService) + static Future navigateToOrderPage(String asetId) async { + try { + // Use ServiceManager to get NavigationService instead of direct Get.find + ServiceManager.navigationService.toOrderSewaAset(asetId); + debugPrint('✅ Successfully navigated to order page via ServiceManager'); + } catch (e) { + debugPrint('⚠️ Error in navigateToOrderPage: $e'); + // Fallback direct navigation + Get.toNamed( + '/warga/order-sewa-aset', + arguments: {'asetId': asetId}, + preventDuplicates: false, + ); + } + } + + @override + void onInit() { + super.onInit(); + debugPrint('🚀 OrderSewaAsetController: onInit called'); + + // Ensure navigation service is available - use ServiceManager instead of direct Get.find + try { + // Use ServiceManager's navigationService getter instead of trying to access it directly + _navigationService.value = ServiceManager.navigationService; + debugPrint('✅ Obtained NavigationService via ServiceManager in onInit'); + } catch (e) { + debugPrint('⚠️ Error setting up NavigationService: $e'); + // Create a new instance as fallback + _navigationService.value = NavigationService(); + Get.put(_navigationService.value!); + } + + // Initialize unavailable dates collection + unavailableDatesForHourly.clear(); + + _initializeController(); + } + + @override + void onReady() { + super.onReady(); + debugPrint('🚀 OrderSewaAsetController: onReady called'); + + // Check if there was an error during initialization or loading aset data + if (hasError.value) { + debugPrint('⚠️ Showing error from onReady: ${errorMessage.value}'); + Future.delayed(const Duration(milliseconds: 100), () { + Get.snackbar( + 'Error', + errorMessage.value, + snackPosition: SnackPosition.BOTTOM, + ); + }); + } + + // If we don't have an asset yet but we have an ID stored, load it + if (aset.value == null && asetId.value.isNotEmpty) { + debugPrint('🔄 Loading asset data from onReady with ID: ${asetId.value}'); + loadAsetData(asetId.value); + } + // If we still don't have an asset and no error is set, go back to the previous screen + else if (aset.value == null && + !hasError.value && + isLoading.value == false) { + debugPrint( + '⚠️ No asset loaded and no error - returning to previous screen', + ); + Future.microtask(() { + navigationService.backFromOrderSewaAset(); + Get.snackbar( + 'Info', + 'Tidak dapat menampilkan aset - data tidak tersedia', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.amber, + colorText: Colors.black, + duration: const Duration(seconds: 3), + ); + }); + } + } + + @override + void onClose() { + // Bersihkan resource yang tidak diperlukan lagi + debugPrint('🧹 OrderSewaAsetController: onClose called'); + + // Simpan ID aset ke storage agar bisa di-load kembali setelah hot reload + if (asetId.value.isNotEmpty) { + box.write('current_aset_id', asetId.value); + debugPrint('💾 Saved asetId to GetStorage: ${asetId.value}'); + } + + super.onClose(); + } + + // Method ini digunakan untuk menghandle hotreload + void handleHotReload() { + debugPrint('🔥 Hot reload detected in OrderSewaAsetController'); + // Jika aset belum di-load tapi ID ada di storage, maka load ulang + if (aset.value == null) { + String? storedId; + + // Coba dapatkan ID dari arguments terlebih dahulu + final args = Get.arguments; + if (args != null && args.containsKey('asetId')) { + storedId = args['asetId'] as String?; + debugPrint('📦 Found asetId in arguments after hot reload: $storedId'); + } + + // Jika tidak ada di arguments, cek di GetStorage + if ((storedId == null || storedId.isEmpty) && + box.hasData('current_aset_id')) { + storedId = box.read('current_aset_id'); + debugPrint('📦 Found asetId in GetStorage after hot reload: $storedId'); + } + + if (storedId != null && storedId.isNotEmpty) { + debugPrint( + '🔄 Reloading asset data with ID after hot reload: $storedId', + ); + asetId.value = storedId; + + // Tambahkan delay kecil untuk memastikan controller sudah siap + Future.delayed(const Duration(milliseconds: 100), () { + loadAsetData(storedId!); + }); + } else { + debugPrint('⚠️ No asetId found after hot reload'); + } + } else { + debugPrint('✅ Asset already loaded, no need to reload after hot reload'); + } + } + + void _initializeController() { + // Get asset ID from arguments + final args = Get.arguments; + debugPrint('📌 Arguments received in controller: $args'); + + String? newAsetId; + if (args != null && args.containsKey('asetId')) { + newAsetId = args['asetId'] as String?; + debugPrint('📌 Asset ID from arguments: $newAsetId'); + + // Simpan ID ke storage segera setelah menerimanya dari arguments + if (newAsetId != null && newAsetId.isNotEmpty) { + box.write('current_aset_id', newAsetId); + debugPrint('💾 Immediately saved asetId to GetStorage: $newAsetId'); + } + } + + // Try to get asetId from GetStorage if not in arguments + if ((newAsetId == null || newAsetId.isEmpty) && + box.hasData('current_aset_id')) { + newAsetId = box.read('current_aset_id'); + debugPrint('📌 Asset ID from GetStorage: $newAsetId'); + } + + if (newAsetId != null && newAsetId.isNotEmpty) { + debugPrint('📌 Using asset ID: $newAsetId'); + asetId.value = newAsetId; + debugPrint('🔄 Loading asset data with ID: ${asetId.value}'); + loadAsetData(asetId.value); + } else { + debugPrint('❌ No asset ID available - returning to previous screen'); + isLoading.value = false; + hasError.value = true; + errorMessage.value = 'ID aset tidak ditemukan'; + // Don't navigate back here, let onReady handle it + } + + // Initialize default date (today) + final now = DateTime.now(); + selectedDate.value = DateFormat('dd MMMM yyyy', 'id_ID').format(now); + debugPrint('📅 Initial selected date: ${selectedDate.value}'); + + // Initialize available hours (6:00 to 21:00) + availableHours.clear(); + for (int i = 6; i <= 21; i++) { + availableHours.add(i); + } + } + + // Method untuk load data aset + Future loadAsetData(String id) async { + if (id.isEmpty) { + debugPrint('❌ Cannot load asset: ID is empty'); + isLoading.value = false; + hasError.value = true; + errorMessage.value = 'ID aset tidak valid'; + return; + } + + try { + isLoading.value = true; + hasError.value = false; + debugPrint('🔄 Loading asset data with ID: $id'); + + // Simpan ID aset saat ini ke storage + asetId.value = id; + box.write('current_aset_id', id); + debugPrint( + '💾 Saved current asetId to GetStorage during loadAsetData: $id', + ); + + // Get asset data + final loadedAset = await asetProvider.getAsetById(id); + + if (loadedAset != null) { + aset.value = loadedAset; + debugPrint('✅ Asset loaded successfully: ${loadedAset.nama}'); + + // Set max unit to total quantity of the asset + maxUnit.value = loadedAset.kuantitas ?? 1; + debugPrint( + '📊 Set max unit to: ${maxUnit.value} (total available: ${loadedAset.kuantitas ?? 0}, used: ${loadedAset.kuantitasTerpakai ?? 0})', + ); + + // Load asset photos + await loadAssetPhotos(id); + + // Load all bookings for the next 30 days to initialize availability data + await loadAllBookings(); + + // Find and select hourly option by default if exists + final hourlyOption = loadedAset.satuanWaktuSewa.firstWhereOrNull( + (element) => + element['nama_satuan_waktu']?.toString().toLowerCase().contains( + 'jam', + ) ?? + false, + ); + + if (hourlyOption != null) { + debugPrint( + '✅ Selected hourly option: ${hourlyOption['nama_satuan_waktu']}', + ); + selectSatuanWaktu(hourlyOption); + } else if (loadedAset.satuanWaktuSewa.isNotEmpty) { + // Otherwise select the first option if any exist + debugPrint( + '✅ Selected first available option: ${loadedAset.satuanWaktuSewa[0]['nama_satuan_waktu']}', + ); + selectSatuanWaktu(loadedAset.satuanWaktuSewa[0]); + } + } else { + debugPrint('❌ Asset with ID $id not found'); + hasError.value = true; + errorMessage.value = 'Aset tidak ditemukan'; + } + } catch (e) { + debugPrint('❌ Error loading asset: $e'); + hasError.value = true; + errorMessage.value = 'Terjadi kesalahan: $e'; + } finally { + isLoading.value = false; + } + } + + // Ketika tombol back pada halaman order-sewa-aset ditekan + void onBackPressed() { + debugPrint('🔙 Back button pressed in OrderSewaAsetView'); + + try { + // Try to use the navigation service + navigationService.backFromOrderSewaAset(); + } catch (e) { + debugPrint('⚠️ Error using navigation service: $e'); + // Fallback to direct navigation + Get.back(); + } + } + + // Format price with IDR currency + String formatPrice(int price) { + final currencyFormatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp', + decimalDigits: 0, + ); + return currencyFormatter.format(price); + } + + // Format hour as string (e.g., "06:00") + String formatHour(int hour) { + return '${hour.toString().padLeft(2, '0')}:00'; + } + + // Load photos for the asset + Future loadAssetPhotos(String asetId) async { + try { + isPhotosLoading.value = true; + debugPrint('🔄 Loading photos for asset $asetId'); + + final photos = await asetProvider.getAsetPhotos(asetId); + assetPhotos.value = photos; + + debugPrint('✅ Loaded ${photos.length} photos for asset'); + } catch (e) { + debugPrint('❌ Error loading asset photos: $e'); + hasError.value = true; + errorMessage.value = 'Gagal memuat foto aset: ${e.toString()}'; + } finally { + isPhotosLoading.value = false; + } + } + + // Move to next photo + void nextPhoto() { + if (assetPhotos.isEmpty) return; + if (currentPhotoIndex.value < assetPhotos.length - 1) { + currentPhotoIndex.value++; + } else { + currentPhotoIndex.value = 0; // Loop back to first photo + } + } + + // Move to previous photo + void previousPhoto() { + if (assetPhotos.isEmpty) return; + if (currentPhotoIndex.value > 0) { + currentPhotoIndex.value--; + } else { + currentPhotoIndex.value = assetPhotos.length - 1; // Loop to last photo + } + } + + // Get current photo URL + String? getCurrentPhotoUrl() { + if (assetPhotos.isEmpty) { + return aset.value?.imageUrl; + } + return assetPhotos[currentPhotoIndex.value].fotoAset; + } + + // Load bookings for the selected date and calculate availability across dates and times + Future loadBookingsForDate(DateTime date) async { + try { + isLoadingBookings(true); + + // Clear selections and booked hours + selectedHours.clear(); + bookedHours.clear(); + bookedHoursList.clear(); + + // Format date for API + final String formattedDate = DateFormat('yyyy-MM-dd').format(date); + debugPrint( + '🔍 Loading bookings for date: $date (formatted: $formattedDate)', + ); + + // Get bookings from server + final bookings = await asetProvider.getAsetBookings( + aset.value!.id, + formattedDate, + ); + + // Update the bookedHours list for reference + bookedHours.assignAll(bookings); + debugPrint( + '📆 Loaded ${bookings.length} bookings for $formattedDate and related days', + ); + + // Initialize a range of dates to check (30 days from now) + Map> fullInventory = {}; + final int totalAssetQuantity = aset.value?.kuantitas ?? 0; + final DateTime today = DateTime.now(); + + // Initialize inventory for next 30 days, each with hours 0-23 + for (int day = 0; day < 30; day++) { + final DateTime currentDate = today.add(Duration(days: day)); + final String currentDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(currentDate); + + // Initialize inventory for each hour of this day with full quantity + Map dayInventory = {}; + for (int hour = 0; hour < 24; hour++) { + dayInventory[hour] = totalAssetQuantity; + } + + fullInventory[currentDateStr] = dayInventory; + } + + debugPrint('📊 INVENTORY - INITIAL STATE:'); + debugPrint('------------------------------------'); + debugPrint('Total asset quantity: $totalAssetQuantity'); + debugPrint('Days initialized: ${fullInventory.length}'); + debugPrint('------------------------------------'); + + // Process all bookings to adjust inventory across dates and times + if (bookings.isNotEmpty) { + debugPrint( + '🔢 Processing ${bookings.length} bookings to calculate inventory:', + ); + + // Process each booking and adjust inventory + for (var booking in bookings) { + final String bookingId = booking['id'] ?? ''; + final String status = booking['status'] ?? ''; + final int bookingQuantity = booking['kuantitas'] ?? 1; + + // Get start and end date-times + final String waktuMulaiStr = booking['waktu_mulai'] ?? ''; + final String waktuSelesaiStr = booking['waktu_selesai'] ?? ''; + + if (waktuMulaiStr.isEmpty || waktuSelesaiStr.isEmpty) { + debugPrint( + '⚠️ Booking ID $bookingId has invalid timestamps, skipping', + ); + continue; + } + + try { + final DateTime waktuMulai = DateTime.parse(waktuMulaiStr); + final DateTime waktuSelesai = DateTime.parse(waktuSelesaiStr); + + debugPrint('🔎 Processing booking ID: $bookingId'); + debugPrint( + ' - Period: ${DateFormat('yyyy-MM-dd HH:mm').format(waktuMulai)} to ${DateFormat('yyyy-MM-dd HH:mm').format(waktuSelesai)}', + ); + debugPrint(' - Status: $status, Quantity: $bookingQuantity'); + + // Calculate the end time for inventory restoration (1 hour after booking ends) + final DateTime inventoryRestorationTime = waktuSelesai.add( + const Duration(hours: 1), + ); + + // Calculate all date-hour combinations in the booking range + final List allDateHours = []; + + // Add all hours from start to end + DateTime currentHour = DateTime( + waktuMulai.year, + waktuMulai.month, + waktuMulai.day, + waktuMulai.hour, + ); + + while (!currentHour.isAfter(waktuSelesai)) { + allDateHours.add(currentHour); + currentHour = currentHour.add(const Duration(hours: 1)); + } + + debugPrint(' - Total hours in booking: ${allDateHours.length}'); + + // Process each hour in the booking range to reduce inventory + for (DateTime dateHour in allDateHours) { + final String dateStr = DateFormat('yyyy-MM-dd').format(dateHour); + final int hour = dateHour.hour; + + // Skip if outside our initialized inventory range + if (!fullInventory.containsKey(dateStr)) { + continue; + } + + // Reduce inventory for this hour + if (fullInventory[dateStr]!.containsKey(hour)) { + final int previousQty = fullInventory[dateStr]![hour]!; + fullInventory[dateStr]![hour] = previousQty - bookingQuantity; + + debugPrint( + '📉 $dateStr Hour $hour: DECREASED inventory from $previousQty to ${fullInventory[dateStr]![hour]} (by $bookingQuantity)', + ); + } + } + + // Handle inventory restoration one hour after booking ends + final String restorationDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(inventoryRestorationTime); + final int restorationHour = inventoryRestorationTime.hour; + + // Verify this restoration point is in our initialized inventory + if (fullInventory.containsKey(restorationDateStr) && + fullInventory[restorationDateStr]!.containsKey( + restorationHour, + )) { + // Don't increase above the total asset quantity + final int currentInventory = + fullInventory[restorationDateStr]![restorationHour]!; + final int newInventory = math.min( + currentInventory + bookingQuantity, + totalAssetQuantity, + ); + + fullInventory[restorationDateStr]![restorationHour] = + newInventory; + + debugPrint( + '📈 $restorationDateStr Hour $restorationHour: INCREASED inventory from $currentInventory to $newInventory (by $bookingQuantity)', + ); + } + } catch (e) { + debugPrint('❌ Error processing booking $bookingId: $e'); + } + } + } else { + debugPrint('✅ No bookings found affecting the selected date'); + } + + // Store the inventory in our controller + hourlyInventory.clear(); + hourlyInventory.addAll(fullInventory); + + // Now determine which hours are available for the selected date + if (hourlyInventory.containsKey(formattedDate)) { + final dayInventory = hourlyInventory[formattedDate]!; + + // Debug output of inventory status for this date + debugPrint('📊 INVENTORY STATUS FOR $formattedDate:'); + debugPrint('------------------------------------'); + debugPrint('Requested quantity: ${jumlahUnit.value}'); + + // Business hours (typically 6-21) + List businessHours = List.generate(16, (index) => index + 6); + + // Count available vs unavailable hours + int availableHoursCount = 0; + int unavailableHoursCount = 0; + + for (int hour in businessHours) { + final int availableQty = + dayInventory.containsKey(hour) ? dayInventory[hour]! : 0; + final bool isAvailable = availableQty >= jumlahUnit.value; + final String status = isAvailable ? "✅ AVAILABLE" : "❌ UNAVAILABLE"; + + debugPrint( + 'Hour ${formatHour(hour)}: $availableQty/$totalAssetQuantity units - $status', + ); + + if (isAvailable) { + availableHoursCount++; + } else { + unavailableHoursCount++; + bookedHoursList.add(hour); // Mark this hour as unavailable + } + } + + debugPrint('------------------------------------'); + debugPrint( + 'Summary: $availableHoursCount hours available, $unavailableHoursCount hours unavailable', + ); + + // If all business hours are unavailable, add this date to unavailable dates list + if (availableHoursCount == 0) { + final DateTime unavailableDate = DateFormat( + 'yyyy-MM-dd', + ).parse(formattedDate); + if (!unavailableDatesForHourly.contains(unavailableDate)) { + unavailableDatesForHourly.add(unavailableDate); + debugPrint( + '🚫 Date $formattedDate FULLY BOOKED - Adding to unavailable dates', + ); + } + } + } + + // Calculate and display fully booked dates (helpful for debugging) + List fullyBookedDates = []; + + // Check all days in our inventory + for (String dateStr in hourlyInventory.keys) { + final Map dayInventory = hourlyInventory[dateStr]!; + + // Business hours (typically 6-21) + List businessHours = List.generate(16, (index) => index + 6); + + // Check if all business hours are unavailable + bool anyHourAvailable = false; + for (int hour in businessHours) { + if (dayInventory.containsKey(hour)) { + final int availableQty = dayInventory[hour]!; + if (availableQty >= jumlahUnit.value) { + anyHourAvailable = true; + break; + } + } + } + + if (!anyHourAvailable) { + fullyBookedDates.add(dateStr); + + // Add to unavailable dates if not already there + final DateTime unavailableDate = DateFormat( + 'yyyy-MM-dd', + ).parse(dateStr); + if (!unavailableDatesForHourly.contains(unavailableDate)) { + unavailableDatesForHourly.add(unavailableDate); + } + } + } + + debugPrint('🗓️ Fully booked dates: ${fullyBookedDates.join(", ")}'); + debugPrint( + '🗓️ Total unavailable dates: ${unavailableDatesForHourly.length}', + ); + } catch (e) { + debugPrint('❌ Error loading bookings: $e'); + } finally { + isLoadingBookings(false); + } + } + + // Select a time unit (e.g., hourly or daily) + void selectSatuanWaktu(Map satuan) { + final bool wasDaily = isDailyRental(); + final bool willBeDaily = + satuan['nama_satuan_waktu']?.toString().toLowerCase().contains( + 'hari', + ) ?? + false; + + // Reset duration and total price before changing selected satuan waktu + duration.value = 0; + totalPrice.value = 0; + + selectedSatuanWaktu.value = satuan; + + // Set the maximum day limit based on maksimal_waktu + maxDayLimit.value = satuan['maksimal_waktu'] ?? 0; + debugPrint('🕒 Set maximum day limit to: ${maxDayLimit.value} days'); + debugPrint('💲 Reset duration to 0 and total price to 0'); + + // Reset selections when switching between hourly and daily + if (willBeDaily) { + // Reset hourly selections + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + + // Initialize date range to null for daily rental + startDate.value = null; + endDate.value = null; + formattedDateRange.value = ''; + + // Load booked dates with current quantity + debugPrint( + '🧮 Initializing daily rental mode with quantity: ${jumlahUnit.value}', + ); + loadBookedDates(); + } else { + // Reset date range selections + startDate.value = null; + endDate.value = null; + formattedDateRange.value = ''; + + // If switching from daily to hourly, reload hourly inventory + if (wasDaily) { + // Get current date for hourly rental + final DateTime currentDate = DateTime.now(); + final String formattedDate = DateFormat( + 'yyyy-MM-dd', + ).format(currentDate); + + debugPrint( + '🔄 Switching to hourly rental mode, loading hourly inventory', + ); + + // Clear the hourly inventory to force recalculation + hourlyInventory.clear(); + unavailableDatesForHourly.clear(); + + // Load current date and next 7 days to have availability data + loadBookingsForDate(currentDate); + for (int i = 1; i <= 7; i++) { + final futureDate = currentDate.add(Duration(days: i)); + loadBookingsForDate(futureDate); + } + } + } + + // Make sure to recalculate total price with the new values (which should be 0) + calculateTotalPrice(); + + update(); + } + + // Calculate total price based on duration and selected time unit + void calculateTotalPrice() { + if (selectedSatuanWaktu.value == null || duration.value <= 0) { + totalPrice.value = 0; + debugPrint( + '💰 Total price set to 0 (no satuan waktu selected or duration is 0)', + ); + return; + } + + final unitPrice = selectedSatuanWaktu.value?['harga'] ?? 0; + totalPrice.value = unitPrice * duration.value * jumlahUnit.value; + debugPrint( + '💰 Calculated total price: ${totalPrice.value} ($unitPrice × ${duration.value} × ${jumlahUnit.value})', + ); + } + + // Pick a date + Future pickDate(BuildContext context) async { + if (aset.value == null) return; + + try { + // Prepare unavailable dates (past dates or fully booked dates) + final unavailableDates = []; + + // Add only past dates (before today, not including today) + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + // Add only past dates to unavailable list (not including today) + for ( + DateTime date = DateTime(today.year, today.month, 1); + date.isBefore(todayDate); // Changed from !date.isAfter(todayDate) + date = date.add(const Duration(days: 1)) + ) { + unavailableDates.add(date); + } + + // Add dates that are fully booked (no available hours) + if (unavailableDatesForHourly.isNotEmpty) { + unavailableDates.addAll(unavailableDatesForHourly); + } + + debugPrint( + '🗓️ Unavailable dates for hourly rental: ${unavailableDatesForHourly.length}', + ); + + // Get a temporary value for storing error messages + final errorMessageText = Rx(''); + + // Try to parse the current selected date as initialDate + DateTime initialDate; + try { + initialDate = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).parse(selectedDate.value); + + // Verify it's not an unavailable date + if (unavailableDates.any((date) => _isSameDay(date, initialDate))) { + // Find the next available date + initialDate = _findNextAvailableDate(todayDate, unavailableDates); + } + } catch (e) { + // If parsing fails, use the next available date + initialDate = _findNextAvailableDate(todayDate, unavailableDates); + } + + // Show our custom date picker in a dialog + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: double.maxFinite, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Pilih Tanggal', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Obx( + () => + errorMessageText.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + ), + ), + child: Text( + errorMessageText.value, + style: TextStyle( + fontSize: 12, + color: AppColors.error, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 16), + // Use the custom calendar widget here for single date selection + CustomDateRangePicker( + disabledDates: unavailableDates, + initialStartDate: initialDate, + initialEndDate: + initialDate, // For hourly rental, set end date to same as start date + singleDateMode: + true, // Force single date selection for hourly rentals + maxDays: 1, // Limit to 1 day + onClearSelection: () { + // Handle selection clearing + debugPrint('🧹 Date selection cleared by user'); + }, + onSelectRange: (start, end) { + try { + // Clear previous error messages + errorMessageText.value = ''; + + // For hourly rental, we only need the start date + // We'll ignore the end date from the picker + debugPrint( + '📅 Selected date for hourly rental: ${DateFormat('yyyy-MM-dd').format(start)}', + ); + + // Update the selected date + final selectedDateFormatted = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).format(start); + selectedDate.value = selectedDateFormatted; + debugPrint( + '📅 Set selected date to: ${selectedDate.value}', + ); + + // Reset selections + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + duration.value = 0; + calculateTotalPrice(); + + // Load bookings for this date + loadBookingsForDate(start); + + Navigator.of( + context, + ).pop(true); // Close dialog with success result + } catch (e) { + debugPrint('❌ Error in date selection: $e'); + errorMessageText.value = + 'Terjadi kesalahan saat memilih tanggal: coba lagi'; + } + }, + ), + ], + ), + ), + ), + ); + }, + ); + + // Handle result if needed + if (result == true) { + debugPrint('📅 Date selected successfully: ${selectedDate.value}'); + } + } catch (e) { + debugPrint('❌ Error in date picker: $e'); + _showError('Terjadi kesalahan saat memilih tanggal: ${e.toString()}'); + } + } + + // Helper method to find the next available date + DateTime _findNextAvailableDate( + DateTime startFrom, + List unavailableDates, + ) { + // Start from the next day after the given date + DateTime testDate = startFrom.add(const Duration(days: 1)); + + // Try up to 30 days in the future + for (int i = 0; i < 30; i++) { + final bool isUnavailable = unavailableDates.any( + (date) => _isSameDay(date, testDate), + ); + if (!isUnavailable) { + return testDate; + } + testDate = testDate.add(const Duration(days: 1)); + } + + // If no available date found in 30 days, return a date anyway + return startFrom.add(const Duration(days: 1)); + } + + // Check if an hour is in the past + bool isHourInPast(int hour) { + final currentDate = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).parse(selectedDate.value); + final today = DateTime.now(); + + // If selected date is today, check if hour is in the past + if (currentDate.year == today.year && + currentDate.month == today.month && + currentDate.day == today.day) { + return hour < + today.hour; // Changed from hour <= today.hour to allow current hour + } + + return false; + } + + // Check if an hour is booked + bool isHourBooked(int hour) { + debugPrint( + '🔍 Checking if hour $hour is booked among ${bookedHoursList.length} blocked hours', + ); + + // Check if hour is in the bookedHoursList + bool isBooked = bookedHoursList.contains(hour); + + if (isBooked) { + debugPrint('🔴 Hour $hour is booked (found in bookedHoursList)'); + } else { + debugPrint('✅ Hour $hour is available'); + } + + return isBooked; + } + + // Check if an hour is disabled (past or booked) + bool isHourDisabled(int hour) { + // Always check if the hour is in the past + if (isHourInPast(hour)) { + return true; + } + + // Get the current date in yyyy-MM-dd format + final currentDate = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).parse(selectedDate.value); + final formattedDate = DateFormat('yyyy-MM-dd').format(currentDate); + + // Check if we have inventory data for this date + if (!hourlyInventory.containsKey(formattedDate)) { + debugPrint( + '⚠️ No inventory data for date $formattedDate - defaulting to disabled', + ); + return true; + } + + // Get the inventory for this hour + final inventory = hourlyInventory[formattedDate]!; + if (!inventory.containsKey(hour)) { + debugPrint( + '⚠️ No inventory data for hour $hour - defaulting to disabled', + ); + return true; + } + + // Check if requested quantity is available for this hour + final int availableQty = inventory[hour]!; + final bool isAvailable = availableQty >= jumlahUnit.value; + + debugPrint( + '🕒 Hour $hour (${formatHour(hour)}): Available=$availableQty, Needed=${jumlahUnit.value}, Available=$isAvailable', + ); + + return !isAvailable; + } + + // Select hour + void selectHour(int hour) { + if (isHourDisabled(hour)) return; + + // Get maximum allowed hours from selected satuan waktu + final int maxHours = selectedSatuanWaktu.value?['maksimal_waktu'] ?? 0; + + // If no hour is selected, set as start hour + if (startHour.value == -1) { + startHour.value = hour; + // Set end hour as the next hour, but check if we have a max limit + endHour.value = hour + 1; + } + // If clicking the same hour, deselect it + else if (startHour.value == hour) { + startHour.value = -1; + endHour.value = -1; + } + // If selecting a different hour + else { + // If selecting a later hour, set as end hour + if (hour > startHour.value) { + // Check if the new selection would exceed the maximum allowed duration + if (maxHours > 0 && (hour - startHour.value + 1) > maxHours) { + _showError('Maksimal waktu sewa untuk aset ini adalah $maxHours jam'); + return; + } + + // Check if all hours between start and selected are available + bool allAvailable = true; + for (int i = startHour.value + 1; i <= hour; i++) { + if (isHourDisabled(i)) { + allAvailable = false; + break; + } + } + + if (allAvailable) { + endHour.value = hour + 1; + } else { + _showError( + 'Terdapat jam yang tidak tersedia di antara rentang waktu yang dipilih', + ); + return; + } + } + // If selecting an earlier hour, set as new start hour and check max duration + else { + // Check if new selection would exceed maximum allowed duration + if (maxHours > 0 && (endHour.value - hour) > maxHours) { + _showError('Maksimal waktu sewa untuk aset ini adalah $maxHours jam'); + return; + } + + startHour.value = hour; + } + } + + _updateFormattedTimeRange(); + calculateDurationFromTimeRange(); + update(); + } + + void _updateFormattedTimeRange() { + if (startHour.value == -1) { + formattedTimeRange.value = ''; + return; + } + + final start = formatHour(startHour.value); + final end = formatHour(endHour.value > 21 ? 21 : endHour.value); + formattedTimeRange.value = '$start - $end'; + } + + void calculateDurationFromTimeRange() { + if (startHour.value == -1 || endHour.value == -1) { + duration.value = 0; + calculateTotalPrice(); + return; + } + + // Calculate hours between start and end hour + int hoursDiff = endHour.value - startHour.value; + + // Get maximum allowed hours + final int maxHours = selectedSatuanWaktu.value?['maksimal_waktu'] ?? 0; + + // If max hours is set and the current duration exceeds it, cap the duration + if (maxHours > 0 && hoursDiff > maxHours) { + // Adjust end hour to match maximum allowed duration + endHour.value = startHour.value + maxHours; + hoursDiff = maxHours; + + // Update formatted time range with the new end hour + _updateFormattedTimeRange(); + + // Show a message to the user + _showError('Durasi disesuaikan ke maksimal $maxHours jam untuk aset ini'); + } + + duration.value = hoursDiff; + calculateTotalPrice(); + } + + // Pick a date range (for daily rental) + Future pickDateRange(BuildContext context) async { + if (aset.value == null) return; + + try { + // First make sure bookedDates is loaded + if (isLoadingBookedDates.value) { + // Show loading indicator + Get.dialog( + const Center(child: CircularProgressIndicator()), + barrierDismissible: false, + ); + + // Wait for booked dates to load with a timeout + int attempts = 0; + while (isLoadingBookedDates.value && attempts < 100) { + await Future.delayed(const Duration(milliseconds: 100)); + attempts++; + } + + // Close the loading dialog + if (Get.isDialogOpen ?? false) { + Get.back(); + } + + // If still loading after timeout, show error + if (isLoadingBookedDates.value) { + _showError('Timeout loading booked dates. Please try again.'); + return; + } + } + + // Log maximum day limit + debugPrint('📅 Maximum rental period: ${maxDayLimit.value} days'); + + // Add today to booked dates to prevent selection + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + // Create a new list with today added to the booked dates + final unavailableDates = []; + + // Add today and past dates + for ( + DateTime date = DateTime(today.year, today.month, 1); + !date.isAfter(todayDate); + date = date.add(const Duration(days: 1)) + ) { + unavailableDates.add(date); + } + + // Then add all booked dates + if (bookedDates.isNotEmpty) { + unavailableDates.addAll(bookedDates); + } + + debugPrint( + '🗓️ Today (${DateFormat('yyyy-MM-dd').format(todayDate)}) added to unavailable dates', + ); + debugPrint('🗓️ Total unavailable dates: ${unavailableDates.length}'); + + // Get a temporary value for storing error messages + final errorMessageText = Rx(''); + + // Show our custom date range picker in a dialog + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: double.maxFinite, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + padding: const EdgeInsets.all(16.0), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Pilih Tanggal', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + if (maxDayLimit.value > 0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Maksimal waktu sewa: ${maxDayLimit.value} hari', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Obx( + () => + errorMessageText.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + ), + ), + child: Text( + errorMessageText.value, + style: TextStyle( + fontSize: 12, + color: AppColors.error, + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(height: 16), + // Use the custom calendar widget here + CustomDateRangePicker( + disabledDates: unavailableDates, + initialStartDate: startDate.value, + initialEndDate: endDate.value, + maxDays: maxDayLimit.value > 0 ? maxDayLimit.value : null, + onClearSelection: () { + // Handle selection clearing + debugPrint('🧹 Date selection cleared by user'); + // Don't close the dialog, let the user select new dates + }, + onSelectRange: (start, end) { + try { + // Clear previous error messages + errorMessageText.value = ''; + + // If start and end are the same, this is a single day selection + final bool isSingleDaySelection = _isSameDay( + start, + end, + ); + debugPrint( + '📅 Selected ${isSingleDaySelection ? "single day" : "date range"}: ${DateFormat('yyyy-MM-dd').format(start)} to ${DateFormat('yyyy-MM-dd').format(end)}', + ); + + // Calculate duration in days + int selectedDuration = + end.difference(start).inDays + 1; + + // Check if the selected duration exceeds the maximum + if (maxDayLimit.value > 0 && + selectedDuration > maxDayLimit.value) { + // Show error message but don't close the dialog + errorMessageText.value = + 'Maksimal waktu sewa untuk aset ini adalah ${maxDayLimit.value} hari. Anda memilih $selectedDuration hari.'; + debugPrint( + '⚠️ Max rental period exceeded: $selectedDuration days selected, max is ${maxDayLimit.value}', + ); + return; // Don't proceed with selection + } + + // This will be called when the user selects a valid range + startDate.value = start; + endDate.value = end; + + // Log selection type for clarity + if (_isSameDay(start, end)) { + debugPrint( + '📅 SINGLE DAY SELECTED: ${DateFormat('yyyy-MM-dd').format(start)} (duration: 1 day)', + ); + } else { + debugPrint( + '📅 DATE RANGE SELECTED: ${DateFormat('yyyy-MM-dd').format(start)} to ${DateFormat('yyyy-MM-dd').format(end)} (duration: $selectedDuration days)', + ); + } + + _updateFormattedDateRange(); + // Calculate duration in days (already validated) + duration.value = selectedDuration; + calculateTotalPrice(); + Navigator.of( + context, + ).pop(true); // Close dialog with success result + } catch (e) { + debugPrint('❌ Error in date range selection: $e'); + errorMessageText.value = + 'Terjadi kesalahan saat memilih tanggal: coba lagi'; + } + }, + ), + ], + ), + ), + ), + ); + }, + ); + + // Handle result if needed + if (result == true) { + debugPrint( + '📅 Date range selected: ${startDate.value} to ${endDate.value}, duration: ${duration.value} days', + ); + } + } catch (e) { + debugPrint('❌ Error in date range picker: $e'); + _showError('Terjadi kesalahan saat memilih tanggal: ${e.toString()}'); + } + } + + // Load booked dates for daily rental with inventory-based logic + Future loadBookedDates() async { + try { + isLoadingBookedDates(true); + bookedDates.clear(); + + // Get the total quantity of the asset + final int totalQuantity = aset.value?.kuantitas ?? 0; + debugPrint('📊 Total asset quantity: $totalQuantity'); + + // Get the requested quantity from user + final int requestedQuantity = jumlahUnit.value; + debugPrint('🔢 Requested quantity: $requestedQuantity'); + + // Date range to check (next 90 days) + final startDateForQuery = DateTime.now(); + final endDateForQuery = DateTime.now().add(const Duration(days: 90)); + + // Format dates for API + final String formattedStartDate = DateFormat( + 'yyyy-MM-dd', + ).format(startDateForQuery); + final String formattedEndDate = DateFormat( + 'yyyy-MM-dd', + ).format(endDateForQuery); + + debugPrint( + '🔍 Checking available quantity from $formattedStartDate to $formattedEndDate', + ); + + // Get all bookings for this asset in the date range + final List> bookings = await asetProvider + .getAsetDailyBookings( + aset.value!.id, + formattedStartDate, + formattedEndDate, + ); + + debugPrint('📑 Found ${bookings.length} bookings for this asset'); + + // Create an inventory map to track available quantity for each day + Map dailyInventory = {}; + + // Initialize inventory for each day in the range + for ( + DateTime day = startDateForQuery; + !day.isAfter(endDateForQuery); + day = day.add(const Duration(days: 1)) + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + dailyInventory[dateStr] = totalQuantity; + } + + // Process all bookings to calculate inventory deductions + for (var booking in bookings) { + final String? startDateStr = booking['waktu_mulai']; + final String? endDateStr = booking['waktu_selesai']; + final int bookingQuantity = booking['kuantitas'] ?? 0; + final String bookingStatus = booking['status'] ?? ''; + final String bookingId = booking['id'] ?? ''; + + debugPrint( + '🔎 Processing booking: ID: $bookingId, $startDateStr to $endDateStr, quantity: $bookingQuantity, status: $bookingStatus', + ); + + if (startDateStr != null && endDateStr != null && bookingQuantity > 0) { + final DateTime bookingStart = DateTime.parse(startDateStr); + final DateTime bookingEnd = DateTime.parse(endDateStr); + + // Get dates without time + final DateTime startDateOnly = DateTime( + bookingStart.year, + bookingStart.month, + bookingStart.day, + ); + final DateTime endDateOnly = DateTime( + bookingEnd.year, + bookingEnd.month, + bookingEnd.day, + ); + + debugPrint( + '📊 Inventory status BEFORE processing booking $bookingId:', + ); + // Show a sample of inventory before changes + int sampleCount = 0; + for ( + DateTime day = startDateOnly; + !day.isAfter(endDateOnly) && sampleCount < 3; + day = day.add(const Duration(days: 1)), sampleCount++ + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + debugPrint( + ' - BEFORE: $dateStr: Available=${dailyInventory[dateStr]}', + ); + } + + // Reduce available quantity for each day in the booking range + for ( + DateTime day = startDateOnly; + !day.isAfter(endDateOnly); + day = day.add(const Duration(days: 1)) + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + + // Subtract the booking quantity from available inventory + if (dailyInventory.containsKey(dateStr)) { + int previousInventory = dailyInventory[dateStr]!; + dailyInventory[dateStr] = previousInventory - bookingQuantity; + debugPrint( + '📉 Day $dateStr: Booking $bookingId reduced inventory from $previousInventory to ${dailyInventory[dateStr]} (by $bookingQuantity)', + ); + } + } + + // Show inventory after changes for the same sample days + debugPrint( + '📊 Inventory status AFTER processing booking $bookingId:', + ); + sampleCount = 0; + for ( + DateTime day = startDateOnly; + !day.isAfter(endDateOnly) && sampleCount < 3; + day = day.add(const Duration(days: 1)), sampleCount++ + ) { + String dateStr = DateFormat('yyyy-MM-dd').format(day); + debugPrint( + ' - AFTER: $dateStr: Available=${dailyInventory[dateStr]}', + ); + } + } + } + + // Final inventory status after processing all bookings + debugPrint('📊 FINAL INVENTORY STATUS SUMMARY:'); + debugPrint('------------------------------------'); + debugPrint('Total asset quantity: $totalQuantity'); + debugPrint('Requested quantity: $requestedQuantity'); + debugPrint('Total bookings processed: ${bookings.length}'); + + // Show detailed inventory for next 10 days + DateTime currentDate = DateTime.now(); + debugPrint('INVENTORY FOR NEXT 10 DAYS:'); + for (int i = 0; i < 10; i++) { + String dateStr = DateFormat('yyyy-MM-dd').format(currentDate); + int available = dailyInventory[dateStr] ?? 0; + bool isAvailable = available >= requestedQuantity; + String availabilityStatus = + isAvailable ? "✅ AVAILABLE" : "❌ UNAVAILABLE"; + + debugPrint( + '${i + 1}. $dateStr: $available/$totalQuantity units available - $availabilityStatus', + ); + currentDate = currentDate.add(const Duration(days: 1)); + } + debugPrint('------------------------------------'); + + // Find days where available quantity is less than requested quantity + for (var entry in dailyInventory.entries) { + if (entry.value < requestedQuantity) { + // Parse the date and add to booked dates + final DateTime bookedDate = DateFormat('yyyy-MM-dd').parse(entry.key); + bookedDates.add(bookedDate); + debugPrint( + '🚫 Disabling date ${entry.key}: available quantity ${entry.value} < requested $requestedQuantity', + ); + } + } + + // Also add past dates (today and before) to booked dates + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + for ( + DateTime day = startDateForQuery; + !day.isAfter(todayDate); + day = day.add(const Duration(days: 1)) + ) { + bookedDates.add(day); + debugPrint( + '🕒 Disabling past date: ${DateFormat('yyyy-MM-dd').format(day)}', + ); + } + + // Log a sample of the inventory status + debugPrint('📊 Inventory status sample:'); + int counter = 0; + for (var entry in dailyInventory.entries) { + if (counter++ >= 10) break; // Show only first 10 days + debugPrint( + ' - ${entry.key}: Available=${entry.value}, Required=$requestedQuantity, Available=${entry.value >= requestedQuantity}', + ); + } + + // Debug the total number of disabled dates + debugPrint('📋 Total dates disabled: ${bookedDates.length}'); + if (bookedDates.isNotEmpty) { + debugPrint('📅 Sample disabled dates:'); + for (int i = 0; i < math.min(5, bookedDates.length); i++) { + debugPrint(' - ${DateFormat('yyyy-MM-dd').format(bookedDates[i])}'); + } + } + } catch (e, stackTrace) { + debugPrint('❌ Error loading booked dates: $e'); + debugPrint('🔍 Stack trace: $stackTrace'); + } finally { + isLoadingBookedDates(false); + } + } + + // Update the formatted date range string + void _updateFormattedDateRange() { + if (startDate.value != null && endDate.value != null) { + final startDateStr = DateFormat( + 'dd MMM yyyy', + 'id_ID', + ).format(startDate.value!); + + // If start and end date are the same, show just one date + if (startDate.value!.year == endDate.value!.year && + startDate.value!.month == endDate.value!.month && + startDate.value!.day == endDate.value!.day) { + formattedDateRange.value = '$startDateStr (1 hari)'; + } else { + // Show date range + final endDateStr = DateFormat( + 'dd MMM yyyy', + 'id_ID', + ).format(endDate.value!); + final days = endDate.value!.difference(startDate.value!).inDays + 1; + formattedDateRange.value = '$startDateStr - $endDateStr ($days hari)'; + } + } else { + formattedDateRange.value = ''; + } + } + + // Helper method to check if we're using daily rental + bool isDailyRental() { + return selectedSatuanWaktu.value != null && + (selectedSatuanWaktu.value!['nama_satuan_waktu'] + ?.toString() + .toLowerCase() + .contains('hari') ?? + false); + } + + // Helper method to show error messages + void _showError(String message) { + hasError.value = true; + errorMessage.value = message; + Future.delayed(const Duration(milliseconds: 100), () { + Get.snackbar( + 'Error', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + }); + } + + // Helper method to show success messages + void _showSuccess(String message) { + Get.snackbar( + 'Sukses', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } + + // Recalculate hourly inventory based on current quantity + void recalculateHourlyInventory() { + // Only proceed if we're in hourly rental mode + if (isDailyRental()) return; + + // Get current date + final currentDate = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).parse(selectedDate.value); + debugPrint( + '🔄 Recalculating hourly inventory for ${DateFormat('yyyy-MM-dd').format(currentDate)}', + ); + + // Reload bookings to recalculate inventory + loadBookingsForDate(currentDate); + } + + // Increase unit quantity + void increaseUnit() { + if (jumlahUnit.value < maxUnit.value) { + // Save previous value to check for changes in availability logic + final int prevValue = jumlahUnit.value; + jumlahUnit.value++; + + // Clear date/time selections when quantity changes + if (isDailyRental()) { + // Clear date selections for daily rental + startDate.value = null; + endDate.value = null; + formattedDateRange.value = ''; + debugPrint('🧹 Cleared date selections due to quantity change'); + } else { + // Clear time selections for hourly rental + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + debugPrint('🧹 Cleared time selections due to quantity change'); + } + + // Reset duration and update total price + duration.value = 0; + calculateTotalPrice(); + + // If we're in daily rental mode, reload booked dates with new quantity + if (isDailyRental()) { + debugPrint( + '🔄 Quantity changed in daily rental mode, reloading availability', + ); + loadBookedDates(); + } else { + // In hourly mode, recalculate hourly inventory + recalculateHourlyInventory(); + } + + // Update the UI since availability may have changed + update(); + } + } + + // Decrease unit quantity + void decreaseUnit() { + if (jumlahUnit.value > 1) { + // Save previous value to check for changes in availability logic + final int prevValue = jumlahUnit.value; + jumlahUnit.value--; + + // Clear date/time selections when quantity changes + if (isDailyRental()) { + // Clear date selections for daily rental + startDate.value = null; + endDate.value = null; + formattedDateRange.value = ''; + debugPrint('🧹 Cleared date selections due to quantity change'); + } else { + // Clear time selections for hourly rental + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + debugPrint('🧹 Cleared time selections due to quantity change'); + } + + // Reset duration and update total price + duration.value = 0; + calculateTotalPrice(); + + // If we're in daily rental mode, reload booked dates with new quantity + if (isDailyRental()) { + debugPrint( + '🔄 Quantity changed in daily rental mode, reloading availability', + ); + loadBookedDates(); + } else { + // In hourly mode, recalculate hourly inventory + recalculateHourlyInventory(); + } + + // Update the UI since availability may have changed + update(); + } + } + + // Update unit from text input + void updateUnitFromInput(String value) { + try { + int newValue = int.parse(value); + + // Ensure value is within allowed range + if (newValue < 1) { + newValue = 1; + } else if (newValue > maxUnit.value) { + newValue = maxUnit.value; + } + + // Check if the value has changed + bool valueChanged = jumlahUnit.value != newValue; + + if (valueChanged) { + jumlahUnit.value = newValue; + + // Clear date/time selections when quantity changes + if (isDailyRental()) { + // Clear date selections for daily rental + startDate.value = null; + endDate.value = null; + formattedDateRange.value = ''; + debugPrint('🧹 Cleared date selections due to quantity change'); + } else { + // Clear time selections for hourly rental + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + debugPrint('🧹 Cleared time selections due to quantity change'); + } + + // Reset duration and update total price + duration.value = 0; + calculateTotalPrice(); + + // If we're in daily rental mode, reload booked dates with new quantity + if (isDailyRental()) { + debugPrint( + '🔄 Quantity changed in daily rental mode, reloading availability', + ); + loadBookedDates(); + } else { + // In hourly mode, load all bookings to recalculate availability + loadAllBookings(); + } + + // Update the UI + update(); + } + } catch (e) { + // If parsing fails, reset to 1 + jumlahUnit.value = 1; + + // Clear date/time selections due to error + if (isDailyRental()) { + // Clear date selections for daily rental + startDate.value = null; + endDate.value = null; + formattedDateRange.value = ''; + } else { + // Clear time selections for hourly rental + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + } + + // Reset duration and calculate total price + duration.value = 0; + calculateTotalPrice(); + + // If we're in daily rental mode, reload booked dates with new quantity + if (isDailyRental()) { + debugPrint( + '🔄 Quantity reset to 1 in daily rental mode, reloading availability', + ); + loadBookedDates(); + } else { + // In hourly mode, load all bookings to recalculate availability + loadAllBookings(); + } + + update(); + } + } + + // Reset time selection + void resetTimeSelection() { + startHour.value = -1; + endHour.value = -1; + formattedTimeRange.value = ''; + duration.value = 0; + calculateTotalPrice(); + } + + // Pesan sekarang + Future pesanSekarang() async { + if (!_validateBookingInputs()) { + return; + } + + final userId = authProvider.getCurrentUserId(); + + // Generate a unique UUID for the order + final uuid = Uuid(); + final String orderId = uuid.v4(); + debugPrint('🆔 Generated order ID: $orderId'); + + try { + isLoading.value = true; + + Map sewaAsetData; + Map bookedDetailData; + Map tagihanSewaData; + + if (isDailyRental()) { + // Create daily rental order + final String formattedStartDate = DateFormat( + 'yyyy-MM-dd', + ).format(startDate.value!); + final String formattedEndDate = DateFormat( + 'yyyy-MM-dd', + ).format(endDate.value!); + + // Create ISO timestamp strings for waktu_mulai and waktu_selesai with default times + // Default time for start is 06:00:00 + final String waktuMulai = '${formattedStartDate}T06:00:00'; + // Default time for end is 21:00:00 + final String waktuSelesai = '${formattedEndDate}T21:00:00'; + + debugPrint( + '📅 Creating daily booking from $waktuMulai to $waktuSelesai', + ); + + // Daily rental price from selected satuan waktu + final int dailyPrice = selectedSatuanWaktu.value?['harga'] ?? 0; + // Calculate days duration + final int daysDuration = + endDate.value!.difference(startDate.value!).inDays + 1; + + // Prepare sewa_aset data + sewaAsetData = { + 'id': orderId, // Set UUID as the ID + 'user_id': userId, + 'aset_id': aset.value!.id, + 'waktu_mulai': waktuMulai, + 'waktu_selesai': waktuSelesai, + 'kuantitas': jumlahUnit.value, + 'status': 'MENUNGGU PEMBAYARAN', + 'tipe_pesanan': 'tunggal', + 'total': totalPrice.value, + 'nama_satuan_waktu': + 'hari', // Set satuan waktu to "hari" for daily rentals + }; + + // Prepare booked_detail data + bookedDetailData = { + 'id': uuid.v4(), // Generate a new UUID for booked_detail + 'aset_id': aset.value!.id, + 'sewa_aset_id': orderId, + 'waktu_mulai': waktuMulai, + 'waktu_selesai': waktuSelesai, + 'kuantitas': jumlahUnit.value, + }; + + // Prepare tagihan_sewa data + tagihanSewaData = { + 'sewa_aset_id': orderId, + 'durasi': daysDuration, + 'satuan_waktu': 'hari', + 'harga_sewa': dailyPrice, + 'tagihan_awal': totalPrice.value, + }; + } else { + // Format date for booking + final DateTime bookingDate = DateFormat( + 'dd MMMM yyyy', + 'id_ID', + ).parse(selectedDate.value); + + // Format start and end times using ISO format + final String formattedDate = DateFormat( + 'yyyy-MM-dd', + ).format(bookingDate); + final String startTime = formatHour(startHour.value); + final String endTime = formatHour(endHour.value); + + // Create ISO timestamp strings for waktu_mulai and waktu_selesai + final String waktuMulai = '${formattedDate}T$startTime:00'; + final String waktuSelesai = '${formattedDate}T$endTime:00'; + + debugPrint( + '📅 Creating hourly booking from $waktuMulai to $waktuSelesai', + ); + debugPrint('🔢 Unit quantity: ${jumlahUnit.value}'); + + // Hourly price from selected satuan waktu + final int hourlyPrice = selectedSatuanWaktu.value?['harga'] ?? 0; + + // Prepare sewa_aset data + sewaAsetData = { + 'id': orderId, // Set UUID as the ID + 'user_id': userId, + 'aset_id': aset.value!.id, + 'kuantitas': jumlahUnit.value, + 'status': 'MENUNGGU PEMBAYARAN', + 'waktu_mulai': waktuMulai, + 'waktu_selesai': waktuSelesai, + 'tipe_pesanan': 'tunggal', + 'total': totalPrice.value, + 'nama_satuan_waktu': 'jam', // Set satuan waktu to "jam" + }; + + // Prepare booked_detail data + bookedDetailData = { + 'id': uuid.v4(), // Generate a new UUID for booked_detail + 'aset_id': aset.value!.id, + 'sewa_aset_id': orderId, + 'waktu_mulai': waktuMulai, + 'waktu_selesai': waktuSelesai, + 'kuantitas': jumlahUnit.value, + }; + + // Prepare tagihan_sewa data + tagihanSewaData = { + 'sewa_aset_id': orderId, + 'durasi': duration.value, + 'satuan_waktu': 'jam', + 'harga_sewa': hourlyPrice, + 'tagihan_awal': totalPrice.value, + }; + } + + // Call the API to create the complete order + final success = await asetProvider.createCompleteOrder( + sewaAsetData: sewaAsetData, + bookedDetailData: bookedDetailData, + tagihanSewaData: tagihanSewaData, + ); + + if (success) { + debugPrint('✅ Complete order created successfully with ID: $orderId'); + + // Navigate to payment page + Get.toNamed(Routes.PEMBAYARAN_SEWA, arguments: {'orderId': orderId}); + } else { + // Show error message + Get.snackbar( + 'Error', + 'Gagal membuat pesanan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } catch (e) { + debugPrint('❌ Error creating order: $e'); + Get.snackbar( + 'Error', + 'Terjadi kesalahan: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + + // Validate booking inputs + bool _validateBookingInputs() { + if (selectedSatuanWaktu.value == null || aset.value == null) { + _showError('Harap pilih satuan waktu terlebih dahulu'); + return false; + } + + // Check if we're using hourly or daily rental + if (isDailyRental()) { + if (startDate.value == null || endDate.value == null) { + _showError('Harap pilih tanggal terlebih dahulu'); + return false; + } + } else { + if (startHour.value == -1 || duration.value <= 0) { + _showError('Harap pilih waktu terlebih dahulu'); + return false; + } + } + + if (jumlahUnit.value <= 0) { + _showError('Jumlah unit tidak valid'); + return false; + } + + if (jumlahUnit.value > maxUnit.value) { + _showError( + 'Jumlah unit melebihi ketersediaan (maksimal ${maxUnit.value})', + ); + return false; + } + + // Check if user is logged in + final userId = authProvider.getCurrentUserId(); + if (userId == null) { + _showError('Anda belum login. Silakan login terlebih dahulu.'); + return false; + } + + return true; + } + + // Load all bookings for the next 30 days to determine available dates + Future loadAllBookings() async { + try { + debugPrint( + '🔄 Loading all bookings for asset ${aset.value!.id} for the next 30 days', + ); + + // Clear current inventory data + hourlyInventory.clear(); + unavailableDatesForHourly.clear(); + + // Date range to check (today + 30 days) + final DateTime today = DateTime.now(); + final DateTime endDate = today.add(const Duration(days: 30)); + + // Format dates for API + final String formattedStartDate = DateFormat('yyyy-MM-dd').format(today); + final String formattedEndDate = DateFormat('yyyy-MM-dd').format(endDate); + + debugPrint( + '📅 Fetching bookings from $formattedStartDate to $formattedEndDate', + ); + + // Get all bookings for this asset in the date range + final List> bookings = await asetProvider + .getAsetDailyBookings( + aset.value!.id, + formattedStartDate, + formattedEndDate, + ); + + debugPrint('📊 Found ${bookings.length} bookings for asset'); + + // Initialize inventory for each day in the 30-day period + final int totalAssetQuantity = aset.value?.kuantitas ?? 0; + Map> fullInventory = {}; + + // Initialize inventory for next 30 days, each with hours 0-23 + for (int day = 0; day < 30; day++) { + final DateTime currentDate = today.add(Duration(days: day)); + final String currentDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(currentDate); + + // Initialize inventory for each hour of this day with full quantity + Map dayInventory = {}; + for (int hour = 0; hour < 24; hour++) { + dayInventory[hour] = totalAssetQuantity; + } + + fullInventory[currentDateStr] = dayInventory; + } + + debugPrint('📊 INVENTORY - INITIAL STATE:'); + debugPrint('------------------------------------'); + debugPrint('Total asset quantity: $totalAssetQuantity'); + debugPrint('Days initialized: ${fullInventory.length}'); + debugPrint('------------------------------------'); + + // Process all bookings chronologically to determine inventory + if (bookings.isNotEmpty) { + // Sort bookings by start time to process them chronologically + bookings.sort((a, b) { + final String startA = a['waktu_mulai'] ?? ''; + final String startB = b['waktu_mulai'] ?? ''; + return startA.compareTo(startB); + }); + + debugPrint( + '🔢 Processing ${bookings.length} bookings to calculate inventory:', + ); + + // Process each booking and adjust inventory + for (var booking in bookings) { + final String bookingId = booking['id'] ?? ''; + final String status = booking['status'] ?? ''; + final int bookingQuantity = booking['kuantitas'] ?? 1; + + // Skip rejected bookings + if (status == 'ditolak') { + debugPrint('⏩ Skipping rejected booking: $bookingId'); + continue; + } + + // Get start and end date-times + final String waktuMulaiStr = booking['waktu_mulai'] ?? ''; + final String waktuSelesaiStr = booking['waktu_selesai'] ?? ''; + + if (waktuMulaiStr.isEmpty || waktuSelesaiStr.isEmpty) { + debugPrint( + '⚠️ Booking ID $bookingId has invalid timestamps, skipping', + ); + continue; + } + + try { + final DateTime waktuMulai = DateTime.parse(waktuMulaiStr); + final DateTime waktuSelesai = DateTime.parse(waktuSelesaiStr); + + debugPrint('🔎 Processing booking ID: $bookingId'); + debugPrint( + ' - Period: ${DateFormat('yyyy-MM-dd HH:mm').format(waktuMulai)} to ${DateFormat('yyyy-MM-dd HH:mm').format(waktuSelesai)}', + ); + debugPrint(' - Status: $status, Quantity: $bookingQuantity'); + + // Calculate all date-hour combinations in the booking range + final List allDateHours = []; + + // Add all hours from start to end + DateTime currentHour = DateTime( + waktuMulai.year, + waktuMulai.month, + waktuMulai.day, + waktuMulai.hour, + ); + + while (!currentHour.isAfter(waktuSelesai)) { + allDateHours.add(currentHour); + currentHour = currentHour.add(const Duration(hours: 1)); + } + + // Process each hour in the booking range to reduce inventory + for (DateTime dateHour in allDateHours) { + final String dateStr = DateFormat('yyyy-MM-dd').format(dateHour); + final int hour = dateHour.hour; + + // Skip if outside our initialized inventory range + if (!fullInventory.containsKey(dateStr)) { + continue; + } + + // Reduce inventory for this hour + if (fullInventory[dateStr]!.containsKey(hour)) { + final int previousQty = fullInventory[dateStr]![hour]!; + fullInventory[dateStr]![hour] = math.max( + 0, + previousQty - bookingQuantity, + ); + + debugPrint( + '📉 $dateStr Hour $hour: DECREASED inventory from $previousQty to ${fullInventory[dateStr]![hour]} (by $bookingQuantity)', + ); + } + } + + // Handle inventory restoration one hour after booking ends + final DateTime inventoryRestorationTime = waktuSelesai.add( + const Duration(hours: 1), + ); + final String restorationDateStr = DateFormat( + 'yyyy-MM-dd', + ).format(inventoryRestorationTime); + final int restorationHour = inventoryRestorationTime.hour; + + // Verify this restoration point is in our initialized inventory + if (fullInventory.containsKey(restorationDateStr) && + fullInventory[restorationDateStr]!.containsKey( + restorationHour, + )) { + // Don't increase above the total asset quantity + final int currentInventory = + fullInventory[restorationDateStr]![restorationHour]!; + final int newInventory = math.min( + currentInventory + bookingQuantity, + totalAssetQuantity, + ); + + fullInventory[restorationDateStr]![restorationHour] = + newInventory; + + debugPrint( + '📈 $restorationDateStr Hour $restorationHour: INCREASED inventory from $currentInventory to $newInventory (by $bookingQuantity)', + ); + } + } catch (e) { + debugPrint('❌ Error processing booking $bookingId: $e'); + } + } + } + + // Store the inventory in our controller + hourlyInventory.clear(); + hourlyInventory.addAll(fullInventory); + + // Now determine which dates are fully booked (no available hours during business hours) + _identifyUnavailableDates(); + + debugPrint( + '✅ Completed loading all bookings and calculating availability', + ); + debugPrint( + '🗓️ Total unavailable dates identified: ${unavailableDatesForHourly.length}', + ); + } catch (e) { + debugPrint('❌ Error loading all bookings: $e'); + } finally { + isLoadingBookings(false); + } + } + + // Identify dates that have no available hours for the current requested quantity + void _identifyUnavailableDates() { + final int requestedQuantity = jumlahUnit.value; + debugPrint( + '🔍 Identifying unavailable dates for quantity: $requestedQuantity', + ); + + // Clear previous unavailable dates + unavailableDatesForHourly.clear(); + + // Business hours (typically 6-21) + List businessHours = List.generate(16, (index) => index + 6); + + // Check each day in our inventory + for (String dateStr in hourlyInventory.keys) { + final Map dayInventory = hourlyInventory[dateStr]!; + + // Check if any business hour is available for this date + bool anyHourAvailable = false; + for (int hour in businessHours) { + if (dayInventory.containsKey(hour)) { + final int availableQty = dayInventory[hour]!; + if (availableQty >= requestedQuantity) { + anyHourAvailable = true; + break; + } + } + } + + // If no business hours are available, mark this date as unavailable + if (!anyHourAvailable) { + final DateTime unavailableDate = DateFormat( + 'yyyy-MM-dd', + ).parse(dateStr); + unavailableDatesForHourly.add(unavailableDate); + debugPrint( + '🚫 Date $dateStr marked as UNAVAILABLE (no available hours)', + ); + } + } + + // Also mark past dates as unavailable (but not today) + final DateTime today = DateTime.now(); + final DateTime todayDate = DateTime(today.year, today.month, today.day); + + // Add only past dates (before today) + for ( + DateTime date = DateTime(today.year, today.month, 1); + date.isBefore(todayDate); + date = date.add(const Duration(days: 1)) + ) { + if (!unavailableDatesForHourly.any((d) => _isSameDay(d, date))) { + unavailableDatesForHourly.add(date); + debugPrint( + '🕒 Past date (${DateFormat('yyyy-MM-dd').format(date)}) marked as unavailable', + ); + } + } + } + + // Helper method to check if two dates represent the same day + bool _isSameDay(DateTime date1, DateTime date2) { + return date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day; + } +} diff --git a/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart b/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart new file mode 100644 index 0000000..94efaa2 --- /dev/null +++ b/lib/app/modules/warga/controllers/order_sewa_paket_controller.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import '../../../data/models/paket_model.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/providers/sewa_provider.dart'; +import '../../../services/service_manager.dart'; +import '../../../services/navigation_service.dart'; + +class OrderSewaPaketController extends GetxController { + // Dependencies + final AsetProvider asetProvider = Get.find(); + final SewaProvider sewaProvider = Get.find(); + final NavigationService navigationService = ServiceManager().navigationService; + + // State variables + final paket = Rx(null); + final paketImages = RxList([]); + final isLoading = RxBool(true); + final isPhotosLoading = RxBool(true); + final selectedSatuanWaktu = Rx?>(null); + final selectedDate = RxString(''); + final selectedStartDate = Rx(null); + final selectedEndDate = Rx(null); + final selectedStartTime = RxInt(-1); + final selectedEndTime = RxInt(-1); + final formattedDateRange = RxString(''); + final formattedTimeRange = RxString(''); + final totalPrice = RxDouble(0.0); + final kuantitas = RxInt(1); + final isSubmitting = RxBool(false); + + // Format currency + final currencyFormat = NumberFormat.currency( + locale: 'id', + symbol: 'Rp', + decimalDigits: 0, + ); + + @override + void onInit() { + super.onInit(); + FlutterLogs.logInfo("OrderSewaPaketController", "onInit", "Initializing OrderSewaPaketController"); + + // Get the paket ID from arguments + final Map args = Get.arguments ?? {}; + final String? paketId = args['id']; + + if (paketId != null) { + loadPaketData(paketId); + } else { + debugPrint('❌ No paket ID provided in arguments'); + isLoading.value = false; + } + } + + // Handle hot reload - restore state if needed + void handleHotReload() { + if (paket.value == null) { + final Map args = Get.arguments ?? {}; + final String? paketId = args['id']; + + if (paketId != null) { + // Try to get from cache first + final cachedPaket = GetStorage().read('cached_paket_$paketId'); + if (cachedPaket != null) { + debugPrint('🔄 Hot reload: Restoring paket from cache'); + paket.value = cachedPaket; + loadPaketPhotos(paketId); + initializePriceOptions(); + } else { + loadPaketData(paketId); + } + } + } + } + + // Load paket data from API + Future loadPaketData(String id) async { + try { + isLoading.value = true; + debugPrint('🔍 Loading paket data for ID: $id'); + + // First check if we have it in cache + final cachedPaket = GetStorage().read('cached_paket_$id'); + if (cachedPaket != null) { + debugPrint('✅ Found cached paket data'); + paket.value = cachedPaket; + await loadPaketPhotos(id); + initializePriceOptions(); + } else { + // Get all pakets and filter for the one we need + final List allPakets = await asetProvider.getPakets(); + final rawPaket = allPakets.firstWhere( + (paket) => paket['id'] == id, + orElse: () => null, + ); + + // Declare loadedPaket outside the if block for wider scope + PaketModel? loadedPaket; + + if (rawPaket != null) { + // Convert to PaketModel + try { + // Handle Map directly - pakets from getPakets() are always maps + loadedPaket = PaketModel.fromMap(rawPaket); + debugPrint('✅ Successfully converted paket to PaketModel'); + } catch (e) { + debugPrint('❌ Error converting paket map to PaketModel: $e'); + // Fallback using our helper methods + loadedPaket = PaketModel( + id: getPaketId(rawPaket), + nama: getPaketNama(rawPaket), + deskripsi: getPaketDeskripsi(rawPaket), + harga: getPaketHarga(rawPaket), + kuantitas: getPaketKuantitas(rawPaket), + foto_paket: getPaketMainPhoto(rawPaket), + satuanWaktuSewa: getPaketSatuanWaktuSewa(rawPaket), + ); + debugPrint('✅ Created PaketModel using helper methods'); + } + + // Update the state with the loaded paket + if (loadedPaket != null) { + debugPrint('✅ Loaded paket: ${loadedPaket.nama}'); + paket.value = loadedPaket; + + // Cache for future use + GetStorage().write('cached_paket_$id', loadedPaket); + + // Load photos for this paket + await loadPaketPhotos(id); + + // Set initial pricing option + initializePriceOptions(); + + // Ensure we have at least one photo if available + if (paketImages.isEmpty) { + String? mainPhoto = getPaketMainPhoto(paket.value); + if (mainPhoto != null && mainPhoto.isNotEmpty) { + paketImages.add(mainPhoto); + debugPrint('✅ Added main paket photo: $mainPhoto'); + } + } + } + } else { + debugPrint('❌ No paket found with id: $id'); + } + } + + // Calculate the total price if we have a paket loaded + if (paket.value != null) { + calculateTotalPrice(); + debugPrint('💰 Total price calculated: ${totalPrice.value}'); + } + } catch (e) { + debugPrint('❌ Error loading paket data: $e'); + } finally { + isLoading.value = false; + } + } + + // Helper methods to safely access paket properties + String? getPaketId(dynamic paket) { + if (paket == null) return null; + try { + return paket.id ?? paket['id']; + } catch (_) { + return null; + } + } + + String? getPaketNama(dynamic paket) { + if (paket == null) return null; + try { + return paket.nama ?? paket['nama']; + } catch (_) { + return null; + } + } + + String? getPaketDeskripsi(dynamic paket) { + if (paket == null) return null; + try { + return paket.deskripsi ?? paket['deskripsi']; + } catch (_) { + return null; + } + } + + double getPaketHarga(dynamic paket) { + if (paket == null) return 0.0; + try { + var harga = paket.harga ?? paket['harga'] ?? 0; + return double.tryParse(harga.toString()) ?? 0.0; + } catch (_) { + return 0.0; + } + } + + int getPaketKuantitas(dynamic paket) { + if (paket == null) return 1; + try { + var qty = paket.kuantitas ?? paket['kuantitas'] ?? 1; + return int.tryParse(qty.toString()) ?? 1; + } catch (_) { + return 1; + } + } + + String? getPaketMainPhoto(dynamic paket) { + if (paket == null) return null; + try { + return paket.foto_paket ?? paket['foto_paket']; + } catch (_) { + return null; + } + } + + List getPaketSatuanWaktuSewa(dynamic paket) { + if (paket == null) return []; + try { + return paket.satuanWaktuSewa ?? paket['satuanWaktuSewa'] ?? []; + } catch (_) { + return []; + } + } + + // Load photos for the paket + Future loadPaketPhotos(String paketId) async { + try { + isPhotosLoading.value = true; + final photos = await asetProvider.getFotoPaket(paketId); + if (photos != null && photos.isNotEmpty) { + paketImages.clear(); + for (var photo in photos) { + try { + if (photo.fotoPaket != null && photo.fotoPaket.isNotEmpty) { + paketImages.add(photo.fotoPaket); + } else if (photo.fotoAset != null && photo.fotoAset.isNotEmpty) { + paketImages.add(photo.fotoAset); + } + } catch (e) { + var fotoUrl = photo['foto_paket'] ?? photo['foto_aset']; + if (fotoUrl != null && fotoUrl.isNotEmpty) { + paketImages.add(fotoUrl); + } + } + } + } + } finally { + isPhotosLoading.value = false; + } + } + + // Initialize price options + void initializePriceOptions() { + if (paket.value == null) return; + + final satuanWaktuSewa = getPaketSatuanWaktuSewa(paket.value); + if (satuanWaktuSewa.isNotEmpty) { + // Default to the first option + selectSatuanWaktu(satuanWaktuSewa.first); + } + } + + // Select satuan waktu + void selectSatuanWaktu(Map satuanWaktu) { + selectedSatuanWaktu.value = satuanWaktu; + + // Reset date and time selections + selectedStartDate.value = null; + selectedEndDate.value = null; + selectedStartTime.value = -1; + selectedEndTime.value = -1; + selectedDate.value = ''; + formattedDateRange.value = ''; + formattedTimeRange.value = ''; + + calculateTotalPrice(); + } + + // Check if the rental is daily + bool isDailyRental() { + final namaSatuan = selectedSatuanWaktu.value?['nama_satuan_waktu'] ?? ''; + return namaSatuan.toString().toLowerCase().contains('hari'); + } + + // Select date range for daily rental + void selectDateRange(DateTime start, DateTime end) { + selectedStartDate.value = start; + selectedEndDate.value = end; + + // Format the date range + final formatter = DateFormat('d MMM yyyy', 'id'); + if (start.year == end.year && start.month == end.month && start.day == end.day) { + formattedDateRange.value = formatter.format(start); + } else { + formattedDateRange.value = '${formatter.format(start)} - ${formatter.format(end)}'; + } + + selectedDate.value = formatter.format(start); + calculateTotalPrice(); + } + + // Select date for hourly rental + void selectDate(DateTime date) { + selectedStartDate.value = date; + selectedDate.value = DateFormat('d MMM yyyy', 'id').format(date); + calculateTotalPrice(); + } + + // Select time range for hourly rental + void selectTimeRange(int start, int end) { + selectedStartTime.value = start; + selectedEndTime.value = end; + + // Format the time range + final startTime = '$start:00'; + final endTime = '$end:00'; + formattedTimeRange.value = '$startTime - $endTime'; + + calculateTotalPrice(); + } + + // Calculate total price + void calculateTotalPrice() { + if (selectedSatuanWaktu.value == null) { + totalPrice.value = 0.0; + return; + } + + final basePrice = double.tryParse(selectedSatuanWaktu.value!['harga'].toString()) ?? 0.0; + + if (isDailyRental()) { + if (selectedStartDate.value != null && selectedEndDate.value != null) { + final days = selectedEndDate.value!.difference(selectedStartDate.value!).inDays + 1; + totalPrice.value = basePrice * days; + } else { + totalPrice.value = basePrice; + } + } else { + if (selectedStartTime.value >= 0 && selectedEndTime.value >= 0) { + final hours = selectedEndTime.value - selectedStartTime.value; + totalPrice.value = basePrice * hours; + } else { + totalPrice.value = basePrice; + } + } + + // Multiply by quantity + totalPrice.value *= kuantitas.value; + } + + // Format price as currency + String formatPrice(double price) { + return currencyFormat.format(price); + } + + // Submit order + Future submitOrder() async { + try { + if (paket.value == null || selectedSatuanWaktu.value == null) { + Get.snackbar( + 'Error', + 'Data paket tidak lengkap', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + if ((isDailyRental() && (selectedStartDate.value == null || selectedEndDate.value == null)) || + (!isDailyRental() && (selectedStartDate.value == null || selectedStartTime.value < 0 || selectedEndTime.value < 0))) { + Get.snackbar( + 'Error', + 'Silakan pilih waktu sewa', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + isSubmitting.value = true; + + // Prepare order data + final Map orderData = { + 'id_paket': paket.value!.id, + 'id_satuan_waktu_sewa': selectedSatuanWaktu.value!['id'], + 'tanggal_mulai': selectedStartDate.value!.toIso8601String(), + 'tanggal_selesai': selectedEndDate.value?.toIso8601String() ?? selectedStartDate.value!.toIso8601String(), + 'jam_mulai': isDailyRental() ? null : selectedStartTime.value, + 'jam_selesai': isDailyRental() ? null : selectedEndTime.value, + 'total_harga': totalPrice.value, + 'kuantitas': kuantitas.value, + }; + + // Submit the order + final result = await sewaProvider.createPaketOrder(orderData); + + if (result != null) { + Get.snackbar( + 'Sukses', + 'Pesanan berhasil dibuat', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + // Navigate to payment page + navigationService.navigateToPembayaranSewa(result['id']); + } else { + Get.snackbar( + 'Error', + 'Gagal membuat pesanan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } catch (e) { + debugPrint('❌ Error submitting order: $e'); + Get.snackbar( + 'Error', + 'Terjadi kesalahan: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isSubmitting.value = false; + } + } + + // Handle back button press + void onBackPressed() { + navigationService.navigateToSewaAset(); + } +} diff --git a/lib/app/modules/warga/controllers/pembayaran_sewa_controller.bak b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.bak new file mode 100644 index 0000000..48831d8 --- /dev/null +++ b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.bak @@ -0,0 +1,1115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:get/get.dart'; +import 'dart:io'; +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:convert'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import '../../../services/navigation_service.dart'; +import '../../../routes/app_routes.dart'; +import '../../../data/providers/aset_provider.dart'; + +class PembayaranSewaController extends GetxController + with GetSingleTickerProviderStateMixin { + // Dependencies + final NavigationService navigationService = Get.find(); + final AsetProvider asetProvider = Get.find(); + + // Direct access to Supabase client for storage operations + final SupabaseClient client = Supabase.instance.client; + + // Tab controller + late TabController tabController; + + // Order details + final orderId = ''.obs; + final orderDetails = Rx>({}); + + // Sewa Aset details with related aset info + final sewaAsetDetails = Rx>({}); + + // Tagihan Sewa details + final tagihanSewa = Rx>({}); + + // Payment details + final paymentMethod = ''.obs; + final selectedPaymentType = ''.obs; + final isLoading = false.obs; + final currentStep = 0.obs; + + // Payment proof images - now a list to support multiple images + final RxList paymentProofImages = [].obs; + + // Get image widget for a specific image in the list + Widget getImageWidget(File imageFile) { + // Check if running on web + if (kIsWeb) { + // For web, we need to use Image.network with the path + return Image.network( + imageFile.path, + height: 120, + width: 120, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 120, + width: 120, + color: Colors.grey[300], + child: const Center(child: Text('Error')), + ); + }, + ); + } else { + // For mobile, we can use Image.file + return Image.file( + imageFile, + height: 120, + width: 120, + fit: BoxFit.cover, + ); + } + } + + // Remove an image from the list + void removeImage(int index) { + if (index >= 0 && index < paymentProofImages.length) { + paymentProofImages.removeAt(index); + update(); + } + } + final isUploading = false.obs; + final uploadProgress = 0.0.obs; + + // Timer countdown + final remainingTime = ''.obs; + Timer? _countdownTimer; + final int paymentTimeLimit = 3600; // 1 hour in seconds + final timeRemaining = 0.obs; + + // Bank accounts for transfer + final bankAccounts = RxList>([]); + + @override + void onInit() { + super.onInit(); + tabController = TabController(length: 3, vsync: this); + + // Get order ID and rental data from arguments + if (Get.arguments != null) { + if (Get.arguments['orderId'] != null) { + orderId.value = Get.arguments['orderId']; + + // If rental data is passed, use it directly + if (Get.arguments['rentalData'] != null) { + Map rentalData = Get.arguments['rentalData']; + debugPrint('Received rental data: $rentalData'); + + // Pre-populate order details with rental data + orderDetails.value = { + 'id': rentalData['id'] ?? '', + 'item_name': rentalData['name'] ?? 'Aset', + 'quantity': rentalData['jumlahUnit'] ?? 0, + 'rental_period': rentalData['waktuSewa'] ?? '', + 'duration': rentalData['duration'] ?? '', + 'price_per_unit': 0, // This might not be available in rental data + 'total_price': rentalData['totalPrice'] != null ? + int.tryParse(rentalData['totalPrice'].toString().replaceAll(RegExp(r'[^0-9]'), '')) ?? 0 : 0, + 'status': rentalData['status'] ?? 'MENUNGGU PEMBAYARAN', + 'created_at': DateTime.now().toString(), + 'denda': 0, // Default value + 'keterangan': '', // Default value + 'image_url': rentalData['imageUrl'], + 'waktu_mulai': rentalData['waktuMulai'], + 'waktu_selesai': rentalData['waktuSelesai'], + 'rentang_waktu': rentalData['rentangWaktu'], + }; + + // Still load additional details from the database + checkSewaAsetTableStructure(); + loadTagihanSewaDetails(); + loadSewaAsetDetails(); + loadBankAccounts(); // Load bank accounts data + } else { + // If no rental data is passed, load everything from the database + checkSewaAsetTableStructure(); + loadOrderDetails(); + loadTagihanSewaDetails(); + loadSewaAsetDetails(); + loadBankAccounts(); // Load bank accounts data + } + } + } + } + + @override + void onClose() { + _countdownTimer?.cancel(); + tabController.dispose(); + super.onClose(); + } + + // Load order details + void loadOrderDetails() { + isLoading.value = true; + + // Simulating API call + Future.delayed(Duration(seconds: 1), () { + // Mock data + orderDetails.value = { + 'id': orderId.value, + 'item_name': 'Sewa Kursi Taman', + 'quantity': 5, + 'rental_period': '24 April 2023, 10:00 - 12:00', + 'duration': '2 jam', + 'price_per_unit': 10000, + 'total_price': 50000, + 'status': 'MENUNGGU PEMBAYARAN', + 'created_at': + DateTime.now().toString(), // Use this for countdown calculation + 'denda': 20000, // Dummy data for denda + 'keterangan': + 'Terjadi kerusakan pada bagian kaki', // Dummy keterangan for denda + }; + + // Update the current step based on the status + updateCurrentStepBasedOnStatus(); + + isLoading.value = false; + startCountdownTimer(); + }); + } + + // Load sewa_aset details with aset data + void loadSewaAsetDetails() { + isLoading.value = true; + + debugPrint( + '🔍 Starting to load sewa_aset details for orderId: ${orderId.value}', + ); + + asetProvider + .getSewaAsetWithAsetData(orderId.value) + .then((data) { + if (data != null) { + // Use actual data without adding dummy values + sewaAsetDetails.value = data; + debugPrint( + '✅ Sewa aset details loaded: ${sewaAsetDetails.value['id']}', + ); + + // Debug all fields in the sewaAsetDetails + debugPrint('📋 SEWA ASET DETAILS (COMPLETE DATA):'); + data.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Specifically debug waktu_mulai and waktu_selesai + debugPrint('⏰ WAKTU DETAILS:'); + debugPrint(' waktu_mulai: ${data['waktu_mulai']}'); + debugPrint(' waktu_selesai: ${data['waktu_selesai']}'); + debugPrint(' denda: ${data['denda']}'); + debugPrint(' keterangan: ${data['keterangan']}'); + + // If aset_detail exists, debug it too + if (data['aset_detail'] != null) { + debugPrint('🏢 ASET DETAILS:'); + (data['aset_detail'] as Map).forEach(( + key, + value, + ) { + debugPrint(' $key: $value'); + }); + } + + // Update order details based on sewa_aset data + orderDetails.update((val) { + if (data['aset_detail'] != null) { + val?['item_name'] = data['aset_detail']['nama'] ?? 'Aset Sewa'; + } + val?['quantity'] = data['kuantitas'] ?? 1; + val?['denda'] = + data['denda'] ?? + 0; // Use data from API or default to 0 + val?['keterangan'] = + data['keterangan'] ?? + ''; // Use data from API or default to empty string + + // Update status if it exists in the data + if (data['status'] != null && data['status'].toString().isNotEmpty) { + val?['status'] = data['status']; + debugPrint('📊 Order status from sewa_aset: ${data['status']}'); + } + + // Format rental period + if (data['waktu_mulai'] != null && + data['waktu_selesai'] != null) { + try { + final startTime = DateTime.parse(data['waktu_mulai']); + final endTime = DateTime.parse(data['waktu_selesai']); + val?['rental_period'] = + '${startTime.day}/${startTime.month}/${startTime.year}, ${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')} - ${endTime.hour}:${endTime.minute.toString().padLeft(2, '0')}'; + debugPrint( + '✅ Successfully formatted rental period: ${val?['rental_period']}', + ); + } catch (e) { + debugPrint('❌ Error parsing date: $e'); + } + } else { + debugPrint( + '⚠️ Missing waktu_mulai or waktu_selesai for formatting rental period', + ); + } + }); + + // Update the current step based on the status + updateCurrentStepBasedOnStatus(); + } else { + debugPrint( + '⚠️ No sewa_aset details found for order: ${orderId.value}', + ); + + // Add dummy data when no real data is available + sewaAsetDetails.value = { + 'id': orderId.value, + 'denda': 20000, + 'keterangan': 'Terjadi kerusakan pada bagian kaki', + }; + } + isLoading.value = false; + }) + .catchError((error) { + debugPrint('❌ Error loading sewa_aset details: $error'); + + // Add dummy data in case of error + sewaAsetDetails.value = { + 'id': orderId.value, + 'denda': 20000, + 'keterangan': 'Terjadi kerusakan pada bagian kaki', + }; + + isLoading.value = false; + }); + } + + // Load tagihan sewa details + Future loadTagihanSewaDetails() async { + isLoading.value = true; + + // Use the AsetProvider to fetch the tagihan_sewa data + asetProvider + .getTagihanSewa(orderId.value) + .then((data) { + if (data != null) { + tagihanSewa.value = data; + debugPrint('✅ Tagihan sewa loaded: ${tagihanSewa.value['id']}'); + + // Debug the tagihan_sewa data + debugPrint('📋 TAGIHAN SEWA DETAILS:'); + data.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Specifically debug denda, keterangan, and foto_kerusakan + debugPrint('💰 DENDA DETAILS:'); + debugPrint(' denda: ${data['denda']}'); + debugPrint(' keterangan: ${data['keterangan']}'); + debugPrint(' foto_kerusakan: ${data['foto_kerusakan']}'); + } else { + debugPrint('⚠️ No tagihan sewa found for order: ${orderId.value}'); + // Initialize with empty data instead of mock data + tagihanSewa.value = { + 'id': '', + 'sewa_aset_id': orderId.value, + 'denda': 0, + 'keterangan': '', + 'foto_kerusakan': '', + }; + } + isLoading.value = false; + }) + .catchError((error) { + debugPrint('❌ Error loading tagihan sewa: $error'); + // Initialize with empty data instead of mock data + tagihanSewa.value = { + 'id': '', + 'sewa_aset_id': orderId.value, + 'denda': 0, + 'keterangan': '', + 'foto_kerusakan': '', + }; + isLoading.value = false; + }); + } + + // Start countdown timer (1 hour) + void startCountdownTimer() { + timeRemaining.value = paymentTimeLimit; + + _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) { + if (timeRemaining.value <= 0) { + timer.cancel(); + handlePaymentTimeout(); + } else { + timeRemaining.value--; + updateRemainingTimeDisplay(); + } + }); + } + + // Update the time display in format HH:MM:SS + void updateRemainingTimeDisplay() { + int hours = timeRemaining.value ~/ 3600; + int minutes = (timeRemaining.value % 3600) ~/ 60; + int seconds = timeRemaining.value % 60; + + remainingTime.value = + '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + // Handle payment timeout - change status to DIBATALKAN + void handlePaymentTimeout() { + if (orderDetails.value['status'] == 'MENUNGGU PEMBAYARAN') { + orderDetails.update((val) { + val?['status'] = 'DIBATALKAN'; + }); + + Get.snackbar( + 'Pesanan Dibatalkan', + 'Batas waktu pembayaran telah berakhir', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + duration: Duration(seconds: 5), + ); + } + } + + // Change payment method + void selectPaymentMethod(String method) { + paymentMethod.value = method; + update(); + } + + // Select payment type (tagihan_awal or denda) + void selectPaymentType(String type) { + selectedPaymentType.value = type; + update(); + } + + // Take photo using camera + Future takePhoto() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + + if (image != null) { + // Add to the list of images instead of replacing + paymentProofImages.add(File(image.path)); + update(); + } + } catch (e) { + debugPrint('❌ Error taking photo: $e'); + Get.snackbar( + 'Error', + 'Gagal mengambil foto: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // Select photo from gallery + Future selectPhotoFromGallery() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + + if (image != null) { + // Add to the list of images instead of replacing + paymentProofImages.add(File(image.path)); + update(); + } + } catch (e) { + debugPrint('❌ Error selecting photo from gallery: $e'); + Get.snackbar( + 'Error', + 'Gagal memilih foto dari galeri: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // Upload payment proof using Base64 encoding for development + Future uploadPaymentProof() async { + // Run the debug method first to diagnose Supabase storage issues + await _debugSupabaseStorage(); + + if (paymentProofImages.isEmpty) { + Get.snackbar( + 'Error', + 'Mohon unggah bukti pembayaran terlebih dahulu', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + try { + isUploading.value = true; + uploadProgress.value = 0.0; + + debugPrint('🔄 Using Base64 approach for development...'); + debugPrint('🔄 Processing ${paymentProofImages.length} payment proof images...'); + + List uploadedUrls = []; + + // Process each image one by one + for (int i = 0; i < paymentProofImages.length; i++) { + // Update progress + uploadProgress.value = (i / paymentProofImages.length) * 0.5; + + final File imageFile = paymentProofImages[i]; + final String timestamp = DateTime.now().millisecondsSinceEpoch.toString(); + final String fileName = 'payment_proof_${timestamp}_$i.jpg'; + + debugPrint('🔄 Processing image $i: ${imageFile.path}'); + + // For development: Create a Base64 representation of the image + // This bypasses Supabase storage completely + final bytes = await imageFile.readAsBytes(); + final base64Image = base64Encode(bytes); + final truncatedBase64 = base64Image.length > 40 + ? '${base64Image.substring(0, 20)}...${base64Image.substring(base64Image.length - 20)}' + : base64Image; + + debugPrint('🔄 Image converted to Base64 (truncated): $truncatedBase64'); + + // Update progress + uploadProgress.value = 0.5 + (i / paymentProofImages.length) * 0.3; + + // Create a data URL that includes the Base64 data + // In a real app, you would upload to Supabase and get a real URL + final String mockUrl = 'data:image/jpeg;base64,$base64Image'; + uploadedUrls.add(mockUrl); + + debugPrint('✅ Created data URL for image $i'); + } + + // Update progress for database saving phase + uploadProgress.value = 0.8; + + // Save all URLs to foto_pembayaran table + for (int i = 0; i < uploadedUrls.length; i++) { + uploadProgress.value = 0.8 + (i / uploadedUrls.length) * 0.2; + await _saveToFotoPembayaranTable(uploadedUrls[i]); + } + + // Update order status + if (Get.isRegistered(tag: 'orderDetails')) { + final orderDetails = Get.find(tag: 'orderDetails'); + orderDetails.update((val) { + val?['status'] = 'MEMERIKSA PEMBAYARAN'; + }); + } + + // Update current step based on status if the method exists + try { + updateCurrentStepBasedOnStatus(); + } catch (e) { + debugPrint('⚠️ Could not update step: $e'); + } + + // Clear the images after successful upload + paymentProofImages.clear(); + + // Set progress to complete + uploadProgress.value = 1.0; + + // Show success message + Get.snackbar( + 'Sukses', + 'Bukti pembayaran berhasil diunggah', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + debugPrint('❌ Error uploading payment proof: $e'); + Get.snackbar( + 'Error', + 'Gagal mengunggah bukti pembayaran: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isUploading.value = false; + } + } + if (currentStep.value < 7) { + currentStep.value++; + updateOrderStatusBasedOnStep(); + } + } + + // Update order status based on current step + void updateOrderStatusBasedOnStep() { + String newStatus; + + switch (currentStep.value) { + case 0: + newStatus = 'MENUNGGU PEMBAYARAN'; + break; + case 1: + newStatus = 'MEMERIKSA PEMBAYARAN'; + break; + case 2: + newStatus = 'DITERIMA'; + break; + case 3: + newStatus = 'PENGEMBALIAN'; + break; + case 4: + newStatus = 'PEMBAYARAN DENDA'; + break; + case 5: + newStatus = 'MEMERIKSA PEMBAYARAN DENDA'; + break; + case 6: + newStatus = 'SELESAI'; + break; + default: + newStatus = 'MENUNGGU PEMBAYARAN'; + } + + orderDetails.update((val) { + val?['status'] = newStatus; + }); + } + + // Update currentStep based on order status + void updateCurrentStepBasedOnStatus() { + final status = orderDetails.value['status']?.toString().toUpperCase() ?? ''; + debugPrint('📊 Updating current step based on status: $status'); + + switch (status) { + case 'MENUNGGU PEMBAYARAN': + currentStep.value = 0; + break; + case 'MEMERIKSA PEMBAYARAN': + currentStep.value = 1; + break; + case 'DITERIMA': + currentStep.value = 2; + break; + case 'PENGEMBALIAN': + currentStep.value = 3; + break; + case 'PEMBAYARAN DENDA': + currentStep.value = 4; + break; + case 'MEMERIKSA PEMBAYARAN DENDA': + currentStep.value = 5; + break; + case 'SELESAI': + currentStep.value = 6; + break; + case 'DIBATALKAN': + // Special case for canceled orders + currentStep.value = 0; + break; + default: + currentStep.value = 0; + break; + } + + debugPrint('📊 Current step updated to: ${currentStep.value}'); + } + + // This method has been moved and improved above + + // Submit cash payment + void submitCashPayment() { + // Update order status + orderDetails.update((val) { + val?['status'] = 'MEMERIKSA PEMBAYARAN'; + }); + + // Cancel countdown timer as payment has been submitted + _countdownTimer?.cancel(); + + // Show success message + Get.snackbar( + 'Sukses', + 'Pembayaran tunai berhasil disubmit', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + // Update step + currentStep.value = 1; + } + + // Cancel payment + void cancelPayment() { + Get.back(); + } + + // Debug function to check sewa_aset table structure + void checkSewaAsetTableStructure() { + try { + debugPrint('🔍 DEBUG: Checking sewa_aset table structure'); + final client = asetProvider.client; + + // Get a single record to check field names + client + .from('sewa_aset') + .select('*') + .limit(1) + .then((response) { + if (response is List && response.isNotEmpty) { + final record = response.first; + debugPrint('📋 SEWA_ASET TABLE STRUCTURE:'); + debugPrint('Available fields in sewa_aset table:'); + + record.forEach((key, value) { + debugPrint(' $key: (${value?.runtimeType})'); + }); + + // Specifically check for time fields + final timeFields = [ + 'waktu_mulai', + 'waktu_selesai', + 'start_time', + 'end_time', + ]; + for (final field in timeFields) { + debugPrint( + ' Field "$field" exists: ${record.containsKey(field)}', + ); + if (record.containsKey(field)) { + debugPrint(' Field "$field" value: ${record[field]}'); + } + } + } else { + debugPrint('⚠️ No records found in sewa_aset table'); + } + }) + .catchError((e) { + debugPrint('❌ Error checking sewa_aset table: $e'); + }); + } catch (e) { + debugPrint('❌ Error in checkSewaAsetTableStructure: $e'); + } + } + + // Load bank accounts from akun_bank table + Future loadBankAccounts() async { + debugPrint('Loading bank accounts from akun_bank table...'); + try { + final data = await asetProvider.getBankAccounts(); + if (data != null && data.isNotEmpty) { + bankAccounts.assignAll(data); + debugPrint('✅ Bank accounts loaded: ${bankAccounts.length} accounts found'); + + // Debug the bank accounts data + debugPrint('📋 BANK ACCOUNTS DETAILS:'); + for (var account in bankAccounts) { + debugPrint(' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}'); + } + } else { + debugPrint('⚠️ No bank accounts found in akun_bank table'); + // Add a default bank account if none found + bankAccounts.add({ + 'id': '1', + 'nama_bank': 'Bank Default', + 'nama_akun': 'BUMDes Maju Jaya', + 'no_rekening': '1234567890', + }); + } + } catch (e) { + debugPrint('❌ Error loading bank accounts: $e'); + // Add a default bank account in case of error + bankAccounts.add({ + 'id': '1', + 'nama_bank': 'Bank Default', + 'nama_akun': 'BUMDes Maju Jaya', + 'no_rekening': '1234567890', + }); + } + } + + // Debug method to diagnose Supabase storage issues + Future _debugSupabaseStorage() async { + try { + debugPrint('\n\n🔍 SUPABASE STORAGE DEBUG 🔍'); + + final supabase = Supabase.instance.client; + + // Check if Supabase client is initialized + debugPrint('1. Supabase client initialized: ${supabase != null}'); + + // Try to list buckets + try { + debugPrint('2. Attempting to list storage buckets...'); + final buckets = await supabase.storage.listBuckets(); + debugPrint(' ✅ Success! Found ${buckets.length} buckets:'); + for (var bucket in buckets) { + debugPrint(' - ${bucket.name} (id: ${bucket.id})'); + } + } catch (e) { + debugPrint(' ❌ Error listing buckets: $e'); + debugPrint(' Stack trace: ${StackTrace.current}'); + } + + // Try to create a test file in memory + try { + debugPrint('3. Creating test file in memory...'); + final testBytes = Uint8List.fromList([1, 2, 3, 4, 5]); // 5 bytes + debugPrint(' ✅ Test file created: ${testBytes.length} bytes'); + + // Try to upload test file + try { + debugPrint('4. Attempting to upload test file...'); + final testFileName = 'test_${DateTime.now().millisecondsSinceEpoch}.bin'; + + // Try to get bucket info first + try { + final buckets = await supabase.storage.listBuckets(); + if (buckets.isNotEmpty) { + final bucket = buckets.first; + debugPrint(' Using bucket: ${bucket.name}'); + + try { + final path = await supabase.storage + .from(bucket.name) + .uploadBinary( + testFileName, + testBytes, + ); + debugPrint(' ✅ Test upload successful! Path: $path'); + } catch (e) { + debugPrint(' ❌ Test upload failed: $e'); + } + } else { + debugPrint(' ❌ No buckets available for test upload'); + } + } catch (e) { + debugPrint(' ❌ Error getting buckets for test: $e'); + } + } catch (e) { + debugPrint(' ❌ Error during test upload: $e'); + } + } catch (e) { + debugPrint(' ❌ Error creating test file: $e'); + } + + debugPrint('🔍 SUPABASE STORAGE DEBUG COMPLETE 🔍\n\n'); + } catch (e) { + debugPrint('❌ Error in debug method: $e'); + } + } + + // Upload image to Supabase storage with enhanced logging + Future _uploadToSupabaseStorage( + File imageFile, + String fileName, + StreamController progressNotifier, + ) async { + try { + debugPrint('🔄 Starting upload to Supabase storage...'); + debugPrint('🔄 File details: ${imageFile.path}, size: ${await imageFile.length()} bytes'); + debugPrint('🔄 Target filename: $fileName'); + + // Update progress to indicate start + progressNotifier.add(0.1); + + // Get the file bytes + final bytes = await imageFile.readAsBytes(); + debugPrint('🔄 File read as bytes: ${bytes.length} bytes'); + progressNotifier.add(0.3); + + // Get direct access to Supabase client + final supabase = Supabase.instance.client; + debugPrint('🔄 Supabase client initialized'); + + // Log Supabase configuration + debugPrint('🔄 Supabase configuration:'); + debugPrint(' - Client ready: ${supabase != null}'); + debugPrint(' - Auth initialized: ${supabase.auth != null}'); + + // Make sure the bucket exists + try { + debugPrint('🔄 Listing available storage buckets...'); + final buckets = await supabase.storage.listBuckets(); + debugPrint('🔄 Available buckets (${buckets.length}): ${buckets.map((b) => b.name).join(', ')}'); + + // Check if our bucket exists + final bucketExists = buckets.any((b) => b.name == 'bukti.pembayaran'); + if (!bucketExists) { + debugPrint('⚠️ Bucket "bukti.pembayaran" not found in available buckets!'); + // Try with a different bucket name format + final altBucketExists = buckets.any((b) => b.name.contains('bukti')); + if (altBucketExists) { + final altBucket = buckets.firstWhere((b) => b.name.contains('bukti')); + debugPrint('🔄 Found alternative bucket: ${altBucket.name}'); + fileName = 'bukti.pembayaran/$fileName'; // Use as folder path instead + } else { + debugPrint('⚠️ No bucket containing "bukti" found!'); + } + } else { + debugPrint('✅ Bucket "bukti.pembayaran" exists'); + } + } catch (e) { + debugPrint('⚠️ Error listing buckets: $e'); + debugPrint('⚠️ Stack trace: ${StackTrace.current}'); + // Continue anyway, it might still work + } + + progressNotifier.add(0.4); + + // Try different upload approaches + String? path; + String? publicUrl; + + // Approach 1: Try using the uploadBinary method + try { + debugPrint('🔄 APPROACH 1: Trying uploadBinary method...'); + debugPrint('🔄 Target: bukti.pembayaran/$fileName'); + + try { + path = await supabase.storage + .from('bukti.pembayaran') + .uploadBinary( + fileName, + Uint8List.fromList(bytes), + fileOptions: FileOptions(contentType: 'image/jpeg', upsert: true), + ); + + debugPrint('✅ Upload path result: $path'); + + // Get public URL + publicUrl = supabase.storage.from('bukti.pembayaran').getPublicUrl(fileName); + debugPrint('✅ Upload successful with uploadBinary! URL: $publicUrl'); + + progressNotifier.add(1.0); + return publicUrl; + } catch (e) { + debugPrint('⚠️ Error details for uploadBinary:'); + debugPrint(' - Error type: ${e.runtimeType}'); + debugPrint(' - Error message: $e'); + debugPrint(' - Stack trace: ${StackTrace.current}'); + throw e; // Rethrow to try next approach + } + } catch (e1) { + debugPrint('⚠️ APPROACH 1 FAILED: $e1'); + + // Approach 2: Try using the upload method + try { + debugPrint('🔄 APPROACH 2: Trying upload method...'); + debugPrint('🔄 Target: bukti.pembayaran/$fileName'); + + try { + path = await supabase.storage + .from('bukti.pembayaran') + .upload(fileName, imageFile); + + debugPrint('✅ Upload path result: $path'); + + // Get public URL + publicUrl = supabase.storage.from('bukti.pembayaran').getPublicUrl(fileName); + debugPrint('✅ Upload successful with upload method! URL: $publicUrl'); + + progressNotifier.add(1.0); + return publicUrl; + } catch (e) { + debugPrint('⚠️ Error details for upload method:'); + debugPrint(' - Error type: ${e.runtimeType}'); + debugPrint(' - Error message: $e'); + debugPrint(' - Stack trace: ${StackTrace.current}'); + throw e; // Rethrow to try next approach + } + } catch (e2) { + debugPrint('⚠️ APPROACH 2 FAILED: $e2'); + + // Approach 3: Try using a different bucket + try { + debugPrint('🔄 APPROACH 3: Trying with different bucket...'); + + try { + final buckets = await supabase.storage.listBuckets(); + + if (buckets.isEmpty) { + debugPrint('⚠️ No buckets available!'); + throw Exception('No buckets available'); + } + + final firstBucket = buckets.first.name; + debugPrint('🔄 Using first available bucket: $firstBucket'); + debugPrint('🔄 Target: $firstBucket/payment_proofs/$fileName'); + + path = await supabase.storage + .from(firstBucket) + .upload('payment_proofs/$fileName', imageFile); + + debugPrint('✅ Upload path result: $path'); + + // Get public URL + publicUrl = supabase.storage.from(firstBucket).getPublicUrl('payment_proofs/$fileName'); + debugPrint('✅ Upload successful with different bucket! URL: $publicUrl'); + + progressNotifier.add(1.0); + return publicUrl; + } catch (e) { + debugPrint('⚠️ Error details for different bucket approach:'); + debugPrint(' - Error type: ${e.runtimeType}'); + debugPrint(' - Error message: $e'); + debugPrint(' - Stack trace: ${StackTrace.current}'); + throw e; + } + } catch (e3) { + debugPrint('⚠️ APPROACH 3 FAILED: $e3'); + debugPrint('⚠️ ALL UPLOAD APPROACHES FAILED'); + debugPrint('⚠️ Errors summary:'); + debugPrint(' - Approach 1 (uploadBinary): $e1'); + debugPrint(' - Approach 2 (upload): $e2'); + debugPrint(' - Approach 3 (different bucket): $e3'); + + throw Exception('All upload approaches failed'); + } + } + } + } catch (e) { + debugPrint('❌ FINAL ERROR in _uploadToSupabaseStorage: $e'); + debugPrint('❌ Stack trace: ${StackTrace.current}'); + + // For development, return a mock URL so the app can continue + final mockUrl = 'https://mock-storage.example.com/bukti.pembayaran/$fileName'; + debugPrint('🔄 Using fallback mock URL: $mockUrl'); + + progressNotifier.add(1.0); + return mockUrl; + } finally { + if (!progressNotifier.isClosed) { + try { + progressNotifier.close(); + } catch (e) { + // Ignore error on close + } + } + } + } + + // Save image URL to foto_pembayaran table + Future _saveToFotoPembayaranTable(String imageUrl) async { + try { + debugPrint('🔄 Saving image URL to foto_pembayaran table...'); + + // Get the Supabase client + final supabase = Supabase.instance.client; + + // Get the tagihan_sewa_id - try multiple approaches + dynamic tagihanSewaId; + + // Try to get it from the controller's state + try { + // First approach: try to get from tagihanSewa if it exists + if (Get.isRegistered(tag: 'tagihanSewa')) { + final tagihanSewaData = Get.find(tag: 'tagihanSewa'); + tagihanSewaId = tagihanSewaData['id']; + debugPrint('🔄 Found tagihan_sewa_id from tagihanSewa: $tagihanSewaId'); + } + // Second approach: try to get from orderDetails if it exists + else if (Get.isRegistered(tag: 'orderDetails')) { + final orderData = Get.find(tag: 'orderDetails'); + tagihanSewaId = orderData['tagihan_sewa_id'] ?? orderData['id']; + debugPrint('🔄 Found tagihan_sewa_id from orderDetails: $tagihanSewaId'); + } + } catch (e) { + debugPrint('⚠️ Error getting tagihan_sewa_id from state: $e'); + } + + // If we still don't have an ID, use a placeholder for development + if (tagihanSewaId == null) { + debugPrint('⚠️ Could not find tagihan_sewa_id, using placeholder value'); + tagihanSewaId = 1; // Placeholder for development + } + + // Prepare the data to insert + final Map data = { + 'tagihan_sewa_id': tagihanSewaId, + 'foto_pembayaran': imageUrl, + 'created_at': DateTime.now().toIso8601String(), + }; + + debugPrint('🔄 Inserting data: $data'); + + // Try to insert the data + try { + final response = await supabase + .from('foto_pembayaran') + .insert(data) + .select(); + + debugPrint('✅ Image URL saved to foto_pembayaran table: ${response.toString()}'); + } catch (dbError) { + debugPrint('⚠️ Database error: $dbError'); + + // For development, we'll just log what would have been saved + debugPrint('🔄 Would have saved: $data'); + } + } catch (e) { + debugPrint('❌ Error in _saveToFotoPembayaranTable: $e'); + // Don't throw the exception, just log it so the app can continue + debugPrint('⚠️ Failed to save image URL to database: $e'); + } + } + + // Simplified refresh method for development + Future refreshData() async { + debugPrint('Refreshing payment page data...'); + + try { + // Simulate a delay for better UX + await Future.delayed(const Duration(seconds: 1)); + + // Show a success message + Get.snackbar( + 'Berhasil', + 'Data berhasil diperbarui', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 2), + ); + + debugPrint('Data refresh completed'); + } catch (e) { + debugPrint('Error refreshing data: $e'); + + // Show an error message + Get.snackbar( + 'Error', + 'Gagal memperbarui data', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + + return Future.value(); + } +} diff --git a/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart new file mode 100644 index 0000000..a8f2f79 --- /dev/null +++ b/lib/app/modules/warga/controllers/pembayaran_sewa_controller.dart @@ -0,0 +1,1202 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../data/providers/aset_provider.dart'; +import '../../../services/navigation_service.dart'; + +// Custom class for web platform to handle image URLs +class WebImageFile { + final String imageUrl; + String id = ''; // Database ID for the foto_pembayaran record (UUID string) + + WebImageFile(this.imageUrl); +} + +class PembayaranSewaController extends GetxController + with GetSingleTickerProviderStateMixin { + // Dependencies + final NavigationService navigationService = Get.find(); + final AsetProvider asetProvider = Get.find(); + + // Direct access to Supabase client for storage operations + final SupabaseClient client = Supabase.instance.client; + + // Tab controller + late TabController tabController; + + // Order details + final orderId = ''.obs; + final orderDetails = Rx>({}); + + // Sewa Aset details with related aset info + final sewaAsetDetails = Rx>({}); + + // Tagihan Sewa details + final tagihanSewa = Rx>({}); + + // Payment details + final paymentMethod = ''.obs; + final selectedPaymentType = ''.obs; + final isLoading = false.obs; + final currentStep = 0.obs; + + // Payment proof images - now a list to support multiple images (both File and WebImageFile) + final RxList paymentProofImages = [].obs; + + // Track original images loaded from database + final RxList originalImages = [].obs; + + // Track images marked for deletion + final RxList imagesToDelete = [].obs; + + // Flag to track if there are changes that need to be saved + final RxBool hasUnsavedChanges = false.obs; + + // Get image widget for a specific image + Widget getImageWidget(dynamic imageFile) { + // Check if it's a WebImageFile (for existing images loaded from URLs) + if (imageFile is WebImageFile) { + return Image.network( + imageFile.imageUrl, + height: 120, + width: 120, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 120, + width: 120, + color: Colors.grey[300], + child: const Center(child: Text('Error')), + ); + }, + ); + } + // Check if running on web with a File object + else if (kIsWeb && imageFile is File) { + // For web, we need to use Image.network with the path + return Image.network( + imageFile.path, + height: 120, + width: 120, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 120, + width: 120, + color: Colors.grey[300], + child: const Center(child: Text('Error')), + ); + }, + ); + } + // For mobile with a File object + else if (imageFile is File) { + return Image.file( + imageFile, + height: 120, + width: 120, + fit: BoxFit.cover, + ); + } + // Fallback for any other type + else { + return Container( + height: 120, + width: 120, + color: Colors.grey[300], + child: const Center(child: Text('Invalid image')), + ); + } + } + + // Remove an image from the list + void removeImage(dynamic image) { + // If this is an existing image (WebImageFile), add it to imagesToDelete + if (image is WebImageFile && image.id.isNotEmpty) { + imagesToDelete.add(image); + debugPrint('🗑️ Marked image for deletion: ${image.imageUrl} (ID: ${image.id})'); + } + + // Remove from the current list + paymentProofImages.remove(image); + + // Check if we have any changes (additions or deletions) + _checkForChanges(); + + update(); + } + + // Show image in full screen when tapped + void showFullScreenImage(dynamic image) { + String imageUrl; + + if (image is WebImageFile) { + imageUrl = image.imageUrl; + } else if (image is File) { + imageUrl = image.path; + } else { + debugPrint('❌ Cannot display image: Unknown image type'); + return; + } + + debugPrint('📷 Showing full screen image: $imageUrl'); + + // Show full screen image dialog + Get.dialog( + Dialog( + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + child: Stack( + alignment: Alignment.center, + children: [ + // Image with pinch to zoom + InteractiveViewer( + panEnabled: true, + minScale: 0.5, + maxScale: 4, + child: kIsWeb + ? Image.network( + imageUrl, + fit: BoxFit.contain, + height: Get.height, + width: Get.width, + errorBuilder: (context, error, stackTrace) { + return const Center(child: Text('Error loading image')); + }, + ) + : Image.file( + File(imageUrl), + fit: BoxFit.contain, + height: Get.height, + width: Get.width, + ), + ), + // Close button + Positioned( + top: 40, + right: 20, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.white, size: 30), + onPressed: () => Get.back(), + ), + ), + ], + ), + ), + barrierDismissible: true, + ); + } + + // Check if there are any changes to save (new images added or existing images removed) + void _checkForChanges() { + // We have changes if: + // 1. We have images marked for deletion + // 2. We have new images (files) added + // 3. The current list differs from the original list + + bool hasChanges = false; + + // Check if any images are marked for deletion + if (imagesToDelete.isNotEmpty) { + hasChanges = true; + } + + // Check if any new images have been added + for (dynamic image in paymentProofImages) { + if (image is File) { + // This is a new image + hasChanges = true; + break; + } + } + + // Check if the number of images has changed + if (paymentProofImages.length != originalImages.length) { + hasChanges = true; + } + + hasUnsavedChanges.value = hasChanges; + debugPrint('💾 Has unsaved changes: $hasChanges'); + } + final isUploading = false.obs; + final uploadProgress = 0.0.obs; + + // Timer countdown + final remainingTime = ''.obs; + Timer? _countdownTimer; + final int paymentTimeLimit = 3600; // 1 hour in seconds + final timeRemaining = 0.obs; + + // Bank accounts for transfer + final bankAccounts = RxList>([]); + + @override + void onInit() { + super.onInit(); + tabController = TabController(length: 3, vsync: this); + + // Get order ID and rental data from arguments + if (Get.arguments != null) { + if (Get.arguments['orderId'] != null) { + orderId.value = Get.arguments['orderId']; + + // If rental data is passed, use it directly + if (Get.arguments['rentalData'] != null) { + Map rentalData = Get.arguments['rentalData']; + debugPrint('Received rental data: $rentalData'); + + // Pre-populate order details with rental data + orderDetails.value = { + 'id': rentalData['id'] ?? '', + 'item_name': rentalData['name'] ?? 'Aset', + 'quantity': rentalData['jumlahUnit'] ?? 0, + 'rental_period': rentalData['waktuSewa'] ?? '', + 'duration': rentalData['duration'] ?? '', + 'price_per_unit': 0, // This might not be available in rental data + 'total_price': rentalData['totalPrice'] != null ? + int.tryParse(rentalData['totalPrice'].toString().replaceAll(RegExp(r'[^0-9]'), '')) ?? 0 : 0, + 'status': rentalData['status'] ?? 'MENUNGGU PEMBAYARAN', + 'created_at': DateTime.now().toString(), + 'denda': 0, // Default value + 'keterangan': '', // Default value + 'image_url': rentalData['imageUrl'], + 'waktu_mulai': rentalData['waktuMulai'], + 'waktu_selesai': rentalData['waktuSelesai'], + 'rentang_waktu': rentalData['rentangWaktu'], + }; + + // Still load additional details from the database + checkSewaAsetTableStructure(); + loadTagihanSewaDetails().then((_) { + // Load existing payment proof images after tagihan_sewa details are loaded + loadExistingPaymentProofImages(); + }); + loadSewaAsetDetails(); + loadBankAccounts(); // Load bank accounts data + } else { + // If no rental data is passed, load everything from the database + checkSewaAsetTableStructure(); + loadOrderDetails(); + loadTagihanSewaDetails().then((_) { + // Load existing payment proof images after tagihan_sewa details are loaded + loadExistingPaymentProofImages(); + }); + loadSewaAsetDetails(); + loadBankAccounts(); // Load bank accounts data + } + } + } + } + + @override + void onClose() { + _countdownTimer?.cancel(); + tabController.dispose(); + super.onClose(); + } + + // Load order details + void loadOrderDetails() { + isLoading.value = true; + + // Simulating API call + Future.delayed(Duration(seconds: 1), () { + // Mock data + orderDetails.value = { + 'id': orderId.value, + 'item_name': 'Sewa Kursi Taman', + 'quantity': 5, + 'rental_period': '24 April 2023, 10:00 - 12:00', + 'duration': '2 jam', + 'price_per_unit': 10000, + 'total_price': 50000, + 'status': 'MENUNGGU PEMBAYARAN', + 'created_at': + DateTime.now().toString(), // Use this for countdown calculation + 'denda': 20000, // Dummy data for denda + 'keterangan': + 'Terjadi kerusakan pada bagian kaki', // Dummy keterangan for denda + }; + + // Update the current step based on the status + updateCurrentStepBasedOnStatus(); + + isLoading.value = false; + startCountdownTimer(); + }); + } + + // Load sewa_aset details with aset data + void loadSewaAsetDetails() { + isLoading.value = true; + + debugPrint( + '🔍 Starting to load sewa_aset details for orderId: ${orderId.value}', + ); + + asetProvider + .getSewaAsetWithAsetData(orderId.value) + .then((data) { + if (data != null) { + // Use actual data without adding dummy values + sewaAsetDetails.value = data; + debugPrint( + '✅ Sewa aset details loaded: ${sewaAsetDetails.value['id']}', + ); + + // Debug all fields in the sewaAsetDetails + debugPrint('📋 SEWA ASET DETAILS (COMPLETE DATA):'); + data.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Specifically debug waktu_mulai and waktu_selesai + debugPrint('⏰ WAKTU DETAILS:'); + debugPrint(' waktu_mulai: ${data['waktu_mulai']}'); + debugPrint(' waktu_selesai: ${data['waktu_selesai']}'); + debugPrint(' denda: ${data['denda']}'); + debugPrint(' keterangan: ${data['keterangan']}'); + + // If aset_detail exists, debug it too + if (data['aset_detail'] != null) { + debugPrint('🏢 ASET DETAILS:'); + (data['aset_detail'] as Map).forEach(( + key, + value, + ) { + debugPrint(' $key: $value'); + }); + } + + // Update order details based on sewa_aset data + orderDetails.update((val) { + if (data['aset_detail'] != null) { + val?['item_name'] = data['aset_detail']['nama'] ?? 'Aset Sewa'; + } + val?['quantity'] = data['kuantitas'] ?? 1; + val?['denda'] = + data['denda'] ?? + 0; // Use data from API or default to 0 + val?['keterangan'] = + data['keterangan'] ?? + ''; // Use data from API or default to empty string + + // Update status if it exists in the data + if (data['status'] != null && data['status'].toString().isNotEmpty) { + val?['status'] = data['status']; + debugPrint('📊 Order status from sewa_aset: ${data['status']}'); + } + + // Format rental period + if (data['waktu_mulai'] != null && + data['waktu_selesai'] != null) { + try { + final startTime = DateTime.parse(data['waktu_mulai']); + final endTime = DateTime.parse(data['waktu_selesai']); + val?['rental_period'] = + '${startTime.day}/${startTime.month}/${startTime.year}, ${startTime.hour}:${startTime.minute.toString().padLeft(2, '0')} - ${endTime.hour}:${endTime.minute.toString().padLeft(2, '0')}'; + debugPrint( + '✅ Successfully formatted rental period: ${val?['rental_period']}', + ); + } catch (e) { + debugPrint('❌ Error parsing date: $e'); + } + } else { + debugPrint( + '⚠️ Missing waktu_mulai or waktu_selesai for formatting rental period', + ); + } + }); + + // Update the current step based on the status + updateCurrentStepBasedOnStatus(); + } else { + debugPrint( + '⚠️ No sewa_aset details found for order: ${orderId.value}', + ); + + // Add dummy data when no real data is available + sewaAsetDetails.value = { + 'id': orderId.value, + 'denda': 20000, + 'keterangan': 'Terjadi kerusakan pada bagian kaki', + }; + } + isLoading.value = false; + }) + .catchError((error) { + debugPrint('❌ Error loading sewa_aset details: $error'); + + // Add dummy data in case of error + sewaAsetDetails.value = { + 'id': orderId.value, + 'denda': 20000, + 'keterangan': 'Terjadi kerusakan pada bagian kaki', + }; + + isLoading.value = false; + }); + } + + // Load tagihan sewa details + Future loadTagihanSewaDetails() { + isLoading.value = true; + + // Use the AsetProvider to fetch the tagihan_sewa data + return asetProvider + .getTagihanSewa(orderId.value) + .then((data) { + if (data != null) { + tagihanSewa.value = data; + debugPrint('✅ Tagihan sewa loaded: ${tagihanSewa.value['id']}'); + + // Debug the tagihan_sewa data + debugPrint('📋 TAGIHAN SEWA DETAILS:'); + data.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + // Specifically debug denda, keterangan, and foto_kerusakan + debugPrint('💰 DENDA DETAILS:'); + debugPrint(' denda: ${data['denda']}'); + debugPrint(' keterangan: ${data['keterangan']}'); + debugPrint(' foto_kerusakan: ${data['foto_kerusakan']}'); + } else { + debugPrint('⚠️ No tagihan sewa found for order: ${orderId.value}'); + // Initialize with empty data instead of mock data + tagihanSewa.value = { + 'id': '', + 'sewa_aset_id': orderId.value, + 'denda': 0, + 'keterangan': '', + 'foto_kerusakan': '', + }; + } + isLoading.value = false; + }) + .catchError((error) { + debugPrint('❌ Error loading tagihan sewa: $error'); + // Initialize with empty data instead of mock data + tagihanSewa.value = { + 'id': '', + 'sewa_aset_id': orderId.value, + 'denda': 0, + 'keterangan': '', + 'foto_kerusakan': '', + }; + isLoading.value = false; + }); + } + + // Start countdown timer (1 hour) + void startCountdownTimer() { + timeRemaining.value = paymentTimeLimit; + + _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) { + if (timeRemaining.value <= 0) { + timer.cancel(); + handlePaymentTimeout(); + } else { + timeRemaining.value--; + updateRemainingTimeDisplay(); + } + }); + } + + // Update the time display in format HH:MM:SS + void updateRemainingTimeDisplay() { + int hours = timeRemaining.value ~/ 3600; + int minutes = (timeRemaining.value % 3600) ~/ 60; + int seconds = timeRemaining.value % 60; + + remainingTime.value = + '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + + // Handle payment timeout - change status to DIBATALKAN + void handlePaymentTimeout() { + if (orderDetails.value['status'] == 'MENUNGGU PEMBAYARAN') { + orderDetails.update((val) { + val?['status'] = 'DIBATALKAN'; + }); + + Get.snackbar( + 'Pesanan Dibatalkan', + 'Batas waktu pembayaran telah berakhir', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + duration: Duration(seconds: 5), + ); + } + } + + // Change payment method + void selectPaymentMethod(String method) { + paymentMethod.value = method; + update(); + } + + // Select payment type (tagihan_awal or denda) + void selectPaymentType(String type) { + selectedPaymentType.value = type; + update(); + } + + // Take photo using camera + Future takePhoto() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + + if (image != null) { + // Add to the list of images instead of replacing + paymentProofImages.add(File(image.path)); + + // Check for changes + _checkForChanges(); + + update(); + } + } catch (e) { + debugPrint('❌ Error taking photo: $e'); + Get.snackbar( + 'Error', + 'Gagal mengambil foto: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // Select photo from gallery + Future selectPhotoFromGallery() async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + + if (image != null) { + // Add to the list of images instead of replacing + paymentProofImages.add(File(image.path)); + update(); + } + } catch (e) { + debugPrint('❌ Error selecting photo from gallery: $e'); + Get.snackbar( + 'Error', + 'Gagal memilih foto dari galeri: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + + // Upload payment proof to Supabase storage and save to foto_pembayaran table + Future uploadPaymentProof() async { + // If there are no images and none marked for deletion, show error + if (paymentProofImages.isEmpty && imagesToDelete.isEmpty) { + Get.snackbar( + 'Error', + 'Mohon unggah bukti pembayaran terlebih dahulu', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // If there are no changes, no need to do anything + if (!hasUnsavedChanges.value) { + Get.snackbar( + 'Info', + 'Tidak ada perubahan yang perlu disimpan', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.blue, + colorText: Colors.white, + ); + return; + } + + try { + isUploading.value = true; + uploadProgress.value = 0.0; + + // Set up upload progress listener + final progressNotifier = StreamController(); + progressNotifier.stream.listen((progress) { + uploadProgress.value = progress; + }); + + // First, delete any images marked for deletion + if (imagesToDelete.isNotEmpty) { + debugPrint('🗑️ Deleting ${imagesToDelete.length} images from database and storage'); + + for (WebImageFile image in imagesToDelete) { + // Delete the record from the foto_pembayaran table + if (image.id.isNotEmpty) { + debugPrint('🗑️ Deleting record with ID: ${image.id}'); + try { + // Delete the record using the UUID string + final result = await client + .from('foto_pembayaran') + .delete() + .eq('id', image.id); // ID is already a string UUID + + debugPrint('🗑️ Delete result: $result'); + } catch (e) { + debugPrint('❌ Error deleting record: $e'); + throw e; // Re-throw so the main catch block handles it + } + + debugPrint('🗑️ Deleted database record with ID: ${image.id}'); + + // Extract the file name from the URL to delete from storage + try { + // Parse the URL to get the filename more reliably + Uri uri = Uri.parse(image.imageUrl); + String path = uri.path; + // The filename is the last part of the path after the last '/' + final String fileName = path.substring(path.lastIndexOf('/') + 1); + + debugPrint('🗑️ Attempting to delete file from storage: $fileName'); + + // Delete the file from storage + await client.storage + .from('bukti.pembayaran') + .remove([fileName]); + + debugPrint('🗑️ Successfully deleted file from storage: $fileName'); + } catch (e) { + debugPrint('⚠️ Error deleting file from storage: $e'); + // Continue even if file deletion fails - we've at least deleted from the database + } + } + } + + // Clear the deleted images list + imagesToDelete.clear(); + } + + // Upload each new image to Supabase Storage and save to database + debugPrint('🔄 Uploading new payment proof images to Supabase storage...'); + + List uploadedUrls = []; + List newImagesToUpload = []; + List existingImageUrls = []; + + // Separate existing WebImageFile objects from new File objects that need uploading + for (final image in paymentProofImages) { + if (image is WebImageFile) { + // This is an existing image, no need to upload again + existingImageUrls.add(image.imageUrl); + } else if (image is File) { + // This is a new image that needs to be uploaded + newImagesToUpload.add(image); + } + } + + debugPrint('🔄 Found ${existingImageUrls.length} existing images and ${newImagesToUpload.length} new images to upload'); + + // If there are new images to upload + if (newImagesToUpload.isNotEmpty) { + // Calculate progress increment per image + final double progressIncrement = 1.0 / newImagesToUpload.length; + double currentProgress = 0.0; + + // Upload each new image + for (int i = 0; i < newImagesToUpload.length; i++) { + final dynamic imageFile = newImagesToUpload[i]; + final String fileName = '${DateTime.now().millisecondsSinceEpoch}_${orderId.value}_$i.jpg'; + + // Create a sub-progress tracker for this image + final subProgressNotifier = StreamController(); + subProgressNotifier.stream.listen((subProgress) { + // Calculate overall progress + progressNotifier.add(currentProgress + (subProgress * progressIncrement)); + }); + + // Upload to Supabase Storage + final String? imageUrl = await _uploadToSupabaseStorage( + imageFile, + fileName, + subProgressNotifier, + ); + + if (imageUrl == null) { + throw Exception('Failed to upload image $i to storage'); + } + + debugPrint('✅ Image $i uploaded successfully: $imageUrl'); + uploadedUrls.add(imageUrl); + + // Update progress for next image + currentProgress += progressIncrement; + } + } else { + // If there are only existing images, set progress to 100% + progressNotifier.add(1.0); + } + + // Save all new URLs to foto_pembayaran table + for (String imageUrl in uploadedUrls) { + await _saveToFotoPembayaranTable(imageUrl); + } + + // Reload the existing images to get fresh data with new IDs + await loadExistingPaymentProofImages(); + + // Update order status in orderDetails + orderDetails.update((val) { + val?['status'] = 'MEMERIKSA PEMBAYARAN'; + }); + + // Also update the status in the sewa_aset table + try { + // Get the sewa_aset_id from the tagihanSewa data + final dynamic sewaAsetId = tagihanSewa.value['sewa_aset_id']; + + if (sewaAsetId != null && sewaAsetId.toString().isNotEmpty) { + debugPrint('🔄 Updating status in sewa_aset table for ID: $sewaAsetId'); + + // Update the status in the sewa_aset table + final updateResult = await client + .from('sewa_aset') + .update({'status': 'PERIKSA PEMBAYARAN'}) + .eq('id', sewaAsetId.toString()); + + debugPrint('✅ Status updated in sewa_aset table: $updateResult'); + } else { + debugPrint('⚠️ Could not update sewa_aset status: No valid sewa_aset_id found'); + } + } catch (e) { + // Don't fail the entire operation if this update fails + debugPrint('❌ Error updating status in sewa_aset table: $e'); + } + + // Update current step based on status + updateCurrentStepBasedOnStatus(); + + // Cancel countdown timer as payment has been submitted + _countdownTimer?.cancel(); + + // Reset change tracking + hasUnsavedChanges.value = false; + + // Show success message + Get.snackbar( + 'Sukses', + 'Bukti pembayaran berhasil diunggah', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + debugPrint('❌ Error uploading payment proof: $e'); + Get.snackbar( + 'Error', + 'Gagal mengunggah bukti pembayaran: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isUploading.value = false; + uploadProgress.value = 0.0; + } + } + + // Go to next step + void nextStep() { + if (currentStep.value < 7) { + currentStep.value++; + updateOrderStatusBasedOnStep(); + } + } + + // Update order status based on current step + void updateOrderStatusBasedOnStep() { + String newStatus; + + switch (currentStep.value) { + case 0: + newStatus = 'MENUNGGU PEMBAYARAN'; + break; + case 1: + newStatus = 'MEMERIKSA PEMBAYARAN'; + break; + case 2: + newStatus = 'DITERIMA'; + break; + case 3: + newStatus = 'PENGEMBALIAN'; + break; + case 4: + newStatus = 'PEMBAYARAN DENDA'; + break; + case 5: + newStatus = 'MEMERIKSA PEMBAYARAN DENDA'; + break; + case 6: + newStatus = 'SELESAI'; + break; + default: + newStatus = 'MENUNGGU PEMBAYARAN'; + } + + orderDetails.update((val) { + val?['status'] = newStatus; + }); + } + + // Update currentStep based on order status + void updateCurrentStepBasedOnStatus() { + final status = orderDetails.value['status']?.toString().toUpperCase() ?? ''; + debugPrint('📊 Updating current step based on status: $status'); + + switch (status) { + case 'MENUNGGU PEMBAYARAN': + currentStep.value = 0; + break; + case 'MEMERIKSA PEMBAYARAN': + currentStep.value = 1; + break; + case 'DITERIMA': + currentStep.value = 2; + break; + case 'PENGEMBALIAN': + currentStep.value = 3; + break; + case 'PEMBAYARAN DENDA': + currentStep.value = 4; + break; + case 'MEMERIKSA PEMBAYARAN DENDA': + currentStep.value = 5; + break; + case 'SELESAI': + currentStep.value = 6; + break; + case 'DIBATALKAN': + // Special case for canceled orders + currentStep.value = 0; + break; + default: + currentStep.value = 0; + break; + } + + debugPrint('📊 Current step updated to: ${currentStep.value}'); + } + + // This method has been moved and improved above + + // Submit cash payment + void submitCashPayment() { + // Update order status + orderDetails.update((val) { + val?['status'] = 'MEMERIKSA PEMBAYARAN'; + }); + + // Cancel countdown timer as payment has been submitted + _countdownTimer?.cancel(); + + // Show success message + Get.snackbar( + 'Sukses', + 'Pembayaran tunai berhasil disubmit', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + // Update step + currentStep.value = 1; + } + + // Cancel payment + void cancelPayment() { + Get.back(); + } + + // Debug function to check sewa_aset table structure + void checkSewaAsetTableStructure() { + try { + debugPrint('🔍 DEBUG: Checking sewa_aset table structure'); + final client = asetProvider.client; + + // Get a single record to check field names + client + .from('sewa_aset') + .select('*') + .limit(1) + .then((response) { + if (response.isNotEmpty) { + final record = response.first; + debugPrint('📋 SEWA_ASET TABLE STRUCTURE:'); + debugPrint('Available fields in sewa_aset table:'); + + record.forEach((key, value) { + debugPrint(' $key: (${value != null ? value.runtimeType : 'null'})'); + }); + + // Specifically check for time fields + final timeFields = [ + 'waktu_mulai', + 'waktu_selesai', + 'start_time', + 'end_time', + ]; + for (final field in timeFields) { + debugPrint( + ' Field "$field" exists: ${record.containsKey(field)}', + ); + if (record.containsKey(field)) { + debugPrint(' Field "$field" value: ${record[field]}'); + } + } + } else { + debugPrint('⚠️ No records found in sewa_aset table'); + } + }) + .catchError((e) { + debugPrint('❌ Error checking sewa_aset table: $e'); + }); + } catch (e) { + debugPrint('❌ Error in checkSewaAsetTableStructure: $e'); + } + } + + // Load bank accounts from akun_bank table + Future loadBankAccounts() async { + debugPrint('Loading bank accounts from akun_bank table...'); + try { + final data = await asetProvider.getBankAccounts(); + if (data.isNotEmpty) { + bankAccounts.assignAll(data); + debugPrint('✅ Bank accounts loaded: ${bankAccounts.length} accounts found'); + + // Debug the bank accounts data + debugPrint('📋 BANK ACCOUNTS DETAILS:'); + for (var account in bankAccounts) { + debugPrint(' Bank: ${account['nama_bank']}, Account: ${account['nama_akun']}, Number: ${account['no_rekening']}'); + } + } else { + debugPrint('⚠️ No bank accounts found in akun_bank table'); + } + } catch (e) { + debugPrint('❌ Error loading bank accounts: $e'); + } + } + + // Helper method to upload image to Supabase storage + Future _uploadToSupabaseStorage(dynamic imageFile, String fileName, StreamController progressNotifier) async { + try { + debugPrint('🔄 Uploading image to Supabase storage: $fileName'); + + // If it's already a WebImageFile, just return the URL + if (imageFile is WebImageFile) { + progressNotifier.add(1.0); // No upload needed + return imageFile.imageUrl; + } + + // Handle File objects + if (imageFile is File) { + // Get file bytes + List fileBytes = await imageFile.readAsBytes(); + + // Upload to Supabase Storage + await client.storage + .from('bukti.pembayaran') + .uploadBinary( + fileName, + Uint8List.fromList(fileBytes), + fileOptions: const FileOptions( + cacheControl: '3600', + upsert: false, + ), + ); + + // Get public URL + final String publicUrl = client.storage.from('bukti.pembayaran').getPublicUrl(fileName); + + debugPrint('✅ Upload successful: $publicUrl'); + progressNotifier.add(1.0); // Upload complete + + return publicUrl; + } + + // If we get here, we don't know how to handle this type + throw Exception('Unsupported image type: ${imageFile.runtimeType}'); + } catch (e) { + debugPrint('❌ Error uploading to Supabase storage: $e'); + return null; + } finally { + progressNotifier.close(); + } + } + + // Helper method to save image URL to foto_pembayaran table + Future _saveToFotoPembayaranTable(String imageUrl) async { + try { + debugPrint('🔄 Saving image URL to foto_pembayaran table...'); + + // Get the tagihan_sewa_id from the tagihanSewa object + final dynamic tagihanSewaId = tagihanSewa.value['id']; + + if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) { + throw Exception('tagihan_sewa_id not found in tagihanSewa data'); + } + + debugPrint('🔄 Using tagihan_sewa_id: $tagihanSewaId'); + + // Prepare the data to insert + final Map data = { + 'tagihan_sewa_id': tagihanSewaId, + 'foto_pembayaran': imageUrl, + 'created_at': DateTime.now().toIso8601String(), + }; + + // Insert data into the foto_pembayaran table + final response = await client + .from('foto_pembayaran') + .insert(data) + .select() + .single(); + + debugPrint('✅ Image URL saved to foto_pembayaran table: ${response['id']}'); + } catch (e) { + debugPrint('❌ Error in _saveToFotoPembayaranTable: $e'); + throw Exception('Failed to save image URL to database: $e'); + } + } + + // Load existing payment proof images + Future loadExistingPaymentProofImages() async { + try { + debugPrint('🔄 Loading existing payment proof images for tagihan_sewa_id: ${tagihanSewa.value['id']}'); + + // Check if we have a valid tagihan_sewa_id + final dynamic tagihanSewaId = tagihanSewa.value['id']; + if (tagihanSewaId == null || tagihanSewaId.toString().isEmpty) { + debugPrint('⚠️ No valid tagihan_sewa_id found, skipping image load'); + return; + } + + // First, make a test query to see the structure of the response + final testResponse = await client + .from('foto_pembayaran') + .select() + .limit(1); + + // Log the test response structure + if (testResponse.isNotEmpty) { + debugPrint('💾 DEBUG: Test database response: ${testResponse[0]}'); + testResponse[0].forEach((key, value) { + debugPrint('💾 DEBUG: Field $key = $value (${value?.runtimeType})'); + }); + } + + // Now make the actual query for this tagihan_sewa_id + final List response = await client + .from('foto_pembayaran') + .select() + .eq('tagihan_sewa_id', tagihanSewaId) + .order('created_at', ascending: false); + + debugPrint('🔄 Found ${response.length} existing payment proof images'); + + // Clear existing tracking lists + paymentProofImages.clear(); + originalImages.clear(); + imagesToDelete.clear(); + hasUnsavedChanges.value = false; + + // Process each image in the response + for (final item in response) { + // Extract the image URL + final String imageUrl = item['foto_pembayaran']; + + // Extract the ID - debug the item structure + debugPrint('💾 Image data: $item'); + + // Get the ID field - in Supabase, this is a UUID string + String imageId = ''; + try { + if (item.containsKey('id')) { + final dynamic rawId = item['id']; + if (rawId != null) { + // Store ID as string since it's a UUID + imageId = rawId.toString(); + } + debugPrint('🔄 Image ID: $imageId'); + } + } catch (e) { + debugPrint('❌ Error getting image ID: $e'); + } + + // Create the WebImageFile object + final webImageFile = WebImageFile(imageUrl); + webImageFile.id = imageId; + + // Add to tracking lists + paymentProofImages.add(webImageFile); + originalImages.add(webImageFile); + + debugPrint('✅ Added image: $imageUrl with ID: $imageId'); + } + + // Update the UI + update(); + + } catch (e) { + debugPrint('❌ Error loading payment proof images: $e'); + } + } + + // Refresh all data + Future refreshData() async { + debugPrint('Refreshing payment page data...'); + isLoading.value = true; + + try { + // Reload all data + await Future.delayed(const Duration(milliseconds: 500)); // Small delay for better UX + loadOrderDetails(); + loadTagihanSewaDetails(); + loadSewaAsetDetails(); + loadBankAccounts(); // Load bank accounts data + + // Explicitly update the current step based on the status + // This ensures the progress timeline is always in sync with the actual status + updateCurrentStepBasedOnStatus(); + + // Restart countdown timer if needed + if (orderDetails.value['status'] == 'MENUNGGU PEMBAYARAN') { + _countdownTimer?.cancel(); + startCountdownTimer(); + } + + debugPrint('Data refresh completed'); + } catch (e) { + debugPrint('Error refreshing data: $e'); + } finally { + isLoading.value = false; + } + + return Future.value(); + } +} diff --git a/lib/app/modules/warga/controllers/sewa_aset_controller.dart b/lib/app/modules/warga/controllers/sewa_aset_controller.dart new file mode 100644 index 0000000..a64d93f --- /dev/null +++ b/lib/app/modules/warga/controllers/sewa_aset_controller.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import '../../../data/providers/aset_provider.dart'; +import '../../../data/models/aset_model.dart'; +import '../../../routes/app_routes.dart'; +import '../../../data/models/pesanan_model.dart'; +import '../../../data/models/satuan_waktu_model.dart'; +import '../../../data/models/satuan_waktu_sewa_model.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../data/providers/pesanan_provider.dart'; +import '../../../services/navigation_service.dart'; +import '../../../services/service_manager.dart'; +import 'package:get_storage/get_storage.dart'; + +class SewaAsetController extends GetxController + with GetSingleTickerProviderStateMixin { + final AsetProvider _asetProvider = Get.find(); + final AuthProvider authProvider = Get.find(); + final PesananProvider pesananProvider = Get.put(PesananProvider()); + final NavigationService navigationService = Get.find(); + final box = GetStorage(); + + // Tab controller + late TabController tabController; + // Reactive tab index + final currentTabIndex = 0.obs; + + // State variables + final asets = [].obs; + final filteredAsets = [].obs; + + // Paket-related variables + final pakets = RxList([]); + final filteredPakets = RxList([]); + final isLoadingPakets = false.obs; + + final isLoading = true.obs; + + // Search controller + final TextEditingController searchController = TextEditingController(); + + // Reactive variables + final isOrdering = false.obs; + final selectedAset = Rx(null); + final selectedSatuanWaktuSewa = Rx(null); + final selectedDurasi = 1.obs; + final totalHarga = 0.obs; + final selectedDate = DateTime.now().obs; + final selectedTime = '08:00'.obs; + final satuanWaktuDropdownItems = + >[].obs; + + // Flag untuk menangani hot reload + final hasInitialized = false.obs; + + @override + void onInit() { + super.onInit(); + debugPrint('🚀 SewaAsetController: onInit called'); + + // Initialize tab controller + tabController = TabController(length: 2, vsync: this); + // Listen for tab changes + tabController.addListener(() { + currentTabIndex.value = tabController.index; + + // Load packages data when switching to package tab for the first time + if (currentTabIndex.value == 1 && pakets.isEmpty) { + loadPakets(); + } + }); + + loadAsets(); + + searchController.addListener(() { + if (currentTabIndex.value == 0) { + filterAsets(searchController.text); + } else { + filterPakets(searchController.text); + } + }); + + hasInitialized.value = true; + } + + @override + void onReady() { + super.onReady(); + debugPrint('🚀 SewaAsetController: onReady called'); + } + + @override + void onClose() { + debugPrint('🧹 SewaAsetController: onClose called'); + searchController.dispose(); + tabController.dispose(); + super.onClose(); + } + + // Method untuk menangani hot reload + void handleHotReload() { + debugPrint('🔥 Hot reload detected in SewaAsetController'); + if (!hasInitialized.value) { + debugPrint('🔄 Reinitializing SewaAsetController after hot reload'); + loadAsets(); + if (currentTabIndex.value == 1) { + loadPakets(); + } + hasInitialized.value = true; + } + } + + // Method untuk menangani tombol back + void onBackPressed() { + debugPrint('🔙 Back button pressed in SewaAsetView'); + navigationService.backFromSewaAset(); + } + + Future loadAsets() async { + try { + isLoading.value = true; + final sewaAsets = await _asetProvider.getSewaAsets(); + + // Debug data satuan waktu sewa yang diterima + debugPrint('===== DEBUG ASET & SATUAN WAKTU SEWA ====='); + for (var aset in sewaAsets) { + debugPrint('Aset: ${aset.nama} (ID: ${aset.id})'); + + if (aset.satuanWaktuSewa.isEmpty) { + debugPrint(' - Tidak ada satuan waktu sewa yang terkait'); + } else { + debugPrint( + ' - Memiliki ${aset.satuanWaktuSewa.length} satuan waktu sewa:', + ); + for (var sws in aset.satuanWaktuSewa) { + debugPrint(' * ID: ${sws['id']}'); + debugPrint(' Aset ID: ${sws['aset_id']}'); + debugPrint(' Satuan Waktu ID: ${sws['satuan_waktu_id']}'); + debugPrint(' Harga: ${sws['harga']}'); + debugPrint(' Nama Satuan Waktu: ${sws['nama_satuan_waktu']}'); + debugPrint(' -----'); + } + } + debugPrint('====================================='); + } + + asets.assignAll(sewaAsets); + filteredAsets.assignAll(sewaAsets); + + // Tambahkan log info tentang jumlah aset yang berhasil dimuat + debugPrint('Loaded ${sewaAsets.length} aset sewa successfully'); + } catch (e) { + debugPrint('Error loading asets: $e'); + Get.snackbar( + 'Error', + 'Terjadi kesalahan saat memuat data aset', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoading.value = false; + } + } + + void filterAsets(String query) { + if (query.isEmpty) { + filteredAsets.assignAll(asets); + } else { + filteredAsets.assignAll( + asets + .where( + (aset) => aset.nama.toLowerCase().contains(query.toLowerCase()), + ) + .toList(), + ); + } + } + + void refreshAsets() { + loadAsets(); + } + + String formatPrice(dynamic price) { + if (price == null) return 'Rp 0'; + + // Handle different types + num numericPrice; + if (price is int || price is double) { + numericPrice = price; + } else if (price is String) { + numericPrice = double.tryParse(price) ?? 0; + } else { + return 'Rp 0'; + } + + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + return formatter.format(numericPrice); + } + + void selectAset(AsetModel aset) { + selectedAset.value = aset; + // Reset related values + selectedSatuanWaktuSewa.value = null; + selectedDurasi.value = 1; + totalHarga.value = 0; + + // Prepare dropdown items for satuan waktu sewa + updateSatuanWaktuDropdown(); + } + + void updateSatuanWaktuDropdown() { + satuanWaktuDropdownItems.clear(); + + if (selectedAset.value != null && + selectedAset.value!.satuanWaktuSewa.isNotEmpty) { + for (var item in selectedAset.value!.satuanWaktuSewa) { + final satuanWaktuSewa = SatuanWaktuSewaModel.fromJson(item); + satuanWaktuDropdownItems.add( + DropdownMenuItem( + value: satuanWaktuSewa, + child: Text( + '${satuanWaktuSewa.namaSatuanWaktu ?? "Unknown"} - Rp${NumberFormat.decimalPattern('id').format(satuanWaktuSewa.harga)}', + ), + ), + ); + } + } + } + + void selectSatuanWaktu(SatuanWaktuSewaModel? satuanWaktuSewa) { + selectedSatuanWaktuSewa.value = satuanWaktuSewa; + calculateTotalPrice(); + } + + void updateDurasi(int durasi) { + if (durasi < 1) durasi = 1; + selectedDurasi.value = durasi; + calculateTotalPrice(); + } + + void calculateTotalPrice() { + if (selectedSatuanWaktuSewa.value != null) { + totalHarga.value = + selectedSatuanWaktuSewa.value!.harga * selectedDurasi.value; + } else { + totalHarga.value = 0; + } + } + + void pickDate(DateTime date) { + selectedDate.value = date; + } + + void pickTime(String time) { + selectedTime.value = time; + } + + // Helper method to show error snackbar + void _showError(String message) { + Get.snackbar( + 'Error', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + + // Method untuk melakukan pemesanan + Future placeOrderAset() async { + if (selectedAset.value == null) { + _showError('Silakan pilih aset terlebih dahulu'); + return; + } + + if (selectedSatuanWaktuSewa.value == null) { + _showError('Silakan pilih satuan waktu sewa'); + return; + } + + if (selectedDurasi.value <= 0) { + _showError('Durasi sewa harus lebih dari 0'); + return; + } + + final userId = authProvider.getCurrentUserId(); + if (userId == null) { + _showError('Anda belum login, silakan login terlebih dahulu'); + return; + } + + try { + final result = await _asetProvider.orderAset( + userId: userId, + asetId: selectedAset.value!.id, + satuanWaktuSewaId: selectedSatuanWaktuSewa.value!.id, + durasi: selectedDurasi.value, + totalHarga: totalHarga.value, + ); + + if (result) { + Get.snackbar( + 'Sukses', + 'Pesanan berhasil dibuat', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + resetSelections(); + } else { + _showError('Gagal membuat pesanan'); + } + } catch (e) { + _showError('Terjadi kesalahan: $e'); + } + } + + // Method untuk reset pilihan setelah pemesanan berhasil + void resetSelections() { + selectedAset.value = null; + selectedSatuanWaktuSewa.value = null; + selectedDurasi.value = 1; + totalHarga.value = 0; + } + + // Load packages data from paket table + Future loadPakets() async { + try { + isLoadingPakets.value = true; + + // Call the provider method to get paket data + final paketData = await _asetProvider.getPakets(); + + // Debug paket data + debugPrint('===== DEBUG PAKET & SATUAN WAKTU SEWA ====='); + for (var paket in paketData) { + debugPrint('Paket: ${paket['nama']} (ID: ${paket['id']})'); + + if (paket['satuanWaktuSewa'] == null || + paket['satuanWaktuSewa'].isEmpty) { + debugPrint(' - Tidak ada satuan waktu sewa yang terkait'); + } else { + debugPrint( + ' - Memiliki ${paket['satuanWaktuSewa'].length} satuan waktu sewa:', + ); + for (var sws in paket['satuanWaktuSewa']) { + debugPrint(' * ID: ${sws['id']}'); + debugPrint(' Paket ID: ${sws['paket_id']}'); + debugPrint(' Satuan Waktu ID: ${sws['satuan_waktu_id']}'); + debugPrint(' Harga: ${sws['harga']}'); + debugPrint(' Nama Satuan Waktu: ${sws['nama_satuan_waktu']}'); + debugPrint(' -----'); + } + } + debugPrint('====================================='); + } + + pakets.assignAll(paketData); + filteredPakets.assignAll(paketData); + + debugPrint('Loaded ${paketData.length} paket successfully'); + } catch (e) { + debugPrint('Error loading pakets: $e'); + Get.snackbar( + 'Error', + 'Terjadi kesalahan saat memuat data paket', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoadingPakets.value = false; + } + } + + // Method to filter pakets based on search query + void filterPakets(String query) { + if (query.isEmpty) { + filteredPakets.assignAll(pakets); + } else { + filteredPakets.assignAll( + pakets + .where( + (paket) => paket['nama'].toString().toLowerCase().contains( + query.toLowerCase(), + ), + ) + .toList(), + ); + } + } + + void refreshPakets() { + loadPakets(); + } + + // Method to load paket data + Future loadPaketData() async { + try { + isLoadingPakets.value = true; + final result = await _asetProvider.getPakets(); + if (result != null) { + pakets.clear(); + filteredPakets.clear(); + pakets.addAll(result); + filteredPakets.addAll(result); + } + } catch (e) { + debugPrint('Error loading pakets: $e'); + Get.snackbar( + 'Error', + 'Gagal memuat data paket. Silakan coba lagi nanti.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + isLoadingPakets.value = false; + } + } + + // Method for placing an order for a paket + Future placeOrderPaket({ + required String paketId, + required String satuanWaktuSewaId, + required int durasi, + required int totalHarga, + }) async { + debugPrint('===== PLACE ORDER PAKET ====='); + debugPrint('paketId: $paketId'); + debugPrint('satuanWaktuSewaId: $satuanWaktuSewaId'); + debugPrint('durasi: $durasi'); + debugPrint('totalHarga: $totalHarga'); + + final userId = authProvider.getCurrentUserId(); + if (userId == null) { + _showError('Anda belum login, silakan login terlebih dahulu'); + return; + } + + try { + final result = await _asetProvider.orderPaket( + userId: userId, + paketId: paketId, + satuanWaktuSewaId: satuanWaktuSewaId, + durasi: durasi, + totalHarga: totalHarga, + ); + + if (result) { + Get.snackbar( + 'Sukses', + 'Pesanan paket berhasil dibuat', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } else { + _showError('Gagal membuat pesanan paket'); + } + } catch (e) { + _showError('Terjadi kesalahan: $e'); + } + } +} diff --git a/lib/app/modules/warga/controllers/warga_dashboard_controller.dart b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart new file mode 100644 index 0000000..739efa1 --- /dev/null +++ b/lib/app/modules/warga/controllers/warga_dashboard_controller.dart @@ -0,0 +1,180 @@ +import 'package:get/get.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../routes/app_routes.dart'; +import '../../../services/navigation_service.dart'; + +class WargaDashboardController extends GetxController { + // Dependency injection + final AuthProvider _authProvider = Get.find(); + final NavigationService navigationService = Get.find(); + + // User data + final userName = 'Pengguna Warga'.obs; + final userRole = 'Warga'.obs; + final userAvatar = Rx(null); + final userEmail = ''.obs; + final userNik = ''.obs; + final userPhone = ''.obs; + final userAddress = ''.obs; + + // Navigation state is now managed by NavigationService + + // Sample data (would be loaded from API) + final activeRentals = >[].obs; + + // Active bills + final activeBills = >[].obs; + + // Active penalties + final activePenalties = >[].obs; + + @override + void onInit() { + super.onInit(); + + // Set navigation index to Home (0) + navigationService.setNavIndex(0); + + // Load user data + _loadUserData(); + + // Load sample data + _loadSampleData(); + + // Load dummy data for bills and penalties + loadDummyData(); + + // Load unpaid rentals + loadUnpaidRentals(); + } + + Future _loadUserData() async { + try { + // Get the full name from warga_desa table + final fullName = await _authProvider.getUserFullName(); + if (fullName != null && fullName.isNotEmpty) { + userName.value = fullName; + } + + // Get the avatar URL + final avatar = await _authProvider.getUserAvatar(); + userAvatar.value = avatar; + + // Get the role name + final roleId = await _authProvider.getUserRoleId(); + if (roleId != null) { + final roleName = await _authProvider.getRoleName(roleId); + if (roleName != null) { + userRole.value = roleName; + } + } + + // Load additional user data + // In a real app, these would come from the API/database + userEmail.value = await _authProvider.getUserEmail() ?? ''; + userNik.value = await _authProvider.getUserNIK() ?? ''; + userPhone.value = await _authProvider.getUserPhone() ?? ''; + userAddress.value = await _authProvider.getUserAddress() ?? ''; + } catch (e) { + print('Error loading user data: $e'); + } + } + + void _loadSampleData() { + // Clear any existing data + activeRentals.clear(); + + // Load active rentals from API + // For now, using sample data + activeRentals.add({ + 'id': '1', + 'name': 'Kursi', + 'time': '24 April 2023, 10:00 - 12:00', + 'duration': '2 jam', + 'price': 'Rp50.000', + 'can_extend': true, + }); + } + + void extendRental(String rentalId) { + // Implementasi untuk memperpanjang sewa + // Seharusnya melakukan API call ke backend + } + + void endRental(String rentalId) { + // Implementasi untuk mengakhiri sewa + // Seharusnya melakukan API call ke backend + } + + void navigateToRentals() { + // Navigate to SewaAset using the navigation service + navigationService.toSewaAset(); + } + + void refreshData() { + // Refresh data from repository + _loadSampleData(); + loadDummyData(); + } + + void onNavItemTapped(int index) { + if (navigationService.currentNavIndex.value == index) { + return; // Don't do anything if same tab + } + + navigationService.setNavIndex(index); + + switch (index) { + case 0: + // Already on Home tab + break; + case 1: + // Navigate to Sewa page + navigationService.toWargaSewa(); + break; + } + } + + void logout() async { + await _authProvider.signOut(); + navigationService.toLogin(); + } + + void loadDummyData() { + // Dummy active bills + activeBills.clear(); + activeBills.add({ + 'id': '1', + 'title': 'Tagihan Air', + 'due_date': '30 Apr 2023', + 'amount': 'Rp 125.000', + }); + activeBills.add({ + 'id': '2', + 'title': 'Sewa Aula Desa', + 'due_date': '15 Apr 2023', + 'amount': 'Rp 350.000', + }); + + // Dummy active penalties + activePenalties.clear(); + activePenalties.add({ + 'id': '1', + 'title': 'Keterlambatan Sewa Traktor', + 'days_late': '7', + 'amount': 'Rp 75.000', + }); + } + + Future loadUnpaidRentals() async { + try { + final results = await _authProvider.getSewaAsetByStatus([ + 'MENUNGGU PEMBAYARAN', + 'PEMBAYARANAN DENDA', + ]); + activeBills.value = results; + } catch (e) { + print('Error loading unpaid rentals: $e'); + } + } +} diff --git a/lib/app/modules/warga/controllers/warga_sewa_controller.dart b/lib/app/modules/warga/controllers/warga_sewa_controller.dart new file mode 100644 index 0000000..e4d90c5 --- /dev/null +++ b/lib/app/modules/warga/controllers/warga_sewa_controller.dart @@ -0,0 +1,710 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import '../../../routes/app_routes.dart'; +import '../../../services/navigation_service.dart'; +import '../../../data/providers/auth_provider.dart'; +import '../../../data/providers/aset_provider.dart'; + +class WargaSewaController extends GetxController + with GetSingleTickerProviderStateMixin { + late TabController tabController; + + // Get navigation service + final NavigationService navigationService = Get.find(); + + // Get auth provider for user data and sewa_aset queries + final AuthProvider authProvider = Get.find(); + + // Get aset provider for asset data + final AsetProvider asetProvider = Get.find(); + + // Observable lists for different rental statuses + final rentals = >[].obs; + final pendingRentals = >[].obs; + final acceptedRentals = >[].obs; + final completedRentals = >[].obs; + final cancelledRentals = >[].obs; + + // Loading states + final isLoading = false.obs; + final isLoadingPending = false.obs; + final isLoadingAccepted = false.obs; + final isLoadingCompleted = false.obs; + final isLoadingCancelled = false.obs; + + @override + void onInit() { + super.onInit(); + + // Ensure tab index is set to Sewa (1) + navigationService.setNavIndex(1); + + // Initialize tab controller with 6 tabs + tabController = TabController(length: 6, vsync: this); + + // Set initial tab and ensure tab view is updated + tabController.index = 0; + + // Load real rental data for all tabs + loadRentalsData(); + loadPendingRentals(); + loadAcceptedRentals(); + loadCompletedRentals(); + loadCancelledRentals(); + + // Listen to tab changes to update state if needed + tabController.addListener(() { + // Update selected tab index when changed via swipe + final int currentIndex = tabController.index; + debugPrint('Tab changed to index: $currentIndex'); + + // Load data for the selected tab if not already loaded + switch (currentIndex) { + case 0: // Belum Bayar + if (rentals.isEmpty && !isLoading.value) { + loadRentalsData(); + } + break; + case 1: // Pending + if (pendingRentals.isEmpty && !isLoadingPending.value) { + loadPendingRentals(); + } + break; + case 2: // Diterima + if (acceptedRentals.isEmpty && !isLoadingAccepted.value) { + loadAcceptedRentals(); + } + break; + case 3: // Aktif + // Add Aktif tab logic when needed + break; + case 4: // Selesai + if (completedRentals.isEmpty && !isLoadingCompleted.value) { + loadCompletedRentals(); + } + break; + case 5: // Dibatalkan + if (cancelledRentals.isEmpty && !isLoadingCancelled.value) { + loadCancelledRentals(); + } + break; + } + }); + } + + @override + void onReady() { + super.onReady(); + // Ensure nav index is set to Sewa (1) when the controller is ready + // This helps maintain correct state during hot reload + navigationService.setNavIndex(1); + } + + @override + void onClose() { + tabController.dispose(); + super.onClose(); + } + + // Load real data from sewa_aset table + Future loadRentalsData() async { + try { + isLoading.value = true; + + // Clear existing data + rentals.clear(); + + // Get sewa_aset data with status "MENUNGGU PEMBAYARAN" or "PEMBAYARAN DENDA" + final sewaAsetList = await authProvider.getSewaAsetByStatus([ + 'MENUNGGU PEMBAYARAN', + 'PEMBAYARAN DENDA' + ]); + + debugPrint('Fetched ${sewaAsetList.length} sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + // Get asset details if aset_id is available + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + + // Parse waktu mulai and waktu selesai + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + + if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + + // Format for display + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + + // Format based on satuan waktu + if (namaSatuanWaktu.toLowerCase() == 'jam') { + // For hours, show time range on same day + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + // For days, show date range + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + // Default format + rentangWaktu = '$jamMulai - $jamSelesai'; + } + + // Full time format for waktuSewa + waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + + // Format price + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + + // Add to rentals list + rentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'MENUNGGU PEMBAYARAN', + 'totalPrice': totalPrice, + 'countdown': '00:59:59', // Default countdown + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + }); + } + + debugPrint('Processed ${rentals.length} rental records'); + } catch (e) { + debugPrint('Error loading rentals data: $e'); + } finally { + isLoading.value = false; + } + } + + // Navigation methods + void navigateToRentals() { + navigationService.toSewaAset(); + } + + void onNavItemTapped(int index) { + if (navigationService.currentNavIndex.value == index) return; + + navigationService.setNavIndex(index); + + switch (index) { + case 0: + // Navigate to Home + Get.offNamed(Routes.WARGA_DASHBOARD); + break; + case 1: + // Already on Sewa tab + break; + case 2: + // Navigate to Langganan + Get.offNamed(Routes.LANGGANAN); + break; + } + } + + // Actions + void cancelRental(String id) { + Get.snackbar( + 'Info', + 'Pembatalan berhasil', + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Navigate to payment page with the selected rental data + void viewRentalDetail(Map rental) { + debugPrint('Navigating to payment page with rental ID: ${rental['id']}'); + + // Navigate to payment page with rental data + Get.toNamed( + Routes.PEMBAYARAN_SEWA, + arguments: { + 'orderId': rental['id'], + 'rentalData': rental, + }, + ); + } + + void payRental(String id) { + Get.snackbar( + 'Info', + 'Navigasi ke halaman pembayaran', + snackPosition: SnackPosition.BOTTOM, + ); + } + + // Load data for the Selesai tab (status: SELESAI) + Future loadCompletedRentals() async { + try { + isLoadingCompleted.value = true; + + // Clear existing data + completedRentals.clear(); + + // Get sewa_aset data with status "SELESAI" + final sewaAsetList = await authProvider.getSewaAsetByStatus(['SELESAI']); + + debugPrint('Fetched ${sewaAsetList.length} completed sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + // Get asset details if aset_id is available + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + + // Parse waktu mulai and waktu selesai + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + + if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + + // Format for display + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + + // Format based on satuan waktu + if (namaSatuanWaktu.toLowerCase() == 'jam') { + // For hours, show time range on same day + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + // For days, show date range + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + // Default format + rentangWaktu = '$jamMulai - $jamSelesai'; + } + + // Full time format for waktuSewa + waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + + // Format price + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + + // Add to completed rentals list + completedRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'SELESAI', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + }); + } + + debugPrint('Processed ${completedRentals.length} completed rental records'); + } catch (e) { + debugPrint('Error loading completed rentals data: $e'); + } finally { + isLoadingCompleted.value = false; + } + } + + // Load data for the Dibatalkan tab (status: DIBATALKAN) + Future loadCancelledRentals() async { + try { + isLoadingCancelled.value = true; + + // Clear existing data + cancelledRentals.clear(); + + // Get sewa_aset data with status "DIBATALKAN" + final sewaAsetList = await authProvider.getSewaAsetByStatus(['DIBATALKAN']); + + debugPrint('Fetched ${sewaAsetList.length} cancelled sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + // Get asset details if aset_id is available + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + + // Parse waktu mulai and waktu selesai + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + + if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + + // Format for display + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + + // Format based on satuan waktu + if (namaSatuanWaktu.toLowerCase() == 'jam') { + // For hours, show time range on same day + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + // For days, show date range + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + // Default format + rentangWaktu = '$jamMulai - $jamSelesai'; + } + + // Full time format for waktuSewa + waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + + // Format price + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + + // Add to cancelled rentals list + cancelledRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'DIBATALKAN', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + 'alasanPembatalan': sewaAset['alasan_pembatalan'] ?? '-', + }); + } + + debugPrint('Processed ${cancelledRentals.length} cancelled rental records'); + } catch (e) { + debugPrint('Error loading cancelled rentals data: $e'); + } finally { + isLoadingCancelled.value = false; + } + } + + // Load data for the Pending tab (status: PERIKSA PEMBAYARAN) + Future loadPendingRentals() async { + try { + isLoadingPending.value = true; + + // Clear existing data + pendingRentals.clear(); + + // Get sewa_aset data with status "PERIKSA PEMBAYARAN" + final sewaAsetList = await authProvider.getSewaAsetByStatus(['PERIKSA PEMBAYARAN']); + + debugPrint('Fetched ${sewaAsetList.length} pending sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + // Get asset details if aset_id is available + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + + // Parse waktu mulai and waktu selesai + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + + if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + + // Format for display + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + + // Format based on satuan waktu + if (namaSatuanWaktu.toLowerCase() == 'jam') { + // For hours, show time range on same day + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + // For days, show date range + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + // Default format + rentangWaktu = '$jamMulai - $jamSelesai'; + } + + // Full time format for waktuSewa + waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + + // Format price + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + + // Add to pending rentals list + pendingRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'PERIKSA PEMBAYARAN', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + }); + } + + debugPrint('Processed ${pendingRentals.length} pending rental records'); + } catch (e) { + debugPrint('Error loading pending rentals data: $e'); + } finally { + isLoadingPending.value = false; + } + } + + // Load data for the Diterima tab (status: DITERIMA) + Future loadAcceptedRentals() async { + try { + isLoadingAccepted.value = true; + + // Clear existing data + acceptedRentals.clear(); + + // Get sewa_aset data with status "DITERIMA" + final sewaAsetList = await authProvider.getSewaAsetByStatus(['DITERIMA']); + + debugPrint('Fetched ${sewaAsetList.length} accepted sewa_aset records'); + + // Process each sewa_aset record + for (var sewaAset in sewaAsetList) { + // Get asset details if aset_id is available + String assetName = 'Aset'; + String? imageUrl; + String namaSatuanWaktu = sewaAset['nama_satuan_waktu'] ?? 'jam'; + + if (sewaAset['aset_id'] != null) { + final asetData = await asetProvider.getAsetById(sewaAset['aset_id']); + if (asetData != null) { + assetName = asetData.nama; + imageUrl = asetData.imageUrl; + } + } + + // Parse waktu mulai and waktu selesai + DateTime? waktuMulai; + DateTime? waktuSelesai; + String waktuSewa = ''; + String tanggalSewa = ''; + String jamMulai = ''; + String jamSelesai = ''; + String rentangWaktu = ''; + + if (sewaAset['waktu_mulai'] != null && sewaAset['waktu_selesai'] != null) { + waktuMulai = DateTime.parse(sewaAset['waktu_mulai']); + waktuSelesai = DateTime.parse(sewaAset['waktu_selesai']); + + // Format for display + final formatTanggal = DateFormat('dd-MM-yyyy'); + final formatWaktu = DateFormat('HH:mm'); + final formatTanggalLengkap = DateFormat('dd MMMM yyyy', 'id_ID'); + + tanggalSewa = formatTanggalLengkap.format(waktuMulai); + jamMulai = formatWaktu.format(waktuMulai); + jamSelesai = formatWaktu.format(waktuSelesai); + + // Format based on satuan waktu + if (namaSatuanWaktu.toLowerCase() == 'jam') { + // For hours, show time range on same day + rentangWaktu = '$jamMulai - $jamSelesai'; + } else if (namaSatuanWaktu.toLowerCase() == 'hari') { + // For days, show date range + final tanggalMulai = formatTanggalLengkap.format(waktuMulai); + final tanggalSelesai = formatTanggalLengkap.format(waktuSelesai); + rentangWaktu = '$tanggalMulai - $tanggalSelesai'; + } else { + // Default format + rentangWaktu = '$jamMulai - $jamSelesai'; + } + + // Full time format for waktuSewa + waktuSewa = '${formatTanggal.format(waktuMulai)} | ${formatWaktu.format(waktuMulai)} - ' + '${formatTanggal.format(waktuSelesai)} | ${formatWaktu.format(waktuSelesai)}'; + } + + // Format price + String totalPrice = 'Rp 0'; + if (sewaAset['total'] != null) { + final formatter = NumberFormat.currency( + locale: 'id', + symbol: 'Rp ', + decimalDigits: 0, + ); + totalPrice = formatter.format(sewaAset['total']); + } + + // Add to accepted rentals list + acceptedRentals.add({ + 'id': sewaAset['id'] ?? '', + 'name': assetName, + 'imageUrl': imageUrl ?? 'assets/images/gambar_pendukung.jpg', + 'jumlahUnit': sewaAset['kuantitas'] ?? 0, + 'waktuSewa': waktuSewa, + 'duration': '${sewaAset['durasi'] ?? 0} ${namaSatuanWaktu}', + 'status': sewaAset['status'] ?? 'DITERIMA', + 'totalPrice': totalPrice, + 'tanggalSewa': tanggalSewa, + 'jamMulai': jamMulai, + 'jamSelesai': jamSelesai, + 'rentangWaktu': rentangWaktu, + 'namaSatuanWaktu': namaSatuanWaktu, + 'waktuMulai': sewaAset['waktu_mulai'], + 'waktuSelesai': sewaAset['waktu_selesai'], + }); + } + + debugPrint('Processed ${acceptedRentals.length} accepted rental records'); + } catch (e) { + debugPrint('Error loading accepted rentals data: $e'); + } finally { + isLoadingAccepted.value = false; + } + } +} diff --git a/lib/app/modules/warga/views/order_sewa_aset_view.dart b/lib/app/modules/warga/views/order_sewa_aset_view.dart new file mode 100644 index 0000000..94a138d --- /dev/null +++ b/lib/app/modules/warga/views/order_sewa_aset_view.dart @@ -0,0 +1,2178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../controllers/order_sewa_aset_controller.dart'; +import '../../../routes/app_routes.dart'; +import '../../../services/navigation_service.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import '../../../theme/app_colors.dart'; + +class OrderSewaAsetView extends GetView { + const OrderSewaAsetView({super.key}); + + @override + Widget build(BuildContext context) { + // Handle hot reload by checking if controller needs to be reset + WidgetsBinding.instance.addPostFrameCallback((_) { + // This will be called after the widget tree is built + controller.handleHotReload(); + + // Ensure navigation service is registered for back button functionality + if (!Get.isRegistered()) { + Get.put(NavigationService()); + debugPrint('✅ Created new NavigationService instance in view'); + } + }); + + // Function to handle back button press + void handleBackButtonPress() { + debugPrint('🔙 Back button pressed - navigating to SewaAsetView'); + try { + // First try to use the controller's method + controller.onBackPressed(); + } catch (e) { + debugPrint('⚠️ Error handling back via controller: $e'); + // Fallback to direct navigation + Get.back(); + } + } + + // Function to show confirmation dialog + void showOrderConfirmationDialog() { + final aset = controller.aset.value!; + final totalPrice = controller.totalPrice.value; + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with success icon + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.primarySoft, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_outline_rounded, + color: AppColors.primary, + size: 40, + ), + ), + SizedBox(height: 20), + + // Title + Text( + 'Konfirmasi Pesanan', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 6), + + // Subtitle + Text( + 'Periksa detail pesanan Anda', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24), + + // Order details + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.borderLight), + ), + child: Column( + children: [ + // Aset name + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aset', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Text( + aset.nama ?? 'Aset tanpa nama', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ], + ), + Divider(height: 24, color: AppColors.divider), + + // Duration info + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Durasi', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + controller.isDailyRental() + ? controller.formattedDateRange.value + : '${controller.selectedDate.value}, ${controller.formattedTimeRange.value}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ], + ), + Divider(height: 24, color: AppColors.divider), + + // Quantity info + Row( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jumlah Unit', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + '${controller.jumlahUnit.value} unit', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + controller.formatPrice( + controller.totalPrice.value, + ), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + SizedBox(height: 24), + + // Action buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Get.back(), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + 'Batal', + style: TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + Get.back(); + controller.pesanSekarang(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + padding: EdgeInsets.symmetric(vertical: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + 'Pesan Sekarang', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], + ), + ), + ), + barrierDismissible: true, + ); + } + + return WillPopScope( + onWillPop: () async { + // Handle back button press here + handleBackButtonPress(); + return false; // We handle the navigation ourselves + }, + child: Scaffold( + backgroundColor: AppColors.background, + body: Obx(() { + if (controller.isLoading.value) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppColors.primary), + SizedBox(height: 24), + Text( + 'Memuat data aset...', + style: TextStyle(color: Colors.grey[700], fontSize: 16), + ), + ], + ), + ); + } + + if (controller.aset.value == null) { + // Jika aset masih null setelah loading selesai, coba muat ulang dari storage + if (!controller.isLoading.value) { + debugPrint( + '⚠️ Asset is null after loading, trying to recover...', + ); + Future.microtask(() => controller.handleHotReload()); + + // Tampilkan loading indicator sementara untuk mencoba recovery + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppColors.primary), + SizedBox(height: 24), + Text( + 'Memuat data aset...', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 16, + ), + ), + ], + ), + ); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline_rounded, + size: 80, + color: AppColors.error, + ), + SizedBox(height: 24), + Text( + 'Data aset tidak ditemukan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey[800], + ), + ), + SizedBox(height: 16), + ElevatedButton.icon( + onPressed: handleBackButtonPress, + icon: Icon(Icons.arrow_back), + label: Text('Kembali'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ], + ), + ); + } + + return SafeArea( + child: Stack( + children: [ + // Main content with scroll + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top section with image and back button + _buildTopSection(), + + // Asset details + _buildAssetDetails(), + + // Price options + _buildPriceOptions(), + + // Date and time selection + _buildDateSelection(context), + + // Add spacing at the bottom for the fixed total price bar + SizedBox(height: 100), + ], + ), + ), + + // Back button positioned at the top - updated to match reference image + Positioned( + top: 20, + left: 20, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: handleBackButtonPress, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + spreadRadius: 0, + offset: Offset(0, 1), + ), + ], + ), + child: Center( + child: Icon( + Icons.arrow_back_ios, + color: AppColors.iconPrimary, + size: 18, + ), + ), + ), + ), + ), + ), + + // Fixed bottom bar for total price and order button + Positioned( + bottom: 0, + left: 0, + right: 0, + child: _buildBottomBar( + onTapPesan: showOrderConfirmationDialog, + ), + ), + ], + ), + ); + }), + ), + ); + } + + Widget _buildTopSection() { + final aset = controller.aset.value!; + return Stack( + children: [ + // Image carousel + SizedBox( + height: 320, + width: double.infinity, + child: GestureDetector( + onHorizontalDragEnd: (details) { + if (details.primaryVelocity! > 0) { + // Swipe right + controller.previousPhoto(); + } else if (details.primaryVelocity! < 0) { + // Swipe left + controller.nextPhoto(); + } + }, + // Add onTap handler for the image + onTap: () { + debugPrint("📸 Image tapped - opening fullscreen viewer"); + final imageUrl = controller.getCurrentPhotoUrl(); + if (imageUrl != null && imageUrl.isNotEmpty) { + debugPrint("📸 Current image URL: $imageUrl"); + debugPrint("📸 Total photos: ${controller.assetPhotos.length}"); + + // Extract all image URLs from the assetPhotos collection + final List photoUrls = []; + for (var photo in controller.assetPhotos) { + final url = photo.fotoAset; + if (url.isNotEmpty) { + photoUrls.add(url); + debugPrint("📸 Added photo URL: $url"); + } + } + + if (photoUrls.isEmpty) { + debugPrint("📸 No valid photo URLs found"); + return; + } + + showDialog( + context: Get.context!, + builder: + (context) => Dialog( + insetPadding: EdgeInsets.zero, + child: Container( + width: double.infinity, + height: double.infinity, + color: Colors.black, + child: Stack( + children: [ + // Image + Center( + child: InteractiveViewer( + panEnabled: true, + boundaryMargin: EdgeInsets.all(80), + minScale: 0.5, + maxScale: 4, + child: CachedNetworkImage( + imageUrl: + photoUrls[controller + .currentPhotoIndex + .value], + fit: BoxFit.contain, + placeholder: + (context, url) => Center( + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + errorWidget: + (context, url, error) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.broken_image_rounded, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'Gagal memuat foto', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + ), + + // Close button + Positioned( + top: 40, + right: 20, + child: IconButton( + icon: Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: Icon( + Icons.close, + color: Colors.white, + ), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ), + ), + ); + } else { + debugPrint("📸 No valid current image URL"); + } + }, + child: ClipRRect( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + child: Obx(() { + // Show loading indicator when images are being loaded + if (controller.isPhotosLoading.value) { + return Container( + color: Colors.grey[200], + child: Center( + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ), + ); + } + + final imageUrl = controller.getCurrentPhotoUrl(); + if (imageUrl == null || imageUrl.isEmpty) { + return Container( + color: Colors.grey[200], + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.image_not_supported_rounded, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'Tidak ada foto', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], + ), + ), + ); + } + + return CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: + (context, url) => Container( + color: Colors.grey[200], + child: Center( + child: CircularProgressIndicator( + color: AppColors.primary, + ), + ), + ), + errorWidget: + (context, url, error) => Container( + color: Colors.grey[200], + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.broken_image_rounded, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'Gagal memuat foto', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + ), + ], + ), + ), + ), + ); + }), + ), + ), + ), + + // Gradient overlay + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.1), + Colors.transparent, + Colors.black.withOpacity(0.3), + ], + stops: [0.0, 0.6, 1.0], + ), + ), + ), + ), + + // Zoom indicator overlay + Positioned( + bottom: 16, + right: 16, + child: Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.zoom_in, color: Colors.white, size: 24), + ), + ), + + // Navigation arrows - only show if we have more than 1 photo + Obx( + () => + controller.assetPhotos.length > 1 + ? Positioned.fill( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + margin: EdgeInsets.only(left: 16), + child: IconButton( + icon: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.black26, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back_ios_rounded, + color: Colors.white, + size: 20, + ), + ), + onPressed: controller.previousPhoto, + ), + ), + Container( + margin: EdgeInsets.only(right: 16), + child: IconButton( + icon: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.black26, + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.white, + size: 20, + ), + ), + onPressed: controller.nextPhoto, + ), + ), + ], + ), + ) + : SizedBox.shrink(), + ), + + // Image indicators - only show if we have more than 1 photo + Obx( + () => + controller.assetPhotos.length > 1 + ? Positioned( + bottom: 20, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + controller.assetPhotos.length, + (index) => AnimatedContainer( + duration: Duration(milliseconds: 200), + width: + index == controller.currentPhotoIndex.value + ? 24 + : 10, + height: 10, + margin: EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: + index == controller.currentPhotoIndex.value + ? AppColors.primary + : AppColors.primaryLight.withOpacity(0.5), + ), + ), + ), + ), + ) + : SizedBox.shrink(), + ), + + // Photo counter + Obx( + () => + controller.assetPhotos.length > 1 + ? Positioned( + top: 20, + right: 20, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + '${controller.currentPhotoIndex.value + 1}/${controller.assetPhotos.length}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ) + : SizedBox.shrink(), + ), + ], + ); + } + + Widget _buildAssetDetails() { + final aset = controller.aset.value!; + return Container( + padding: EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset name with availability indicator + Row( + children: [ + Expanded( + child: Text( + aset.nama ?? 'Aset tanpa nama', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + color: AppColors.textPrimary, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.success.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 6), + Text( + 'Tersedia', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.success, + ), + ), + ], + ), + ), + ], + ), + + SizedBox(height: 24), + + // Description with card styling - Wrapped in Container with consistent padding + Container( + padding: EdgeInsets.all(20), + width: double.infinity, + constraints: BoxConstraints(minHeight: 120), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.description_outlined, + color: AppColors.primary, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Deskripsi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Text( + aset.deskripsi ?? 'Tidak ada deskripsi untuk aset ini.', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ], + ), + ), + + SizedBox(height: 24), + + // Jumlah Unit with card styling - Removed icons, added manual input + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.inventory_2_outlined, + color: AppColors.primary, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Jumlah Unit', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + SizedBox(height: 16), + + Row( + children: [ + // Decrease button + Obx( + () => Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: + controller.jumlahUnit.value <= 1 + ? null + : () { + HapticFeedback.lightImpact(); + controller.decreaseUnit(); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + controller.jumlahUnit.value <= 1 + ? Colors.grey[200] + : Color(0xFF92B4D7).withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon( + Icons.remove_rounded, + size: 20, + color: + controller.jumlahUnit.value <= 1 + ? Colors.grey[400] + : Color(0xFF3A6EA5), + ), + ), + ), + ), + ), + ), + + // Text field for manual input + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Obx(() { + final textController = TextEditingController( + text: controller.jumlahUnit.value.toString(), + ); + // Posisi kursor di akhir teks + textController.selection = TextSelection.fromPosition( + TextPosition(offset: textController.text.length), + ); + + return TextField( + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + controller: textController, + onTap: () { + // Pilih semua teks saat di-tap + textController.selection = TextSelection( + baseOffset: 0, + extentOffset: textController.text.length, + ); + }, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: 12, + horizontal: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey[300]!, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey[300]!, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Color(0xFF3A6EA5), + ), + ), + ), + style: TextStyle( + color: Color(0xFF3A6EA5), + fontWeight: FontWeight.bold, + fontSize: 16, + ), + onSubmitted: (value) { + controller.updateUnitFromInput(value); + }, + ); + }), + ), + ), + + // Increase button + Obx( + () => Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: + controller.jumlahUnit.value >= + controller.maxUnit.value + ? null + : () { + HapticFeedback.lightImpact(); + controller.increaseUnit(); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + controller.jumlahUnit.value >= + controller.maxUnit.value + ? Colors.grey[200] + : Color(0xFF92B4D7).withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon( + Icons.add_rounded, + size: 20, + color: + controller.jumlahUnit.value >= + controller.maxUnit.value + ? Colors.grey[400] + : Color(0xFF3A6EA5), + ), + ), + ), + ), + ), + ), + ], + ), + + SizedBox(height: 8), + + // Maximum unit info + Center( + child: Obx( + () => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Maksimal ${controller.maxUnit.value} unit', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + SizedBox(width: 4), + Tooltip( + message: 'Jumlah unit yang tersedia untuk disewa', + child: Icon( + Icons.info_outline, + size: 14, + color: AppColors.textLight, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildPriceOptions() { + final aset = controller.aset.value!; + + // Filter for hourly and daily options + final hourlyOption = aset.satuanWaktuSewa.firstWhereOrNull( + (element) => + element['nama_satuan_waktu']?.toString().toLowerCase().contains( + 'jam', + ) ?? + false, + ); + + final dailyOption = aset.satuanWaktuSewa.firstWhereOrNull( + (element) => + element['nama_satuan_waktu']?.toString().toLowerCase().contains( + 'hari', + ) ?? + false, + ); + + // Count available options to handle the case when only one option is available + final availableOptionsCount = (hourlyOption != null ? 1 : 0) + (dailyOption != null ? 1 : 0); + + return Container( + padding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.access_time_filled_rounded, + size: 20, + color: AppColors.primary, + ), + SizedBox(width: 8), + Text( + 'Opsi Durasi Sewa', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + // Show number of available options + availableOptionsCount == 1 + ? Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '1 opsi tersedia', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ) + : SizedBox.shrink(), + ], + ), + ), + + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Only show hourly option if it exists + if (hourlyOption != null) + InkWell( + onTap: () { + // Only perform action if this is not already selected + if (controller.selectedSatuanWaktu.value?['id'] != hourlyOption['id']) { + HapticFeedback.lightImpact(); + controller.selectSatuanWaktu(hourlyOption); + } + }, + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + // If this is the only option, also round bottom corners + bottom: dailyOption == null ? Radius.circular(16) : Radius.zero, + ), + child: Obx(() { + bool isSelected = controller.selectedSatuanWaktu.value?['id'] == hourlyOption['id']; + + return AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? AppColors.primarySoft : AppColors.surface, + borderRadius: BorderRadius.vertical( + top: Radius.circular(16), + // If this is the only option, also round bottom corners + bottom: dailyOption == null ? Radius.circular(16) : Radius.zero, + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 300), + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected ? AppColors.primary.withOpacity(0.2) : AppColors.surfaceLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary.withOpacity(0.3) : AppColors.borderLight, + width: isSelected ? 1.5 : 1, + ), + ), + child: Center( + child: Icon( + Icons.access_time_rounded, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + size: 24, + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sewa per Jam', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + ), + SizedBox(height: 4), + Text( + controller.formatPrice(hourlyOption['harga'] ?? 0), + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + ), + ), + ], + ), + ), + AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.0, + duration: Duration(milliseconds: 300), + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: AppColors.textOnPrimary, + size: 16, + ), + ), + ), + ], + ), + ); + }), + ), + + // Add divider only if both options exist + if (hourlyOption != null && dailyOption != null) + Divider(height: 1, thickness: 1, color: Colors.grey[200]), + + // Only show daily option if it exists + if (dailyOption != null) + InkWell( + onTap: () { + // Only perform action if this is not already selected + if (controller.selectedSatuanWaktu.value?['id'] != dailyOption['id']) { + HapticFeedback.lightImpact(); + controller.selectSatuanWaktu(dailyOption); + } + }, + borderRadius: BorderRadius.vertical( + // If this is the only option, also round top corners + top: hourlyOption == null ? Radius.circular(16) : Radius.zero, + bottom: Radius.circular(16), + ), + child: Obx(() { + bool isSelected = controller.selectedSatuanWaktu.value?['id'] == dailyOption['id']; + + return AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? AppColors.primarySoft : AppColors.surface, + borderRadius: BorderRadius.vertical( + // If this is the only option, also round top corners + top: hourlyOption == null ? Radius.circular(16) : Radius.zero, + bottom: Radius.circular(16), + ), + ), + child: Row( + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 300), + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected ? AppColors.primary.withOpacity(0.2) : AppColors.surfaceLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary.withOpacity(0.3) : AppColors.borderLight, + width: isSelected ? 1.5 : 1, + ), + ), + child: Center( + child: Icon( + Icons.calendar_today_rounded, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + size: 20, + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sewa per Hari', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? AppColors.primary : AppColors.textPrimary, + ), + ), + SizedBox(height: 4), + Text( + controller.formatPrice(dailyOption['harga'] ?? 0), + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? AppColors.primary : AppColors.textSecondary, + ), + ), + ], + ), + ), + AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.0, + duration: Duration(milliseconds: 300), + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + color: AppColors.textOnPrimary, + size: 16, + ), + ), + ), + ], + ), + ); + }), + ), + + // Show message when no options are available (should never happen, but just in case) + if (availableOptionsCount == 0) + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Column( + children: [ + Icon( + Icons.warning_amber_rounded, + color: AppColors.warning, + size: 48, + ), + SizedBox(height: 16), + Text( + 'Tidak ada opsi durasi tersedia', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 8), + Text( + 'Silakan pilih aset lain yang memiliki opsi durasi', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDateSelection(BuildContext context) { + return Container( + padding: EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.event_available_rounded, + size: 20, + color: AppColors.primary, + ), + SizedBox(width: 8), + Text( + 'Pilih Waktu Sewa', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + // Information badge + Obx( + () => + controller.isDailyRental() + ? _buildInfoBadge('Harian') + : _buildInfoBadge('Per Jam'), + ), + ], + ), + + SizedBox(height: 16), + + // Show different UI based on rental type (hourly or daily) + Obx( + () => + controller.isDailyRental() + ? _buildDailyRentalDateSelection(context) + : _buildHourlyRentalDateSelection(context), + ), + ], + ), + ); + } + + Widget _buildInfoBadge(String text) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF3A6EA5), + ), + ), + ); + } + + // Date selection for daily rentals + Widget _buildDailyRentalDateSelection(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date range picker button + InkWell( + onTap: () => controller.pickDateRange(context), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon( + Icons.date_range_rounded, + color: Color(0xFF3A6EA5), + size: 24, + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rentang Tanggal', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + SizedBox(height: 4), + Obx( + () => Text( + controller.formattedDateRange.value.isNotEmpty + ? controller.formattedDateRange.value + : 'Pilih tanggal sewa', + style: TextStyle( + fontSize: 16, + fontWeight: + controller.formattedDateRange.value.isNotEmpty + ? FontWeight.w600 + : FontWeight.normal, + color: + controller.formattedDateRange.value.isNotEmpty + ? Color(0xFF3A6EA5) + : Colors.grey[500], + ), + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + color: Colors.grey[400], + ), + ], + ), + ), + ), + + // Display selected duration + Obx( + () => + controller.formattedDateRange.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.1), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: Color(0xFF3A6EA5), + ), + SizedBox(width: 8), + Text( + 'Durasi: ${controller.duration.value} hari', + style: TextStyle( + fontSize: 14, + color: Color(0xFF3A6EA5), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ); + } + + // Date and time selection for hourly rentals + Widget _buildHourlyRentalDateSelection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date picker button + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: InkWell( + onTap: () => controller.pickDate(context), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Icon( + Icons.date_range_rounded, + color: Color(0xFF3A6EA5), + size: 24, + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tanggal Sewa', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + ), + ), + SizedBox(height: 4), + Obx( + () => Text( + controller.selectedDate.value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF3A6EA5), + ), + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + size: 16, + color: Colors.grey[400], + ), + ], + ), + ), + ), + ), + + SizedBox(height: 24), + + // Time selection section + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Icon( + Icons.access_time_rounded, + color: Color(0xFF3A6EA5), + size: 20, + ), + ), + ), + SizedBox(width: 12), + Text( + 'Pilih Jam', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[900], + ), + ), + ], + ), + + // Show selected time range if any + Obx( + () => + controller.formattedTimeRange.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + controller.formattedTimeRange.value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF3A6EA5), + ), + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ), + + Divider(height: 1, thickness: 1, color: Colors.grey[200]), + + // Time selection grid + Padding( + padding: const EdgeInsets.all(16.0), + child: Obx(() => _buildTimeGrid()), + ), + + // Show selected duration + Obx( + () => + controller.formattedTimeRange.value.isNotEmpty + ? Container( + padding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: Color(0xFF92B4D7).withOpacity(0.1), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: Color(0xFF3A6EA5), + ), + SizedBox(width: 8), + Text( + 'Durasi: ${controller.duration.value} jam', + style: TextStyle( + fontSize: 14, + color: Color(0xFF3A6EA5), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ) + : SizedBox.shrink(), + ), + ], + ), + ), + ], + ); + } + + Widget _buildTimeGrid() { + // Create rows of hours, with 4 hours per row + final List rows = []; + final hours = controller.availableHours; + + // Create rows of 4 hours each + for (int i = 0; i < hours.length; i += 4) { + final rowItems = []; + + // Add up to 4 hours for this row + for (int j = 0; j < 4 && i + j < hours.length; j++) { + final hour = hours[i + j]; + rowItems.add(Expanded(child: _buildTimeButton(hour))); + + // Add spacing between buttons + if (j < 3 && i + j + 1 < hours.length) { + rowItems.add(SizedBox(width: 8)); + } + } + + rows.add(Row(children: rowItems)); + + // Add spacing between rows + if (i + 4 < hours.length) { + rows.add(SizedBox(height: 8)); + } + } + + return Column(children: rows); + } + + Widget _buildTimeButton(int hour) { + bool isSelected = + controller.startHour.value <= hour && hour < controller.endHour.value; + bool isDisabled = controller.isHourDisabled(hour); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: + isDisabled + ? null + : () { + // Add haptic feedback when selecting an hour + HapticFeedback.selectionClick(); + controller.selectHour(hour); + }, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + height: 48, + decoration: BoxDecoration( + color: + isSelected + ? Color(0xFF3A6EA5) + : isDisabled + ? Colors.grey[200] + : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isSelected + ? Color(0xFF3A6EA5) + : isDisabled + ? Colors.grey[300]! + : Colors.grey[300]!, + width: 1.5, + ), + ), + child: Center( + child: Text( + controller.formatHour(hour), + style: TextStyle( + color: + isSelected + ? Colors.white + : isDisabled + ? Colors.grey[500] + : Colors.grey[800], + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontSize: 14, + ), + ), + ), + ), + ), + ); + } + + Widget _buildBottomBar({required Function onTapPesan}) { + return Container( + height: 80, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 10, + offset: Offset(0, -5), + ), + ], + ), + child: Row( + children: [ + // Price info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + SizedBox(height: 2), + Obx( + () => Text( + controller.formatPrice(controller.totalPrice.value), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + // Order button + SizedBox( + width: 140, + child: ElevatedButton( + onPressed: () => onTapPesan(), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: EdgeInsets.symmetric(vertical: 12), + ), + child: Text( + 'Pesan Sekarang', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ); + } +} + +// Full Screen Image Viewer Widget +class FullScreenImageViewer extends StatefulWidget { + final List imageUrls; + final int initialIndex; + + const FullScreenImageViewer({ + super.key, + required this.imageUrls, + this.initialIndex = 0, + }); + + @override + _FullScreenImageViewerState createState() => _FullScreenImageViewerState(); +} + +class _FullScreenImageViewerState extends State { + late int currentIndex; + late PageController pageController; + + @override + void initState() { + super.initState(); + currentIndex = + widget.initialIndex < widget.imageUrls.length ? widget.initialIndex : 0; + pageController = PageController(initialPage: currentIndex); + + debugPrint("📸 FullScreenImageViewer initialized"); + debugPrint("📸 Images count: ${widget.imageUrls.length}"); + debugPrint("📸 Initial index: $currentIndex"); + + // Log the first few URLs + for (int i = 0; i < widget.imageUrls.length && i < 3; i++) { + debugPrint("📸 Image URL $i: ${widget.imageUrls[i]}"); + } + } + + @override + void dispose() { + pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // Photo Gallery + PhotoViewGallery.builder( + scrollPhysics: const BouncingScrollPhysics(), + builder: (BuildContext context, int index) { + final imageUrl = widget.imageUrls[index]; + debugPrint("📸 Building image at index $index: $imageUrl"); + + return PhotoViewGalleryPageOptions( + imageProvider: CachedNetworkImageProvider(imageUrl), + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained * 0.8, + maxScale: PhotoViewComputedScale.covered * 2.0, + heroAttributes: PhotoViewHeroAttributes(tag: "photo_$index"), + ); + }, + itemCount: widget.imageUrls.length, + loadingBuilder: + (context, event) => Center( + child: SizedBox( + width: 30.0, + height: 30.0, + child: CircularProgressIndicator( + value: + event == null + ? 0 + : event.cumulativeBytesLoaded / + event.expectedTotalBytes!, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), + backgroundDecoration: BoxDecoration(color: Colors.black), + pageController: pageController, + onPageChanged: (index) { + setState(() { + currentIndex = index; + debugPrint("📸 Page changed to index: $index"); + }); + }, + ), + + // Close button + Positioned( + top: 40, + left: 20, + child: IconButton( + icon: Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: Icon(Icons.close, color: Colors.white), + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + + // Image counter + Positioned( + top: 40, + right: 20, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + '${currentIndex + 1}/${widget.imageUrls.length}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/warga/views/order_sewa_paket_view.dart b/lib/app/modules/warga/views/order_sewa_paket_view.dart new file mode 100644 index 0000000..bce4d2d --- /dev/null +++ b/lib/app/modules/warga/views/order_sewa_paket_view.dart @@ -0,0 +1,981 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../controllers/order_sewa_paket_controller.dart'; +import '../../../data/models/paket_model.dart'; +import '../../../routes/app_routes.dart'; +import '../../../services/navigation_service.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:flutter_logs/flutter_logs.dart'; +import '../../../theme/app_colors.dart'; +import 'package:intl/intl.dart'; + +class OrderSewaPaketView extends GetView { + const OrderSewaPaketView({super.key}); + + // Function to show confirmation dialog + void showOrderConfirmationDialog() { + final paket = controller.paket.value!; + final PaketModel? paketModel = paket is PaketModel ? paket : null; + final totalPrice = controller.totalPrice.value; + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Container( + width: double.infinity, + padding: EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header with success icon + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.primarySoft, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_circle_outline_rounded, + color: AppColors.primary, + size: 40, + ), + ), + SizedBox(height: 20), + + // Title + Text( + 'Konfirmasi Pesanan', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 6), + + // Subtitle + Text( + 'Periksa detail pesanan Anda', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 24), + + // Order details + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.borderLight), + ), + child: Column( + children: [ + // Paket name + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Paket', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Text( + paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + ], + ), + Divider(height: 24, color: AppColors.divider), + + // Duration info + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Durasi', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + controller.isDailyRental() + ? controller.formattedDateRange.value + : '${controller.selectedDate.value}, ${controller.formattedTimeRange.value}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + ], + ), + Divider(height: 24, color: AppColors.divider), + + // Total price info + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + controller.formatPrice(controller.totalPrice.value), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + SizedBox(height: 24), + + // Action buttons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Get.back(), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Batal', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: Obx( + () => ElevatedButton( + onPressed: controller.isSubmitting.value + ? null + : () { + Get.back(); + controller.submitOrder(); + }, + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 16), + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: controller.isSubmitting.value + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Pesan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Handle hot reload by checking if controller needs to be reset + WidgetsBinding.instance.addPostFrameCallback((_) { + // This will be called after the widget tree is built + controller.handleHotReload(); + + // Ensure navigation service is registered for back button functionality + if (!Get.isRegistered()) { + Get.put(NavigationService()); + debugPrint('✅ Created new NavigationService instance in view'); + } + }); + + // Function to handle back button press + void handleBackButtonPress() { + debugPrint('🔙 Back button pressed - navigating to SewaAsetView'); + try { + // First try to use the controller's method + controller.onBackPressed(); + } catch (e) { + debugPrint('⚠️ Error handling back via controller: $e'); + // Fallback to direct navigation + Get.back(); + } + } + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: handleBackButtonPress, + ), + title: Text( + 'Pesan Paket', + style: TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + ), + body: Obx( + () => controller.isLoading.value + ? Center(child: CircularProgressIndicator()) + : controller.paket.value == null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline_rounded, + size: 64, + color: AppColors.error, + ), + SizedBox(height: 16), + Text( + 'Paket tidak ditemukan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 8), + Text( + 'Silakan kembali dan pilih paket lain', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: handleBackButtonPress, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text('Kembali'), + ), + ], + ), + ) + : SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTopSection(), + _buildPaketDetails(), + _buildPriceOptions(), + _buildDateSelection(context), + SizedBox(height: 100), // Space for bottom bar + ], + ), + ), + ), + bottomSheet: Obx( + () => controller.isLoading.value || controller.paket.value == null + ? SizedBox.shrink() + : _buildBottomBar(onTapPesan: showOrderConfirmationDialog), + ), + ); + } + + // Build top section with paket images + Widget _buildTopSection() { + return Container( + height: 280, + width: double.infinity, + child: Stack( + children: [ + // Photo gallery + Obx( + () => controller.isPhotosLoading.value + ? Center(child: CircularProgressIndicator()) + : controller.paketImages.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported_outlined, + size: 64, + color: AppColors.textSecondary, + ), + SizedBox(height: 16), + Text( + 'Tidak ada foto', + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + ), + ], + ), + ) + : PhotoViewGallery.builder( + scrollPhysics: BouncingScrollPhysics(), + builder: (BuildContext context, int index) { + return PhotoViewGalleryPageOptions( + imageProvider: CachedNetworkImageProvider( + controller.paketImages[index], + ), + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + heroAttributes: PhotoViewHeroAttributes( + tag: 'paket_image_$index', + ), + ); + }, + itemCount: controller.paketImages.length, + loadingBuilder: (context, event) => Center( + child: CircularProgressIndicator(), + ), + backgroundDecoration: BoxDecoration( + color: Colors.black, + ), + pageController: PageController(), + ), + ), + + // Gradient overlay at the top for back button + Positioned( + top: 0, + left: 0, + right: 0, + height: 80, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.5), + Colors.transparent, + ], + ), + ), + ), + ), + ], + ), + ); + } + + // Build paket details section + Widget _buildPaketDetails() { + final paket = controller.paket.value!; + final PaketModel? paketModel = paket is PaketModel ? paket : null; + + return Container( + padding: EdgeInsets.all(16), + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Paket name and availability badge + Row( + children: [ + Expanded( + child: Text( + paketModel?.nama ?? controller.getPaketNama(paket) ?? 'Paket tanpa nama', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Tersedia', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.success, + ), + ), + ), + ], + ), + SizedBox(height: 16), + + // Description + Text( + 'Deskripsi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 8), + Text( + paketModel?.deskripsi ?? controller.getPaketDeskripsi(paket) ?? 'Tidak ada deskripsi untuk paket ini.', + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ], + ), + ); + } + // Build price options section + Widget _buildPriceOptions() { + final paket = controller.paket.value!; + final PaketModel? paketModel = paket is PaketModel ? paket : null; + final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? controller.getPaketSatuanWaktuSewa(paket); + + return Container( + padding: EdgeInsets.all(16), + color: Colors.white, + margin: EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilih Durasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 16), + + // Price options grid + GridView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 2.5, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: satuanWaktuSewa.length, + itemBuilder: (context, index) { + final option = satuanWaktuSewa[index]; + final isSelected = controller.selectedSatuanWaktu.value != null && + controller.selectedSatuanWaktu.value!['id'] == option['id']; + + return GestureDetector( + onTap: () => controller.selectSatuanWaktu(option), + child: AnimatedContainer( + duration: Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary : AppColors.borderLight, + width: 1, + ), + ), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + option['nama_satuan_waktu'] ?? 'Durasi', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + ), + SizedBox(height: 4), + Text( + controller.formatPrice(double.tryParse(option['harga'].toString()) ?? 0), + style: TextStyle( + fontSize: 12, + color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + // Build date selection section + Widget _buildDateSelection(BuildContext context) { + return Obx( + () => controller.selectedSatuanWaktu.value == null + ? SizedBox.shrink() + : Container( + padding: EdgeInsets.all(16), + color: Colors.white, + margin: EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.isDailyRental() ? 'Pilih Tanggal' : 'Pilih Waktu', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 16), + + // Date selection for daily rental + if (controller.isDailyRental()) + GestureDetector( + onTap: () async { + // Show date range picker + final now = DateTime.now(); + final initialStartDate = controller.selectedStartDate.value ?? now; + final initialEndDate = controller.selectedEndDate.value ?? now.add(Duration(days: 1)); + + final DateTimeRange? picked = await showDateRangePicker( + context: context, + initialDateRange: DateTimeRange(start: initialStartDate, end: initialEndDate), + firstDate: now, + lastDate: now.add(Duration(days: 365)), + builder: (context, child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null) { + controller.selectDateRange(picked.start, picked.end); + } + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.calendar_today, color: AppColors.primary), + SizedBox(width: 12), + Expanded( + child: Text( + controller.formattedDateRange.value.isEmpty + ? 'Pilih tanggal sewa' + : controller.formattedDateRange.value, + style: TextStyle( + fontSize: 14, + color: controller.formattedDateRange.value.isEmpty + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ), + Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary), + ], + ), + ), + ) + // Time selection for hourly rental + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date selection + GestureDetector( + onTap: () async { + final now = DateTime.now(); + final initialDate = controller.selectedStartDate.value ?? now; + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: now, + lastDate: now.add(Duration(days: 30)), + builder: (context, child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null) { + controller.selectDate(picked); + } + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.calendar_today, color: AppColors.primary), + SizedBox(width: 12), + Expanded( + child: Text( + controller.selectedDate.value.isEmpty + ? 'Pilih tanggal sewa' + : controller.selectedDate.value, + style: TextStyle( + fontSize: 14, + color: controller.selectedDate.value.isEmpty + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ), + Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary), + ], + ), + ), + ), + SizedBox(height: 16), + + // Time range selection + controller.selectedDate.value.isEmpty + ? SizedBox.shrink() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilih Jam', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 12), + Row( + children: [ + // Start time + Expanded( + child: GestureDetector( + onTap: () async { + // Show time picker for start time (8-20) + final List<int> availableHours = List.generate(13, (i) => i + 8); + final int? selectedHour = await showDialog<int>( + context: context, + builder: (context) => SimpleDialog( + title: Text('Pilih Jam Mulai'), + children: availableHours.map((hour) { + return SimpleDialogOption( + onPressed: () => Navigator.pop(context, hour), + child: Text('$hour:00'), + ); + }).toList(), + ), + ); + + if (selectedHour != null) { + // If end time is already selected and less than start time, reset it + if (controller.selectedEndTime.value > 0 && + controller.selectedEndTime.value <= selectedHour) { + controller.selectedEndTime.value = -1; + } + controller.selectedStartTime.value = selectedHour; + if (controller.selectedEndTime.value > 0) { + controller.selectTimeRange( + controller.selectedStartTime.value, + controller.selectedEndTime.value, + ); + } + } + }, + child: Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.access_time, size: 16, color: AppColors.primary), + SizedBox(width: 8), + Text( + controller.selectedStartTime.value < 0 + ? 'Jam Mulai' + : '${controller.selectedStartTime.value}:00', + style: TextStyle( + fontSize: 14, + color: controller.selectedStartTime.value < 0 + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ), + SizedBox(width: 16), + // End time + Expanded( + child: GestureDetector( + onTap: () async { + if (controller.selectedStartTime.value < 0) { + Get.snackbar( + 'Perhatian', + 'Pilih jam mulai terlebih dahulu', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: AppColors.warning, + colorText: Colors.white, + ); + return; + } + + // Show time picker for end time (start+1 to 21) + final List<int> availableHours = List.generate( + 21 - controller.selectedStartTime.value, + (i) => i + controller.selectedStartTime.value + 1, + ); + final int? selectedHour = await showDialog<int>( + context: context, + builder: (context) => SimpleDialog( + title: Text('Pilih Jam Selesai'), + children: availableHours.map((hour) { + return SimpleDialogOption( + onPressed: () => Navigator.pop(context, hour), + child: Text('$hour:00'), + ); + }).toList(), + ), + ); + + if (selectedHour != null) { + controller.selectedEndTime.value = selectedHour; + controller.selectTimeRange( + controller.selectedStartTime.value, + controller.selectedEndTime.value, + ); + } + }, + child: Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.access_time, size: 16, color: AppColors.primary), + SizedBox(width: 8), + Text( + controller.selectedEndTime.value < 0 + ? 'Jam Selesai' + : '${controller.selectedEndTime.value}:00', + style: TextStyle( + fontSize: 14, + color: controller.selectedEndTime.value < 0 + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ); + } + + // Build bottom bar with total price and order button + Widget _buildBottomBar({required VoidCallback onTapPesan}) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, -5), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + // Price info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + controller.formatPrice(controller.totalPrice.value), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + // Order button + Obx( + () => ElevatedButton( + onPressed: controller.selectedSatuanWaktu.value == null || + (controller.isDailyRental() && + (controller.selectedStartDate.value == null || + controller.selectedEndDate.value == null)) || + (!controller.isDailyRental() && + (controller.selectedStartDate.value == null || + controller.selectedStartTime.value < 0 || + controller.selectedEndTime.value < 0)) + ? null + : onTapPesan, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Pesan Sekarang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + \ No newline at end of file diff --git a/lib/app/modules/warga/views/order_sewa_paket_view.dart.remaining b/lib/app/modules/warga/views/order_sewa_paket_view.dart.remaining new file mode 100644 index 0000000..325f320 --- /dev/null +++ b/lib/app/modules/warga/views/order_sewa_paket_view.dart.remaining @@ -0,0 +1,470 @@ + // Build price options section + Widget _buildPriceOptions() { + final paket = controller.paket.value!; + final PaketModel? paketModel = paket is PaketModel ? paket : null; + final satuanWaktuSewa = paketModel?.satuanWaktuSewa ?? controller.getPaketSatuanWaktuSewa(paket); + + return Container( + padding: EdgeInsets.all(16), + color: Colors.white, + margin: EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilih Durasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 16), + + // Price options grid + GridView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 2.5, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: satuanWaktuSewa.length, + itemBuilder: (context, index) { + final option = satuanWaktuSewa[index]; + final isSelected = controller.selectedSatuanWaktu.value != null && + controller.selectedSatuanWaktu.value!['id'] == option['id']; + + return GestureDetector( + onTap: () => controller.selectSatuanWaktu(option), + child: AnimatedContainer( + duration: Duration(milliseconds: 200), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppColors.primary : AppColors.borderLight, + width: 1, + ), + ), + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + option['nama_satuan_waktu'] ?? 'Durasi', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : AppColors.textPrimary, + ), + ), + SizedBox(height: 4), + Text( + controller.formatPrice(double.tryParse(option['harga'].toString()) ?? 0), + style: TextStyle( + fontSize: 12, + color: isSelected ? Colors.white.withOpacity(0.8) : AppColors.textSecondary, + ), + ), + ], + ), + ), + ); + }, + ), + ], + ), + ); + } + + // Build date selection section + Widget _buildDateSelection(BuildContext context) { + return Obx( + () => controller.selectedSatuanWaktu.value == null + ? SizedBox.shrink() + : Container( + padding: EdgeInsets.all(16), + color: Colors.white, + margin: EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.isDailyRental() ? 'Pilih Tanggal' : 'Pilih Waktu', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 16), + + // Date selection for daily rental + if (controller.isDailyRental()) + GestureDetector( + onTap: () async { + // Show date range picker + final now = DateTime.now(); + final initialStartDate = controller.selectedStartDate.value ?? now; + final initialEndDate = controller.selectedEndDate.value ?? now.add(Duration(days: 1)); + + final DateTimeRange? picked = await showDateRangePicker( + context: context, + initialDateRange: DateTimeRange(start: initialStartDate, end: initialEndDate), + firstDate: now, + lastDate: now.add(Duration(days: 365)), + builder: (context, child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null) { + controller.selectDateRange(picked.start, picked.end); + } + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.calendar_today, color: AppColors.primary), + SizedBox(width: 12), + Expanded( + child: Text( + controller.formattedDateRange.value.isEmpty + ? 'Pilih tanggal sewa' + : controller.formattedDateRange.value, + style: TextStyle( + fontSize: 14, + color: controller.formattedDateRange.value.isEmpty + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ), + Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary), + ], + ), + ), + ) + // Time selection for hourly rental + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date selection + GestureDetector( + onTap: () async { + final now = DateTime.now(); + final initialDate = controller.selectedStartDate.value ?? now; + + final DateTime? picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: now, + lastDate: now.add(Duration(days: 30)), + builder: (context, child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: AppColors.primary, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: AppColors.textPrimary, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null) { + controller.selectDate(picked); + } + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.calendar_today, color: AppColors.primary), + SizedBox(width: 12), + Expanded( + child: Text( + controller.selectedDate.value.isEmpty + ? 'Pilih tanggal sewa' + : controller.selectedDate.value, + style: TextStyle( + fontSize: 14, + color: controller.selectedDate.value.isEmpty + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ), + Icon(Icons.arrow_forward_ios, size: 16, color: AppColors.textSecondary), + ], + ), + ), + ), + SizedBox(height: 16), + + // Time range selection + controller.selectedDate.value.isEmpty + ? SizedBox.shrink() + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilih Jam', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 12), + Row( + children: [ + // Start time + Expanded( + child: GestureDetector( + onTap: () async { + // Show time picker for start time (8-20) + final List availableHours = List.generate(13, (i) => i + 8); + final int? selectedHour = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('Pilih Jam Mulai'), + children: availableHours.map((hour) { + return SimpleDialogOption( + onPressed: () => Navigator.pop(context, hour), + child: Text('$hour:00'), + ); + }).toList(), + ), + ); + + if (selectedHour != null) { + // If end time is already selected and less than start time, reset it + if (controller.selectedEndTime.value > 0 && + controller.selectedEndTime.value <= selectedHour) { + controller.selectedEndTime.value = -1; + } + controller.selectedStartTime.value = selectedHour; + if (controller.selectedEndTime.value > 0) { + controller.selectTimeRange( + controller.selectedStartTime.value, + controller.selectedEndTime.value, + ); + } + } + }, + child: Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.access_time, size: 16, color: AppColors.primary), + SizedBox(width: 8), + Text( + controller.selectedStartTime.value < 0 + ? 'Jam Mulai' + : '${controller.selectedStartTime.value}:00', + style: TextStyle( + fontSize: 14, + color: controller.selectedStartTime.value < 0 + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ), + SizedBox(width: 16), + // End time + Expanded( + child: GestureDetector( + onTap: () async { + if (controller.selectedStartTime.value < 0) { + Get.snackbar( + 'Perhatian', + 'Pilih jam mulai terlebih dahulu', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: AppColors.warning, + colorText: Colors.white, + ); + return; + } + + // Show time picker for end time (start+1 to 21) + final List availableHours = List.generate( + 21 - controller.selectedStartTime.value, + (i) => i + controller.selectedStartTime.value + 1, + ); + final int? selectedHour = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: Text('Pilih Jam Selesai'), + children: availableHours.map((hour) { + return SimpleDialogOption( + onPressed: () => Navigator.pop(context, hour), + child: Text('$hour:00'), + ); + }).toList(), + ), + ); + + if (selectedHour != null) { + controller.selectedEndTime.value = selectedHour; + controller.selectTimeRange( + controller.selectedStartTime.value, + controller.selectedEndTime.value, + ); + } + }, + child: Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.access_time, size: 16, color: AppColors.primary), + SizedBox(width: 8), + Text( + controller.selectedEndTime.value < 0 + ? 'Jam Selesai' + : '${controller.selectedEndTime.value}:00', + style: TextStyle( + fontSize: 14, + color: controller.selectedEndTime.value < 0 + ? AppColors.textSecondary + : AppColors.textPrimary, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ); + } + + // Build bottom bar with total price and order button + Widget _buildBottomBar({required VoidCallback onTapPesan}) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: Offset(0, -5), + ), + ], + ), + child: SafeArea( + child: Row( + children: [ + // Price info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + Obx( + () => Text( + controller.formatPrice(controller.totalPrice.value), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ), + // Order button + Obx( + () => ElevatedButton( + onPressed: controller.selectedSatuanWaktu.value == null || + (controller.isDailyRental() && + (controller.selectedStartDate.value == null || + controller.selectedEndDate.value == null)) || + (!controller.isDailyRental() && + (controller.selectedStartDate.value == null || + controller.selectedStartTime.value < 0 || + controller.selectedEndTime.value < 0)) + ? null + : onTapPesan, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + 'Pesan Sekarang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } diff --git a/lib/app/modules/warga/views/pembayaran_sewa_view.dart b/lib/app/modules/warga/views/pembayaran_sewa_view.dart new file mode 100644 index 0000000..cc24e79 --- /dev/null +++ b/lib/app/modules/warga/views/pembayaran_sewa_view.dart @@ -0,0 +1,2154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; // Added for Clipboard +import 'package:get/get.dart'; +import '../controllers/pembayaran_sewa_controller.dart'; +import 'package:intl/intl.dart'; +import '../../../theme/app_colors.dart'; + +class PembayaranSewaView extends GetView { + const PembayaranSewaView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + title: const Text( + 'Detail Pesanan', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + centerTitle: true, + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Get.back(), + ), + ), + body: Column( + children: [ + Container( + decoration: BoxDecoration( + color: AppColors.primary, + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Container( + margin: const EdgeInsets.only(bottom: 4), + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: TabBar( + controller: controller.tabController, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textSecondary, + indicatorColor: AppColors.primary, + indicatorWeight: 3, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + tabs: const [ + Tab(text: 'Ringkasan'), + Tab(text: 'Detail Tagihan'), + Tab(text: 'Pembayaran'), + ], + ), + ), + ), + Expanded( + child: TabBarView( + controller: controller.tabController, + children: [ + _buildSummaryTab(), + _buildBillingTab(), + _buildPaymentTab(), + ], + ), + ), + ], + ), + ); + } + + // First Tab - Summary Tab (renamed from Order Details) + Widget _buildSummaryTab() { + return RefreshIndicator( + onRefresh: controller.refreshData, + color: AppColors.primary, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildOrderStatusCard(), + const SizedBox(height: 16), + _buildPaymentSummaryCard(), + const SizedBox(height: 16), + _buildOrderProgressTimeline(), + ], + ), + ), + ); + } + + // Second Tab - Billing Tab (new tab) + Widget _buildBillingTab() { + return RefreshIndicator( + onRefresh: controller.refreshData, + color: AppColors.primary, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx( + () => + controller.isLoading.value + ? Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.deepPurple, + ), + ), + const SizedBox(height: 16), + Text( + 'Memuat data tagihan...', + style: TextStyle( + color: Colors.grey[700], + fontSize: 14, + ), + ), + ], + ), + ), + ) + : Column( + children: [ + _buildInvoiceIdCard(), + const SizedBox(height: 16), + _buildTagihanAwalCard(), + const SizedBox(height: 16), + _buildDendaCard(), + ], + ), + ), + ], + ), + ), + ); + } + + // Third Tab - Payment Tab (renamed from Payment Instructions) + Widget _buildPaymentTab() { + return RefreshIndicator( + onRefresh: controller.refreshData, + color: AppColors.primary, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPaymentTypeSelection(), + const SizedBox(height: 24), + Obx(() { + // Show payment method selection only after selecting a payment type + if (controller.selectedPaymentType.value.isNotEmpty) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPaymentMethodSelection(), + const SizedBox(height: 24), + if (controller.paymentMethod.value == 'transfer') + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTransferInstructions(), + const SizedBox(height: 24), + _buildPaymentProofUpload(), + ], + ) + else if (controller.paymentMethod.value == 'cash') + _buildCashInstructions() + else + _buildSelectPaymentMethodPrompt(), + ], + ); + } else { + // Prompt to select payment type first + return _buildSelectPaymentTypePrompt(); + } + }), + ], + ), + ), + ); + } + + // Order Status Card + Widget _buildOrderStatusCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Status Pesanan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.orange.withOpacity(0.3)), + ), + child: Obx( + () => Text( + controller.orderDetails.value['status'] ?? + 'MENUNGGU PEMBAYARAN', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + const Text( + 'ID Pesanan', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 4), + Obx( + () => Text( + controller.orderDetails.value['id'] ?? '-', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Icon(Icons.access_time, size: 16, color: Colors.grey), + const SizedBox(width: 4), + const Text( + 'Batas waktu pembayaran: ', + style: TextStyle(fontSize: 14, color: Colors.grey), + ), + Obx( + () => Text( + controller.orderDetails.value['status'] == 'DIBATALKAN' + ? 'Dibatalkan' + : controller.remainingTime.value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red[700], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + // Modern Order Progress Timeline + Widget _buildOrderProgressTimeline() { + final steps = [ + { + 'title': 'Menunggu Pembayaran', + 'description': 'Segera lakukan pembayaran untuk melanjutkan pesanan', + 'icon': Icons.payment, + 'step': 0, + }, + { + 'title': 'Memeriksa Pembayaran', + 'description': 'Pembayaran sedang diverifikasi oleh petugas', + 'icon': Icons.receipt_long, + 'step': 1, + }, + { + 'title': 'Diterima', + 'description': 'Pesanan Anda telah diterima dan dikonfirmasi', + 'icon': Icons.check_circle, + 'step': 2, + }, + { + 'title': 'Pengembalian', + 'description': 'Proses pengembalian aset sewa', + 'icon': Icons.assignment_return, + 'step': 3, + }, + { + 'title': 'Pembayaran Denda', + 'description': 'Pembayaran denda jika ada kerusakan atau keterlambatan', + 'icon': Icons.money, + 'step': 4, + }, + { + 'title': 'Memeriksa Pembayaran Denda', + 'description': 'Verifikasi pembayaran denda oleh petugas', + 'icon': Icons.fact_check, + 'step': 5, + }, + { + 'title': 'Selesai', + 'description': 'Pesanan sewa telah selesai', + 'icon': Icons.task_alt, + 'step': 6, + }, + ]; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.timeline, color: AppColors.primary, size: 22), + const SizedBox(width: 10), + const Text( + 'Progress Pesanan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 20), + Obx(() { + final currentStep = controller.currentStep.value; + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: steps.length, + itemBuilder: (context, index) { + final step = steps[index]; + final stepNumber = step['step'] as int; + final isActive = currentStep >= stepNumber; + final isCompleted = currentStep > stepNumber; + final isLast = index == steps.length - 1; + + // Determine the appropriate colors + final Color iconColor = + isActive + ? (isCompleted + ? AppColors.success + : AppColors.primary) + : Colors.grey[300]!; + + final Color lineColor = + isCompleted ? AppColors.success : Colors.grey[300]!; + + final Color bgColor = + isActive + ? (isCompleted + ? AppColors.successLight + : AppColors.primarySoft) + : Colors.grey[100]!; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: bgColor, + shape: BoxShape.circle, + border: Border.all(color: iconColor, width: 2), + ), + child: Icon( + isCompleted + ? Icons.check + : step['icon'] as IconData, + color: iconColor, + size: 18, + ), + ), + if (!isLast) + Container(width: 2, height: 40, color: lineColor), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step['title'] as String, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: + isActive + ? AppColors.textPrimary + : AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + step['description'] as String, + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + if (!isLast) const SizedBox(height: 20), + ], + ), + ), + if (isCompleted) + Icon( + Icons.check_circle, + color: AppColors.success, + size: 18, + ) + else if (currentStep == stepNumber) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primary.withOpacity(0.3), + ), + ), + child: Text( + 'Saat ini', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ); + }, + ); + }), + ], + ), + ), + ); + } + + // ID Tagihan Card + Widget _buildInvoiceIdCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.receipt_long, color: Colors.deepPurple, size: 24), + const SizedBox(width: 8), + const Text( + 'ID Tagihan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + controller.tagihanSewa.value['id'] ?? '-', + style: const TextStyle( + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 18), + onPressed: () { + // Copy to clipboard functionality would go here + ScaffoldMessenger.of(Get.context!).showSnackBar( + const SnackBar( + content: Text('ID Tagihan disalin ke clipboard'), + duration: Duration(seconds: 2), + ), + ); + }, + color: Colors.deepPurple, + tooltip: 'Salin ID Tagihan', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Tagihan Awal Card (renamed from BillingDetailsCard) + Widget _buildTagihanAwalCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.receipt, color: Colors.deepPurple, size: 24), + const SizedBox(width: 8), + const Text( + 'Tagihan Awal', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 16), + Obx( + () => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Item name from aset.nama + _buildDetailItem( + 'Item', + controller.sewaAsetDetails.value['aset_detail'] != null + ? controller + .sewaAsetDetails + .value['aset_detail']['nama'] ?? + '-' + : controller.tagihanSewa.value['nama_aset'] ?? + controller.orderDetails.value['item_name'] ?? + '-', + ), + // Quantity from sewa_aset.kuantitas + _buildDetailItem( + 'Jumlah', + '${controller.sewaAsetDetails.value['kuantitas'] ?? controller.orderDetails.value['quantity'] ?? 0} unit', + ), + // Waktu Sewa with sub-points for Waktu Mulai and Waktu Selesai + _buildDetailItemWithSubpoints( + 'Waktu Sewa', + [ + { + 'label': 'Waktu Mulai', + 'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_mulai']), + }, + { + 'label': 'Waktu Selesai', + 'value': _formatDateTime(controller.sewaAsetDetails.value['waktu_selesai']), + }, + ], + ), + _buildDetailItem( + 'Durasi', + controller.tagihanSewa.value['durasi'] != null + ? '${controller.tagihanSewa.value['durasi']} ${controller.tagihanSewa.value['satuan_waktu'] ?? ''}' + : controller.orderDetails.value['duration'] ?? '-', + ), + const Divider(height: 32), + _buildDetailItem( + 'Harga per Unit', + 'Rp ${controller.tagihanSewa.value['harga_sewa'] ?? controller.orderDetails.value['price_per_unit'] ?? 0}', + isImportant: false, + ), + _buildDetailItem( + 'Total Harga', + 'Rp ${controller.tagihanSewa.value['tagihan_awal'] ?? controller.orderDetails.value['total_price'] ?? 0}', + isImportant: true, + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Denda Card + Widget _buildDendaCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.warning_amber_rounded, + color: Colors.orange[700], + size: 24, + ), + const SizedBox(width: 8), + Text( + 'Denda', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.orange[700], + ), + ), + ], + ), + const SizedBox(height: 16), + Obx(() { + // Get values from tagihan_sewa table + final denda = controller.tagihanSewa.value['denda']; + final keterangan = controller.tagihanSewa.value['keterangan']; + final fotoKerusakan = controller.tagihanSewa.value['foto_kerusakan']; + + debugPrint('Tagihan Denda: $denda'); + debugPrint('Tagihan Keterangan: $keterangan'); + debugPrint('Tagihan Foto Kerusakan: $fotoKerusakan'); + + // Always show the denda amount, using "-" when it's null or zero + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Show denda amount + _buildDetailItem( + 'Jumlah Denda', + denda != null && denda != 0 + ? 'Rp ${NumberFormat('#,###').format(denda)}' + : '-', + isImportant: true, + valueColor: + denda != null && denda != 0 + ? Colors.red[700] + : Colors.grey[700], + ), + + // Show keterangan if it exists + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Keterangan:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Text( + (keterangan != null && + keterangan.toString().isNotEmpty) + ? keterangan.toString() + : (denda != null && denda != 0 + ? 'Terdapat denda untuk penyewaan ini.' + : 'Tidak ada denda untuk penyewaan ini.'), + style: TextStyle( + color: Colors.grey[800], + fontSize: 14, + ), + ), + ), + ], + ), + ), + + // Supporting Image - always show if denda exists + if (denda != null && denda != 0) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Gambar Pendukung:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GestureDetector( + onTap: () { + // Show fullscreen image when tapped + // Use the BuildContext from the current widget tree + _showFullScreenImage(Get.context!, fotoKerusakan); + }, + child: Hero( + tag: 'damage-photo-${fotoKerusakan ?? 'default'}', + child: fotoKerusakan != null && fotoKerusakan.toString().isNotEmpty && fotoKerusakan.toString().startsWith('http') + ? Image.network( + fotoKerusakan.toString(), + width: double.infinity, + height: 200, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + debugPrint('Error loading image: $error'); + return Image.asset( + 'assets/images/gambar_pendukung.jpg', + width: double.infinity, + height: 200, + fit: BoxFit.cover, + ); + }, + ) + : Image.asset( + 'assets/images/gambar_pendukung.jpg', + width: double.infinity, + height: 200, + fit: BoxFit.cover, + ), + ), + ), + ), + ], + ), + ), + ], + ); + }), + ], + ), + ), + ); + } + + // Helper method to format rental period from ISO timestamps + String _formatRentalPeriod(String? startTime, String? endTime) { + debugPrint('🏷️ _formatRentalPeriod called with:'); + debugPrint(' startTime: $startTime'); + debugPrint(' endTime: $endTime'); + + // Get satuan_waktu from tagihan + final satuanWaktu = controller.tagihanSewa.value['satuan_waktu'] ?? 'jam'; + debugPrint(' satuan_waktu: $satuanWaktu'); + + // Also debug the entire sewaAsetDetails object + debugPrint('🔍 Current sewaAsetDetails data:'); + controller.sewaAsetDetails.value.forEach((key, value) { + debugPrint(' $key: $value'); + }); + + if (startTime == null || endTime == null) { + debugPrint('⚠️ startTime or endTime is null, using fallback value:'); + debugPrint( + ' Fallback: ${controller.orderDetails.value['rental_period']}', + ); + return controller.orderDetails.value['rental_period'] ?? '-'; + } + + try { + final start = DateTime.parse(startTime); + final end = DateTime.parse(endTime); + + debugPrint('✅ Successfully parsed dates:'); + debugPrint(' start: $start'); + debugPrint(' end: $end'); + + // Format based on satuan_waktu + String formattedPeriod; + + if (satuanWaktu.toLowerCase() == 'hari') { + // Format for daily rentals: "22 April 2025, 06:00 - 23 April 2025, 21:00" + final startDateStr = + "${start.day} ${_getMonthName(start.month)} ${start.year}"; + final startTimeStr = + "${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}"; + final endDateStr = "${end.day} ${_getMonthName(end.month)} ${end.year}"; + final endTimeStr = + "${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}"; + + formattedPeriod = + "$startDateStr, $startTimeStr - $endDateStr, $endTimeStr"; + } else { + // Format for hourly rentals: "24 April 2023, 10:00 - 12:00" + final dateStr = + "${start.day} ${_getMonthName(start.month)} ${start.year}"; + final startTimeStr = + "${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}"; + final endTimeStr = + "${end.hour.toString().padLeft(2, '0')}:${end.minute.toString().padLeft(2, '0')}"; + + formattedPeriod = "$dateStr, $startTimeStr - $endTimeStr"; + } + + debugPrint( + '✅ Formatted period: $formattedPeriod (satuan_waktu: $satuanWaktu)', + ); + return formattedPeriod; + } catch (e) { + debugPrint('❌ Error formatting rental period: $e'); + debugPrint(' Stack trace: ${StackTrace.current}'); + return controller.orderDetails.value['rental_period'] ?? '-'; + } + } + + // Helper method to get month name in Indonesian + String _getMonthName(int month) { + const monthNames = [ + 'Januari', + 'Februari', + 'Maret', + 'April', + 'Mei', + 'Juni', + 'Juli', + 'Agustus', + 'September', + 'Oktober', + 'November', + 'Desember', + ]; + return monthNames[month - 1]; + } + + // Show fullscreen image dialog + void _showFullScreenImage(BuildContext context, dynamic imageUrl) { + final String imageSource = (imageUrl != null && + imageUrl.toString().isNotEmpty && + imageUrl.toString().startsWith('http')) + ? imageUrl.toString() + : ''; + + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + child: Stack( + alignment: Alignment.center, + children: [ + // Fullscreen image with Hero animation + InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Hero( + tag: 'damage-photo-${imageUrl ?? 'default'}', + child: Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + color: Colors.black.withOpacity(0.8), + child: Center( + child: imageSource.isNotEmpty + ? Image.network( + imageSource, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + debugPrint('Error loading fullscreen image: $error'); + return Image.asset( + 'assets/images/gambar_pendukung.jpg', + fit: BoxFit.contain, + ); + }, + ) + : Image.asset( + 'assets/images/gambar_pendukung.jpg', + fit: BoxFit.contain, + ), + ), + ), + ), + ), + + // Close button at the top right + Positioned( + top: 40, + right: 20, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + // Detail Item Helper + Widget _buildDetailItem( + String label, + String value, { + bool isImportant = false, + Color? valueColor, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: isImportant ? Colors.black : Colors.grey[700], + fontWeight: isImportant ? FontWeight.bold : FontWeight.normal, + ), + ), + Text( + value, + style: TextStyle( + fontSize: isImportant ? 16 : 14, + fontWeight: isImportant ? FontWeight.bold : FontWeight.w500, + color: + valueColor ?? + (isImportant ? Colors.deepPurple : Colors.black), + ), + ), + ], + ), + ); + } + + // Payment Type Selection (Tagihan Awal or Denda) + Widget _buildPaymentTypeSelection() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.payments_outlined, + color: AppColors.primary, + size: 22, + ), + const SizedBox(width: 10), + const Text( + 'Pilih Jenis Pembayaran', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 20), + Obx(() { + // Get tagihan awal value + final tagihanAwal = + controller.tagihanSewa.value['tagihan_awal'] ?? + controller.orderDetails.value['total_price'] ?? + 0; + + // Get denda value + final denda = controller.sewaAsetDetails.value['denda'] ?? 0; + + return Column( + children: [ + _buildPaymentTypeOption( + icon: Icons.receipt, + title: 'Pembayaran Tagihan Awal', + amount: 'Rp ${NumberFormat('#,###').format(tagihanAwal)}', + type: 'tagihan_awal', + description: 'Pembayaran untuk tagihan sewa aset', + isSelected: + controller.selectedPaymentType.value == 'tagihan_awal', + ), + const SizedBox(height: 12), + _buildPaymentTypeOption( + icon: Icons.warning_amber_rounded, + title: 'Pembayaran Denda', + amount: 'Rp ${NumberFormat('#,###').format(denda)}', + type: 'denda', + description: 'Pembayaran untuk denda yang diberikan', + isDisabled: denda == 0, + isSelected: controller.selectedPaymentType.value == 'denda', + ), + ], + ); + }), + ], + ), + ), + ); + } + + // Payment Type Option + Widget _buildPaymentTypeOption({ + required IconData icon, + required String title, + required String amount, + required String type, + required String description, + bool isDisabled = false, + bool isSelected = false, + }) { + final Color cardColor = + isDisabled + ? Colors.grey[100]! + : isSelected + ? AppColors.primarySoft + : Colors.white; + + final Color borderColor = + isDisabled + ? Colors.grey[300]! + : isSelected + ? AppColors.primary + : Colors.grey[200]!; + + final Color iconBgColor = + isDisabled + ? Colors.grey[200]! + : isSelected + ? AppColors.primary.withOpacity(0.2) + : AppColors.surfaceLight; + + final Color iconColor = + isDisabled + ? Colors.grey[400]! + : isSelected + ? AppColors.primary + : AppColors.textSecondary; + + return InkWell( + onTap: + isDisabled + ? null + : () { + controller.selectPaymentType(type); + // Reset payment method when changing payment type + controller.paymentMethod.value = ''; + }, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor, width: isSelected ? 2 : 1), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: + isDisabled ? Colors.grey[500] : AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amount, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: isDisabled ? Colors.grey[500] : AppColors.primary, + ), + ), + const SizedBox(height: 4), + if (isSelected) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Dipilih', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + // Payment Method Selection + Widget _buildPaymentMethodSelection() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.account_balance_wallet, + color: AppColors.primary, + size: 22, + ), + const SizedBox(width: 10), + const Text( + 'Pilih Metode Pembayaran', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 20), + _buildPaymentMethodOption( + icon: Icons.account_balance, + title: 'Transfer Bank', + description: 'Transfer melalui rekening bank', + value: 'transfer', + ), + const Divider(height: 1, color: AppColors.divider), + _buildPaymentMethodOption( + icon: Icons.payments, + title: 'Bayar Tunai', + description: 'Bayar langsung di kantor BUMDes', + value: 'cash', + ), + ], + ), + ), + ); + } + + // Payment Method Option + Widget _buildPaymentMethodOption({ + required IconData icon, + required String title, + required String description, + required String value, + }) { + final isSelected = controller.paymentMethod.value == value; + + return Obx( + () => InkWell( + onTap: () => controller.selectPaymentMethod(value), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: + isSelected + ? AppColors.primary.withOpacity(0.2) + : AppColors.surfaceLight, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: + isSelected ? AppColors.primary : AppColors.textSecondary, + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Radio( + value: value, + groupValue: controller.paymentMethod.value, + onChanged: (val) => controller.selectPaymentMethod(val!), + activeColor: AppColors.primary, + ), + ], + ), + ), + ), + ); + } + + // Transfer Instructions + Widget _buildTransferInstructions() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Instruksi Transfer', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Obx(() { + if (controller.bankAccounts.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + + return Column( + children: controller.bankAccounts.map((account) { + return Column( + children: [ + _buildBankAccount( + bankName: account['nama_bank'] ?? 'Bank', + accountNumber: account['no_rekening'] ?? '', + accountName: account['nama_akun'] ?? '', + bankLogo: 'assets/images/bank_logo.png', + ), + const SizedBox(height: 16), + ], + ); + }).toList(), + ); + }), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 16), + _buildTransferStep( + icon: Icons.account_balance, + title: 'Transfer ke rekening BUMDes', + description: 'Lakukan transfer sesuai nominal yang tertera', + ), + _buildTransferStep( + icon: Icons.camera_alt, + title: 'Ambil bukti pembayaran', + description: + 'Simpan bukti transfer/screenshot sebagai bukti pembayaran', + ), + _buildTransferStep( + icon: Icons.upload_file, + title: 'Unggah bukti pembayaran', + description: 'Unggah foto bukti pembayaran pada form di bawah', + ), + _buildTransferStep( + icon: Icons.check_circle, + title: 'Tunggu konfirmasi', + description: 'Pembayaran Anda akan dikonfirmasi oleh petugas', + ), + _buildTransferStep( + icon: Icons.receipt_long, + title: 'Dapatkan struk pembayaran', + description: + 'Setelah dikonfirmasi, akan dibuatkan struk pembayaran', + isLast: true, + ), + ], + ), + ), + ); + } + + // Show image source options (camera or gallery) + void _showImageSourceOptions(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Pilih Sumber Foto', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue[50], + shape: BoxShape.circle, + ), + child: Icon(Icons.camera_alt, color: Colors.blue[700]), + ), + title: const Text('Kamera'), + subtitle: const Text('Ambil foto dengan kamera'), + onTap: () { + Navigator.pop(context); + controller.takePhoto(); + }, + ), + const SizedBox(height: 8), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green[50], + shape: BoxShape.circle, + ), + child: Icon(Icons.photo_library, color: Colors.green[700]), + ), + title: const Text('Galeri'), + subtitle: const Text('Pilih foto dari galeri'), + onTap: () { + Navigator.pop(context); + controller.selectPhotoFromGallery(); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + } + + // Bank Account Widget + Widget _buildBankAccount({ + required String bankName, + required String accountNumber, + required String accountName, + required String bankLogo, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: [ + Row( + children: [ + // Replace this with an actual image when available + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + child: const Center(child: Text('BCA')), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bankName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + accountName, + style: TextStyle(color: Colors.grey[700], fontSize: 14), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + accountNumber, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ElevatedButton.icon( + onPressed: () { + // Copy to clipboard functionality + Clipboard.setData(ClipboardData(text: accountNumber)); + + // Show feedback to user + final scaffoldMessenger = ScaffoldMessenger.of(Get.context!); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text('Nomor rekening $accountNumber disalin ke clipboard'), + duration: const Duration(seconds: 2), + backgroundColor: Colors.green[700], + behavior: SnackBarBehavior.floating, + ), + ); + }, + icon: const Icon(Icons.copy, size: 16), + label: const Text('Salin'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurple, + foregroundColor: Colors.white, + elevation: 0, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total Transfer:', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + GestureDetector( + onTap: () { + // Get the total price + final totalPrice = controller.orderDetails.value['total_price'] ?? 0; + // Format the total price as a number without 'Rp' prefix + final formattedPrice = totalPrice.toString(); + + // Copy to clipboard + Clipboard.setData(ClipboardData(text: formattedPrice)); + + // Show feedback to user + final scaffoldMessenger = ScaffoldMessenger.of(Get.context!); + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text('Nominal Rp $formattedPrice disalin ke clipboard'), + duration: const Duration(seconds: 2), + backgroundColor: Colors.green[700], + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: Row( + children: [ + Text( + 'Rp ${controller.orderDetails.value['total_price'] ?? 0}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.deepPurple, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.copy, + size: 14, + color: Colors.deepPurple[300], + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + // Transfer Step Widget + Widget _buildTransferStep({ + required IconData icon, + required String title, + required String description, + bool isLast = false, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.deepPurple, + shape: BoxShape.circle, + ), + child: Icon(icon, color: Colors.white, size: 16), + ), + if (!isLast) + Container(width: 2, height: 32, color: Colors.grey[300]), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle(color: Colors.grey[700], fontSize: 12), + ), + SizedBox(height: isLast ? 0 : 16), + ], + ), + ), + ], + ); + } + + // Payment Proof Upload + Widget _buildPaymentProofUpload() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.photo_camera, size: 24), + SizedBox(width: 8), + Text( + 'Unggah Bukti Pembayaran', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 16), + Obx(() { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // Display all existing images + ...List.generate( + controller.paymentProofImages.length, + (index) => _buildImageItem(index), + ), + // Add photo button + _buildAddPhotoButton(), + ], + ); + }), + const SizedBox(height: 16), + // Upload button + Obx(() { + // Disable button if there are no changes or if upload is in progress + final bool isDisabled = controller.isUploading.value || !controller.hasUnsavedChanges.value; + + return ElevatedButton.icon( + onPressed: isDisabled ? null : controller.uploadPaymentProof, + icon: controller.isUploading.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.save), + label: Text(controller.isUploading.value + ? 'Menyimpan...' + : (controller.hasUnsavedChanges.value ? 'Simpan' : 'Tidak Ada Perubahan')), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 48), + // Gray out button when disabled + disabledBackgroundColor: Colors.grey[300], + disabledForegroundColor: Colors.grey[600], + ), + ); + }), + // Upload progress indicator + Obx(() { + if (controller.isUploading.value) { + return Column( + children: [ + const SizedBox(height: 16), + LinearProgressIndicator( + value: controller.uploadProgress.value, + backgroundColor: Colors.grey[200], + valueColor: AlwaysStoppedAnimation(Colors.blue[700]!), + ), + const SizedBox(height: 8), + Text( + 'Mengunggah bukti pembayaran... ${(controller.uploadProgress.value * 100).toInt()}%', + style: const TextStyle(fontSize: 12), + ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }), + ], + ), + ), + ); + } + + // Build individual image item with remove button + Widget _buildImageItem(int index) { + final image = controller.paymentProofImages[index]; + return Stack( + children: [ + // Make the container tappable to show full-screen image + GestureDetector( + onTap: () => controller.showFullScreenImage(image), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: controller.getImageWidget(image), + ), + ), + ), + // Close/remove button remains the same + Positioned( + top: 4, + right: 4, + child: InkWell( + onTap: () => controller.removeImage(image), + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 18, color: Colors.red), + ), + ), + ), + ], + ); + } + + // Build add photo button + Widget _buildAddPhotoButton() { + return InkWell( + onTap: () => _showImageSourceOptions(Get.context!), + child: Container( + width: 140, + height: 140, + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue[200]!), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_a_photo, + size: 40, + color: Colors.blue[700], + ), + const SizedBox(height: 8), + Text( + 'Tambah Foto', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.blue[700], + ), + ), + ], + ), + ), + ); + } + + // Cash Payment Instructions + Widget _buildCashInstructions() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Instruksi Pembayaran Tunai', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: Colors.blue[700], + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Pembayaran tunai dapat dilakukan di kantor BUMDes dengan menunjukkan ID pesanan.', + style: TextStyle( + fontSize: 14, + color: Colors.blue[700], + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + _buildCashStep( + number: 1, + title: 'Datang ke kantor BUMDes', + description: 'Alamat: Jl. Merdeka No. 123, Desa Maju Jaya', + ), + _buildCashStep( + number: 2, + title: 'Tunjukkan ID pesanan', + description: + 'ID Pesanan: ${controller.orderDetails.value['id'] ?? '-'}', + ), + _buildCashStep( + number: 3, + title: 'Lakukan pembayaran tunai', + description: + 'Total: Rp ${controller.orderDetails.value['total_price'] ?? 0}', + ), + _buildCashStep( + number: 4, + title: 'Dapatkan struk pembayaran', + description: + 'Setelah dikonfirmasi, akan dibuatkan struk pembayaran', + isLast: true, + ), + ], + ), + ), + ); + } + + // Cash Step Widget + Widget _buildCashStep({ + required int number, + required String title, + required String description, + bool isLast = false, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Colors.deepPurple, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + number.toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ), + if (!isLast) + Container(width: 2, height: 32, color: Colors.grey[300]), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle(color: Colors.grey[700], fontSize: 12), + ), + SizedBox(height: isLast ? 0 : 16), + ], + ), + ), + ], + ); + } + + // Select Payment Type Prompt + Widget _buildSelectPaymentTypePrompt() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: [ + Icon(Icons.payment, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Pilih jenis pembayaran terlebih dahulu', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey[700], + ), + ), + ], + ), + ); + } + + // Select Payment Method Prompt + Widget _buildSelectPaymentMethodPrompt() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: [ + Icon(Icons.payment, size: 48, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Pilih metode pembayaran terlebih dahulu', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey[700], + ), + ), + ], + ), + ); + } + + // Payment Summary Card + Widget _buildPaymentSummaryCard() { + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(Icons.receipt_long, color: Colors.deepPurple, size: 24), + const SizedBox(width: 8), + const Text( + 'Ringkasan Tagihan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 16), + Obx(() { + // Get values from the tagihan_sewa data + final tagihanAwal = + controller.tagihanSewa.value['tagihan_awal'] ?? + controller.orderDetails.value['total_price'] ?? + 0; + + // Get denda from tagihan_sewa + final denda = controller.tagihanSewa.value['denda'] ?? 0; + + // Get total dibayarkan from tagihan_dibayar + final dibayarkan = controller.tagihanSewa.value['tagihan_dibayar'] ?? 0; + + debugPrint('Tagihan Awal: $tagihanAwal'); + debugPrint('Denda: $denda'); + debugPrint('Total Dibayarkan: $dibayarkan'); + + // Calculate sisa tagihan + final totalTagihan = tagihanAwal + denda; + final sisaTagihan = totalTagihan - dibayarkan; + + return Column( + children: [ + _buildDetailItem( + 'Tagihan Awal', + 'Rp ${NumberFormat('#,###').format(tagihanAwal)}', + ), + _buildDetailItem( + 'Denda', + 'Rp ${NumberFormat('#,###').format(denda)}', + ), + const Divider(height: 24), + _buildDetailItem( + 'Total Tagihan', + 'Rp ${NumberFormat('#,###').format(totalTagihan)}', + isImportant: true, + ), + _buildDetailItem( + 'Total Dibayarkan', + 'Rp ${NumberFormat('#,###').format(dibayarkan)}', + valueColor: Colors.green[700], + ), + const Divider(height: 24), + _buildDetailItem( + 'Sisa Tagihan', + 'Rp ${NumberFormat('#,###').format(sisaTagihan)}', + isImportant: true, + valueColor: Colors.red[700], + ), + ], + ); + }), + ], + ), + ), + ); + } + + // Helper method to build detail item with subpoints + Widget _buildDetailItemWithSubpoints(String label, List> subpoints) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main label + Text( + label, + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + // Subpoints with indentation + ...subpoints.map((subpoint) => Padding( + padding: const EdgeInsets.only(left: 16, bottom: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + subpoint['label'] ?? '', + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + ), + ), + ), + Expanded( + flex: 3, + child: Text( + subpoint['value'] ?? '-', + style: const TextStyle( + fontSize: 13, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + )), + ], + ), + ); + } + + // Helper method to format date time for display + String _formatDateTime(String? dateTimeStr) { + if (dateTimeStr == null || dateTimeStr.isEmpty) { + return '-'; + } + + try { + final dateTime = DateTime.parse(dateTimeStr); + final day = dateTime.day.toString().padLeft(2, '0'); + final month = _getMonthName(dateTime.month); + final year = dateTime.year.toString(); + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + + return "$day $month $year, $hour:$minute"; + } catch (e) { + debugPrint('❌ Error formatting date time: $e'); + return dateTimeStr; + } + } +} diff --git a/lib/app/modules/warga/views/sewa_aset_view.dart b/lib/app/modules/warga/views/sewa_aset_view.dart new file mode 100644 index 0000000..8a09c53 --- /dev/null +++ b/lib/app/modules/warga/views/sewa_aset_view.dart @@ -0,0 +1,1805 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/sewa_aset_controller.dart'; +import '../controllers/order_sewa_aset_controller.dart'; +import '../../../routes/app_routes.dart'; +import 'package:intl/intl.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../../../theme/app_colors.dart'; + +class SewaAsetView extends GetView { + const SewaAsetView({super.key}); + + @override + Widget build(BuildContext context) { + // Handle hot reload by checking if controller needs to be reset + WidgetsBinding.instance.addPostFrameCallback((_) { + // This will be called after the widget tree is built + controller.handleHotReload(); + }); + + return WillPopScope( + onWillPop: () async { + // Handle back button press here + debugPrint('🔙 Back button pressed - navigating to WargaDashboard'); + controller.onBackPressed(); + return false; // We handle the navigation ourselves + }, + child: Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text( + 'Sewa Aset', + style: TextStyle( + color: Colors.black, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () { + debugPrint( + '🔙 Back button clicked - navigating to WargaDashboard', + ); + controller.onBackPressed(); + }, + ), + ), + body: Column( + children: [ + // Search bar + Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: controller.searchController, + decoration: InputDecoration( + hintText: 'Cari aset...', + hintStyle: TextStyle(color: Colors.grey[400]), + prefixIcon: Icon(Icons.search, color: Colors.grey[600]), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16.0), + ), + onChanged: (value) { + controller.filterAsets(value); + }, + ), + ), + + // Tab bar + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: TabBar( + controller: controller.tabController, + indicator: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: const LinearGradient( + colors: [ + Color(0xFF92B4D7), // Light blue + Color(0xFF3A6EA5), // Medium blue + Color(0xFF0E2A47), // Dark navy blue + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + boxShadow: [ + BoxShadow( + color: const Color( + 0xFF3A6EA5, + ).withOpacity(0.3), // Medium blue with opacity + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + labelColor: Colors.white, + unselectedLabelColor: const Color( + 0xFF718093, + ), // Text secondary color + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + padding: const EdgeInsets.all(4), + tabs: [ + Tab( + height: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inventory_2_outlined), + const SizedBox(width: 8), + const Text('Aset Tunggal'), + ], + ), + ), + Tab( + height: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.category_outlined), + const SizedBox(width: 8), + const Text('Paket'), + ], + ), + ), + ], + ), + ), + + // Content Label + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Obx(() { + bool isFirstTab = controller.currentTabIndex.value == 0; + final assetCount = controller.filteredAsets.length; + final paketCount = controller.filteredPakets.length; + + return Row( + children: [ + Icon( + isFirstTab ? Icons.inventory_2 : Icons.category, + size: 20, + color: const Color(0xFF3A6EA5), // Primary blue + ), + const SizedBox(width: 8), + Text( + isFirstTab ? 'Daftar Aset Tersedia' : 'Paket Sewa', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF3A6EA5), // Primary blue + ), + ), + const Spacer(), + if (isFirstTab) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color( + 0xFF92B4D7, + ).withOpacity(0.2), // Light blue with opacity + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$assetCount aset', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF3A6EA5), // Primary blue + ), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color( + 0xFF92B4D7, + ).withOpacity(0.2), // Light blue with opacity + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$paketCount paket', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF3A6EA5), // Primary blue + ), + ), + ), + ], + ); + }), + ), + + // Tab content + Expanded( + child: TabBarView( + controller: controller.tabController, + children: [ + // Aset Tunggal tab content + _buildAsetTunggalTab(), + + // Paket tab content + _buildPaketTab(), + ], + ), + ), + ], + ), + ), + ); + } + + // Aset Tunggal tab content + Widget _buildAsetTunggalTab() { + return Obx(() { + if (controller.isLoading.value) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator( + color: Color(0xFF3A6EA5), + ), // Primary blue + const SizedBox(height: 16), + Text( + 'Memuat daftar aset...', + style: TextStyle(color: Colors.grey[600]), + ), + ], + ), + ); + } + + if (controller.filteredAsets.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 80, color: Colors.grey[300]), + const SizedBox(height: 24), + Text( + 'Tidak ada aset yang ditemukan', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Coba gunakan kata kunci lain', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: controller.loadAsets, + color: const Color(0xFF3A6EA5), // Primary blue + child: GridView.builder( + padding: const EdgeInsets.all(16.0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.50, // Make cards taller to avoid overflow + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: controller.filteredAsets.length, + itemBuilder: (context, index) { + final aset = controller.filteredAsets[index]; + return _buildGridAsetCard(aset); + }, + ), + ); + }); + } + + // Paket tab content + Widget _buildPaketTab() { + return Obx(() { + if (controller.isLoadingPakets.value) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Color(0xFF3A6EA5), + ), // Primary blue + ), + const SizedBox(height: 16), + Text( + 'Memuat data paket...', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ], + ), + ); + } + + if (controller.filteredPakets.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 80, color: Colors.grey[300]), + const SizedBox(height: 24), + Text( + 'Tidak ada paket yang ditemukan', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Coba gunakan kata kunci lain', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.50, // Make cards taller to avoid overflow + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: controller.filteredPakets.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final paket = controller.filteredPakets[index]; + final List satuanWaktuSewa = + paket['satuanWaktuSewa'] ?? []; + + // Find the lowest price + int lowestPrice = + satuanWaktuSewa.isEmpty + ? 0 + : satuanWaktuSewa + .map((sws) => sws['harga'] ?? 0) + .reduce((a, b) => a < b ? a : b); + + // Get image URL or default + String imageUrl = paket['gambar_url'] ?? ''; + + return GestureDetector( + onTap: () => _showPaketDetailModal(paket), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12.0), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image section + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), + child: AspectRatio( + aspectRatio: 1.0, + child: CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: + (context, url) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.purple, + ), + ), + ), + errorWidget: + (context, url, error) => Container( + color: Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported, + size: 32, + color: Colors.grey[400], + ), + ), + ), + ), + ), + ), + + // Content section + Expanded( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Package name + Text( + paket['nama'] ?? 'Paket', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + + // Status availability + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'Tersedia', + style: TextStyle( + color: AppColors.success, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Package pricing - show all pricing options with scrolling + if (satuanWaktuSewa.isNotEmpty) + SizedBox( + width: double.infinity, + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...satuanWaktuSewa.map((sws) { + // Pastikan data yang ditampilkan valid + final harga = sws['harga'] ?? 0; + final namaSatuan = + sws['nama_satuan_waktu'] ?? 'Satuan'; + return Container( + margin: const EdgeInsets.only( + bottom: 4, + ), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular( + 4, + ), + border: Border.all( + color: Colors.grey[300]!, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Rp ${_formatNumber(harga)}", + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + Text( + "/$namaSatuan", + style: TextStyle( + color: Colors.grey[700], + fontSize: 10, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Mulai dari Rp ${NumberFormat('#,###').format(lowestPrice)}', + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], + ), + ), + + const Spacer(), + + // Remove the items count badge and replace with direct Order button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => _showPaketDetailModal(paket), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + vertical: 6, + ), + minimumSize: const Size(double.infinity, 30), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Pesan', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); + }); + } + + void _showPaketDetailModal(Map paket) { + final List satuanWaktuSewa = paket['satuanWaktuSewa'] ?? []; + + // Sort pricing options by price + satuanWaktuSewa.sort( + (a, b) => (a['harga'] ?? 0).compareTo(b['harga'] ?? 0), + ); + + Get.bottomSheet( + Container( + height: Get.height * 0.85, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title and close button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Detail Paket', + style: Theme.of( + Get.context!, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + ), + ], + ), + + Expanded( + child: ListView( + children: [ + // Package image + Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.grey[200], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: + paket['gambar_url'] ?? + 'https://placehold.co/600x400/png?text=Paket', + fit: BoxFit.cover, + placeholder: + (context, url) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.purple, + ), + ), + ), + errorWidget: + (context, url, error) => Container( + color: Colors.grey[200], + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + color: Colors.grey[400], + size: 40, + ), + const SizedBox(height: 8), + Text( + 'Tidak ada gambar tersedia', + style: TextStyle( + color: Colors.grey[500], + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 16), + + // Package name + Text( + paket['nama'] ?? 'Paket', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Item count + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(16), + ), + child: Text( + '${paket['jumlah_item'] ?? 0} item dalam paket ini', + style: TextStyle(fontSize: 13, color: Colors.grey[700]), + ), + ), + const SizedBox(height: 16), + + // Description + const Text( + 'Deskripsi', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + paket['deskripsi'] ?? 'Deskripsi tidak tersedia', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + const SizedBox(height: 16), + + // Items in package + const Text( + 'Aset dalam Paket', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var item in (paket['items'] ?? [])) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + const Icon( + Icons.check_circle_outline, + color: Colors.green, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item['aset_nama'] ?? 'Item', + style: const TextStyle(fontSize: 14), + ), + ), + Text( + 'x${item['jumlah'] ?? 1}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Pricing options + const Text( + 'Pilihan Harga', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + + if (satuanWaktuSewa.isEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange[200]!), + ), + child: const Row( + children: [ + Icon(Icons.info_outline, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + 'Harga belum tersedia untuk paket ini', + style: TextStyle(color: Colors.orange), + ), + ), + ], + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: satuanWaktuSewa.length, + itemBuilder: (context, index) { + final sws = satuanWaktuSewa[index]; + final String namaSatuanWaktu = + sws['nama_satuan_waktu'] ?? 'Jam'; + final int harga = sws['harga'] ?? 0; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Per $namaSatuanWaktu', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + 'Min. ${sws['durasi_min'] ?? 1} ${namaSatuanWaktu.toLowerCase()}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Rp ${NumberFormat('#,###').format(harga)}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.deepOrange, + ), + ), + Text( + 'per ${namaSatuanWaktu.toLowerCase()}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + ), + ), + ], + ), + ], + ), + ); + }, + ), + ], + ), + ), + + // Order button + Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () { + if (satuanWaktuSewa.isEmpty) { + Get.snackbar( + 'Tidak Dapat Memesan', + 'Pilihan harga belum tersedia untuk paket ini', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red[100], + colorText: Colors.red[800], + ); + return; + } + + _showOrderPaketForm(paket, satuanWaktuSewa); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Pesan Paket Ini', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ), + ], + ), + ), + ); + } + + void _showOrderPaketForm( + Map paket, + List satuanWaktuSewa, + ) { + // Selected pricing option + final Rx?> selectedSWS = Rx?>( + satuanWaktuSewa.isNotEmpty ? satuanWaktuSewa[0] : null, + ); + + // Duration + final RxInt duration = RxInt(selectedSWS.value?['durasi_min'] ?? 1); + + // Calculate total price + final calculateTotal = () { + if (selectedSWS.value == null) return 0; + return (selectedSWS.value!['harga'] ?? 0) * duration.value; + }; + final RxInt totalPrice = RxInt(calculateTotal()); + + // Update total when duration or pricing option changes + ever(duration, (_) => totalPrice.value = calculateTotal()); + ever(selectedSWS, (_) { + duration.value = selectedSWS.value?['durasi_min'] ?? 1; + totalPrice.value = calculateTotal(); + }); + + Get.bottomSheet( + Container( + height: Get.height * 0.7, + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title and close button + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Form Pemesanan Paket', + style: Theme.of( + Get.context!, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Get.back(), + ), + ], + ), + + Expanded( + child: ListView( + children: [ + // Package name + const Text( + 'Paket yang Dipilih', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.purple[50], + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Colors.white, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: + paket['gambar_url'] ?? + 'https://placehold.co/600x400/png?text=Paket', + fit: BoxFit.cover, + placeholder: + (context, url) => const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.purple, + ), + ), + ), + errorWidget: + (context, url, error) => Container( + color: Colors.grey[200], + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + color: Colors.grey[400], + size: 40, + ), + const SizedBox(height: 8), + Text( + 'Tidak ada gambar tersedia', + style: TextStyle( + color: Colors.grey[500], + fontSize: 14, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + paket['nama'] ?? 'Paket', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + '${paket['jumlah_item'] ?? 0} item dalam paket', + style: TextStyle( + fontSize: 12, + color: Colors.grey[700], + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Pricing option + const Text( + 'Pilih Satuan Waktu', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Obx( + () => ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: satuanWaktuSewa.length, + itemBuilder: (context, index) { + final sws = satuanWaktuSewa[index]; + final bool isSelected = selectedSWS.value == sws; + + return GestureDetector( + onTap: () => selectedSWS.value = sws, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + isSelected ? Colors.purple[50] : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: + isSelected + ? Colors.purple + : Colors.grey[300]!, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_off, + color: + isSelected + ? Colors.purple + : Colors.grey[400], + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'Per ${sws['nama_satuan_waktu'] ?? 'Jam'}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + 'Min. ${sws['durasi_min'] ?? 1} ${(sws['nama_satuan_waktu'] ?? 'jam').toLowerCase()}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + Text( + 'Rp ${NumberFormat('#,###').format(sws['harga'] ?? 0)}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: + isSelected + ? Colors.purple + : Colors.grey[700], + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + + // Duration + const Text( + 'Durasi Sewa', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Obx(() { + final minDuration = selectedSWS.value?['durasi_min'] ?? 1; + final namaSatuanWaktu = + selectedSWS.value?['nama_satuan_waktu'] ?? 'Jam'; + + return Column( + children: [ + Row( + children: [ + IconButton( + onPressed: () { + if (duration.value > minDuration) { + duration.value--; + } + }, + icon: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.grey[200], + shape: BoxShape.circle, + ), + child: const Icon( + Icons.remove, + size: 18, + color: Colors.black, + ), + ), + ), + Expanded( + child: Center( + child: Text( + '${duration.value} ${namaSatuanWaktu.toLowerCase()}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + IconButton( + onPressed: () => duration.value++, + icon: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.purple[100], + shape: BoxShape.circle, + ), + child: const Icon( + Icons.add, + size: 18, + color: Colors.purple, + ), + ), + ), + ], + ), + Text( + 'Minimum ${minDuration} ${namaSatuanWaktu.toLowerCase()}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ); + }), + const SizedBox(height: 24), + + // Total price + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Total Harga', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Obx( + () => Text( + 'Rp ${NumberFormat('#,###').format(totalPrice.value)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.deepOrange, + ), + ), + ), + ], + ), + ), + ], + ), + ), + + // Order button + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () { + Get.back(); // Close the form + + // Navigate to order_sewa_paket page + // Get the navigation service from the controller + final navigationService = controller.navigationService; + + // Store the selected parameters in a controller or pass as arguments + Get.toNamed( + Routes.ORDER_SEWA_PAKET, + arguments: { + 'paketId': paket['id'], + 'satuanWaktuSewaId': selectedSWS.value?['id'] ?? '', + 'durasi': duration.value, + 'totalHarga': totalPrice.value, + 'paketData': paket, + }, + ); + }, + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Konfirmasi Pesanan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAsetCard(aset) { + // Add debug information for this asset + debugPrint('📦 Building card for aset: ${aset.id} - ${aset.nama}'); + if (aset.id == null || aset.id.isEmpty) { + debugPrint('⚠️ WARNING: Aset has no ID!'); + } + + return Container( + margin: const EdgeInsets.only(bottom: 20.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16.0), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: + aset.imageUrl != null + ? Image.network( + aset.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported, + size: 40, + color: Colors.grey[400], + ), + ), + ); + }, + ) + : Container( + color: Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported, + size: 40, + color: Colors.grey[400], + ), + ), + ), + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset name + Text( + aset.nama, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + + // Status availability + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'Tersedia', + style: TextStyle( + color: AppColors.success, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Tampilkan harga dan satuan waktu dari join + if (aset.satuanWaktuSewa.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ...aset.satuanWaktuSewa.map((sws) { + // Pastikan data yang ditampilkan valid + final harga = sws['harga'] ?? 0; + final namaSatuan = sws['nama_satuan_waktu'] ?? 'Satuan'; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Rp ${_formatNumber(harga)}", + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + Text( + " / $namaSatuan", + style: TextStyle(color: Colors.grey[700]), + ), + ], + ), + ); + }).toList(), + ], + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + controller.formatPrice(aset.harga), + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + Text( + " / Jam", + style: TextStyle(color: Colors.grey[700]), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // Order button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (aset.id == null || aset.id.isEmpty) { + debugPrint('⚠️ Cannot navigate: Aset has no ID!'); + Get.snackbar( + 'Error', + 'ID aset tidak valid', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + _showOrderPage(aset); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 6), + minimumSize: const Size(double.infinity, 30), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Pesan Sekarang', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + // Grid-style asset card with a more compact layout + Widget _buildGridAsetCard(aset) { + debugPrint('📦 Building grid card for aset: ${aset.id} - ${aset.nama}'); + if (aset.id == null || aset.id.isEmpty) { + debugPrint('⚠️ WARNING: Aset has no ID!'); + } + + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12.0), + boxShadow: [ + BoxShadow( + color: AppColors.shadow, + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: AspectRatio( + aspectRatio: 1.0, + child: + aset.imageUrl != null + ? Image.network( + aset.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported, + size: 32, + color: Colors.grey[400], + ), + ), + ); + }, + ) + : Container( + color: Colors.grey[200], + child: Center( + child: Icon( + Icons.image_not_supported, + size: 32, + color: Colors.grey[400], + ), + ), + ), + ), + ), + + // Asset details + Expanded( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset name + Text( + aset.nama, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + + // Status availability and price in same row to save space + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + 'Tersedia', + style: TextStyle( + color: AppColors.success, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ], + ), + const SizedBox(height: 6), + + // Price - show only first price option + if (aset.satuanWaktuSewa.isNotEmpty) + SizedBox( + width: double.infinity, + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...aset.satuanWaktuSewa.map((sws) { + // Pastikan data yang ditampilkan valid + final harga = sws['harga'] ?? 0; + final namaSatuan = + sws['nama_satuan_waktu'] ?? 'Satuan'; + return Container( + margin: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Rp ${_formatNumber(harga)}", + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + Text( + "/$namaSatuan", + style: TextStyle( + color: Colors.grey[700], + fontSize: 10, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + controller.formatPrice(aset.harga), + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + Text( + " / Jam", + style: TextStyle( + color: Colors.grey[700], + fontSize: 11, + ), + ), + ], + ), + ), + + const Spacer(), + + // Order button + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + if (aset.id == null || aset.id.isEmpty) { + debugPrint('⚠️ Cannot navigate: Aset has no ID!'); + Get.snackbar( + 'Error', + 'ID aset tidak valid', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + _showOrderPage(aset); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 6), + minimumSize: const Size(double.infinity, 30), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text( + 'Pesan', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + // Method untuk navigasi ke halaman order aset + void _showOrderPage(aset) { + // Debug print untuk memastikan ID aset valid + print('🚀 Navigating to order page with asset ID: ${aset.id}'); + print('🔍 Asset object: ${aset.toJson()}'); + + // Make sure the asset ID is not empty + if (aset.id == null || aset.id.isEmpty) { + Get.snackbar( + 'Error', + 'ID aset tidak valid', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Use the static navigation method to ensure consistent behavior + OrderSewaAsetController.navigateToOrderPage(aset.id); + } + + // Helper to format numbers for display + String _formatNumber(dynamic number) { + if (number == null) return '0'; + + // Ensure we're working with a String + final numStr = number.toString(); + + try { + // Format with thousand separators + return NumberFormat('#,###').format(int.parse(numStr)); + } catch (e) { + return numStr; + } + } +} diff --git a/lib/app/modules/warga/views/warga_dashboard_view.dart b/lib/app/modules/warga/views/warga_dashboard_view.dart new file mode 100644 index 0000000..090004d --- /dev/null +++ b/lib/app/modules/warga/views/warga_dashboard_view.dart @@ -0,0 +1,758 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/warga_dashboard_controller.dart'; +import '../views/warga_layout.dart'; +import '../../../theme/app_colors.dart'; +import '../../../widgets/app_drawer.dart'; +import '../../../routes/app_routes.dart'; +import 'package:intl/intl.dart'; + +class WargaDashboardView extends GetView { + const WargaDashboardView({super.key}); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return WillPopScope( + onWillPop: () async => false, // Prevent back navigation + child: WargaLayout( + drawer: AppDrawer( + onNavItemTapped: controller.onNavItemTapped, + onLogout: controller.logout, + ), + backgroundColor: AppColors.background, + appBar: AppBar( + elevation: 0, + backgroundColor: AppColors.primary, + title: const Text( + 'Beranda', + style: TextStyle(fontWeight: FontWeight.w600), + ), + centerTitle: true, + ), + body: RefreshIndicator( + color: AppColors.primary, + onRefresh: () async { + // Re-fetch data when pulled down + await Future.delayed(const Duration(seconds: 1)); + controller.refreshData(); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildUserGreetingHeader(context), + _buildActionButtons(), + _buildActiveRentalsSection(context), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + // Modern welcome header with user profile + Widget _buildUserGreetingHeader(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(30), + bottomRight: Radius.circular(30), + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 30), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // User avatar + Obx(() { + final avatarUrl = controller.userAvatar.value; + return Container( + height: 60, + width: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + color: Colors.white.withOpacity(0.2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(30), + child: + avatarUrl != null && avatarUrl.isNotEmpty + ? Image.network( + avatarUrl, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + _buildAvatarFallback(), + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _buildAvatarFallback(); + }, + ) + : _buildAvatarFallback(), + ), + ); + }), + const SizedBox(width: 16), + + // Greeting and name + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _getGreeting(), + style: const TextStyle( + fontSize: 15, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Obx( + () => Text( + controller.userName.value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + // Action buttons in a horizontal scroll + Widget _buildActionButtons() { + // Define services - removed Langganan and Pengaduan + final services = [ + { + 'title': 'Sewa', + 'icon': Icons.home_work_outlined, + 'color': const Color(0xFF4CAF50), + 'route': () => controller.navigateToRentals(), + }, + { + 'title': 'Bayar', + 'icon': Icons.payment_outlined, + 'color': const Color(0xFF2196F3), + 'route': () => Get.toNamed(Routes.PEMBAYARAN_SEWA), + }, + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 10), + child: Text( + 'Layanan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + + // Service cards in grid + GridView.count( + crossAxisCount: 2, + childAspectRatio: 1.5, + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + mainAxisSpacing: 16, + crossAxisSpacing: 16, + children: + services + .map( + (service) => _buildServiceCard( + title: service['title'] as String, + icon: service['icon'] as IconData, + color: service['color'] as Color, + onTap: service['route'] as VoidCallback, + ), + ) + .toList(), + ), + + // Activity Summaries Section + Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 10), + child: Text( + 'Ringkasan Aktivitas', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ), + + // Summary Cards + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + // Sewa Diterima + _buildActivityCard( + title: 'Sewa Diterima', + value: controller.activeRentals.length.toString(), + icon: Icons.check_circle_outline, + color: AppColors.success, + onTap: () => controller.navigateToRentals(), + ), + const SizedBox(height: 12), + + // Tagihan Aktif + _buildActivityCard( + title: 'Tagihan Aktif', + value: controller.activeBills.length.toString(), + icon: Icons.receipt_long_outlined, + color: AppColors.warning, + onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA), + ), + const SizedBox(height: 12), + + // Denda Aktif + _buildActivityCard( + title: 'Denda Aktif', + value: controller.activePenalties.length.toString(), + icon: Icons.warning_amber_outlined, + color: AppColors.error, + onTap: () => Get.toNamed(Routes.PEMBAYARAN_SEWA), + ), + ], + ), + ), + ], + ); + } + + Widget _buildServiceCard({ + required String title, + required IconData icon, + required Color color, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color.withOpacity(0.7), color], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + children: [ + // Background decoration + Positioned( + right: -15, + bottom: -15, + child: Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + shape: BoxShape.circle, + ), + ), + ), + Positioned( + left: -20, + top: -20, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + shape: BoxShape.circle, + ), + ), + ), + // Icon and text + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(icon, color: Colors.white, size: 24), + ), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + // Active rentals section with improved card design + Widget _buildActiveRentalsSection(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sewa Diterima', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + TextButton.icon( + onPressed: () => controller.onNavItemTapped(1), + icon: const Icon(Icons.arrow_forward, size: 18), + label: const Text('Lihat Semua'), + style: TextButton.styleFrom(foregroundColor: AppColors.primary), + ), + ], + ), + const SizedBox(height: 12), + Obx(() { + if (controller.activeRentals.isEmpty) { + return _buildEmptyState( + message: 'Belum ada sewa aset yang aktif', + icon: Icons.inventory_2_outlined, + buttonText: 'Sewa Sekarang', + onPressed: () => controller.navigateToRentals(), + ); + } + + return Column( + children: + controller.activeRentals + .map((rental) => _buildModernRentalCard(rental)) + .toList(), + ); + }), + ], + ), + ); + } + + // Empty state widget with consistent design + Widget _buildEmptyState({ + required String message, + required IconData icon, + required String buttonText, + required VoidCallback onPressed, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + border: Border.all(color: Colors.grey.shade100), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 40, color: AppColors.primary), + ), + const SizedBox(height: 20), + Text( + message, + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + ), + child: Text( + buttonText, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + // Modern rental card with better layout + Widget _buildModernRentalCard(Map rental) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, 4), + blurRadius: 15, + ), + ], + border: Border.all(color: Colors.grey.shade100, width: 1.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header with glass effect + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary.withOpacity(0.04), + AppColors.primary.withOpacity(0.08), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Row( + children: [ + // Asset icon + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary.withOpacity(0.7), + AppColors.primary, + ], + ), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: const Icon( + Icons.local_shipping, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 16), + + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'], + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + rental['time'], + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + + // Price tag + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.primary.withOpacity(0.3), + width: 1.0, + ), + ), + child: Text( + rental['price'], + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + ), + + // Details and actions + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + children: [ + // Details row + Row( + children: [ + Expanded( + child: _buildInfoItem( + icon: Icons.timer_outlined, + title: 'Durasi', + value: rental['duration'], + ), + ), + Expanded( + child: _buildInfoItem( + icon: Icons.calendar_today_outlined, + title: 'Status', + value: 'Diterima', + valueColor: AppColors.success, + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Action buttons + if (rental['can_extend']) + OutlinedButton.icon( + onPressed: () => controller.extendRental(rental['id']), + icon: const Icon(Icons.update, size: 18), + label: const Text('Perpanjang'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + // Info item for displaying details + Widget _buildInfoItem({ + required IconData icon, + required String title, + required String value, + Color? valueColor, + }) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(icon, size: 16, color: AppColors.primary), + const SizedBox(width: 4), + Text( + value, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: valueColor ?? AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + ); + } + + // Build avatar fallback for when image is not available + Widget _buildAvatarFallback() { + return Center(child: Icon(Icons.person, color: Colors.white70, size: 30)); + } + + // Get appropriate greeting based on time of day + String _getGreeting() { + final hour = DateTime.now().hour; + if (hour < 12) { + return 'Selamat Pagi'; + } else if (hour < 17) { + return 'Selamat Siang'; + } else { + return 'Selamat Malam'; + } + } + + // Build a summary card for activities + Widget _buildActivityCard({ + required String title, + required String value, + required IconData icon, + required Color color, + required VoidCallback onTap, + }) { + return Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.shade200), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios, + color: AppColors.textSecondary.withOpacity(0.5), + size: 16, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/warga/views/warga_layout.dart b/lib/app/modules/warga/views/warga_layout.dart new file mode 100644 index 0000000..1abaa55 --- /dev/null +++ b/lib/app/modules/warga/views/warga_layout.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../widgets/app_bottom_navbar.dart'; +import '../../../services/navigation_service.dart'; +import '../../../routes/app_routes.dart'; + +/// A wrapper layout that provides a persistent bottom navigation bar +/// and a content area for warga user pages. +class WargaLayout extends StatelessWidget { + final Widget body; + final PreferredSizeWidget? appBar; + final Widget? drawer; + final Color? backgroundColor; + final Widget? floatingActionButton; + final FloatingActionButtonLocation? floatingActionButtonLocation; + + const WargaLayout({ + super.key, + required this.body, + this.appBar, + this.drawer, + this.backgroundColor, + this.floatingActionButton, + this.floatingActionButtonLocation, + }); + + @override + Widget build(BuildContext context) { + // Access the navigation service + final navigationService = Get.find(); + + // Single Scaffold that contains all components + return Scaffold( + backgroundColor: backgroundColor ?? Colors.grey.shade100, + appBar: appBar, + // Drawer configuration for proper overlay + drawer: drawer, + drawerEdgeDragWidth: 60, // Wider drag area for easier access + drawerEnableOpenDragGesture: true, + // Higher opacity ensures good contrast & visibility when drawer opens + drawerScrimColor: Colors.black.withOpacity(0.6), + // Main body content + body: body, + // Bottom navigation bar + bottomNavigationBar: AppBottomNavbar( + selectedIndex: navigationService.currentNavIndex.value, + onItemTapped: (index) => _handleNavigation(index, navigationService), + ), + floatingActionButton: floatingActionButton, + floatingActionButtonLocation: floatingActionButtonLocation, + ); + } + + // Handle navigation for bottom navbar + void _handleNavigation(int index, NavigationService navigationService) { + if (navigationService.currentNavIndex.value == index) { + return; // Don't do anything if already on this tab + } + + navigationService.setNavIndex(index); + + // Navigate to the appropriate page + switch (index) { + case 0: + Get.offAllNamed(Routes.WARGA_DASHBOARD); + break; + case 1: + navigationService.toWargaSewa(); + break; + case 2: + navigationService.toProfile(); + break; + } + } +} diff --git a/lib/app/modules/warga/views/warga_profile_view.dart b/lib/app/modules/warga/views/warga_profile_view.dart new file mode 100644 index 0000000..5d01563 --- /dev/null +++ b/lib/app/modules/warga/views/warga_profile_view.dart @@ -0,0 +1,455 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/warga_dashboard_controller.dart'; +import '../views/warga_layout.dart'; +import '../../../theme/app_colors.dart'; + +class WargaProfileView extends GetView { + const WargaProfileView({super.key}); + + @override + Widget build(BuildContext context) { + return WargaLayout( + appBar: AppBar( + title: const Text('Profil Saya'), + backgroundColor: AppColors.primary, + elevation: 0, + centerTitle: true, + actions: [ + IconButton( + onPressed: () { + Get.snackbar( + 'Info', + 'Fitur edit profil akan segera tersedia', + snackPosition: SnackPosition.BOTTOM, + ); + }, + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit Profil', + ), + ], + ), + backgroundColor: Colors.grey.shade100, + body: RefreshIndicator( + color: AppColors.primary, + onRefresh: () async { + await Future.delayed(const Duration(milliseconds: 500)); + controller.refreshData(); + return; + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + children: [ + _buildProfileHeader(context), + const SizedBox(height: 16), + _buildInfoCard(context), + const SizedBox(height: 16), + _buildSettingsCard(context), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } + + Widget _buildProfileHeader(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary, + AppColors.primary.withBlue(AppColors.primary.blue + 30), + ], + ), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(30), + bottomRight: Radius.circular(30), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 36), + child: Column( + children: [ + // Profile picture with shadow effect + Obx(() { + final avatarUrl = controller.userAvatar.value; + return Container( + height: 110, + width: 110, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 4), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(55), + child: + avatarUrl != null && avatarUrl.isNotEmpty + ? Image.network( + avatarUrl, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => + _buildAvatarFallback(), + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _buildAvatarFallback(); + }, + ) + : _buildAvatarFallback(), + ), + ); + }), + const SizedBox(height: 16), + // User name with subtle text shadow + Obx( + () => Text( + controller.userName.value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: [ + Shadow( + color: Colors.black26, + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + // User role in a stylish chip + Obx( + () => Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: Colors.white.withOpacity(0.5), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.verified_user, + size: 14, + color: Colors.white.withOpacity(0.9), + ), + const SizedBox(width: 6), + Text( + controller.userRole.value, + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.9), + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoCard(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Icon(Icons.person_outline, color: AppColors.primary, size: 18), + const SizedBox(width: 8), + Text( + 'INFORMASI PERSONAL', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + const Divider(height: 1), + _buildInfoItem( + icon: Icons.email_outlined, + title: 'Email', + value: + controller.userEmail.value.isEmpty + ? 'emailpengguna@example.com' + : controller.userEmail.value, + ), + Divider(height: 1, color: Colors.grey.shade200), + _buildInfoItem( + icon: Icons.credit_card_outlined, + title: 'NIK', + value: + controller.userNik.value.isEmpty + ? '123456789012345' + : controller.userNik.value, + ), + Divider(height: 1, color: Colors.grey.shade200), + _buildInfoItem( + icon: Icons.phone_outlined, + title: 'Nomor Telepon', + value: + controller.userPhone.value.isEmpty + ? '081234567890' + : controller.userPhone.value, + ), + Divider(height: 1, color: Colors.grey.shade200), + _buildInfoItem( + icon: Icons.home_outlined, + title: 'Alamat Lengkap', + value: + controller.userAddress.value.isEmpty + ? 'Jl. Contoh No. 123, Desa Sejahtera, Kec. Makmur, Kab. Berkah, Prov. Damai' + : controller.userAddress.value, + isMultiLine: true, + ), + ], + ), + ); + } + + Widget _buildInfoItem({ + required IconData icon, + required String title, + required String value, + bool isMultiLine = false, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + crossAxisAlignment: + isMultiLine ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: AppColors.primary, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + ), + const SizedBox(height: 3), + Text( + value, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + maxLines: isMultiLine ? 3 : 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSettingsCard(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Icon( + Icons.settings_outlined, + color: AppColors.primary, + size: 18, + ), + const SizedBox(width: 8), + Text( + 'PENGATURAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + const Divider(height: 1), + _buildActionItem( + icon: Icons.lock_outline, + title: 'Ubah Password', + iconColor: AppColors.primary, + onTap: () { + Get.snackbar( + 'Info', + 'Fitur Ubah Password akan segera tersedia', + snackPosition: SnackPosition.BOTTOM, + ); + }, + ), + Divider(height: 1, color: Colors.grey.shade200), + _buildActionItem( + icon: Icons.logout, + title: 'Keluar', + iconColor: Colors.red.shade400, + isDestructive: true, + onTap: () { + _showLogoutConfirmation(context); + }, + ), + ], + ), + ); + } + + Widget _buildActionItem({ + required IconData icon, + required String title, + required VoidCallback onTap, + Color? iconColor, + bool isDestructive = false, + }) { + final color = + isDestructive ? Colors.red.shade400 : iconColor ?? AppColors.primary; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: + isDestructive ? Colors.red.shade400 : Colors.grey.shade800, + ), + ), + const Spacer(), + Icon(Icons.chevron_right, color: Colors.grey.shade400, size: 20), + ], + ), + ), + ); + } + + void _showLogoutConfirmation(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Konfirmasi Keluar'), + content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: Colors.grey.shade700, + ), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + controller.logout(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade400, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Keluar'), + ), + ], + ); + }, + ); + } + + Widget _buildAvatarFallback() { + return Container( + color: Colors.grey.shade200, + child: Center( + child: Icon( + Icons.person, + color: AppColors.primary.withOpacity(0.7), + size: 50, + ), + ), + ); + } +} diff --git a/lib/app/modules/warga/views/warga_sewa_view.dart b/lib/app/modules/warga/views/warga_sewa_view.dart new file mode 100644 index 0000000..a94ba12 --- /dev/null +++ b/lib/app/modules/warga/views/warga_sewa_view.dart @@ -0,0 +1,1493 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:get/get.dart'; +import '../controllers/warga_sewa_controller.dart'; +import '../views/warga_layout.dart'; +import '../../../services/navigation_service.dart'; +import '../../../widgets/app_drawer.dart'; +import '../../../theme/app_colors.dart'; + +class WargaSewaView extends GetView { + const WargaSewaView({super.key}); + + @override + Widget build(BuildContext context) { + final navigationService = Get.find(); + + return WargaLayout( + drawer: AppDrawer( + onNavItemTapped: controller.onNavItemTapped, + onLogout: () { + Get.find().toLogin(); + }, + ), + appBar: AppBar( + backgroundColor: AppColors.primary, + elevation: 0, + title: const Text( + 'Sewa Aset Saya', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: _buildTabBar(), + ), + ), + body: Column( + children: [ + Expanded( + child: TabBarView( + controller: controller.tabController, + physics: const PageScrollPhysics(), + dragStartBehavior: DragStartBehavior.start, + children: [ + _buildBelumBayarTab(), + _buildPendingTab(), + _buildDiterimaTab(), + _buildAktifTab(), + _buildSelesaiTab(), + _buildDibatalkanTab(), + ], + ), + ), + ], + ), + floatingActionButton: Container( + margin: const EdgeInsets.only(bottom: 16, right: 16), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + borderRadius: BorderRadius.circular(16), + ), + child: FloatingActionButton.extended( + onPressed: () => controller.navigateToRentals(), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon( + Icons.add_circle_outline, + size: 20, + color: Colors.white, + ), + label: const Text( + 'Sewa Baru', + style: TextStyle( + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: Colors.white, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + ); + } + + Widget _buildTabBar() { + return Container( + width: double.infinity, + height: 60, + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: TabBar( + controller: controller.tabController, + labelColor: AppColors.primary, + unselectedLabelColor: Colors.grey.shade600, + indicatorColor: AppColors.primary, + indicatorWeight: 3, + indicatorSize: TabBarIndicatorSize.label, + labelPadding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 8), + isScrollable: true, + tabs: [ + _buildTab(text: 'Belum Bayar', icon: Icons.payment_outlined), + _buildTab(text: 'Pending', icon: Icons.pending_outlined), + _buildTab(text: 'Diterima', icon: Icons.check_circle_outline), + _buildTab(text: 'Aktif', icon: Icons.play_circle_outline), + _buildTab(text: 'Selesai', icon: Icons.task_alt_outlined), + _buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined), + ], + ), + ); + } + + Widget _buildTab({required String text, required IconData icon}) { + return Tab( + height: 60, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + Widget _buildPendingTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingPending.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.pendingRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.pendingRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildUnpaidRentalCard(rental), + )) + .toList(), + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.pending_actions, + title: 'Tidak ada pembayaran yang sedang diperiksa', + subtitle: 'Tidak ada sewa yang sedang dalam verifikasi pembayaran', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.warning, + ); + }); + } + + Widget _buildAktifTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Placeholder content for the Aktif tab + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.play_circle_filled, + size: 80, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'Tab Aktif', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Konten tab Aktif akan ditampilkan di sini', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ); + }); + } + + Widget _buildBelumBayarTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.rentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each rental item + ...controller.rentals.map((rental) => Column( + children: [ + _buildUnpaidRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.payment_outlined, + title: 'Belum ada pembayaran', + subtitle: 'Tidak ada sewa yang menunggu pembayaran', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.primary, + ); + }); + } + + Widget _buildUnpaidRentalCard(Map rental) { + // Determine status color based on status + final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA'; + final Color statusColor = isPembayaranDenda ? AppColors.error : AppColors.warning; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + isPembayaranDenda ? Icons.warning_amber_rounded : Icons.access_time_rounded, + size: 18, + color: statusColor, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'MENUNGGU PEMBAYARAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Countdown timer + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timer_outlined, + size: 14, + color: AppColors.error, + ), + const SizedBox(width: 4), + Text( + 'Bayar dalam ${rental['countdown'] ?? '00:59:59'}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // Pay button + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' ? AppColors.error : AppColors.warning, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + rental['status'] == 'PEMBAYARAN DENDA' ? 'Bayar Denda' : 'Bayar Sekarang', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action buttons + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow({required IconData icon, required String text}) { + return Row( + children: [ + Icon(icon, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + text, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + required Color iconColor, + }) { + return TextButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16, color: iconColor), + label: Text( + label, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } + + Widget _buildDiterimaTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingAccepted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.acceptedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each accepted rental item + ...controller.acceptedRentals.map((rental) => Column( + children: [ + _buildDiterimaRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionDiterima(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum ada sewa diterima', + subtitle: 'Sewa yang sudah diterima akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildDiterimaRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DITERIMA', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action buttons + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSelesaiTab() { + return Obx(() { + if (controller.isLoadingCompleted.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.completedRentals.isEmpty) { + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum Ada Sewa Selesai', + subtitle: 'Anda belum memiliki riwayat sewa yang telah selesai', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.info, + ); + } + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.completedRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildSelesaiRentalCard(rental), + )) + .toList(), + ), + ); + }); + } + + Widget _buildSelesaiRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.task_alt_outlined, size: 18, color: AppColors.info), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'SELESAI', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.info, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${rental['jumlahUnit'] ?? 0} Unit', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.calendar_today, + text: rental['rentangWaktu'] ?? '-', + ), + const SizedBox(height: 4), + _buildInfoRow( + icon: Icons.access_time, + text: rental['duration'] ?? '-', + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider( + color: Colors.grey.shade200, + thickness: 1, + height: 1, + ), + + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDibatalkanTab() { + return Obx(() { + if (controller.isLoadingCancelled.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.cancelledRentals.isEmpty) { + return _buildTabContent( + icon: Icons.cancel_outlined, + title: 'Belum Ada Sewa Dibatalkan', + subtitle: 'Anda belum memiliki riwayat sewa yang dibatalkan', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.error, + ); + } + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.cancelledRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDibatalkanRentalCard(rental), + )) + .toList(), + ), + ); + }); + } + + Widget _buildDibatalkanRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.cancel_outlined, size: 18, color: AppColors.error), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DIBATALKAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${rental['jumlahUnit'] ?? 0} Unit', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.calendar_today, + text: rental['rentangWaktu'] ?? '-', + ), + const SizedBox(height: 4), + _buildInfoRow( + icon: Icons.access_time, + text: rental['duration'] ?? '-', + ), + if (rental['alasanPembatalan'] != null && rental['alasanPembatalan'] != '-') + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _buildInfoRow( + icon: Icons.info_outline, + text: 'Alasan: ${rental['alasanPembatalan']}', + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider( + color: Colors.grey.shade200, + thickness: 1, + height: 1, + ), + + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.refresh, + label: 'Pesan Kembali', + onPressed: () {}, + iconColor: AppColors.success, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTabContent({ + required IconData icon, + required String title, + required String subtitle, + required String buttonText, + required VoidCallback onButtonPressed, + required Color color, + }) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.08), + blurRadius: 16, + offset: const Offset(0, 4), + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 60, color: color), + ), + const SizedBox(height: 28), + Text( + title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + subtitle, + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), + SizedBox( + width: double.infinity, + height: 54, + child: ElevatedButton( + onPressed: onButtonPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_circle_outline, size: 20), + const SizedBox(width: 10), + Text( + buttonText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + // Tips section + _buildTipsSection(), + ], + ), + ), + ); + } + + Widget _buildTipsSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: AppColors.warning), + const SizedBox(width: 8), + Text( + 'Tips & Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.schedule, + title: 'Pembayaran dalam 1 jam', + description: + 'Lakukan pembayaran dalam 1 jam untuk menghindari pembatalan otomatis.', + ), + ], + ), + ); + } + + Widget _buildTipItem({ + required IconData icon, + required String title, + required String description, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: AppColors.info), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ); + } + + Widget _buildTipsSectionDiterima() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: AppColors.warning), + const SizedBox(width: 8), + Text( + 'Tips & Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Pengembalian Tepat Waktu', + description: + 'Lakukan pengembalian aset sebelum waktu sewa berakhir agar tidak dikenakan denda.', + ), + ], + ), + ); + } +} diff --git a/lib/app/modules/warga/views/warga_sewa_view.dart.bak b/lib/app/modules/warga/views/warga_sewa_view.dart.bak new file mode 100644 index 0000000..3e5b7cf --- /dev/null +++ b/lib/app/modules/warga/views/warga_sewa_view.dart.bak @@ -0,0 +1,2851 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:get/get.dart'; +import '../controllers/warga_sewa_controller.dart'; +import '../views/warga_layout.dart'; +import '../../../services/navigation_service.dart'; +import '../../../widgets/app_drawer.dart'; +import '../../../theme/app_colors.dart'; + +class WargaSewaView extends GetView { + const WargaSewaView({super.key}); + + @override + Widget build(BuildContext context) { + final navigationService = Get.find(); + + return WargaLayout( + drawer: AppDrawer( + onNavItemTapped: controller.onNavItemTapped, + onLogout: () { + Get.find().toLogin(); + }, + ), + appBar: AppBar( + backgroundColor: AppColors.primary, + elevation: 0, + title: const Text( + 'Sewa Aset Saya', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: _buildTabBar(), + ), + ), + body: Column( + children: [ + Expanded( + child: TabBarView( + controller: controller.tabController, + physics: const PageScrollPhysics(), + dragStartBehavior: DragStartBehavior.start, + children: [ + _buildBelumBayarTab(), + _buildPendingTab(), + _buildDiterimaTab(), + _buildAktifTab(), + _buildSelesaiTab(), + _buildDibatalkanTab(), + ], + ), + ), + ], + ), + floatingActionButton: Container( + margin: const EdgeInsets.only(bottom: 16, right: 16), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + borderRadius: BorderRadius.circular(16), + ), + child: FloatingActionButton.extended( + onPressed: () => controller.navigateToRentals(), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon( + Icons.add_circle_outline, + size: 20, + color: Colors.white, + ), + label: const Text( + 'Sewa Baru', + style: TextStyle( + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: Colors.white, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + ); + } + + Widget _buildTabBar() { + return Container( + width: double.infinity, + height: 60, + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: TabBar( + controller: controller.tabController, + labelColor: AppColors.primary, + unselectedLabelColor: Colors.grey.shade600, + indicatorColor: AppColors.primary, + indicatorWeight: 3, + indicatorSize: TabBarIndicatorSize.label, + labelPadding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 8), + isScrollable: true, + tabs: [ + _buildTab(text: 'Belum Bayar', icon: Icons.payment_outlined), + _buildTab(text: 'Pending', icon: Icons.hourglass_empty_outlined), + _buildTab(text: 'Diterima', icon: Icons.check_circle_outline), + _buildTab(text: 'Aktif', icon: Icons.play_circle_outline), + _buildTab(text: 'Selesai', icon: Icons.task_alt_outlined), + _buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined), + ], + ), + ); + } + + Widget _buildTab({required String text, required IconData icon}) { + return Tab( + height: 60, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + Widget _buildBelumBayarTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.rentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each rental item + ...controller.rentals.map((rental) => Column( + children: [ + _buildUnpaidRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.payment_outlined, + title: 'Belum ada pembayaran', + subtitle: 'Tidak ada sewa yang menunggu pembayaran', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.primary, + ); + }); + } + + Widget _buildPendingTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingPending.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.pendingRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each pending rental item + ...controller.pendingRentals.map((rental) => Column( + children: [ + _buildPendingRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionPending(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.hourglass_empty_outlined, + title: 'Tidak ada pembayaran pending', + subtitle: 'Pembayaran yang sedang diverifikasi akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.orange, + ); + }); + } + + Widget _buildAktifTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingActive.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.activeRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each active rental item + ...controller.activeRentals.map((rental) => Column( + children: [ + _buildAktifRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionAktif(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.play_circle_outline, + title: 'Tidak ada sewa aktif', + subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.blue, + ); + }); + } + + Widget _buildPendingRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon( + Icons.hourglass_empty_outlined, + size: 18, + color: Colors.orange, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'PERIKSA PEMBAYARAN', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Verification status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.orange.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.pending_outlined, + size: 14, + color: Colors.orange, + ), + const SizedBox(width: 4), + const Text( + 'Menunggu verifikasi', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAktifRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon( + Icons.play_circle_outline, + size: 18, + color: Colors.blue, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'AKTIF', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Active status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.blue.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.access_time_filled, + size: 14, + color: Colors.blue, + ), + const SizedBox(width: 4), + const Text( + 'Sedang berlangsung', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildUnpaidRentalCard(Map rental) { + // Determine status color based on status + final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA'; + final Color statusColor = isPembayaranDenda ? AppColors.error : AppColors.warning; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + isPembayaranDenda ? Icons.warning_amber_rounded : Icons.access_time_rounded, + size: 18, + color: statusColor, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'MENUNGGU PEMBAYARAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Countdown timer + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timer_outlined, + size: 14, + color: AppColors.error, + ), + const SizedBox(width: 4), + Text( + 'Bayar dalam ${rental['countdown'] ?? '00:59:59'}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // Pay button + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' ? AppColors.error : AppColors.warning, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + rental['status'] == 'PEMBAYARAN DENDA' ? 'Bayar Denda' : 'Bayar Sekarang', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action buttons + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow({required IconData icon, required String text}) { + return Row( + children: [ + Icon(icon, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + text, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + required Color iconColor, + }) { + return TextButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16, color: iconColor), + label: Text( + label, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } + + Widget _buildDiterimaTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingAccepted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.acceptedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each accepted rental item + ...controller.acceptedRentals.map((rental) => Column( + children: [ + _buildDiterimaRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionDiterima(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum ada sewa diterima', + subtitle: 'Sewa yang sudah diterima akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildAktifTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingActive.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.activeRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each active rental item + ...controller.activeRentals.map((rental) => Column( + children: [ + _buildAktifRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionAktif(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.play_circle_outline, + title: 'Tidak ada sewa aktif', + subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.blue, + ); + }); + } + + Widget _buildDiterimaRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DITERIMA', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action buttons + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSelesaiTab() { + return Obx(() { + if (controller.isLoadingCompleted.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.completedRentals.isEmpty) { + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum Ada Sewa Selesai', + subtitle: 'Anda belum memiliki riwayat sewa yang telah selesai', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.info, + ); + } + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.completedRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildSelesaiRentalCard(rental), + )) + .toList(), + ), + ); + }); + } + + Widget _buildSelesaiRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.task_alt_outlined, size: 18, color: AppColors.info), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'SELESAI', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.info, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${rental['jumlahUnit'] ?? 0} Unit', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.calendar_today, + text: rental['rentangWaktu'] ?? '-', + ), + const SizedBox(height: 4), + _buildInfoRow( + icon: Icons.access_time, + text: rental['duration'] ?? '-', + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider( + color: Colors.grey.shade200, + thickness: 1, + height: 1, + ), + + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDibatalkanTab() { + return Obx(() { + if (controller.isLoadingCancelled.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.cancelledRentals.isEmpty) { + return _buildTabContent( + icon: Icons.cancel_outlined, + title: 'Belum Ada Sewa Dibatalkan', + subtitle: 'Anda belum memiliki riwayat sewa yang dibatalkan', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.error, + ); + } + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.cancelledRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDibatalkanRentalCard(rental), + )) + .toList(), + ), + ); + }); + } + + Widget _buildDibatalkanRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.cancel_outlined, size: 18, color: AppColors.error), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DIBATALKAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${rental['jumlahUnit'] ?? 0} Unit', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.calendar_today, + text: rental['rentangWaktu'] ?? '-', + ), + const SizedBox(height: 4), + _buildInfoRow( + icon: Icons.access_time, + text: rental['duration'] ?? '-', + ), + if (rental['alasanPembatalan'] != null && rental['alasanPembatalan'] != '-') + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _buildInfoRow( + icon: Icons.info_outline, + text: 'Alasan: ${rental['alasanPembatalan']}', + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider( + color: Colors.grey.shade200, + thickness: 1, + height: 1, + ), + + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.refresh, + label: 'Pesan Kembali', + onPressed: () {}, + iconColor: AppColors.success, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTabContent({ + required IconData icon, + required String title, + required String subtitle, + required String buttonText, + required VoidCallback onButtonPressed, + required Color color, + }) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.08), + blurRadius: 16, + offset: const Offset(0, 4), + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 60, color: color), + ), + const SizedBox(height: 28), + Text( + title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + subtitle, + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), + SizedBox( + width: double.infinity, + height: 54, + child: ElevatedButton( + onPressed: onButtonPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_circle_outline, size: 20), + const SizedBox(width: 10), + Text( + buttonText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + // Tips section + _buildTipsSection(), + ], + ), + ), + ); + } + + Widget _buildTipsSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: AppColors.warning), + const SizedBox(width: 8), + Text( + 'Tips & Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.schedule, + title: 'Pembayaran dalam 1 jam', + description: + 'Lakukan pembayaran dalam 1 jam untuk menghindari pembatalan otomatis.', + ), + ], + ), + ); + } + + Widget _buildTipsSectionPending() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: Colors.orange), + const SizedBox(width: 8), + Text( + 'Tips Verifikasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Proses Verifikasi', + description: + 'Verifikasi pembayaran biasanya membutuhkan waktu 1-2 jam kerja.', + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.image_outlined, + title: 'Bukti Pembayaran', + description: + 'Pastikan bukti pembayaran yang diupload jelas dan terbaca.', + ), + ], + ), + ); + } + + Widget _buildTipsSectionAktif() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Tips Sewa Aktif', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.rule_outlined, + title: 'Ketentuan Penggunaan', + description: + 'Gunakan aset sesuai dengan ketentuan yang berlaku.', + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Waktu Pengembalian', + description: + 'Kembalikan aset tepat waktu untuk menghindari denda.', + ), + ], + ), + ); + } + Widget _buildTipItem({ + required IconData icon, + required String title, + required String description, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: AppColors.info), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ); + } + + Widget _buildTipsSectionDiterima() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: AppColors.warning), + const SizedBox(width: 8), + Text( + 'Tips & Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Pengembalian Tepat Waktu', + description: + 'Lakukan pengembalian aset sebelum waktu sewa berakhir agar tidak dikenakan denda.', + ), + ], + ), + ); + } +} + Widget _buildDiterimaTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingAccepted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.acceptedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each accepted rental item + ...controller.acceptedRentals.map((rental) => Column( + children: [ + _buildAcceptedRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum ada pembayaran diterima', + subtitle: 'Pembayaran yang telah diverifikasi akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildSelesaiTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingCompleted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.completedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each completed rental item + ...controller.completedRentals.map((rental) => Column( + children: [ + _buildCompletedRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.task_alt_outlined, + title: 'Belum ada sewa selesai', + subtitle: 'Sewa yang telah selesai akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildDibatalkanTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingCancelled.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.cancelledRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each cancelled rental item + ...controller.cancelledRentals.map((rental) => Column( + children: [ + _buildCancelledRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.cancel_outlined, + title: 'Tidak ada sewa dibatalkan', + subtitle: 'Sewa yang dibatalkan akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.error, + ); + }); + } + + Widget _buildAcceptedRentalCard(Map<String, dynamic> rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'PEMBAYARAN DITERIMA', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Payment accepted status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.success.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 14, + color: AppColors.success, + ), + const SizedBox(width: 4), + Text( + 'Pembayaran diterima', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action section + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCompletedRentalCard(Map<String, dynamic> rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.task_alt_outlined, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'SELESAI', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Completed status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.success.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 14, + color: AppColors.success, + ), + const SizedBox(width: 4), + Text( + 'Sewa selesai', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action section + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCancelledRentalCard(Map<String, dynamic> rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.cancel_outlined, + size: 18, + color: AppColors.error, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DIBATALKAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Cancelled status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cancel, + size: 14, + color: AppColors.error, + ), + const SizedBox(width: 4), + Text( + 'Sewa dibatalkan', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action section + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + \ No newline at end of file diff --git a/lib/app/modules/warga/views/warga_sewa_view.dart.clean b/lib/app/modules/warga/views/warga_sewa_view.dart.clean new file mode 100644 index 0000000..13ef913 --- /dev/null +++ b/lib/app/modules/warga/views/warga_sewa_view.dart.clean @@ -0,0 +1,2851 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:get/get.dart'; +import '../controllers/warga_sewa_controller.dart'; +import '../views/warga_layout.dart'; +import '../../../services/navigation_service.dart'; +import '../../../widgets/app_drawer.dart'; +import '../../../theme/app_colors.dart'; + +class WargaSewaView extends GetView { + const WargaSewaView({super.key}); + + @override + Widget build(BuildContext context) { + final navigationService = Get.find(); + + return WargaLayout( + drawer: AppDrawer( + onNavItemTapped: controller.onNavItemTapped, + onLogout: () { + Get.find().toLogin(); + }, + ), + appBar: AppBar( + backgroundColor: AppColors.primary, + elevation: 0, + title: const Text( + 'Sewa Aset Saya', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: _buildTabBar(), + ), + ), + body: Column( + children: [ + Expanded( + child: TabBarView( + controller: controller.tabController, + physics: const PageScrollPhysics(), + dragStartBehavior: DragStartBehavior.start, + children: [ + _buildBelumBayarTab(), + _buildPendingTab(), + _buildDiterimaTab(), + _buildAktifTab(), + _buildSelesaiTab(), + _buildDibatalkanTab(), + ], + ), + ), + ], + ), + floatingActionButton: Container( + margin: const EdgeInsets.only(bottom: 16, right: 16), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + borderRadius: BorderRadius.circular(16), + ), + child: FloatingActionButton.extended( + onPressed: () => controller.navigateToRentals(), + backgroundColor: AppColors.primary, + elevation: 0, + icon: const Icon( + Icons.add_circle_outline, + size: 20, + color: Colors.white, + ), + label: const Text( + 'Sewa Baru', + style: TextStyle( + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + color: Colors.white, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + ); + } + + Widget _buildTabBar() { + return Container( + width: double.infinity, + height: 60, + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: TabBar( + controller: controller.tabController, + labelColor: AppColors.primary, + unselectedLabelColor: Colors.grey.shade600, + indicatorColor: AppColors.primary, + indicatorWeight: 3, + indicatorSize: TabBarIndicatorSize.label, + labelPadding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 8), + isScrollable: true, + tabs: [ + _buildTab(text: 'Belum Bayar', icon: Icons.payment_outlined), + _buildTab(text: 'Pending', icon: Icons.hourglass_empty_outlined), + _buildTab(text: 'Diterima', icon: Icons.check_circle_outline), + _buildTab(text: 'Aktif', icon: Icons.play_circle_outline), + _buildTab(text: 'Selesai', icon: Icons.task_alt_outlined), + _buildTab(text: 'Dibatalkan', icon: Icons.cancel_outlined), + ], + ), + ); + } + + Widget _buildTab({required String text, required IconData icon}) { + return Tab( + height: 60, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + Widget _buildBelumBayarTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.rentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each rental item + ...controller.rentals.map((rental) => Column( + children: [ + _buildUnpaidRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.payment_outlined, + title: 'Belum ada pembayaran', + subtitle: 'Tidak ada sewa yang menunggu pembayaran', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.primary, + ); + }); + } + + Widget _buildPendingTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingPending.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.pendingRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each pending rental item + ...controller.pendingRentals.map((rental) => Column( + children: [ + _buildPendingRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionPending(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.hourglass_empty_outlined, + title: 'Tidak ada pembayaran pending', + subtitle: 'Pembayaran yang sedang diverifikasi akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.orange, + ); + }); + } + + Widget _buildAktifTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingActive.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.activeRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each active rental item + ...controller.activeRentals.map((rental) => Column( + children: [ + _buildAktifRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionAktif(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.play_circle_outline, + title: 'Tidak ada sewa aktif', + subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.blue, + ); + }); + } + + Widget _buildPendingRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon( + Icons.hourglass_empty_outlined, + size: 18, + color: Colors.orange, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'PERIKSA PEMBAYARAN', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Verification status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.orange.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.pending_outlined, + size: 14, + color: Colors.orange, + ), + const SizedBox(width: 4), + const Text( + 'Menunggu verifikasi', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAktifRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Icon( + Icons.play_circle_outline, + size: 18, + color: Colors.blue, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'AKTIF', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Active status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.blue.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.access_time_filled, + size: 14, + color: Colors.blue, + ), + const SizedBox(width: 4), + const Text( + 'Sedang berlangsung', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildUnpaidRentalCard(Map rental) { + // Determine status color based on status + final bool isPembayaranDenda = rental['status'] == 'PEMBAYARAN DENDA'; + final Color statusColor = isPembayaranDenda ? AppColors.error : AppColors.warning; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + isPembayaranDenda ? Icons.warning_amber_rounded : Icons.access_time_rounded, + size: 18, + color: statusColor, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'MENUNGGU PEMBAYARAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Countdown timer + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.timer_outlined, + size: 14, + color: AppColors.error, + ), + const SizedBox(width: 4), + Text( + 'Bayar dalam ${rental['countdown'] ?? '00:59:59'}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // Pay button + ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: rental['status'] == 'PEMBAYARAN DENDA' ? AppColors.error : AppColors.warning, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + rental['status'] == 'PEMBAYARAN DENDA' ? 'Bayar Denda' : 'Bayar Sekarang', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action buttons + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoRow({required IconData icon, required String text}) { + return Row( + children: [ + Icon(icon, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Text( + text, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required VoidCallback onPressed, + required Color iconColor, + }) { + return TextButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16, color: iconColor), + label: Text( + label, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ); + } + + Widget _buildDiterimaTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingAccepted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.acceptedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each accepted rental item + ...controller.acceptedRentals.map((rental) => Column( + children: [ + _buildDiterimaRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionDiterima(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum ada sewa diterima', + subtitle: 'Sewa yang sudah diterima akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildAktifTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingActive.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.activeRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each active rental item + ...controller.activeRentals.map((rental) => Column( + children: [ + _buildAktifRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSectionAktif(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.play_circle_outline, + title: 'Tidak ada sewa aktif', + subtitle: 'Sewa yang sedang berlangsung akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: Colors.blue, + ); + }); + } + + Widget _buildDiterimaRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DITERIMA', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Price section + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Bayar', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action buttons + Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.cancel_outlined, + label: 'Batalkan', + onPressed: () => controller.cancelRental(rental['id']), + iconColor: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSelesaiTab() { + return Obx(() { + if (controller.isLoadingCompleted.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.completedRentals.isEmpty) { + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum Ada Sewa Selesai', + subtitle: 'Anda belum memiliki riwayat sewa yang telah selesai', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.info, + ); + } + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.completedRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildSelesaiRentalCard(rental), + )) + .toList(), + ), + ); + }); + } + + Widget _buildSelesaiRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.task_alt_outlined, size: 18, color: AppColors.info), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'SELESAI', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.info, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${rental['jumlahUnit'] ?? 0} Unit', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.calendar_today, + text: rental['rentangWaktu'] ?? '-', + ), + const SizedBox(height: 4), + _buildInfoRow( + icon: Icons.access_time, + text: rental['duration'] ?? '-', + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider( + color: Colors.grey.shade200, + thickness: 1, + height: 1, + ), + + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDibatalkanTab() { + return Obx(() { + if (controller.isLoadingCancelled.value) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.cancelledRentals.isEmpty) { + return _buildTabContent( + icon: Icons.cancel_outlined, + title: 'Belum Ada Sewa Dibatalkan', + subtitle: 'Anda belum memiliki riwayat sewa yang dibatalkan', + buttonText: 'Lihat Aset', + onButtonPressed: () => Get.toNamed('/warga-aset'), + color: AppColors.error, + ); + } + + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: controller.cancelledRentals + .map((rental) => Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildDibatalkanRentalCard(rental), + )) + .toList(), + ), + ); + }); + } + + Widget _buildDibatalkanRentalCard(Map rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.cancel_outlined, size: 18, color: AppColors.error), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DIBATALKAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${rental['jumlahUnit'] ?? 0} Unit', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.calendar_today, + text: rental['rentangWaktu'] ?? '-', + ), + const SizedBox(height: 4), + _buildInfoRow( + icon: Icons.access_time, + text: rental['duration'] ?? '-', + ), + if (rental['alasanPembatalan'] != null && rental['alasanPembatalan'] != '-') + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _buildInfoRow( + icon: Icons.info_outline, + text: 'Alasan: ${rental['alasanPembatalan']}', + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider( + color: Colors.grey.shade200, + thickness: 1, + height: 1, + ), + + // Price section + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildActionButton( + icon: Icons.info_outline, + label: 'Lihat Detail', + onPressed: () => controller.viewRentalDetail(rental), + iconColor: AppColors.info, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildActionButton( + icon: Icons.refresh, + label: 'Pesan Kembali', + onPressed: () {}, + iconColor: AppColors.success, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTabContent({ + required IconData icon, + required String title, + required String subtitle, + required String buttonText, + required VoidCallback onButtonPressed, + required Color color, + }) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 40), + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.08), + blurRadius: 16, + offset: const Offset(0, 4), + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 60, color: color), + ), + const SizedBox(height: 28), + Text( + title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + subtitle, + style: TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), + SizedBox( + width: double.infinity, + height: 54, + child: ElevatedButton( + onPressed: onButtonPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_circle_outline, size: 20), + const SizedBox(width: 10), + Text( + buttonText, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + // Tips section + _buildTipsSection(), + ], + ), + ), + ); + } + + Widget _buildTipsSection() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: AppColors.warning), + const SizedBox(width: 8), + Text( + 'Tips & Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.schedule, + title: 'Pembayaran dalam 1 jam', + description: + 'Lakukan pembayaran dalam 1 jam untuk menghindari pembatalan otomatis.', + ), + ], + ), + ); + } + + Widget _buildTipsSectionPending() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: Colors.orange), + const SizedBox(width: 8), + Text( + 'Tips Verifikasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Proses Verifikasi', + description: + 'Verifikasi pembayaran biasanya membutuhkan waktu 1-2 jam kerja.', + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.image_outlined, + title: 'Bukti Pembayaran', + description: + 'Pastikan bukti pembayaran yang diupload jelas dan terbaca.', + ), + ], + ), + ); + } + + Widget _buildTipsSectionAktif() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Tips Sewa Aktif', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.rule_outlined, + title: 'Ketentuan Penggunaan', + description: + 'Gunakan aset sesuai dengan ketentuan yang berlaku.', + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Waktu Pengembalian', + description: + 'Kembalikan aset tepat waktu untuk menghindari denda.', + ), + ], + ), + ); + } + Widget _buildTipItem({ + required IconData icon, + required String title, + required String description, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: AppColors.info), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ); + } + + Widget _buildTipsSectionDiterima() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: AppColors.warning), + const SizedBox(width: 8), + Text( + 'Tips & Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildTipItem( + icon: Icons.access_time, + title: 'Pengembalian Tepat Waktu', + description: + 'Lakukan pengembalian aset sebelum waktu sewa berakhir agar tidak dikenakan denda.', + ), + ], + ), + ); + } +} + Widget _buildDiterimaTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingAccepted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.acceptedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each accepted rental item + ...controller.acceptedRentals.map((rental) => Column( + children: [ + _buildAcceptedRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.check_circle_outline, + title: 'Belum ada pembayaran diterima', + subtitle: 'Pembayaran yang telah diverifikasi akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildSelesaiTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingCompleted.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.completedRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each completed rental item + ...controller.completedRentals.map((rental) => Column( + children: [ + _buildCompletedRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.task_alt_outlined, + title: 'Belum ada sewa selesai', + subtitle: 'Sewa yang telah selesai akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.success, + ); + }); + } + + Widget _buildDibatalkanTab() { + return Obx(() { + // Show loading indicator while fetching data + if (controller.isLoadingCancelled.value) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + // Check if there is any data to display + if (controller.cancelledRentals.isNotEmpty) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Build a card for each cancelled rental item + ...controller.cancelledRentals.map((rental) => Column( + children: [ + _buildCancelledRentalCard(rental), + const SizedBox(height: 20), + ], + )).toList(), + _buildTipsSection(), + ], + ), + ); + } + + // Return empty state if no data + return _buildTabContent( + icon: Icons.cancel_outlined, + title: 'Tidak ada sewa dibatalkan', + subtitle: 'Sewa yang dibatalkan akan muncul di sini', + buttonText: 'Sewa Sekarang', + onButtonPressed: () => controller.navigateToRentals(), + color: AppColors.error, + ); + }); + } + + Widget _buildAcceptedRentalCard(Map<String, dynamic> rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'PEMBAYARAN DITERIMA', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Payment accepted status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.success.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 14, + color: AppColors.success, + ), + const SizedBox(width: 4), + Text( + 'Pembayaran diterima', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action section + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCompletedRentalCard(Map<String, dynamic> rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.task_alt_outlined, + size: 18, + color: AppColors.success, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'SELESAI', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Completed status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.success.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.success.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 14, + color: AppColors.success, + ), + const SizedBox(width: 4), + Text( + 'Sewa selesai', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.success, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action section + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.success, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCancelledRentalCard(Map<String, dynamic> rental) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Header section with status + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.cancel_outlined, + size: 18, + color: AppColors.error, + ), + const SizedBox(width: 8), + Text( + rental['status'] ?? 'DIBATALKAN', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + + // Asset details + Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Asset image with rounded corners + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: rental['imageUrl'] != null && rental['imageUrl'].toString().startsWith('http') + ? Image.network( + rental['imageUrl'], + width: 90, + height: 90, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 90, + height: 90, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.image_not_supported_outlined, + size: 36, + color: Colors.grey.shade400, + ), + ); + }, + ) + : Image.asset( + rental['imageUrl'] ?? 'assets/images/gambar_pendukung.jpg', + width: 90, + height: 90, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 16), + // Asset details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + rental['name'] ?? 'Nama Aset', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + _buildInfoRow( + icon: Icons.inventory_2_outlined, + text: '${rental['jumlahUnit'] ?? 0} Unit', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.calendar_today_outlined, + text: rental['tanggalSewa'] ?? '', + ), + const SizedBox(height: 6), + _buildInfoRow( + icon: Icons.schedule, + text: rental['rentangWaktu'] ?? '', + ), + const SizedBox(height: 12), + // Cancelled status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.error.withOpacity(0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cancel, + size: 14, + color: AppColors.error, + ), + const SizedBox(width: 4), + Text( + 'Sewa dibatalkan', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + + // Divider + Divider(height: 1, thickness: 1, color: Colors.grey.shade100), + + // Action section + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 2), + Text( + rental['totalPrice'] ?? 'Rp 0', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ), + // View detail button + ElevatedButton( + onPressed: () => controller.viewRentalDetail(rental), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Lihat Detail', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + diff --git a/lib/app/modules/warga/views/warga_sewa_view.dart.fixed b/lib/app/modules/warga/views/warga_sewa_view.dart.fixed new file mode 100644 index 0000000..9cd86d8 Binary files /dev/null and b/lib/app/modules/warga/views/warga_sewa_view.dart.fixed differ diff --git a/lib/app/modules/warga/widgets/app_bottom_navbar.dart b/lib/app/modules/warga/widgets/app_bottom_navbar.dart new file mode 100644 index 0000000..f082442 --- /dev/null +++ b/lib/app/modules/warga/widgets/app_bottom_navbar.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../../../routes/app_routes.dart'; +import '../../../services/navigation_service.dart'; + +class AppBottomNavbar extends StatelessWidget { + final int selectedIndex; + final Function(int) onItemTapped; + + const AppBottomNavbar({ + super.key, + required this.selectedIndex, + required this.onItemTapped, + }); + + @override + Widget build(BuildContext context) { + // Get navigation service to sync with drawer + final navigationService = Get.find(); + + return Container( + height: 76, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.07), + blurRadius: 14, + offset: const Offset(0, -2), + ), + ], + ), + child: Obx( + () => Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildNavItem( + context: context, + icon: Icons.home_rounded, + activeIcon: Icons.home_rounded, + label: 'Beranda', + isSelected: navigationService.currentNavIndex.value == 0, + onTap: () { + if (navigationService.currentNavIndex.value != 0) { + onItemTapped(0); + navigationService.setNavIndex(0); + Get.offAllNamed(Routes.WARGA_DASHBOARD); + } + }, + ), + _buildNavItem( + context: context, + icon: Icons.inventory_outlined, + activeIcon: Icons.inventory_rounded, + label: 'Sewa', + isSelected: navigationService.currentNavIndex.value == 1, + onTap: () { + if (navigationService.currentNavIndex.value != 1) { + onItemTapped(1); + navigationService.toWargaSewa(); + } + }, + ), + _buildNavItem( + context: context, + icon: Icons.person_outline, + activeIcon: Icons.person, + label: 'Profil', + isSelected: navigationService.currentNavIndex.value == 2, + onTap: () { + if (navigationService.currentNavIndex.value != 2) { + onItemTapped(2); + navigationService.toProfile(); + } + }, + ), + ], + ), + ), + ); + } + + // Modern navigation item for bottom bar + Widget _buildNavItem({ + required BuildContext context, + required IconData icon, + required IconData activeIcon, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + final theme = Theme.of(context); + final primaryColor = theme.primaryColor; + final tabWidth = MediaQuery.of(context).size.width / 3; // Changed to 3 tabs + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + customBorder: const StadiumBorder(), + splashColor: primaryColor.withOpacity(0.1), + highlightColor: primaryColor.withOpacity(0.05), + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + width: tabWidth, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: isSelected ? primaryColor : Colors.transparent, + width: 2, + ), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon with animated scale effect when selected + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.all(isSelected ? 8 : 0), + decoration: BoxDecoration( + color: + isSelected + ? primaryColor.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isSelected ? activeIcon : icon, + color: isSelected ? primaryColor : Colors.grey.shade400, + size: 24, + ), + ), + const SizedBox(height: 4), + // Label with animated opacity + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? primaryColor : Colors.grey.shade500, + ), + child: Text(label), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/modules/warga/widgets/custom_date_range_picker.dart b/lib/app/modules/warga/widgets/custom_date_range_picker.dart new file mode 100644 index 0000000..7bcf6b0 --- /dev/null +++ b/lib/app/modules/warga/widgets/custom_date_range_picker.dart @@ -0,0 +1,517 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../../theme/app_colors.dart'; + +class CustomDateRangePicker extends StatefulWidget { + final List disabledDates; + final Function(DateTime startDate, DateTime endDate) onSelectRange; + final DateTime? initialStartDate; + final DateTime? initialEndDate; + final int? maxDays; // Maximum allowed days between start and end date + final Function? onClearSelection; // Callback when selection is cleared + final bool singleDateMode; // When true, only allows selecting a single date + + const CustomDateRangePicker({ + super.key, + required this.disabledDates, + required this.onSelectRange, + this.initialStartDate, + this.initialEndDate, + this.maxDays, + this.onClearSelection, + this.singleDateMode = false, + }); + + @override + _CustomDateRangePickerState createState() => _CustomDateRangePickerState(); +} + +class _CustomDateRangePickerState extends State { + late DateTime _currentMonth; + DateTime? _startDate; + DateTime? _endDate; + DateTime? _hoverDate; + bool _selectionMode = + false; // true means selecting end date, false means selecting start date + + // Map for O(1) lookup of disabled dates + late Set _disabledDateStrings; + + @override + void initState() { + super.initState(); + _currentMonth = DateTime.now(); + _startDate = widget.initialStartDate; + _endDate = widget.initialEndDate; + _selectionMode = _startDate != null && _endDate == null; + + // Create a set of strings from disabled dates for faster lookup + _disabledDateStrings = {}; + for (var date in widget.disabledDates) { + _disabledDateStrings.add('${date.year}-${date.month}-${date.day}'); + } + } + + // Check if a date is disabled + bool _isDisabled(DateTime date) { + final dateString = '${date.year}-${date.month}-${date.day}'; + return _disabledDateStrings.contains(dateString); + } + + // Check if a date is before today or is today + bool _isBeforeToday(DateTime date) { + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + final checkDate = DateTime(date.year, date.month, date.day); + // Return true if date is before today (not including today) + return checkDate.isBefore(todayDate); + } + + // Check if a date can be selected + bool _canSelectDate(DateTime date) { + return !_isDisabled(date) && !_isBeforeToday(date); + } + + // Get the status of a date (start, end, in-range, disabled, normal) + String _getDateStatus(DateTime date) { + if (_isDisabled(date) || _isBeforeToday(date)) { + return 'disabled'; + } + + if (_startDate != null && _isSameDay(date, _startDate!)) { + return 'start'; + } + + if (_endDate != null && _isSameDay(date, _endDate!)) { + return 'end'; + } + + if (_startDate != null && + _endDate != null && + date.isAfter(_startDate!) && + date.isBefore(_endDate!)) { + return 'in-range'; + } + + return 'normal'; + } + + // Check if two dates are the same day + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + // Handle date tap - now just sets start and optionally end date + void _onDateTap(DateTime date) { + if (!_canSelectDate(date)) return; + + setState(() { + // If we're in single date mode, simply set both start and end date to the selected date + if (widget.singleDateMode) { + // If tapping on the already selected date, clear the selection + if (_startDate != null && _isSameDay(date, _startDate!)) { + _startDate = null; + _endDate = null; + _selectionMode = false; + if (widget.onClearSelection != null) { + widget.onClearSelection!(); + } + } else { + // Set both start and end date to the selected date + _startDate = date; + _endDate = date; + + // Immediately confirm selection in single date mode + Future.microtask(() => _confirmSelection()); + } + return; + } + + // Regular date range selection behavior (for non-single date mode) + // If tapping on the start date when already selected + if (_startDate != null && _isSameDay(date, _startDate!)) { + // If only start date is selected, clear selection + if (_endDate == null) { + _startDate = null; + _selectionMode = false; + if (widget.onClearSelection != null) { + widget.onClearSelection!(); + } + return; + } + // If both dates are selected, move end date to start and clear end date + else if (!_isSameDay(_startDate!, _endDate!)) { + _startDate = _endDate; + _endDate = null; + _selectionMode = true; + return; + } + // If both dates are the same, clear both + else { + _startDate = null; + _endDate = null; + _selectionMode = false; + if (widget.onClearSelection != null) { + widget.onClearSelection!(); + } + return; + } + } + + // If tapping on the end date when already selected + if (_endDate != null && _isSameDay(date, _endDate!)) { + // Clear end date but keep start date + _endDate = null; + _selectionMode = true; + return; + } + + if (!_selectionMode) { + // Selecting start date + _startDate = date; + _endDate = null; + _selectionMode = true; + } else { + // Selecting end date + if (date.isBefore(_startDate!)) { + // If selecting a date before start, swap them + _endDate = _startDate; + _startDate = date; + } else { + // Check if the selection exceeds the maximum allowed days + if (widget.maxDays != null) { + final daysInRange = date.difference(_startDate!).inDays + 1; + if (daysInRange > widget.maxDays!) { + // Show a message about exceeding the maximum days + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Maksimal ${widget.maxDays} hari! Anda memilih $daysInRange hari.', + ), + backgroundColor: Colors.red, + ), + ); + return; // Don't proceed with the selection + } + } + + _endDate = date; + } + + // Check if any date in the range is disabled (only if we have an end date) + if (_endDate != null && !_isSameDay(_startDate!, _endDate!)) { + _checkRangeForDisabledDates(); + } + } + }); + } + + // Check if range contains any disabled dates + bool _checkRangeForDisabledDates() { + if (_startDate == null || _endDate == null) return false; + + bool hasDisabledDate = false; + for ( + DateTime d = _startDate!; + !d.isAfter(_endDate!); + d = d.add(const Duration(days: 1)) + ) { + if (d != _startDate && d != _endDate && _isDisabled(d)) { + hasDisabledDate = true; + break; + } + } + + if (hasDisabledDate) { + // Reset selection if range contains disabled date + _endDate = null; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Rentang tanggal mengandung tanggal yang tidak tersedia', + ), + backgroundColor: Colors.red, + ), + ); + return true; + } + return false; + } + + // Confirm the selection (either single day or range) + void _confirmSelection() { + if (_startDate == null) return; + + // If no end date is selected, use start date as end date + _endDate ??= _startDate; + + // Now notify the parent widget + widget.onSelectRange(_startDate!, _endDate!); + } + + // Generate the calendar for a month + Widget _buildCalendarMonth(DateTime month) { + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + final firstDayOfMonth = DateTime(month.year, month.month, 1); + final dayOfWeek = firstDayOfMonth.weekday % 7; // 0 = Sunday, 6 = Saturday + + // Headers for days of week + final daysOfWeek = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + + return Column( + children: [ + // Month and year header + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Text( + DateFormat('MMMM yyyy', 'id_ID').format(month), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + + // Days of week header + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: + daysOfWeek + .map( + (day) => SizedBox( + width: 36, + child: Text( + day, + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.textSecondary, + ), + ), + ), + ) + .toList(), + ), + + const SizedBox(height: 8), + + // Calendar days grid + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + childAspectRatio: 1, + ), + itemCount: (dayOfWeek + daysInMonth), + itemBuilder: (context, index) { + // Empty cells for days before the 1st of the month + if (index < dayOfWeek) { + return const SizedBox(); + } + + final day = index - dayOfWeek + 1; + final date = DateTime(month.year, month.month, day); + final status = _getDateStatus(date); + + return GestureDetector( + onTap: () => _onDateTap(date), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: + status == 'in-range' + ? AppColors.primarySoft + : status == 'start' || status == 'end' + ? AppColors.primary + : null, + borderRadius: BorderRadius.circular(8), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Date number + Text( + day.toString(), + style: TextStyle( + color: + status == 'disabled' + ? Colors.grey.shade400 + : status == 'start' || status == 'end' + ? AppColors.textOnPrimary + : AppColors.textPrimary, + fontWeight: + status == 'start' || status == 'end' + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } + + // Get selection status text + String? _getSelectionStatusText() { + if (widget.singleDateMode) { + if (_startDate == null) { + return 'Silakan pilih tanggal untuk sewa per jam'; + } else { + return 'Tanggal dipilih: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)}'; + } + } + + if (_startDate == null) { + return 'Pilih tanggal mulai'; // Guide user to select start date + } else if (_endDate == null) { + return 'Tanggal mulai: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)} - Pilih tanggal akhir atau konfirmasi untuk sewa satu hari'; + } else { + if (_isSameDay(_startDate!, _endDate!)) { + return 'Satu hari dipilih: ${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)}'; + } else { + final int days = _endDate!.difference(_startDate!).inDays + 1; + return '${DateFormat('dd MMM yyyy', 'id_ID').format(_startDate!)} - ${DateFormat('dd MMM yyyy', 'id_ID').format(_endDate!)} ($days hari)'; + } + } + } + + // Check if a date can be highlighted as potential end date during hover + bool _canBeEndDate(DateTime date) { + if (!_canSelectDate(date)) return false; + if (_startDate == null) return false; + + // If date is before start date, it can't be an end date + if (date.isBefore(_startDate!)) return false; + + // Check if the range would exceed the maximum days + if (widget.maxDays != null) { + final daysInRange = date.difference(_startDate!).inDays + 1; + if (daysInRange > widget.maxDays!) return false; + } + + // Check if any dates in the range are disabled + for ( + DateTime d = _startDate!; + !d.isAfter(date); + d = d.add(const Duration(days: 1)) + ) { + if (!_isSameDay(d, _startDate!) && + !_isSameDay(d, date) && + _isDisabled(d)) { + return false; + } + } + + return true; + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Selection status - only shown when a date is selected + Builder( + builder: (context) { + final statusText = _getSelectionStatusText(); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + statusText ?? 'Pilih tanggal untuk memesan', + style: TextStyle( + color: AppColors.textSecondary, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ); + }, + ), + + // Display current month + _buildCalendarMonth(_currentMonth), + + // Hint for deselection + if (_startDate != null) + Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 4.0), + child: Text( + "Tekan tanggal yang sudah dipilih untuk membatalkan", + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ), + + // Month navigation + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon(Icons.arrow_back_ios, color: AppColors.primary), + onPressed: () { + setState(() { + _currentMonth = DateTime( + _currentMonth.year, + _currentMonth.month - 1, + ); + }); + }, + ), + IconButton( + icon: Icon(Icons.arrow_forward_ios, color: AppColors.primary), + onPressed: () { + setState(() { + _currentMonth = DateTime( + _currentMonth.year, + _currentMonth.month + 1, + ); + }); + }, + ), + ], + ), + ), + + // Controls + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + ), + child: const Text('Batal'), + ), + // Hide confirm button in single date mode as selection is auto-confirmed + if (!widget.singleDateMode) + ElevatedButton( + onPressed: _startDate != null ? _confirmSelection : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.textOnPrimary, + ), + child: const Text('Konfirmasi'), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/app/routes/app_pages.dart b/lib/app/routes/app_pages.dart new file mode 100644 index 0000000..e2546b0 --- /dev/null +++ b/lib/app/routes/app_pages.dart @@ -0,0 +1,210 @@ +import 'package:get/get.dart'; + +// Import bindings +import '../bindings/auth_binding.dart'; +import '../bindings/warga_binding.dart'; +import '../bindings/petugas_bumdes_binding.dart'; +import '../bindings/petugas_mitra_binding.dart'; +import '../bindings/splash_binding.dart'; +import '../modules/warga/bindings/sewa_aset_binding.dart'; +import '../modules/warga/bindings/order_sewa_aset_binding.dart'; +import '../modules/warga/bindings/order_sewa_paket_binding.dart'; +import '../modules/warga/bindings/warga_sewa_binding.dart'; +import '../modules/warga/bindings/pembayaran_sewa_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_aset_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_paket_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_sewa_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_manajemen_bumdes_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_tambah_aset_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_tambah_paket_binding.dart'; +import '../modules/petugas_bumdes/bindings/petugas_bumdes_cbp_binding.dart'; +import '../modules/petugas_bumdes/bindings/list_petugas_mitra_binding.dart'; +import '../modules/petugas_bumdes/bindings/list_pelanggan_aktif_binding.dart'; +import '../modules/petugas_bumdes/bindings/list_tagihan_periode_binding.dart'; + +// Import views +import '../modules/auth/views/login_view.dart'; +import '../modules/auth/views/registration_view.dart'; +import '../modules/auth/views/registration_success_view.dart'; +import '../modules/auth/views/forgot_password_view.dart'; +import '../modules/warga/views/warga_dashboard_view.dart'; +import '../modules/warga/views/sewa_aset_view.dart'; +import '../modules/warga/views/warga_sewa_view.dart'; +import '../modules/warga/views/warga_profile_view.dart'; +import '../modules/petugas_bumdes/views/petugas_bumdes_dashboard_view.dart'; +import '../modules/petugas_bumdes/views/petugas_aset_view.dart'; +import '../modules/petugas_bumdes/views/petugas_paket_view.dart'; +import '../modules/petugas_bumdes/views/petugas_sewa_view.dart'; +import '../modules/petugas_bumdes/views/petugas_manajemen_bumdes_view.dart'; +import '../modules/splash/views/splash_view.dart'; +import '../modules/warga/views/order_sewa_aset_view.dart'; +import '../modules/warga/views/order_sewa_paket_view.dart'; +import '../modules/warga/views/pembayaran_sewa_view.dart'; +import '../modules/petugas_bumdes/views/petugas_tambah_aset_view.dart'; +import '../modules/petugas_bumdes/views/petugas_tambah_paket_view.dart'; +import '../modules/petugas_bumdes/views/petugas_bumdes_cbp_view.dart'; +import '../modules/petugas_bumdes/views/list_petugas_mitra_view.dart'; +import '../modules/petugas_bumdes/views/list_pelanggan_aktif_view.dart'; +import '../modules/petugas_bumdes/views/list_tagihan_periode_view.dart'; + +// Import fixed routes (standalone file) +import 'app_routes.dart'; + +class AppPages { + static const INITIAL = Routes.SPLASH; + + static final routes = [ + GetPage( + name: Routes.SPLASH, + page: () => const SplashView(), + binding: SplashBinding(), + transition: Transition.fade, + transitionDuration: const Duration(milliseconds: 500), + ), + GetPage( + name: Routes.LOGIN, + page: () => const LoginView(), + binding: AuthBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.REGISTER, + page: () => const RegistrationView(), + binding: AuthBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.REGISTRATION_SUCCESS, + page: () => const RegistrationSuccessView(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.FORGOT_PASSWORD, + page: () => const ForgotPasswordView(), + binding: AuthBinding(), + transition: Transition.fadeIn, + ), + // Warga Dashboard with navbar + GetPage( + name: Routes.WARGA_DASHBOARD, + page: () => const WargaDashboardView(), + binding: WargaBinding(), + transition: Transition.noTransition, + ), + GetPage( + name: Routes.PETUGAS_BUMDES_DASHBOARD, + page: () => const PetugasBumdesDashboardView(), + binding: PetugasBumdesBinding(), + transition: Transition.fadeIn, + ), + // Warga Features Routes + GetPage( + name: Routes.SEWA_ASET, + page: () => const SewaAsetView(), + binding: SewaAsetBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.ORDER_SEWA_ASET, + page: () => const OrderSewaAsetView(), + binding: OrderSewaAsetBinding(), + transition: Transition.rightToLeftWithFade, + transitionDuration: const Duration(milliseconds: 300), + preventDuplicates: false, + maintainState: true, + opaque: true, + ), + GetPage( + name: Routes.ORDER_SEWA_PAKET, + page: () => const OrderSewaPaketView(), + binding: OrderSewaPaketBinding(), + transition: Transition.rightToLeftWithFade, + transitionDuration: const Duration(milliseconds: 300), + preventDuplicates: false, + maintainState: true, + opaque: true, + ), + GetPage( + name: Routes.PEMBAYARAN_SEWA, + page: () => const PembayaranSewaView(), + binding: PembayaranSewaBinding(), + transition: Transition.rightToLeftWithFade, + transitionDuration: const Duration(milliseconds: 300), + ), + // Warga Sewa with navbar + GetPage( + name: Routes.WARGA_SEWA, + page: () => const WargaSewaView(), + binding: WargaSewaBinding(), + transition: Transition.noTransition, + ), + // Profile page + GetPage( + name: Routes.PROFILE, + page: () => const WargaProfileView(), + binding: WargaBinding(), + transition: Transition.noTransition, + ), + // Petugas BUMDes Features + GetPage( + name: Routes.PETUGAS_ASET, + page: () => const PetugasAsetView(), + binding: PetugasAsetBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.PETUGAS_PAKET, + page: () => const PetugasPaketView(), + binding: PetugasPaketBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.PETUGAS_SEWA, + page: () => const PetugasSewaView(), + binding: PetugasSewaBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.PETUGAS_MANAJEMEN_BUMDES, + page: () => const PetugasManajemenBumdesView(), + binding: PetugasManajemenBumdesBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.PETUGAS_TAMBAH_ASET, + page: () => const PetugasTambahAsetView(), + binding: PetugasTambahAsetBinding(), + transition: Transition.rightToLeft, + ), + GetPage( + name: Routes.PETUGAS_TAMBAH_PAKET, + page: () => const PetugasTambahPaketView(), + binding: PetugasTambahPaketBinding(), + transition: Transition.rightToLeft, + ), + GetPage( + name: Routes.PETUGAS_BUMDES_CBP, + page: () => const PetugasBumdesCbpView(), + binding: PetugasBumdesCbpBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.LIST_PETUGAS_MITRA, + page: () => const ListPetugasMitraView(), + binding: ListPetugasMitraBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.LIST_PELANGGAN_AKTIF, + page: () => const ListPelangganAktifView(), + binding: ListPelangganAktifBinding(), + transition: Transition.fadeIn, + ), + GetPage( + name: Routes.LIST_TAGIHAN_PERIODE, + page: () => const ListTagihanPeriodeView(), + binding: ListTagihanPeriodeBinding(), + transition: Transition.fadeIn, + ), + ]; +} diff --git a/lib/app/routes/app_routes.dart b/lib/app/routes/app_routes.dart new file mode 100644 index 0000000..9ea3f2d --- /dev/null +++ b/lib/app/routes/app_routes.dart @@ -0,0 +1,48 @@ +// Daftar route constant untuk aplikasi +// DO NOT EDIT. This is code generated via package:get_cli/get_cli.dart + +abstract class Routes { + Routes._(); + + // Auth + static const LOGIN = '/login'; + static const REGISTER = '/register'; + static const REGISTRATION_SUCCESS = '/registration-success'; + static const FORGOT_PASSWORD = '/forgot-password'; + + // Splash + static const SPLASH = '/splash'; + + // Main Dashboards + static const WARGA_DASHBOARD = '/warga-dashboard'; + static const PETUGAS_BUMDES_DASHBOARD = '/petugas-bumdes-dashboard'; + + // Warga Features + static const SEWA_ASET = '/sewa-aset'; + static const ORDER_SEWA_ASET = '/order-sewa-aset'; + static const ORDER_SEWA_PAKET = '/order-sewa-paket'; + static const PEMBAYARAN_SEWA = '/pembayaran-sewa'; + static const WARGA_SEWA = '/warga-sewa'; + static const LANGGANAN = '/langganan'; + static const LANGGANAN_ASET = '/langganan-aset'; + + // Petugas BUMDes Features + static const PETUGAS_ASET = '/petugas-aset'; + static const PETUGAS_PAKET = '/petugas-paket'; + static const PETUGAS_SEWA = '/petugas-sewa'; + static const PETUGAS_MANAJEMEN_BUMDES = '/petugas-manajemen-bumdes'; + static const PETUGAS_TAMBAH_ASET = '/petugas-tambah-aset'; + static const PETUGAS_TAMBAH_PAKET = '/petugas-tambah-paket'; + static const PETUGAS_BUMDES_CBP = '/petugas-bumdes-cbp'; + static const LIST_PETUGAS_MITRA = '/list-petugas-mitra'; + static const LIST_PELANGGAN_AKTIF = '/list-pelanggan-aktif'; + static const LIST_TAGIHAN_PERIODE = '/list-tagihan-periode'; + static const PETUGAS_LANGGANAN = '/petugas-langganan'; + static const PETUGAS_TAGIHAN_LANGGANAN = '/petugas-tagihan-langganan'; + + // Petugas Mitra Features + static const PETUGAS_MITRA_DASHBOARD = '/petugas-mitra-dashboard'; + + // Other common routes + static const PROFILE = '/profile'; +} diff --git a/lib/app/services/navigation_service.dart b/lib/app/services/navigation_service.dart new file mode 100644 index 0000000..c202b86 --- /dev/null +++ b/lib/app/services/navigation_service.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../routes/app_routes.dart'; + +/// Service untuk menangani navigasi dalam aplikasi +/// Memisahkan logika navigasi dari controller +class NavigationService extends GetxService { + static NavigationService get to => Get.find(); + + // Track the current navbar index for warga pages + final currentNavIndex = 0.obs; + + /// Inisialisasi service + Future init() async { + debugPrint('🧭 NavigationService initialized'); + return this; + } + + /// Set current navbar index + void setNavIndex(int index) { + currentNavIndex.value = index; + } + + /// Navigasi ke halaman Sewa Aset + void toSewaAset() { + debugPrint('🧭 Navigating to SewaAset'); + setNavIndex(0); // Set appropriate index + Get.toNamed(Routes.SEWA_ASET, preventDuplicates: false); + } + + /// Navigasi ke halaman Detail Sewa Aset dengan ID + Future toOrderSewaAset(String asetId) async { + debugPrint('🧭 Navigating to OrderSewaAset with ID: $asetId'); + if (asetId.isEmpty) { + Get.snackbar( + 'Error', + 'ID aset tidak valid', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Navigasi dengan arguments + Get.toNamed( + Routes.ORDER_SEWA_ASET, + arguments: {'asetId': asetId}, + preventDuplicates: false, + ); + } + + /// Navigasi ke halaman Order Sewa Paket dengan ID + Future toOrderSewaPaket(String paketId) async { + debugPrint('🧭 Navigating to OrderSewaPaket with ID: $paketId'); + if (paketId.isEmpty) { + Get.snackbar( + 'Error', + 'ID paket tidak valid', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + // Navigasi dengan arguments + Get.toNamed( + Routes.ORDER_SEWA_PAKET, + arguments: {'paketId': paketId}, + preventDuplicates: false, + ); + } + + /// Navigasi kembali dari OrderSewaAset ke SewaAset + void backFromOrderSewaAset() { + debugPrint('🧭 Navigating back from OrderSewaAset to SewaAset'); + Get.back(); + } + + /// Navigasi kembali dari SewaAset ke dashboard warga + void backFromSewaAset() { + debugPrint('🧭 Navigating back from SewaAset to WargaDashboard'); + setNavIndex(0); // Home tab + Get.offNamed(Routes.WARGA_DASHBOARD); + } + + /// Navigasi ke Warga Sewa (tab sewa) + void toWargaSewa() { + debugPrint('🧭 Navigating to WargaSewa'); + setNavIndex(1); // Sewa tab + Get.offNamed(Routes.WARGA_SEWA); + } + + /// Navigasi ke Warga Langganan (tab langganan) + void toWargaLangganan() { + debugPrint('🧭 Navigating to WargaLangganan'); + setNavIndex(2); // Langganan tab + Get.offNamed(Routes.LANGGANAN); + } + + /// Navigasi ke Profile (tab profil) + void toProfile() { + debugPrint('🧭 Navigating to Profile'); + setNavIndex(2); // Profile tab + Get.offNamed(Routes.PROFILE); + } + + /// Navigasi ke dashboard sesuai role + void toDashboard(String role) { + debugPrint('🧭 Navigating to dashboard for role: $role'); + switch (role.toLowerCase()) { + case 'warga': + setNavIndex(0); // Reset to home tab + Get.offAllNamed(Routes.WARGA_DASHBOARD); + break; + case 'petugas_bumdes': + Get.offAllNamed(Routes.PETUGAS_BUMDES_DASHBOARD); + break; + default: + Get.offAllNamed(Routes.LOGIN); + } + } + + /// Navigasi ke login + void toLogin() { + debugPrint('🧭 Navigating to login'); + Get.offAllNamed(Routes.LOGIN); + } + + /// Navigasi mundur satu langkah + void back() { + debugPrint('🧭 Going back'); + Get.back(); + } +} diff --git a/lib/app/services/service_manager.dart b/lib/app/services/service_manager.dart new file mode 100644 index 0000000..cb29284 --- /dev/null +++ b/lib/app/services/service_manager.dart @@ -0,0 +1,68 @@ +import 'package:get/get.dart'; +import 'navigation_service.dart'; +import '../data/providers/auth_provider.dart'; +import '../modules/warga/controllers/warga_dashboard_controller.dart'; + +/// Abstract class untuk mengelola lifecycle service dan dependency +abstract class ServiceManager { + /// Getter untuk akses NavigationService + static NavigationService get navigationService { + if (!Get.isRegistered()) { + Get.put(NavigationService()); + } + return Get.find(); + } + + /// Mendaftarkan semua service yang dibutuhkan aplikasi + /// Sebaiknya dipanggil di awal aplikasi (main.dart) + static void registerServices() { + // Register service yang bersifat global dan permanent + if (!Get.isRegistered()) { + Get.put(NavigationService()); + } + + // Register AuthProvider if not already registered + if (!Get.isRegistered()) { + Get.put(AuthProvider(), permanent: true); + } + + // Register WargaDashboardController as a permanent controller + // This ensures it's always available for the drawer + registerWargaDashboardController(); + } + + /// Register WargaDashboardController as a singleton + static void registerWargaDashboardController() { + // Make sure Auth Provider is registered first + if (!Get.isRegistered()) { + Get.put(AuthProvider(), permanent: true); + } + + // Register WargaDashboardController if not already registered + if (!Get.isRegistered()) { + Get.put(WargaDashboardController(), permanent: true); + } + } + + /// Mendaftarkan controller untuk suatu page + /// Sebaiknya dipanggil di method dependencies() dalam Binding class + static void registerController(T controller, {bool permanent = false}) { + if (Get.isRegistered()) { + Get.delete(force: true); + } + Get.put(controller, permanent: permanent); + } + + /// Membersihkan controller ketika tidak digunakan + /// Sebaiknya dipanggil dalam method onClose() di controller + static void cleanupController() { + if (Get.isRegistered()) { + Get.delete(force: true); + } + } + + /// Memeriksa apakah sebuah controller sudah terdaftar + static bool isControllerRegistered() { + return Get.isRegistered(); + } +} diff --git a/lib/app/theme/app_colors.dart b/lib/app/theme/app_colors.dart new file mode 100644 index 0000000..82b27a0 --- /dev/null +++ b/lib/app/theme/app_colors.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; + +/// App color palette for consistent theming +class AppColors { + // Primary Color and its shades - based on new palette from image + static const Color primary = Color(0xFF3A6EA5); // Medium blue from image + static const Color primaryLight = Color(0xFF92B4D7); // Light blue from image + static const Color primaryDark = Color( + 0xFF0E2A47, + ); // Dark navy blue from image + static const Color primarySoft = Color( + 0xFFE7EEF6, + ); // Very light blue from image + + // Additional blue variations for UI elements + static const Color blueGrey = Color( + 0xFF607D8B, + ); // Blue-grey for subtle elements + static const Color skyBlue = Color(0xFF4FC3F7); // Sky blue for highlights + static const Color royalBlue = Color( + 0xFF1976D2, + ); // Royal blue for important actions + static const Color navyBlue = Color( + 0xFF0D47A1, + ); // Navy blue for dark backgrounds + static const Color teal = Color( + 0xFF009688, + ); // Teal for variety in the blue palette + + // Accent colors - complementary to the new palette + static const Color accent = Color(0xFF4C81B6); // Accent blue from image + static const Color accentLight = Color( + 0xFFA3C1DE, + ); // Lighter accent based on image + static const Color accentDark = Color( + 0xFF244D7A, + ); // Darker accent based on image + + // Category/Feature colors - for different sections of the app + static const Color rental = Color(0xFF5C6BC0); // Indigo for rental features + static const Color subscription = Color( + 0xFF26A69A, + ); // Teal for subscription features + static const Color billing = Color( + 0xFF66BB6A, + ); // Green for billing/payment features + static const Color notification = Color(0xFFEF5350); // Red for notifications + static const Color analytics = Color( + 0xFF7E57C2, + ); // Purple for analytics features + + // Background colors + static const Color background = Color(0xFFF5F7FA); // Light background + static const Color surface = Colors.white; // Surface color for cards + static const Color surfaceLight = Color( + 0xFFF8FAFF, + ); // Alternate surface color + static const Color surfaceDark = Color( + 0xFFF0F2F5, + ); // Darker surface for emphasis + static const Color modalBackground = Color( + 0xFFFAFAFA, + ); // Background for modal dialogs + + // Text colors + static const Color textPrimary = Color(0xFF2C3E50); // Primary text color + static const Color textSecondary = Color(0xFF718093); // Secondary text color + static const Color textLight = Color( + 0xFF95A5A6, + ); // Light text color for hints + static const Color textOnPrimary = + Colors.white; // Text on primary color backgrounds + static const Color textOnDark = Colors.white; // Text on dark backgrounds + static const Color textLink = Color(0xFF2980B9); // Link text color + + // Functional colors + static const Color success = Color(0xFF2ECC71); // Success/confirmation color + static const Color warning = Color(0xFFF39C12); // Warning color + static const Color error = Color(0xFFE74C3C); // Error color + static const Color info = Color(0xFF3498DB); // Information color + + // Additional status colors + static const Color pending = Color(0xFFFFA726); // Orange for pending status + static const Color processing = Color( + 0xFF42A5F5, + ); // Blue for processing status + static const Color completed = Color( + 0xFF66BB6A, + ); // Green for completed status + static const Color cancelled = Color(0xFFEF5350); // Red for cancelled status + static const Color neutral = Color(0xFF78909C); // Neutral for inactive status + + // Status colors with transparency + static Color successLight = success.withOpacity( + 0.1, + ); // Light success for backgrounds + static Color warningLight = warning.withOpacity( + 0.1, + ); // Light warning for backgrounds + static Color errorLight = error.withOpacity( + 0.1, + ); // Light error for backgrounds + static Color infoLight = info.withOpacity(0.1); // Light info for backgrounds + static Color pendingLight = pending.withOpacity( + 0.1, + ); // Light pending for backgrounds + static Color processingLight = processing.withOpacity( + 0.1, + ); // Light processing for backgrounds + static Color completedLight = completed.withOpacity( + 0.1, + ); // Light completed for backgrounds + static Color cancelledLight = cancelled.withOpacity( + 0.1, + ); // Light cancelled for backgrounds + static Color neutralLight = neutral.withOpacity( + 0.1, + ); // Light neutral for backgrounds + + // Gradient colors based on image + static const List primaryGradient = [ + Color(0xFF92B4D7), // Light blue + Color(0xFF3A6EA5), // Medium blue + Color(0xFF0E2A47), // Dark navy blue + ]; + + static const List accentGradient = [ + Color(0xFFA3C1DE), // Lighter blue + Color(0xFF4C81B6), // Medium blue + ]; + + // Additional gradients for variety + static const List sunsetGradient = [ + Color(0xFFFFA726), // Orange + Color(0xFFEF5350), // Red + ]; + + static const List mintGradient = [ + Color(0xFF66BB6A), // Light green + Color(0xFF26A69A), // Teal + ]; + + static const List skyGradient = [ + Color(0xFF4FC3F7), // Light blue + Color(0xFF2196F3), // Blue + ]; + + // Divider and border colors + static const Color divider = Color(0xFFECEFF1); + static const Color border = Color(0xFFCFD8DC); + static const Color borderLight = Color(0xFFE0E0E0); // Lighter border + static const Color borderFocus = Color( + 0xFF90CAF9, + ); // Border for focused elements + + // Shadow color + static Color shadow = const Color(0xFF000000).withOpacity(0.1); + static Color shadowStrong = const Color( + 0xFF000000, + ).withOpacity(0.2); // Stronger shadow + static Color shadowLight = const Color( + 0xFF000000, + ).withOpacity(0.05); // Lighter shadow + + // Icon colors + static const Color iconPrimary = Color( + 0xFF3A6EA5, + ); // Medium blue from new palette + static const Color iconLight = Color( + 0xFFA3C1DE, + ); // Light blue from new palette + static const Color iconGrey = Color(0xFF9E9E9E); + static const Color iconSecondary = Color(0xFF607D8B); // Secondary icon color + static const Color iconSuccess = Color(0xFF66BB6A); // Success icon color + static const Color iconWarning = Color(0xFFFFA726); // Warning icon color + static const Color iconError = Color(0xFFEF5350); // Error icon color + + // Button colors + static const Color buttonText = Colors.white; + static const Color buttonDisabled = Color(0xFFBDBDBD); + static const Color buttonPrimary = Color(0xFF3A6EA5); // Primary button color + static const Color buttonSecondary = Color( + 0xFF78909C, + ); // Secondary button color + static const Color buttonSuccess = Color(0xFF66BB6A); // Success button color + static const Color buttonCancel = Color(0xFFEF5350); // Cancel button color + + // Input field colors + static const Color inputBackground = Color( + 0xFFE7EEF6, + ); // Very light blue from new palette + static const Color inputBorder = Color( + 0xFFA3C1DE, + ); // Light blue from new palette + static const Color inputFocused = Color( + 0xFF3A6EA5, + ); // Medium blue from new palette + static const Color inputError = Color( + 0xFFFFCDD2, + ); // Error background for inputs + static const Color inputSuccess = Color( + 0xFFE8F5E9, + ); // Success background for inputs + + // Chip and badge colors + static const Color chipBackground = Color( + 0xFFE0E0E0, + ); // Default chip background + static const Color chipActive = Color(0xFFBBDEFB); // Active chip background + static const Color badgeRed = Color( + 0xFFEF5350, + ); // Red badge for notifications + static const Color badgeGreen = Color( + 0xFF66BB6A, + ); // Green badge for positive counts + static const Color badgeBlue = Color( + 0xFF42A5F5, + ); // Blue badge for neutral counts + static const Color badgeGrey = Color( + 0xFF9E9E9E, + ); // Grey badge for disabled items + + // Toggle and switch colors + static const Color toggleActive = Color(0xFF3A6EA5); // Active toggle color + static const Color toggleInactive = Color( + 0xFFBDBDBD, + ); // Inactive toggle color + static const Color switchTrackActive = Color( + 0xFFBBDEFB, + ); // Active switch track + static const Color switchTrackInactive = Color( + 0xFFE0E0E0, + ); // Inactive switch track +} diff --git a/lib/app/theme/app_colors_petugas.dart b/lib/app/theme/app_colors_petugas.dart new file mode 100644 index 0000000..2f267e5 --- /dev/null +++ b/lib/app/theme/app_colors_petugas.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +/// Kelas yang menyimpan konstanta warna untuk aplikasi petugas BUMDes +class AppColorsPetugas { + // Warna Utama + static const Color navyBlue = Color(0xFF05445E); + static const Color blueGrotto = Color(0xFF189AB4); + static const Color blueGreen = Color(0xFF75E6DA); + static const Color babyBlue = Color(0xFFD4F1F4); + static const Color babyBlueLight = Color(0xFFEAF6F6); + static const Color babyBlueBright = Color( + 0xFFF5FCFC, + ); // Extra light blue for subtle backgrounds + + // Gradien + static List primaryGradient = [navyBlue, blueGrotto]; + + static List secondaryGradient = [blueGrotto, blueGreen]; + + // Warna Fungsional + static const Color success = Color(0xFF4CAF50); + static const Color warning = Color(0xFFFFC107); + static const Color error = Color(0xFFF44336); + static const Color info = Color(0xFF2196F3); + + // Warna Shadow + static Color shadowColor = navyBlue.withOpacity(0.1); + + // Warna Text + static const Color textPrimary = Color(0xFF333333); + static const Color textSecondary = Color(0xFF718093); + static const Color textLight = Color(0xFFAAAAAA); + + // Shade variations (lebih terang dan lebih gelap) + static const Color navyBlueDark = Color( + 0xFF02323F, + ); // Lebih gelap dari Navy Blue + static const Color navyBlueLight = Color( + 0xFF0A5F7F, + ); // Lebih terang dari Navy Blue + + static const Color blueGrottoDark = Color( + 0xFF107A8F, + ); // Lebih gelap dari Blue Grotto + static const Color blueGrottoLight = Color( + 0xFF24B6D3, + ); // Lebih terang dari Blue Grotto + + static const Color blueGreenDark = Color( + 0xFF4ECDBE, + ); // Lebih gelap dari Blue Green + static const Color blueGreenLight = Color( + 0xFF9EEFEA, + ); // Lebih terang dari Blue Green + + static const Color babyBlueDark = Color( + 0xFFAFE3EA, + ); // Lebih gelap dari Baby Blue + + // Gradient colors + static const List fullGradient = [ + navyBlue, + blueGrotto, + blueGreen, + babyBlue, + ]; + + // Functional colors + static const Color primary = navyBlue; // Primary color + static const Color primaryLight = navyBlueLight; + static const Color accent = blueGrotto; // Accent color + static const Color accentLight = blueGrottoLight; + static const Color background = babyBlue; // Background color + static const Color surface = Colors.white; // Surface color for cards + static const Color primarySoft = + babyBlueLight; // Very light version for backgrounds + + // Functional status colors + static Color successLight = success.withOpacity(0.1); + static Color warningLight = warning.withOpacity(0.1); + static Color errorLight = error.withOpacity(0.1); + static Color infoLight = info.withOpacity(0.1); + + // Text colors + static const Color textOnPrimary = + Colors.white; // Text on primary color backgrounds + static const Color textOnDark = Colors.white; // Text on dark backgrounds + + // Border and divider colors + static const Color divider = Color(0xFFECEFF1); + static const Color border = blueGreen; + static Color shadow = navyBlue.withOpacity(0.1); + static Color shadowStrong = navyBlue.withOpacity(0.2); + + // Button colors + static const Color buttonPrimary = navyBlue; + static const Color buttonSecondary = blueGrotto; + static const Color buttonAccent = blueGreen; + static const Color buttonText = Colors.white; +} diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart new file mode 100644 index 0000000..a077146 --- /dev/null +++ b/lib/app/theme/app_theme.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +/// App theme configuration +class AppTheme { + /// Get light theme for the app + static ThemeData get lightTheme { + return ThemeData( + // Base colors + primaryColor: AppColors.primary, + colorScheme: ColorScheme.light( + primary: AppColors.primary, + primaryContainer: AppColors.primaryLight, + secondary: AppColors.accent, + secondaryContainer: AppColors.accentLight, + surface: AppColors.surface, + background: AppColors.background, + error: AppColors.error, + ), + scaffoldBackgroundColor: AppColors.background, + + // App bar theme + appBarTheme: AppBarTheme( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + iconTheme: const IconThemeData(color: Colors.white), + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + + // Card theme + cardTheme: CardTheme( + color: AppColors.surface, + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shadowColor: AppColors.shadow, + ), + + // Button themes + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: AppColors.buttonText, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ), + + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + + // Input decoration theme + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.inputBackground, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColors.inputFocused, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColors.error, width: 1.5), + ), + hintStyle: TextStyle(color: AppColors.textLight), + labelStyle: TextStyle(color: AppColors.textSecondary), + ), + + // Checkbox theme + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return AppColors.buttonDisabled; + } + return AppColors.primary; + }), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ), + + // Text themes + textTheme: TextTheme( + displayLarge: TextStyle(color: AppColors.textPrimary), + displayMedium: TextStyle(color: AppColors.textPrimary), + displaySmall: TextStyle(color: AppColors.textPrimary), + headlineLarge: TextStyle(color: AppColors.textPrimary), + headlineMedium: TextStyle(color: AppColors.textPrimary), + headlineSmall: TextStyle(color: AppColors.textPrimary), + titleLarge: TextStyle(color: AppColors.textPrimary), + titleMedium: TextStyle(color: AppColors.textPrimary), + titleSmall: TextStyle(color: AppColors.textPrimary), + bodyLarge: TextStyle(color: AppColors.textPrimary), + bodyMedium: TextStyle(color: AppColors.textPrimary), + bodySmall: TextStyle(color: AppColors.textSecondary), + labelLarge: TextStyle(color: AppColors.textPrimary), + labelMedium: TextStyle(color: AppColors.textSecondary), + labelSmall: TextStyle(color: AppColors.textLight), + ), + + // Divider theme + dividerTheme: DividerThemeData( + color: AppColors.divider, + thickness: 1, + space: 16, + ), + + // Icon theme + iconTheme: IconThemeData(color: AppColors.iconPrimary, size: 24), + + // Dialog theme + dialogTheme: DialogTheme( + backgroundColor: AppColors.surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 4, + ), + ); + } +} diff --git a/lib/app/widgets/app_drawer.dart b/lib/app/widgets/app_drawer.dart new file mode 100644 index 0000000..60544fb --- /dev/null +++ b/lib/app/widgets/app_drawer.dart @@ -0,0 +1,477 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../modules/warga/controllers/warga_dashboard_controller.dart'; +import '../routes/app_routes.dart'; +import '../services/navigation_service.dart'; +import '../theme/app_colors.dart'; +import 'dart:math' as math; + +class AppDrawer extends StatelessWidget { + final Function(int) onNavItemTapped; + final VoidCallback onLogout; + + const AppDrawer({ + Key? key, + required this.onNavItemTapped, + required this.onLogout, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final navigationService = Get.find(); + + // Safely check if WargaDashboardController is registered + final bool hasController = Get.isRegistered(); + // Only find the controller if it's registered to avoid errors + final controller = + hasController ? Get.find() : null; + + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + // Calculate drawer width - 80% of screen width but max 320pt + final screenWidth = MediaQuery.of(context).size.width; + final drawerWidth = math.min(screenWidth * 0.8, 320.0); + + // Modern, narrower drawer with clean UI + return Drawer( + width: drawerWidth, // 80% width with 320pt max + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(0), + bottomRight: Radius.circular(0), + ), + ), + child: Column( + children: [ + // Compact, modern header + Container( + padding: const EdgeInsets.fromLTRB(20, 56, 20, 20), + color: AppColors.primary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Avatar with white border + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: + hasController + ? Obx(() { + final avatarUrl = controller!.userAvatar.value; + return CircleAvatar( + radius: 28, + backgroundColor: Colors.white, + child: ClipRRect( + borderRadius: BorderRadius.circular(28), + child: + avatarUrl != null && + avatarUrl.isNotEmpty + ? Image.network( + avatarUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: ( + context, + error, + stackTrace, + ) { + return Text( + controller + .userName + .value + .isNotEmpty + ? controller + .userName + .value[0] + .toUpperCase() + : 'W', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ); + }, + ) + : Text( + controller + .userName + .value + .isNotEmpty + ? controller.userName.value[0] + .toUpperCase() + : 'W', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ); + }) + : CircleAvatar( + radius: 28, + backgroundColor: Colors.white, + child: Text( + 'W', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + ), + const SizedBox(width: 16), + // User info with better typography + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + hasController + ? Obx( + () => Text( + controller!.userName.value, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + : const Text( + 'Pengguna Warga', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: 0.3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + hasController + ? Obx( + () => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + controller!.userRole.value, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ) + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(100), + ), + child: Text( + 'Warga', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + + // Menu items + Expanded( + child: Container( + color: Colors.white, + child: Obx( + () => ListView( + padding: EdgeInsets.zero, + children: [ + const SizedBox(height: 16), + + // Navigation Section Label + Padding( + padding: const EdgeInsets.only(left: 20, bottom: 8), + child: Text( + 'NAVIGASI', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + ), + ), + ), + + // Navigation Items + _buildDrawerItem( + icon: Icons.home_rounded, + title: 'Beranda', + subtitle: 'Aksi cepat dan aset aktif', + isSelected: navigationService.currentNavIndex.value == 0, + onTap: () { + Navigator.pop(context); + if (navigationService.currentNavIndex.value != 0) { + navigationService.setNavIndex(0); + Get.offAllNamed(Routes.WARGA_DASHBOARD); + } + }, + ), + _buildDrawerItem( + icon: Icons.inventory_2_rounded, + title: 'Sewa Aset Saya', + subtitle: 'List sewa dan status', + isSelected: navigationService.currentNavIndex.value == 1, + onTap: () { + Navigator.pop(context); + if (navigationService.currentNavIndex.value != 1) { + navigationService.toWargaSewa(); + } + }, + ), + _buildDrawerItem( + icon: Icons.person, + title: 'Profil Saya', + subtitle: 'Pengaturan akun dan profil', + isSelected: navigationService.currentNavIndex.value == 2, + onTap: () { + Navigator.pop(context); + if (navigationService.currentNavIndex.value != 2) { + navigationService.toProfile(); + } + }, + ), + + const Divider(height: 32), + + // Settings Section Label + Padding( + padding: const EdgeInsets.only(left: 20, bottom: 8), + child: Text( + 'PENGATURAN', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + ), + ), + ), + + // Settings Items + _buildDrawerItem( + icon: Icons.info_outline_rounded, + title: 'Tentang Aplikasi', + subtitle: 'Informasi dan bantuan', + showTrailing: false, + onTap: () { + Navigator.pop(context); + // Show about dialog + showAboutDialog( + context: context, + applicationName: 'BumRent App', + applicationVersion: '1.0.0', + applicationIcon: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/logo.png', + width: 40, + height: 40, + ), + ), + children: [ + const Text( + 'Aplikasi penyewaan dan berlangganan aset milik BUMDes untuk warga desa.', + ), + ], + ); + }, + ), + _buildDrawerItem( + icon: Icons.logout_rounded, + title: 'Keluar', + subtitle: 'Keluar dari aplikasi', + iconColor: Colors.red.shade400, + showTrailing: false, + onTap: () { + Navigator.pop(context); + _showLogoutConfirmation(context); + }, + ), + ], + ), + ), + ), + ), + + // Bottom copyright section with BumRent App logo + Container( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 12, + bottom: 16 + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey.shade200)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '© 2025 BumRent App', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.asset( + 'assets/images/logo.png', + width: 24, + height: 24, + ), + ), + ], + ), + ), + ], + ), + ); + } + + // Modern drawer menu item with subtitle + Widget _buildDrawerItem({ + required IconData icon, + required String title, + String? subtitle, + Color? iconColor, + bool isSelected = false, + bool showTrailing = true, + required VoidCallback onTap, + }) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: + isSelected + ? AppColors.primaryLight.withOpacity(0.3) + : Colors.grey.withOpacity(0.05), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + color: + iconColor ?? + (isSelected ? AppColors.primary : Colors.grey.shade700), + size: 20, + ), + ), + title: Text( + title, + style: TextStyle( + color: isSelected ? AppColors.primary : Colors.black87, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + fontSize: 14, + ), + ), + subtitle: + subtitle != null + ? Text( + subtitle, + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ) + : null, + trailing: + showTrailing && isSelected + ? Container( + width: 4, + height: 24, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + ) + : null, + onTap: onTap, + ); + } + + // Modern logout confirmation dialog + void _showLogoutConfirmation(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text('Konfirmasi Keluar'), + content: const Text('Apakah Anda yakin ingin keluar dari aplikasi?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: Colors.grey.shade700, + ), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + onLogout(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade400, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Keluar'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/app/widgets/custom_button.dart b/lib/app/widgets/custom_button.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/widgets/custom_button.dart @@ -0,0 +1 @@ + diff --git a/lib/app/widgets/custom_text_field.dart b/lib/app/widgets/custom_text_field.dart new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/app/widgets/custom_text_field.dart @@ -0,0 +1 @@ + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a68e259 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'app/data/providers/auth_provider.dart'; +import 'app/modules/petugas_bumdes/controllers/petugas_bumdes_dashboard_controller.dart'; +import 'app/routes/app_pages.dart'; +import 'app/services/navigation_service.dart'; +import 'app/services/service_manager.dart'; +import 'app/theme/app_theme.dart'; + +void main() async { + // Pastikan Flutter diinisialisasi dengan benar + WidgetsFlutterBinding.ensureInitialized(); + + // Set orientasi layar ke portrait + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + + // Initialize GetStorage + await GetStorage.init(); + debugPrint('✅ GetStorage initialized successfully'); + + // Load .env file + try { + await dotenv.load(); + debugPrint('✅ .env file loaded successfully'); + } catch (e) { + debugPrint('❌ Error loading .env file: $e'); + } + + // Initialize intl package for Indonesian locale + await initializeDateFormatting('id_ID', null); + + // Initialize navigation service + await Get.putAsync(() => NavigationService().init()); + debugPrint('✅ NavigationService initialized successfully'); + + // Buat instance dan inisialisasi Supabase (hanya 1 kali) + final authProvider = Get.put(AuthProvider(), permanent: true); + + try { + await authProvider.init(); + debugPrint('✅ Auth provider initialized successfully'); + } catch (e) { + debugPrint('❌ Error initializing auth provider: $e'); + } + + // Pre-register the dashboard controller to fix dependency issues + Get.put(PetugasBumdesDashboardController(), permanent: true); + debugPrint('✅ PetugasBumdesDashboardController initialized globally'); + + // Register services yang akan digunakan secara global + ServiceManager.registerServices(); + + // Jalankan aplikasi + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return GetMaterialApp( + title: 'BumRent', + theme: AppTheme.lightTheme, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('id', 'ID'), Locale('en', 'US')], + locale: const Locale('id', 'ID'), + debugShowCheckedModeBanner: false, + initialRoute: AppPages.INITIAL, + getPages: AppPages.routes, + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..17cc497 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "bumrent_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.bumrent_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..ea3bde6 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,27 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..0420466 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux + flutter_secure_storage_linux + gtk + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..2b1df15 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "bumrent_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "bumrent_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..5a0330e --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import file_selector_macos +import flutter_image_compress_macos +import flutter_secure_storage_macos +import path_provider_foundation +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..de3e6dd --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* bumrent_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "bumrent_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* bumrent_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* bumrent_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bumrent_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bumrent_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bumrent_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bumrent_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bumrent_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bumrent_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..ea774e1 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..d4164db --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = bumrent_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.bumrentApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..e2d2322 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1023 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + app_links: + dependency: transitive + description: + name: app_links + sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba" + url: "https://pub.dev" + source: hosted + version: "6.4.0" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" + url: "https://pub.dev" + source: hosted + version: "0.9.4+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.dev" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_logs: + dependency: "direct main" + description: + name: flutter_logs + sha256: "46880b3da87bf66d10c69505fd8dec1ef78fe1793fc909169ddb9aa9fe34f164" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" + url: "https://pub.dev" + source: hosted + version: "2.0.27" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: bf7404619d7ab5c0a1151d7c4e802edad8f33535abfbeff2f9e1fe1274e2d705 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: a49876ebae32a50eb62483c5c5ac80ed0d8da34f98ccc23986b03a8d28cee07c + url: "https://pub.dev" + source: hosted + version: "2.4.1" + get: + dependency: "direct main" + description: + name: get + sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + url: "https://pub.dev" + source: hosted + version: "4.7.2" + get_storage: + dependency: "direct main" + description: + name: get_storage + sha256: "39db1fffe779d0c22b3a744376e86febe4ade43bf65e06eab5af707dc84185a2" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: d6362dff9a54f8c1c372bb137c858b4024c16407324d34e6473e59623c9b9f50 + url: "https://pub.dev" + source: hosted + version: "2.11.1" + gtk: + dependency: transitive + description: + name: gtk + sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c + url: "https://pub.dev" + source: hosted + version: "2.1.0" + http: + dependency: transitive + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" + source: hosted + version: "1.3.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9" + url: "https://pub.dev" + source: hosted + version: "0.8.12+22" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100" + url: "https://pub.dev" + source: hosted + version: "0.8.12+2" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1" + url: "https://pub.dev" + source: hosted + version: "0.2.1+2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0" + url: "https://pub.dev" + source: hosted + version: "2.10.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb" + url: "https://pub.dev" + source: hosted + version: "0.2.1+1" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + url: "https://pub.dev" + source: hosted + version: "10.0.8" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + url: "https://pub.dev" + source: hosted + version: "3.0.9" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" + url: "https://pub.dev" + source: hosted + version: "2.2.16" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: b74dc0f57b5dca5ce9f57a54b08110bf41d6fc8a0483c0fec10c79e9aa0fb2bb + url: "https://pub.dev" + source: hosted + version: "2.4.1" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: e3089dac2121917cc0c72d42ab056fea0abbaf3c2229048fc50e64bafc731adf + url: "https://pub.dev" + source: hosted + version: "2.4.2" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: c2c8c46297b5d6a80bed7741ec1f2759742c77d272f1a1698176ae828f8e1a18 + url: "https://pub.dev" + source: hosted + version: "2.4.9" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + url: "https://pub.dev" + source: hosted + version: "2.5.5" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "9f9ed283943313b23a1b27139bb18986e9b152a6d34530232c702c468d98e91a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: c3ebddba69ddcf16d8b78e8c44c4538b0193d1cf944fde3b72eb5b279892a370 + url: "https://pub.dev" + source: hosted + version: "2.6.3" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "3b5b5b492e342f63f301605d0c66f6528add285b5744f53c9fd9abd5ffdbce5b" + url: "https://pub.dev" + source: hosted + version: "2.8.4" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + url: "https://pub.dev" + source: hosted + version: "3.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" + url: "https://pub.dev" + source: hosted + version: "6.3.1" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4" + url: "https://pub.dev" + source: hosted + version: "6.3.15" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + url: "https://pub.dev" + source: hosted + version: "14.3.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b + url: "https://pub.dev" + source: hosted + version: "1.0.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f + url: "https://pub.dev" + source: hosted + version: "5.12.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: "56155e9e0002cc51ea7112857bbcdc714d4c35e176d43e4d3ee233009ff410c9" + url: "https://pub.dev" + source: hosted + version: "2.0.3" +sdks: + dart: ">=3.7.2 <4.0.0" + flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..23b071d --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,111 @@ +name: bumrent_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.7.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # GetX for state management + get: ^4.6.6 + + # Supabase for backend + supabase_flutter: ^2.3.4 + + # Additional useful packages + flutter_secure_storage: ^9.0.0 + cached_network_image: ^3.3.1 + google_fonts: ^6.1.0 + flutter_dotenv: ^5.1.0 + image_picker: ^1.0.7 + intl: 0.19.0 + flutter_localizations: + sdk: flutter + get_storage: ^2.1.1 + photo_view: ^0.15.0 + flutter_logs: ^2.2.1 + flutter_image_compress: ^2.4.0 + path_provider: ^2.1.5 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/images/ + - .env + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..31af8b2 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// 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:bumrent_app/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/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d7341ab --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + bumrent_app + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..478b4ef --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "bumrent_app", + "short_name": "bumrent_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..41e92da --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(bumrent_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "bumrent_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..bf6dc58 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b4be188 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + app_links + file_selector_windows + flutter_secure_storage_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..8167420 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "bumrent_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "bumrent_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "bumrent_app.exe" "\0" + VALUE "ProductName", "bumrent_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..8e5d475 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"bumrent_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_